内容纲要

前言

本章来谈一谈两条 Apache Commons Collections 的 POP 利用链 , 分别是 TransformedMap攻击链LazyMap攻击链 . 废话不多说 , 直接开搞 !


漏洞复现

复现环境是 IntelliJ IDEA + jdk1.7.080 + Apache Commons Collections 3.1 ,

环境搭建时我玩了一下 OBS , 制作成动态图片后也方便没有接触过 Java 的师傅搭建测试环境.

  1. 首先我们需要一个 Apache Commons Collections » 3.1 的 Jar 包 , 可以从 MVNRepository 上获取到它.

  2. 然后打开 IntelliJ IDEA , 创建一个普通的 Java 项目 , 注意 JDK 版本需要为 1.7

  3. 导入之前下载的 commons-collections-3.1.jar 包 , 注意勾选

    File -> Project Structure -> Modules -> Dependencies -> JARs or directories

  4. 编写一个 POC , 网上很多地方都有现成的 , 例如 : EvalObject

    我的桌面环境是 Mate , 因此想要弹出计算机需要执行命令 mate-calc

    如果您是其他操作系统环境 , 则需要修改要执行的命令 .

  5. 编译并执行 EvalObject 类 , 即可成功弹出计算机 , 漏洞复现成功


漏洞分析

上面视频中给出的POC是比较复杂的 , 而且是不完整的 , 废话不多说 , 我们来看一下这个利用链是怎么实现的 .

org/apache/commons/collections/functors/InvokerTransformer.class

  1. InvokerTransformer.class 类中的 Transformer() 方法中存在一组反射调用 , 这组反射调用是漏洞产生的根源.

    举例 : 利用 Java 反射机制执行系统命令 .

    对比一下不难看出 , 只要我们能控制 input , this.iMethodName , this.iParamTypes , this.iArgs 四个参数 , 那么就可以通过这组反射调用执行任意代码 !

  2. 那么这些参数是否可控呢 ? 回顾 InvokerTransformer.class 类的构造函数 , 可以发现 this.iMethodName , this.iParamTypes , this.iArgs 三个参数都是直接可控的 , 我们可以直接传入参数值 , 而 input 参数是 transform() 函数调用方传入的 , 同样可控 .

    因此 , 这里的 transform() 方法是完全可以利用的 . 下面构造一个最简单的 POC , 用于弹出计算器 :

  3. 虽然可以成功执行命令 , 但是这种攻击方式肯定是没有意义的 , 我们构造代码是为了在远端服务上执行 , 而并非在本地服务器上执行 . 远程服务器上肯定不会出现像 t1.transform(Runtime.getRuntime()); 这样的代码 . 所以我们必须对上述代码做优化 , 减少反序列化后的操作 .

    优化代码的第一步就是消除transform() 方法的参数限制 : Runtime.getRuntime().


org/apache/commons/collections/functors/ChainedTransformer.class

  1. 由于 Runtime 类并没有继承 Serializable 接口 , 因此我们无法直接传入实例对象 ,

  2. 所以 , 消除上述限制的最好方法就是通过 Java 反射机制来构建 java.lang.Runtime.getRuntime().exec() 方法调用链 . 由于 Runtime.class 构造函数的特殊性 , 我们在编写 Java 反射代码时至少要调用 getMethod() , getRuntime() , exec() , invoke() 四个方法 . 因此 , 一组反射是完全不够用的 , 我们必须要找到一条链 , 来拼接多组反射 , 从而实现命令执行 .

    ChainedTransformer.transform() 方法恰好符合这个要求.

    ChainedTransformer 类的构造函数返回一个 Transformer[] 类型的数组this.iTransformers . 该类的 transform() 方法会循环获取 this.iTransformers 数组中的每一项 , 调用它的 transform() 方法 , 并将返回结果作为下次循环调用的参数 .

    所以 , 我们可以编写多个 InvokerTransformer 实例对象 , 分别获取 getRuntime() , invoke() , exec() 方法 , 然后将这些实例对象添加到 this.iTransformers 数组中 , 从而获得一条完整的调用链 .

    如果不明白这里的代码是怎么来的 , 可以参考 Java反射机制 或者 getMethod() , Invoke() 方法的定义 .

    那么如何获取 java.lang.Runtime 实例对象 , 来开启这个调用链呢 ?


