CC1——Commons Collections 反序列化链精讲
CC1 反序列化链精讲一、背景2004 年CC 开发者想在 Java 里引入这类组织思路2000 年代初Java 没有泛型没有 lambda没有java.util.function。而当时 Haskell、Lisp、Erlang、Scala 早已把函数式编程做得很成熟。CC 设计者用了三年、分两个阶段搭出这套体系版本年份内容CC 1.0~2001Transformer、Predicate、Closure三个 functor 接口CC 2.1~2002Factory接口CC 3.02004年6月ConstantTransformer、InvokerTransformer、ChainedTransformer等 40 个实现类先定接口再写实现——三年后才有了完整的 functor 体系。Apache Commons 的开发者想给 Java 也加上这种函数式编程的能力。但 Java 里没有函数这种类型他们只能用接口来模拟函数。他们定义了四个核心接口接口方法签名十年后 Java 8 的对应TransformerObject transform(Object input)FunctionT, RPredicateboolean evaluate(Object obj)PredicateTClosurevoid execute(Object input)ConsumerTFactoryObject create()SupplierT实现类放在org.apache.commons.collections.functors包下——“functor” 是函数式编程术语意思是可以像函数一样被调用的对象。CC 3.0 源码中四个接口的 Javadoc 第一句话都写了Defines a functor interface定义一个 functor 接口// Transformer.java/** Defines a functor interface implemented by classes that transform one object into another. */// Predicate.java/** Defines a functor interface implemented by classes that perform a predicate test on an object. */// Closure.java/** Defines a functor interface implemented by classes that do something. */// Factory.java/** Defines a functor interface implemented by classes that create objects. */其中有三块很关键的积木后面也正好成了 CC1 链里的核心部件表达CC 实现类f(x) c常量函数ConstantTransformer把一次调用包装成一步InvokerTransformerf(x) f₃(f₂(f₁(x)))函数复合ChainedTransformer十年后 Java 8 发布java.util.function包也提供了非常接近的一套概念体系至少说明 CC 当年试图在 Java 里模拟这类能力并不是一个偏门想法。1.1 把 CC 放到 Java 8 之后再看如果借今天的眼光回头看会发现 CC 这套 functor 接口和 Java 8 的标准函数接口在思路上非常接近它们都在描述“把一段处理逻辑单独拿出来再交给别的代码去执行”。CC functorJava 8 中最接近的接口含义TransformerFunctionT, R给一个输入返回一个输出PredicatePredicateT给一个输入判断真假ClosureConsumerT接收一个输入执行动作不返回结果FactorySupplierT不要输入直接产出一个值如果再往本文这三块积木上对照ConstantTransformer可以近似看成x - cInvokerTransformer可以近似看成“对输入对象做一步既定调用”ChainedTransformer则对应多个Function的串联。如果只看“多个步骤接起来”这件事两边也很像。CC 里用的是ChainedTransformerJava 8 里则可以直接用Function的andThen()/compose()FunctionString,Stringf((FunctionString,String)String::trim).andThen(String::toUpperCase);System.out.println(f.apply( hello ));// HELLO这段代码表达的是f(x) f₂(f₁(x))这就是把多个处理步骤接起来。从这个角度看CC 和 Java 8 的思路是共通的。二、用函数式的视角看这三块积木为了方便理解这三块积木可以抓住三个词起点、步骤、组合。这里真正想借的是一种看问题的方式先把步骤拆开再把它们接起来。这种“拆开再组合”的思路比 Java 老得多。再往上追它的数学根基可以追溯到 1930 年代阿隆佐·丘奇提出的 λ 演算。f(x) x² 1 ← 给定输入 x按规则得到结果CC 的Transformer可以近似理解成这里的f(x)。下面看一条大家都非常熟悉的命令echobaidu.com|./SubFinder/subfinder-silent|./KsubDomain/ksubdomain-silent|./HTTProbe/httprobe|./HTTPX/httpx -title/-ip它的作用是从 baidu.com 出发发现子域名筛选存活的加上 https://提取页面标题。每一步是一个独立的工具管道符把这些工具串成了流水线上一个工具的输出是下一个工具的输入。这条命令里正好能把这种思路拆成三个动作逐个看——2.1 固定一个结果f(x) c最简单的函数是不管你给它什么输入输出都一样f(x) cConstantTransformer很像流水线最开头那个“先给一个起点”的步骤它不处理上游输入只负责固定给出一个值。这里的c是一个固定值。无论输入是x1、xhello还是xnull结果都不会变。newConstantTransformer(baidu.com).transform(null);// → baidu.com2.2 把一次调用包装成一步InvokerTransformer干的事情可以直接理解成先把一次方法调用需要的信息记下来等真正拿到输入对象时再在这个对象上把这次调用执行掉。比如它会先记住调哪个方法参数类型是什么参数值是什么等输入对象来了再按这套规则去调。这样一来“调一个方法”就不再只是临时写在代码里的一行语句而是被包装成了流水线里可以单独拿出来的一步。也正因为每一步都能这样单独包装后面的ChainedTransformer才能继续把这些步骤串起来。2.3 多个步骤可以串起来F(x) f₃(f₂(f₁(x)))有了前面这些步骤下一步就是把它们接起来。F(x) f₃(f₂(f₁(x)))意思是先执行f₁它的输出交给f₂f₂的输出再交给f₃这样一层层接下去就形成了一条流水线。如果借 Linux 管道来打比方整条命令echo baidu.com | subfinder | ksubdomain | httprobe | httpx从处理结构上看可以近似看成函数复合f(baidu.com) httpx(httprobe(ksubdomain(subfinder(baidu.com))))CC 里的ChainedTransformer干的就是把这些步骤接起来。2.4 CC 用 40 个类实现了这套体系上面这三类基础积木CC 的 functors 包里 40 个类都是它们的变体和组合。第二章先讲思路下面直接看它们在 Java 里是怎么拼起来的。三、把管道符翻译成 Java第二章讲的是思路这一章就不再重复解释“流水线”本身了直接把前面的类比翻译成 Java 里的 F1、F2、F3。echobaidu.com|./SubFinder/subfinder-silent|./KsubDomain/ksubdomain-silent|./HTTProbe/httprobe|./HTTPX/httpx -title/-ip如果只看处理结构可以先把每条命令近似看成一步处理echo baidu.com → 类比成一步 F1 常量函数提供起始值 subfinder → 类比成一步 F2 抽出来的一步 ksubdomain → 类比成一步 F2 抽出来的一步 httprobe → 类比成一步 F2 抽出来的一步 httpx → 类比成一步 F2 抽出来的一步 整条管道 → F3([F1,F2,F2,F2,F2]) 函数组合三个积木的定义// F1f(x) c — 不管输入是什么永远返回固定值staticTransformerF1(Objectconstant){returnnewConstantTransformer(constant);}// F2近似看成 f(x) x.m(a) — 把一次方法调用包装成流水线里的一步staticTransformerF2(Stringmethod,Class[]types,Object[]args){returnnewInvokerTransformer(method,types,args);}// F3f(x) f₃(f₂(f₁(x))) — 把多个函数串成管道staticTransformerF3(Transformer[]transformers){returnnewChainedTransformer(transformers);}如果不用这套积木2004 年的 Java 代码通常会写成这样// 2004 年没有泛型没有方法链每步一个中间变量Stringdomainbaidu.com;ListsubsSubFinder.getSubdomains(domain);ListaliveKsubDomain.filterAlive(subs);ListurlsHttpProbe.addHttps(alive);ListresultsHttpX.extractTitles(urls);在 2004 年的 Java 里这套写法还没有现成、统一的表达。Commons Collections 把它显式包装成了三块积木。下面开始看参数一换它为什么会从正常处理流程变成利用链。四、设计者没预料到的用法F1、F2、F3 是通用积木。设计者没有限制你能传什么参数——这种抽象积木本来就不关心你拿它去处理什么。但攻击者发现同一套积木把参数换掉就能从正常处理流程变成危险调用链。设计者往管道里装的 攻击者往管道里装的 ───────────────────── ───────────────────── F1(baidu.com) F1(Runtime.class) F2(subfinder) F2(getMethod, [String,Class[]], [getRuntime,null]) F2(ksubdomain) F2(invoke, [Object,Object[]], [null,null]) F2(httprobe) F2(exec, [String], [calc]) F2(httpx) F3 这层没变变的是前面每一步装进去的参数// 还是那F1、F2、F3 三块积木staticTransformerF4(){returnF3(newTransformer[]{F1(Runtime.class),F2(getMethod,newClass[]{String.class,Class[].class},newObject[]{getRuntime,null}),F2(invoke,newClass[]{Object.class,Object[].class},newObject[]{null,null}),F2(exec,newClass[]{String.class},newObject[]{calc})});}F4().transform(null);// calc 弹出API 没变设计意图没变。变的是传进去的参数。设计者造了积木攻击者用这些积木搭了一把枪。第四章这把枪还只是手动扣响的。要进入反序列化利用还差一个能自动把整条链带起来的入口。五、TransformedMap写时触发反序列化攻击不会靠攻击者亲手去调transform()而是要借助反序列化过程中某个自动执行的入口。这一节讨论TransformedMap这条写时触发链它怎样把setValue()接到transform()又怎样被AnnotationInvocationHandler.readObject()带起来。5.1TransformedMap想解决什么问题先看它原本的设计目标。TransformedMap.decorate(map, null, valueTransformer)是在原始map外面包一层让它变成一个“写 value 前先经过valueTransformer处理”的Map。它想解决的问题很朴素我已经有一个普通Map但我希望以后无论谁往里面写值都自动先做一遍转换。比如字符串先trim()统一转小写做类型适配做格式清洗ConstantTransformer很像流水线最开头那个“先给一个起点”的步骤而TransformedMap想做的则是把“写值之前先处理一下”这件事稳定接进Map的写入流程里。TransformedMap的关键不是“它能调用transform()”而是“它试图保证所有写入都先经过transform()”。5.2 难点写入口不止一个如果只从直觉出发很多人会以为“往 Map 里写值”只有一种方式map.put(key,value);但实际上还有另一条路map.entrySet().iterator().next().setValue(value);调用方完全可以先拿到一个Map.Entry再直接改它的值。这就引出了TransformedMap设计里最关键的一点如果它只拦截put()却放过了Map.Entry.setValue()那它就没法保证“所有写入都经过转换”。所以对一个“写入自动转换”的Map来说setValue()不是边角细节而是很难绕开的写入口。5.3 它是怎么把setValue()接进来的Commons Collections 的做法是entrySet()返回的不是底层原始Entry而是包装过的Entry。核心逻辑可以浓缩成这样publicObjectsetValue(Objectvalue){valueparent.checkSetValue(value);returnentry.setValue(value);}意思很简单外界调用setValue()不直接写回底层 Map先经过checkSetValue()再写入所以在这里setValue()已经不再只是普通写值而是被接进了“写前先处理”的流程里。5.4 为什么它最后会通到transform()因为在TransformedMap里checkSetValue()的实现就是protectedObjectcheckSetValue(Objectvalue){returnvalueTransformer.transform(value);}于是链路自然接通entry.setValue(x) ↓ checkSetValue(x) ↓ valueTransformer.transform(x)这样一来前面那句“所有写入都先经过transform()”就落到了代码上。也正因为这样只要后面有人替攻击者调用entry.setValue()这条链就会被触发。5.5 谁点燃了这根引线前面TransformedMap这一侧已经讲清楚了只要有人调用entry.setValue(x)执行流就会一路进入checkSetValue(x)再进入valueTransformer.transform(x)。TransformedMap已经把引线埋好了。这里的关键问题是反序列化过程中到底是谁替攻击者调用了这一下entry.setValue()在 CC1 里答案是AnnotationInvocationHandler.readObject()。5.6AnnotationInvocationHandler在这里扮演什么角色AnnotationInvocationHandler在这里做的事情很具体它内部维护着一张表记录“注解成员名 → 成员值”的对应关系。拿代码审计里经常见到的 Spring 控制器写法来举例RestControllerRequestMapping(value/user,methodRequestMethod.GET)publicclassUserController{GetMapping(/list)publicStringlist(){returnok;}}如果只盯住类上的这个RequestMapping可以把它粗略理解成一张这样的表value - /user method - GET这里value、method是注解成员名/user、GET是这些成员当前对应的值对 JDK 来说注解在运行时最终会落成一组“名字 - 值”的映射关系而AnnotationInvocationHandler管的正是这张表。readObject()的任务就是在反序列化完成后重新检查这张表里的内容是否还对得上当前注解定义成员名要存在成员值类型也要匹配。因为defaultReadObject()只负责把字段从字节流里恢复回来并不保证恢复之后仍然构成一个合法的注解对象。所以readObject()在把type和memberValues读回来之后还要再做一次“体检”。如果发现某一项不匹配它不会重建整个 Map也不会立刻抛异常而是会在遍历当前条目时直接调用entry.setValue(...)把这一项原地换成AnnotationTypeMismatchExceptionProxy等以后有人访问这个成员时再按规范抛异常。也正因为readObject()会在这里调用entry.setValue(...)前面埋在TransformedMap里的那根引线才终于有了被点燃的机会。5.7TransformedMap为什么会接上 CC1如果这张表只是普通Map这就是一次普通的修值动作。但在 CC1 里攻击者把这张表换成了TransformedMap。这里还有一个很容易忽略的点CC1 并不是把一份“完全正常”的注解成员表塞进AnnotationInvocationHandler再等它自然触发。相反攻击者需要故意构造一份“成员名正确但成员值类型错误”的memberValues。以Target.class为例value()成员期望的是ElementType[]而 payload 却故意塞入一个String。这样一来readObject()在类型检查时就会发现不匹配从而进入memberValue.setValue(...)。而这次本来只是为了“修正错误值”的setValue()正好被TransformedMap接进了 transformer 链。于是这次setValue()就不再只是改值而会继续进入readObject() - entry.setValue(...) - checkSetValue(...) - transform(...) - ChainedTransformer链路也就在这里接通了。CC1 利用的不是AnnotationInvocationHandler自己去执行什么危险操作而是它在反序列化时那次“修值”动作恰好撞上了攻击者提前埋好的TransformedMap。5.8 小结五个零件一把枪ConstantTransformer 子弹Runtime.class InvokerTransformer 火药三节getMethod → invoke → exec ChainedTransformer 枪管串联全部火药 TransformedMap 扳机setValue → checkSetValue → transform AnnotationInvocationHandler.readObject() 扣动扳机 Runtime.class ┌──────┐ │ 子弹 │ └──┬───┘ │ getMethod → invoke → exec ┌──────────┼───────────────────────────────┐ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← 枪管 (ChainedTransformer) │ 一节火药 二节火药 三节火药 │ └──────────────────────────────┬───────────┘ │ ┌────┴────┐ │ 扳机 │ ← setValue() └────┬────┘ (TransformedMap) │ ┌────┴────┐ │ 扣动扳机 │ ← readObject() └─────────┘ (AnnotationInvocationHandler)每个零件单独拆开看都是很正常的设计。组合在一起就是一把上了膛的枪。一句话总结这一章setValue()会成为引线不是因为它天生危险而是因为一边要保证“所有写入都经过转换”另一边要在反序列化时“原地修正当前条目”攻击链正是借用了这两个正常设计之间的接缝。六、LazyMap读时触发TransformedMap版的关键是写值时触发LazyMap版则是另一条思路把触发点放到get()上。6.1 get() 是怎么把链子带起来的这一节讨论LazyMap这条读时触发链。6.2LazyMap想解决什么问题普通Map.get()的语义很简单有这个 key就返回对应的 value没有就返回null。而LazyMap把get()改成了另一种语义有这个 key就正常返回没有就先现场生成一个 value放回Map再把它返回。如果借缓存来打比方这个设计会更直观。比如一张Map用来缓存网页内容key 是 URLvalue 是页面 HTML。普通Map在缓存未命中时只会返回null而LazyMap可以把这一步改成先抓一次这个 URL 的页面把抓到的 HTML 放回Map再把结果返回给调用方如果用代码把这个想法写出来大概像这样MapString,StringcachenewHashMap();StringgetHtml(Stringurl){Stringhtmlcache.get(url);if(htmlnull){htmlHttpUtil.fetch(url);// 第一次访问时现场抓取cache.put(url,html);// 放回缓存}returnhtml;}它把get(url)从“单纯取值”变成了取值 必要时现场生成 回填缓存这就是LazyMap里“懒”的意思它不会一开始就把所有 value 都准备好而是等某个 key 第一次被访问时再去补出这个 value。所以这里的 “lazy”更接近“延迟计算”或“按需生成”。6.3 这个设计为什么会被 CC1 利用CC1 利用的关键就在“现场生成”这一步交给谁来做。在LazyMap里如果某个 key 不存在就会调用事先传进去的Transformer用这个 key 生成一个 value。正常场景下这本来只是一次很普通的自动补值逻辑但在 CC1 的LazyMap版里攻击者把这里的Transformer换成了ChainedTransformer。于是语义就变了原本是key 不存在 → 补一个正常值现在变成key 不存在 → 触发一条 transformer 链所以这里真正相关的动作是map.get(key)只要调用最终落到LazyMap.get(...)并且这个 key 原本不存在后面的transform()就会被带起来。那 CC1 的LazyMap版是谁替攻击者调用了这一下get()答案不是readObject()直接去调了LazyMap.get()而是中间多了一层动态代理。构造过程可以概括成这几步MaplazyMapLazyMap.decorate(innerMap,chainedTransformer);InvocationHandlerinnerHandlernewAnnotationInvocationHandler(Override.class,lazyMap);MapproxyMap(Map)Proxy.newProxyInstance(Map.class.getClassLoader(),newClass[]{Map.class},innerHandler);ObjectouterHandlernewAnnotationInvocationHandler(Override.class,proxyMap);这条链里有两个AnnotationInvocationHandler内层AnnotationInvocationHandler包着LazyMap负责接住代理对象的方法调用外层AnnotationInvocationHandler包着proxyMap负责在反序列化时进入readObject()这里的proxyMap不是普通Map而是 JDK 动态代理生成出来的对象。new Class[]{Map.class}指定这个代理对象要表现得像一个MapinnerHandler指定以后谁调用这个Map的方法都先交给innerHandler.invoke(...)。动态代理把这次proxyMap.entrySet()调用转发到了内层AnnotationInvocationHandler.invoke()。链路可以压成这样outer AnnotationInvocationHandler.readObject() - outer.memberValues.entrySet() // 这里的 memberValues proxyMap - proxyMap.entrySet() - inner AnnotationInvocationHandler.invoke() - inner.memberValues.get(entrySet) // 这里的 memberValues LazyMap - LazyMap.get(entrySet) - ChainedTransformer.transform(entrySet)进入invoke()之后它会取出这次调用的方法名也就是entrySet再拿这个名字去内部那张表里取值memberValues.get(entrySet)而这张表正是攻击者提前布置好的LazyMap。因为LazyMap里原本并没有entrySet这个 key所以get(entrySet)会走进懒加载分支触发ChainedTransformer.transform(...)。七、为什么 CC 组件的使用这么广泛一个自然会冒出来的问题是Java 8 已经在java.util.function包里提供了完整的函数式编程方案InvokerTransformer这些类几乎没人用了。可为什么 CC1 链依然还会在这么多服务器上出现利用条件答案不在 CC 的 functor 包在 CC 的其他 240 个类。7.1 CC 有 273 个类functor 只有 40 多个commons-collections-3.2.1.jar (273 个类) │ ├── 被大量使用的 (220 个类) │ ├── CollectionUtils — isEmpty、isNotEmpty到处都在用 │ ├── MapUtils — 同上 │ ├── LRUMap — LRU 缓存Hibernate、Servlet 容器在用 │ ├── MultiMap — 一个 key 对应多个 value │ ├── Bag — 计数集合Multiset │ ├── BidiMap — 双向 Map │ ├── BeanMap — JavaBean 反射成 Map │ └── 各种装饰器 — Unmodifiable、Synchronized、FixedSize... │ └── 几乎没人用的 (40 个 functor 类) ├── InvokerTransformer ← CC1 的核心 ├── ChainedTransformer ← CC1 的核心 ├── ConstantTransformer ← CC1 的核心 └── ...7.2 依赖链你的项目不需要 CC但你的框架需要你的项目 └── spring-webmvc └── ... └── commons-collections-3.2.1.jar ← 躺在这里两个条件同时成立CC1 链就能打穿① classpath 上有 CC 的 jar② 应用暴露了反序列化入口。那框架到底在用 CC 的什么以下是 CC 3.x 中被广泛使用的类类用途CollectionUtils集合判空、过滤、转换等静态工具方法MapUtilsMap 判空、安全取值ListUtilsList 差集、交集、分区LRUMapLRU 缓存Hibernate、Servlet 容器大量使用MultiHashMap一个 key 对应多个 valueBag/HashBag计数集合MultisetBidiMap双向 Mapkey↔value 互查BeanMap反射 JavaBean 为 MapFastHashMap装饰器模式的快速 Map这些才是 CC 被广泛依赖的原因。40 个 functor 类无人问津但跟着一起被塞进了无数 classpath——门票是这些工具类买的。7.3 CC 4.x 的修复去掉 SerializableApache 后来意识到问题在 CC 4.x 中把InvokerTransformer的Serializable接口去掉了CC 3.2.1publicclassInvokerTransformerimplementsTransformer,Serializable{privatestaticfinallongserialVersionUID-8653385846894047688L;privatefinalStringiMethodName;privatefinalClass[]iParamTypes;privatefinalObject[]iArgs;publicObjecttransform(Objectinput){Classclsinput.getClass();Methodmethodcls.getMethod(iMethodName,iParamTypes);returnmethod.invoke(input,iArgs);}}CC 4.4publicclassInvokerTransformerI,OimplementsTransformerI,O{privatefinalStringiMethodName;privatefinalClass?[]iParamTypes;privatefinalObject[]iArgs;publicOtransform(Objectinput){// 内部逻辑完全一样}}transform()的逻辑完全没变。修复没动功能只动了接触面把Serializable去了。不能被序列化就无法进入反序列化攻击链。另外 CC 4.x 改了包名和 groupId迁移成本太高大多数项目至今仍停留在 CC 3.x。八、附录环境信息组件版本JDK1.8.0_60Commons Collections3.2.1关键依赖位置org.apache.commons.collections.functors