文章总结: 本文详细记录了针对FineReport11.0中J2V8引擎漏洞的完整利用过程。作者通过未授权导出接口注入JavaScript代码,利用CVE-2023-3420漏洞实现越界读写原语,最终通过WASM实例完成RCE链构造。关键发现包括V8版本滞后三年、触发器对JIT编译条件极度敏感、崩溃日志对堆布局分析的价值。建议企业及时更新第三方组件并监控未授权脚本执行接口。 综合评分: 85 文章分类: 漏洞分析,渗透测试,WEB安全,实战经验,安全工具
崩溃后的重生-用概率博胜率
原创
黑屋牛马 黑屋牛马
漕河泾小黑屋
2026年5月18日 23:05 上海
在小说阅读器读本章
去阅读
一次 FineReport J2V8 引擎漏洞利用的完整记录
连续搞崩一台服务器无数次,重启了无数轮,就为弹一个计算器。但确实是这段时间干的事。
怎么发现的这个点
事情的起因很普通。拿到一套 FineReport 11.0 的环境,常规思路先看已知漏洞——反序列化、未授权接口、文件上传那些老路子。扫了一圈,该修的都修了,没什么直接能打的。
转折点出现在翻 WEB-INF/lib 的时候。在 fine-third-11.0.jar 里发现了一个 15MB 的 DLL:libj2v8-windows-x86_64.dll。J2V8——Google V8 引擎的 Java 封装。再看同包里的类,com.eclipsesource.v8,完整的 V8 运行时。
这东西在帆软里干嘛?往上追调用链,找到 ScriptFormulaForJ2V8——帆软的公式引擎,部分公式通过 V8 来执行 JavaScript。
接下来就是确认版本。没有直接的版本接口,但通过 WebAssembly 支持情况、BigInt 特性、SharedArrayBuffer 等 API 的存在性做了指纹比对,再结合 DLL 的编译时间戳,锁定 V8 版本 11.4.183.11,Chromium 114 时期的产物。
然后去翻这个版本区间的 CVE 列表——CVE-2023-3079、CVE-2023-3420,都在射程范围内。
关键一步是找注入点。帆软的报表导出接口有个 exportJSXMLConf 参数,接受 XML 格式的导出配置。里面的 ScriptFormula 类型会把 <Attributes> 标签内的内容直接丢给 V8 执行,返回值作为导出文件名。
一个未鉴权的导出接口 + 一个三年没更新的 V8 引擎。入口有了。
第一阶段:OOB 原语
CVE-2023-3420 的原理不复杂——TurboFan 在特定的原型链操作序列下,会错误地消除 StackCheck,导致类型混淆。精心构造 JIT 预热流程后,可以把一个数组的长度字段篡改成一个大数,拿到堆上几百字节的越界读写窗口。
实际操作下来,这个触发器的脾气比想象中大得多。它依赖后台编译线程的竞争条件,函数体大小、变量声明方式、循环迭代次数这些东西稍微一动,JIT 的编译决策就变了,窗口直接消失。
最后稳定下来的触发器大概 1200 字节。在刚重启的服务器上,首次请求基本 100% 触发。但只要往函数体里多塞任何东西——哪怕一个长一点的字符串字面量——就再也触发不了。
这个特性直接决定了后面所有的痛苦。
第二阶段:在黑暗中定位
有了 OOB 读,下一步是搞清楚堆上的布局。但 V8 用了指针压缩,所有堆地址都是 cage_base + 32位偏移。不知道 cage_base,读出来的数据就是一堆看不懂的碎片。
最开始想的是频率统计——扫描 OOB 范围里所有 64 位值的高 32 位,出现最多的就是 cage。听起来有道理,实际打下去发现频率最高的往往是 malloc 分配的外部指针,不是 V8 堆地址。
这个问题卡了很久。转机来自崩溃日志。
每次服务器崩溃,JVM 都会写一份 hs_err_pid 文件,里面有完整的寄存器状态和栈内存转储。我把几次崩溃的栈数据拉出来对比,发现了一个规律:正确的 cage 对应的指针条目,它们的压缩部分(低32位)高16位总是和 addrof 拿到的已知对象前缀一致。而那些干扰项——外部 backing_store 之类的——前缀完全不同。
简单说就是:先通过 addrof 拿到一个已知对象的压缩指针前缀,再到 OOB 数据里找高16位匹配的条目,它对应的高32位就是 cage。
这个方法在后续的每一次崩溃日志里都得到了验证。
第三阶段:在崩溃中前进
利用链的后半段是教科书式的:OOB 改 ArrayBuffer 的 backing_store 实现任意地址读写,创建 WASM 实例拿到 RWX 页面,往里面写 shellcode,调用 WASM 函数触发执行。
本地环境下,借助 Java 的 Unsafe 绕过 EPT 沙箱,整条链跑通了,calc 弹了。
但远程是另一回事。
核心矛盾前面说了:触发器不允许函数体里有任何多余代码。而 RCE payload 本身就是一大坨代码。
试过的方案:
双函数分离——触发器和 payload 各自在独立的 new Function() 里。结果 V8 的堆分配器把 payload 的字符串元数据分配到了触发器数组的 OOB 范围内。触发器一跑,越界访问直接把 payload 字符串的内部结构给写花了。V8 后续访问这个字符串时,跟着一个被篡改的指针走进了未映射内存,当场崩溃。
这种崩溃在日志里的表现非常稳定:永远是 +0x47fa83 偏移处的 movzx r12d, byte [r8+rcx],一条字符串字节读取指令。R8 寄存器里是一个被 OOB 篡改过的地址。
全局变量隔离——把 payload 编码后存进全局属性,让它在堆上的分配时机更早、位置更远。这招管用了,30 次尝试零崩溃。但代价是触发器的成功率从接近 100% 掉到了不到 5%。原因推测是全局属性的设置改变了 ScriptFormula 外层函数的编译上下文,间接影响了内层触发器的 JIT 时序。
强制 GC 隔离——在 payload 和触发器之间插入大量无用分配,迫使 V8 执行 Scavenge,把 payload 提升到 old space。崩溃问题彻底解决了,但 GC 活动同时搅乱了后台编译线程的调度,触发器直接哑火。
每修一个问题就冒出来一个新的。
有一次崩溃地址跳到了 +0x6ebd4,访问地址是 0xFFFFFFFFFFFFFFFF。这说明整条链已经走到了最后——读 WASM 实例的 RWX 页面地址——但 GC 在 addrof 和实际读取之间移动了 WASM 实例,读到的是垃圾值,拿去当地址访问就炸了。
差一步。但就是差这一步。
现实
最终没有在远程稳定地弹出 calc。但是在尝试了无数次失败之后,终于成了一次。
说”稳定”是因为从概率上讲,它是可以打通的。全局变量方案下零崩溃、5% 的触发率,配合自动化重试脚本,理论上二十次重启内能命中一次完整的利用链。但在我不断的测试中,终于成了那么一两次。
不过从漏洞评估的角度,结论已经很清楚了:
- 未授权 JS 执行——通过导出接口的 ScriptFormula 注入,可以在服务端 V8 引擎中执行任意 JavaScript,无需认证。
- V8 引擎存在已知高危漏洞——CVE-2023-3420 可稳定触发 OOB,具备完整的读写原语。
- 利用链在本地环境完整验证通过——从类型混淆到 WASM JIT 代码篡改到任意命令执行。
- 远程利用受限于竞争条件的敏感性——但这是工程问题,不是理论障碍。
一个 2023 年的 V8 跑在 2026 年的生产环境里。三年的 CVE 积累,配合一个没人注意的公式引擎接口。这种组合在甲方的资产清单里可能根本不会被标记为风险项。
几点收获
做完这个case回头看,有几件事印象比较深:
崩溃日志是金矿。 hs_err_pid 里的寄存器快照和栈转储,在没有调试器的情况下几乎是唯一的堆布局信息来源。好几个关键参数——cage_base 的验证、backing_store 偏移的确认、RWX 页面的定位——都是从崩溃日志里反推出来的。
V8 JIT 的蝴蝶效应。 做浏览器漏洞的人可能对此习以为常,但在服务端 J2V8 这种封装环境下,触发器的敏感程度还是超出预期。不是”改个参数就行”的那种敏感,是”多声明一个变量整个编译流程就走另一条路”的那种。
从 PoC 到武器化的距离比想象中远。 CVE 编号加一个 PoC 脚本,和一个能在真实环境里打通的利用链,中间隔着堆风水、GC 时序、内存布局随机化这些工程层面的东西。每一个都可能是成功和崩溃之间的分界线。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:漕河泾小黑屋 黑屋牛马 黑屋牛马《崩溃后的重生-用概率博胜率》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论