org/apache/commons/collections/functors/ConstantTransformer.class

  1. 目前的问题是如何获取 Runtime 实例对象 . 而 ConstantTransformer.transform() 方法满足我们的需求 .

    ConstantTransformer.transform() 方法恰好会返回 Runtime() 实例对象 , 因此我们只需要将 Runtime.class 传入 ConstantTransformer 的构造方法中即可 .

    至此 , transform() 方法的参数限制已经被去除 , 此时无论 transform() 方法的参数是什么 , 都会执行 java.lang.Runtime.getRuntime().exec() 调用链 , 实现命令执行.


transform() 方法的利用条件

上一步 , 我们通过 java 反射机制消除了 transform() 方法的参数限制 . 但是依旧需要手动触发 transform() 方法 , 这样的场景是比较少的 .

况且根据 Java 反序列化漏洞的定义 , 我们更加期望后端程序在执行 readObject() 方法时就会自动执行 transform() 方法.

综上所述 , 为了实现完整的利用链 , 必须要达成如下两个目标 :

  • 找到一个 tansform() 方法 , 该方法所属的实例对象是可控的.
  • 找到一个重写的 readObject() 方法 , 该方法会自动调用 transform() 方法.

这个限制是非常严谨的 , 目前研究人员共发掘出了两条攻击链 , 也就是经典的 TransformedMap攻击链LazyMap攻击链


TransformedMap 攻击链

首先要知道什么是 TransformedMap 类 , 以及这个类是干什么的 . 官方文档关于这个类的定义非常少 , 个人认为下面这种说法是比较通俗易懂的 .

Apache Commons Collections 实现了一个 TransformedMap 类,该类是对 Java 标准数据结构 Map 接口的一个扩展 .

该类可以在一个元素被加入到集合内时,自动对该元素进行特定的修饰变换 , 具体的变换逻辑由Transformer 类定义,TransformerTransformedMap 实例化时作为参数传入.


TransformedMap.checkSetValue()

TransformedMap 类的 checkSetValue() 方法中调用了 Transform() 方法 .

只要我们能控制 this.valueTransformer , 那么就可以利用该方法执行 ChainedTransformer.transform() 方法 , 进入构造好的函数调用链 . 根据上文可以得知 , this.valueTransformer 会在 TransformedMap 类被实例化时被传入 .

由于 TransformedMap 类的构造方法通过 protected 修饰符修饰 , 所以无法在外界获得 TransformedMap 实例对象 . 对此 , 该类提供了 decorate() 方法来返回 TransformedMap 实例对象 , 而 decorate() 方法通过 public 修饰符修饰 , 外界可以直接调用 .

综上所述 , this.valueTransformer 是完全可控的 . 我们可以通过这里调用 ChainedTransformer.transform() 方法


TransformedMap.decorate()

先来看一下函数定义 .

Map : 需要转换的 Map 对象
KeyTransformer : 用于转换键的转换器 , 如果为 null 则表示不进行转换
ValueTransformer : 用于转换值的转换器 , 如果为 null 则表示不进行转换

既然要调用 TransformedMap.decorate() 方法 , 那么这里 ValueTransformer 就应为 ChainedTransformer . 此外 , 我们还需要一个 Map类型的变量 , 而获取 Map 最简单的方式就是构造一个 HashMap , 然后将该 Map 实例对象传入 decorate() 方法中.

因此 , 我们需要构造如下代码 :

那么现在还剩一个问题 : 如何调用 checkSetValue() 方法呢? 要知道该方法同样通过 protected 修饰符修饰 , 外界是无法直接调用的 .


AbstractInputCheckedMapDecorator$MapEntry.setValue()

