一条被低估的链
2026 年 7 月 24 日,Depthfirst 公开了一条 GitLab 远程代码执行链。两个在 Oj(GitLab 依赖的高性能 JSON 解析器)里潜伏了近五年的内存损坏缺陷,被串起来打穿了整个 Rails 进程。
这条链最值得聊的点不是技术难度本身,而是它击穿了"Ruby 是内存安全语言"这个直觉。Oj 的核心是 C 扩展,里面有指针、定长缓冲区、手动生命周期管理。GitLab 通过 Oj::Parser.usual.parse 把用户提交的 notebook 文件喂进这个 C 解析器——然后攻击者只需要一个能推送代码的普通账户,就能触发内存损坏。
受影响版本:GitLab CE/EE 15.2.0–18.10.7、18.11.0–18.11.4、19.0.0–19.0.1。修复版本 18.10.8 / 18.11.5 / 19.0.2。GitLab 把这次 Oj 升级归类为"Bug 修复"而非安全补丁——这意味着运维人员没有理由把它当紧急事件处理,这是该漏洞长期暴露的关键运营因素。
Oj 里的两个 bug
漏洞一是个越界写入。Oj 解析器用定长 1024 字节数组跟踪 JSON 嵌套深度:
typedef struct _ojParser {int depth;unsigned char stack[1024]; // 定长嵌套栈void (*start)(struct _ojParser *p); // 回调指针// ...
} *ojParser;
打开数组时的处理没有深度检查:
case OPEN_ARRAY:p->depth++;p->stack[p->depth] = ARRAY_FUN; // 超过 1024 层直接溢出
攻击者构造超过 1024 层嵌套数组,stack 之后的字段被持续写入 0x01。配合 Oj 的"自延续"特性——每次写入的 0x01 在下一层被当作 Array 回调选择器读回——这个扫掠可以越过 padding 一路推进到 buf.head 指针,把它的低字节从 0x80 改为 0x01,向后回退 127 字节。
漏洞二是堆指针泄露。一个超长对象键(65565 字节)的长度从 size_t 被截断为有符号 16 位整数,回绕为 29。Oj 为完整键分配了堆内存,但截断后的长度让返回路径从内联缓冲区读 29 字节,其中恰好包含活跃的堆指针。GitLab 渲染 diff 时把它作为输出的一部分返回给攻击者。
堆风水 + ASLR 探测 = RCE
两个单独的 bug 看起来都不严重——一个只能写 0x01,另一个只泄露少量内存。链化才是关键。整个利用分三步:
ASLR 绕过。 攻击者拿到堆指针后,不直接读内存,而是构造一个基于"执行反馈"的侧信道:把自循环指令地址写入 p->start 回调指针,观察解析器是挂起还是存活。挂起说明地址落在可执行映射内。在新实例上 5-10 分钟、成熟实例上 1-2 小时就能恢复 libc 基址。
堆风水。 写原语只能写 0x01,怎么把任意地址写进 p->start?利用链把被篡改的 buf.head 喂给 realloc,迫使其缓存一个伪造的内部指针。随后 Ruby Array 分配恰好回收同一块内存,Array 的元素内容覆盖了解析器的 p->start 指针。这是对 jemalloc 的 size class(3584 字节区域)和 slab 缓存语义的精确利用——没写 shellcode,靠分配器自己的行为完成了指针替换。
命令执行。 NX/DEP 禁止堆上直接执行,所以攻击者不投 shellcode。p->start 被覆盖后,跳转到 libruby 内的两个跳板 gadget,最终落入 system(),命令字符串作为参数投递。整个利用在单个 diffs_stream 请求内完成,两个 notebook 按字典序排列,分别负责泄漏和触发。
一句话总结这条链:普通认证用户推送含恶意 notebook 的提交 → 打开 diff → notebook 渲染器调用 Oj::Parser.usual.parse → 内存损坏改写回调指针 → 经 libruby 跳板进 system() → 以 git 用户执行命令。
缓解与修复
| 缓解机制 | 效果 | 绕过方式 |
|---|---|---|
| ASLR | 关键防线 | 堆指针泄露 + 侧信道探测恢复基址 |
| Stack Canary | 完全无效 | 不覆盖栈返回地址,攻击目标在堆上 |
| NX/DEP | 阻止直接 shellcode | 不投 shellcode,ROP 式跳转进 system() |
| PIE | 与 ASLR 协同 | 同 ASLR 绕过路径 |
Stack Canary 彻底失效这件事值得多想一层——它针对的是栈返回地址覆盖,而这个漏洞的写入目标在堆上的结构体内。对"结构体内部越界写 + 函数指针劫持"类缺陷,CFI(控制流完整性)才是对症的缓解。如果 libruby 启用 CFI,p->start(p) 的间接调用和跳板路径都可能被阻断。
上游修复(Oj 3.17.2/3.17.3,ec368db)做了四件事:在 open_array/open_object 的 depth++ 后加边界检查(超限直接 parse_error);冻结输入字符串防止解析期间缓冲区被外部改写;把多处 int len 收窄统一改为 size_t 消除大长度回绕;加缩进上限 16 字符。
怎么防
WAF 侧: 传统 WAF 很难拦截——恶意载荷藏在仓库文件内容里,走的是正常的 diff 流式接口。能做的是限制单文件大小,对 .ipynb 做 JSON 结构审计(嵌套深度 >200 或单键 >8192 字节就告警),对同一提交里包含多个字典序相邻 .ipynb 的情况提高风险评分。
HIDS 侧: git 用户派生 sh/bash/curl/wget 立即告警。监控 Rails secrets 和凭证文件的异常读取。网络层关注 Puma worker 向非预期内部服务的横向连接。
应急检查: oj -v 确认版本 ≥ 3.17.3。版本不满足且无法立即升级的,限制 diffs_stream 接口的访问频率,挂个监控盯着 Puma worker 异常重启和长时间 CPU 占用——那个 5 分钟到 2 小时的 ASLR 探测窗口,就是你的响应窗口。
我个人对这条链最大的感受是:别信任"内存安全语言"这个标签。Ruby 是安全的,但它的 gem 堆里塞满了不安全的 C 扩展。Oj 不是特例——你跑 gem list | wc -l 看看你项目里有多少 gem,再想想每个 gem 的 ext/ 目录里藏了多少指针运算。下次升级依赖的时候,至少看一眼 C 扩展变化的 changelog。
免责声明
本文仅供安全研究与防御建设参考。所有技术细节基于公开披露的研究资料与上游补丁还原。请勿将文中技术用于未经授权的测试或攻击;因不当使用造成的一切后果由使用者自行承担。受影响用户应尽快升级至 18.10.8 / 18.11.5 / 19.0.2。
参考资料: Depthfirst 公开报告 / Oj commit ec368db / GitLab 19.0.2 安全补丁 / CVE-2026-54903(Oj 整数溢出,同族内存安全缺陷)