transformedMap 的父类 AbstractInputCheckedMapDecorator 中存在一个静态内部类 MapEntry , 该类 setValue() 中调用了 checkSetValue() 方法.

我们现在的目标是调用 TransformedMap.checkSetValue() 方法 , 因此只需要令 this.parent 指向 TransformedMap 实例对象即可 . 查看 MapEntry 内部类的构造函数 , 可以确定 this.parent 参数值是作为参数传入的 . 是可控的 .


AbstractInputCheckedMapDecorator$EntrySetIterator.next()

我们对 this.parent = parent 打断点 , 查看 parent 参数值的函数调用栈 .

可以看到 , AbstractInputCheckedMapDecorator 类的静态内部类 EntrySetIterator 中的 next() 方法触发了 MapEntry 内部类的构造函数 , 并传入 parent 参数值 . 最后返回 AbstractInputCheckedMapDecorator$MapEntry 实例对象 .


AbstractInputCheckedMapDecorator$EntrySet.iterator()

这里 this.parent 参数依旧是可控的 ,我们继续跟踪 this.parent = parent ,

可以看到 , AbstractInputCheckedMapDecorator 类的静态内部类 EntrySet 中的 iterator() 方法触发了 EntrySetIterator 内部类的构造函数 , 并且传入 parent 参数. 最后返回 AbstractInputCheckedMapDecorator$EntrySetIterator 实例对象.


AbstractInputCheckedMapDecorator.entrySet()

依旧没看到 this.parent 的控制点 , 我们继续追踪 this.parent = parent .

这里会根据 isSetValueChecking() 方法的返回值决定是否调用 AbstractInputCheckedMapDecorator.EntrySet() 方法 .

而抽象类 AbstractInputCheckedMapDecorator 恰好是 TransformedMap 的父类 , 因此这里我们可以直接将 this 指向 TransformedMap . 使得最后调用 TransformedMap.checkSetValue() 方法 .


TransformedMap.isSetValueChecking()

那么现在还剩最后一个问题 : 我们需要让 isSetValueChecking() 方法的返回值为 True

TransformedMap 实现了抽象父类的 isSetValueChecking() 方法 , 来看一下函数定义

只需要让 this.valueTransformer 不会空即可 ! 这当然是成立的 , 我们在将 TransfromedMap.decorate() 方法时已经将 ChainedTransformer 赋值给了 this.valueTransformer


本地命令执行 => POC

现在你会惊奇的发现 , 我们上面所有提到的方法调用构成了一个闭环 , 我们只需要获取 AbstractInputCheckedMapDecorator$MapEntry 实例对象并手动触发 setValue() 方法 , 就可以执行任意代码 .

上面用到一些逆推的思想 , 说明了函数调用链是如何构造的 . 至此 , 本文开始时视频中出现的 POC 就构造完毕了 .


延长攻击链 => annotation/AnnotationInvocationHandler.readObject()

为什么本文分析前提到这个 POC 是不完善的呢 ? 因为这个 POC 压根没法投入使用 ! 我们最终的目标是让上文构造的恶意类在远程服务器上执行 , 也就是让恶意类经过序列化/反序列化后直接执行 .

按照前面几篇文章的思路 , 我们的目标是找到一个重写 readObject() 方法的地方 . 该方法中会调用可控的 setValue() 方法 . 那么是否存在这样的地方呢 ? 在 Jdk1.7 中 , annotation/AnnotationInvocationHandler 类的 readObject() 方法实现了我们的需求 .

这里的代码比较复杂 , 涉及非常多的变量 ,不过不要慌 , 我们一步步倒推分析 .

  • 第一步

    要想调用 AbstractInputCheckedMapDecorator$MapEntry.setValue() 方法 , 第一步要达成如下三个条件 .

    var5 = AbstractInputCheckedMapDecorator$MapEntry
    !var7.isInstance(var8)
    !(var8 instanceof ExceptionProxy) == True

    var7 的值通过 var3.get(var6) 返回 , 且不能为空 .

    var6var8 的值比较好看 , 分别通过 var5.getKey() 方法和 var5.getValue() 方法获取 var5 的键名与值.

    var5 var4.next() 方法返回的 , 根据上文的内容 , 我们希望 var4AbstractInputCheckedMapDecorator$EntrySetIterator实例对象.

  • 第二步

    从第一步的结果来看 , 我们需要知道 var3var4 的赋值过程 .

    Iterator var4 = this.memberValues.entrySet().iterator()
    Map var3 = var2.memberTypes();

    var4this.memberValues.entrySet().Iterator() 方法返回的 , 对比前面的 POC , 我们期望 this.memberValues 指向 TransformedMap .

    var3var2.memberTypes() 方法返回的 , 我们跟踪该方法 .

    通过几步跳转 , 可以确定这里 var3 是一个 HashMap , 因此上文 var3.get() 就是调用 HashMap.get() 方法.

  • 第三步

    现在需要关注 this.memberValuesvar2 的值了 . 其中 var2 的赋值如下 :

    为了确定 this.typethis.memberValues 的值 , 我们来看一下当前类构造函数的定义 .

    AnnotationInvocationHandler 的构造函数第二个参数类型为 Map , 这点非常巧 , 我们可以直接传入 TransformedMap 实例对象.

    关于 var1 , 我们目前只知道它是个注解类 . 网上很多分析文章中都使用了 java.lang.annotation.Retention 这个注解类 . 笔者能力有限 , 就不去找其他的了 , 来看一看这个注解类是否满足下面各种 if 条件吧 !

    注意 : 这里 var1 , var2 是构造函数的形式参数 , 并非 readObject() 方法中的 var1var2.

  • 第四步

    这里 var1 , var2readObject() 方法中的形式参数.

    1. 构造函数 AnnotationInvocationHandler.AnnotationInvocationHandler()

      这里 this.type 被赋值为 java.lang.annotation.Retention

    2. var2 = AnnotationType.getInstance(this.type)

      经过这步赋值 , var2 实例对象中的 memberTypes 参数变为一个 HashMap , 其中存在键值对 : {"value" : "java.lang.annotation.RetentionPolicy"}

    3. Class var7 = (Class)var3.get(var6)

      这里调用了 var3.get(var6) , 并将结果赋值给变量 var7 , var3 是一个 HashMap , var6 值为 "value" , 所以实际执行的是 HashMap.get("value") . 而 var3 中恰好存在名为 "value" 的键名 , 因此可以把 "value" 的值 "java.lang.annotation.RetentionPolicy" , 赋值给变量 var7.

      这样变量 var7 就不为空了 , 自然通过了下文 if (var7 != null) 的条件判断 .

      综上所述 , 由于变量 var6 的值为 value , 因此变量 var7 可以获取到值并通过下面的条件判断 . 而 var6 的值又是通过 var5.getKey() 获取的 , 而 var5 就是我们代码中创建的 HashMap

      因此这里也引出了该 POC 利用成功的一个核心要求 : 手工创建的 HashMap 的键名必须为 Value .

      相反 , 如果这里 HashMap 的键名被赋予其他值( 例如 "epicccal" ) , 那么此处将执行 HashMap.get("epicccal") , 哈希表中不存在这个键名 , 因此 var7 会被赋值 null , 不会通过下面的 if 条件判断 .

  • 第五步

    后面就没啥好说的, 程序将执行到 var5.setValue() , 即执行 AbstractInputCheckedMapDecorator$MapEntry.setValue() 方法 , 进入恶意函数调用链 .

    至此 , 当我们构造的恶意类在远程服务器通过 readObject() 方法进行反序列化时 , 会自动调用 AbstractInputCheckedMapDecorator$MapEntry.setValue() 方法 , 进入我们构造好的恶意函数调用链 , 最终执行任意代码 .


反序列化命令执行 => POC

现在可以构造完整的 POC 了 . 我们仅需要获取 AnnotationInvocationHandler 实例对象 , 并向构造函数中传入RetentionTransformedMap实例对象 , 即可实现POP攻击链自动调用 .

需要注意的是 , AnnotationInvocationHandler类的构造函数使用了默认修饰符 , 通过默认修饰符修饰的方法只能同包访问 , 因此这里无法直接访问 .

这里与获取 java.lang.Runtime 实例对象的思路类似 , 即通过反射来获取类 , 通过 getDeclaredConstructor()方法获取构造器 , 通过 setAccessible() 方法来开放构造器访问权限.

最后写一个本地 Demo , 将当前类序列化后反序列化 , 即可得到完整的 POC.


LazyMap攻击链

LazyMap.get()

与分析 TransformedMap 攻击链类似 , 我们需要寻找其他可控且调用 transform() 方法地方 . LazyMap 类的 get() 方法同样满足我们的要求 .

查看当前类的构造函数 , 发现 this.factory 是可控的 , 因此这里的 get() 方法是可以利用的 .

这里恰好有一个重载构造函数的第二个参数为 transformer 类型 , 因此我们可以利用该函数 , 将 ChainedTransformer 实例对象给传进去 . 至于第一个参数 , 随便实例化个 HashMap 实例对象传进去就可以了 , 本身并不关键 .

该构造函数通过 protected 修饰符修饰 , 肯定无法直接调用 , 不过这里与 TransformedMap 类似 , 提供了 decorate() 方法来返回 LazyMap 实例对象 . 代码如下所示 :


本地命令执行 => POC

相比于构造 TransformedMap 函数调用链 , LazyMap 调用链实在过于简单 . 由于 LazyMap.get() 方法通过 public 修饰符修饰 , 所以根本不需要管其他的 , 直接调用就完事了.


延长攻击链 => TiedMapEntry.getValue()

与上文同理 , 这里的 POC 只能在本地执行 , 无法反序列化后执行 . 但安全研究前辈们暂未发现可控并调用 get() 方法的 readObject() 方法 .

通过跟踪 跟踪 get() 方法的调用栈 , 前辈们发现 TiedMapEntry 类的 getValue() 方法中调用了 get() 方法 .

这里简单很多 , 我们只要使得 this.map 指向 LazyMap 实例对象即可 .很明显这里是可控的 .


延长攻击链 => TiedMapEntry.toString()

但是 , 安全研究前辈们暂未发现可控并调用 getValue() 方法的 readObject() 方法 . 因此继续跟着 get() 方法的调用链往上找 . 最终 , 前辈们找到了 TiedMapEntry 类的 toString() 方法 .

toString() 函数通常在与字符串拼接时被自动调用 , 该方法中调用了 getValue() 方法并且可控 , 通过该方法可以间接控制 LazyMap.get() 方法

那么是否存在可控并调用 getValue() 方法的 readObject() 方法呢 ? 这一次 , 前辈们终于找到了想要的方法 !


延长攻击链 => javax/management/BadAttributeValueExpException.readObject()

BadAttributeValueExpException 类的 readObject() 方法中调用了 toString() 方法 , 来看一看该方法是否可以利用 .

要想利用该方法 , 只需要似的 valObj 指向 TiedMapEntry 即可 .

跟踪 valObj 变量可以确定 , 要想执行 toString 方法 , valObj 需要满足上面一堆条件 . 此外 , valObj 变量通过 gf.get("val", null) 赋值 .

valBadAttributeValueExpException 类中的一个私有变量 , 其类型为 Object . 因此我们可以直接通过反射对私有变量赋值 .

通过这一步赋值 , 我们先获取了 TiedMapEntry 实例对象 与 BadAttributeValueExpException 实例对象 , 并通过 class.getDeclaredField() 方法获取到 val 变量 . 又由于 val 变量为私有变量 , 因此我们通过 setAccessible() 方法去除限制 , 并通过 set() 方法进行赋值 .

通过这一步反射赋值 , valObj 变量的值最终会变为 TiedMapEntry , 这样自然能通过后面几个判断 , 最终调用 TiedMapEntry.toString() 方法 , 进入构建好的函数调用链 .


反序列化命令执行 => POC

把剩下的序列化内容补充完整 , 即可得到完整的 POC . 我们构造的恶意类在服务端反序列化后将自动进入函数调用链 , 实现命令执行 .

最终完整的 POC 如下所示 :

有注意到 POC 中我画了两个红框吗? 其实这里有很多有意思的东西~


注意点1 : 为什么在初始化 HashMap 时 , 键名不能为 null ?

针对这个问题 , 我们不妨将键名赋值为 null 后运行一下 POC , 并将断点打在漏洞触发点 : LazyMap.get() 方法 .

调试并注意下面这行代码 .

简单的说 , 这里会判断我们创建的 HashMap 的键名是否为 null , 如果为 null 则不会进入 if 语句 , 自然不会执行 ChainedTransformer.transform() .

因此 , 这里 HashMap 初始化时不要将键名设置为 null , 否则反序列化后不会进入函数调用链 . 或者干脆就不要初始化 HashMap , 经过测试确定不初始化也能进入函数调用链 .

  • HashMap初始化 , 键名不为空 , 进入函数调用链

  • HashMap不初始化 . 也能进入函数调用链


注意点2 : 无处不在的命令执行( 例如实例化 TiedMapEntry 时会触发命令执行 )

如果你有调试过代码就会发现 , 在很多莫名其妙的地方会触发命令执行 , 弹出计算器 . 来看下面这边

TiedMapEntry 构造函数中 , this.map = map 这一步赋值操作触发了命令执行 .

这个是为什么呢 ? 我们知道 LazyMap 攻击链的关键是 toString() 方法 , 来看一下官方文档中该方法的定义 .

也就是说 , 当涉及对象打印时 , Java编译器会在内部自动调用 toString() 方法 . 由于是内部自动调用 , 因此通过 IDEA 工具的 DEBUG 是看不到的 .

那么就有一种可能 , 会不会是在进行 this.map = map 时 由于传入的 map 参数非空 , 因此自动调用了 toString() 方法呢 ?

于是我对构造函数传入的 Map 对象进行了初始化赋值 , 赋值为 null( 这里只是做实验 ). 然后执行 , 发现果然没有触发命令执行 , 说明我这个猜想很有可能是正确的 .

由于能力有限 , 我目前还不能肯定上述观点 , 如果您知道真正的原因 , 欢迎您的留言 . 不过这也不是关键点 , 我们的重点应该在反序列化后 .


注意点3 : BadAttributeValueExpException() 实例化时传入参数 null

为什么构造函数要传入 null 呢 ? 先来看一下构造函数的定义 .

如果这里传入非 null 的值 , 就会调用 val.toString() 方法 .

这里很多同学就激动了 , 如果这里传入 TiedMapEntry 实例对象 , 那不就直接进入函数调用链了吗?

真是这样吗 ? 我们不妨将 TiedMapEntry 实例对象传入 , 并调试看一看 .

发现在序列化 writeObject() 时报错了 . 报错信息很好理解 , 即 : java.lang.UNIXProcess 不能被序列化 . 那么这个是 UNIXProcess 实例对象是哪里来的呢 ?

通过调试发现 , 如果我们将 entry 传入 BadAttributeValueExpException 构造函数中 , 那么 value 的值就会变成 UNIXProcess 对象 , 然而这个对象是无法被序列化的 , 我们的最终目的是构造好的恶意类经过序列化 / 反序列化后被执行 , 因此这里只能向构造函数中传入 null .


总结

本章主要是研究了下 ACC 中最基础的两条攻击链 : TransformedMap攻击链LazyMap攻击链 , 内容比较多 , 也非常复杂 . 不过还是挺有意思的 .

文中可能会有不正确或者不精准的地方 , 如果您发现 , 欢迎指出 .

Java真是太难了

最后修改日期:2020年7月7日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。