
Reproducible lab for CVE-2026-10053 (GitLab npm package-registry path traversal -> arbitrary file write as git). Vulnerable 19.2.1 vs patched 19.2.2, deterministic oracle.
Self-contained lab that proves the GitLab npm package-registry path traversal (CVE-2026-10053) and shows it fixed, using a deterministic on-disk oracle — not HTTP status.
What is and isn't proven. This lab proves authenticated arbitrary file write as the
gitOS user on vulnerable GitLab, and that the patched release blocks it. It is not a standalone remote-code-execution PoC — see Scope. Don't cite it as RCE.
| Vulnerability | CWE-22 path traversal in the npm package registry (TOCTOU: check ran before :cache, not before :store) |
| CVSS | 8.5 High — AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H |
| Affected | GitLab CE/EE 18.8 → <19.0.6, 19.1 → <19.1.4, 19.2 → <19.2.2 |
| Fixed | 19.0.6 / 19.1.4 / 19.2.2 — commit 435cf863 "Re-validate upload path traversal before store" |
python3, git, curl on the host../run.sh # up + provision + exploit + verify BOTH; exits 0 iff vuln writes AND patched blocks
Expected result:
================ CVE-2026-10053 verification ================
INSTANCE EXPECT RESULT STATUS
gitlab-vuln (19.2.1) written written PASS
gitlab-patched (19.2.2) blocked blocked PASS
------------------------------------------------------------
PROVEN: authenticated arbitrary file write as git on 19.2.1.
NOT a standalone RCE — see README.md 'Scope' and ./run.sh rce-gate.
============================================================
Other subcommands:
./run.sh up # just start + wait until healthy
./run.sh rce-gate # honest exec-bit-gate demo (below)
./run.sh down # docker compose down -v
Both the vulnerable and patched servers answer the malicious publish with
HTTP 200 {"status":"processing"} — the file is written later, in the finalize worker. So HTTP
status is not an oracle. exploit/poc.py --verify-docker <container> instead polls inside the
container for the controlled file at the traversed path and compares its SHA-1:
/var/tmp/CVE_2026_10053_PROOF-1.0.0.tgz appears (owner git:git, our bytes) → exit 0.Gitlab::PathTraversal::PathTraversalAttackError: Invalid path → exit 2.The exploit itself is nothing but the authenticated PUT …/packages/npm/:pkg request; the traversal
lives entirely in the JSON body name (file_name = "#{name}-#{version}.tgz", only blank-checked).
The docker exec calls are verification and setup, never part of the attacker's capability.
git-writable path. Files land 0644.-<semver>.tgz (NUL is rejected by
the kernel, newline doesn't truncate), so no exact-name target (secrets.yml, authorized_keys,
a .rb, gitaly binaries) can be overwritten. The only filename-agnostic executor,
custom_hooks/<hook>.d/, runs any filename but only if executable — and this write is 0644.
Overwriting a pre-existing 0755 file does not help: the store replaces the inode, resetting it
to 0644. So RCE needs a separate executable-bit / execution primitive this PoC does not supply../run.sh rce-gate demonstrates exactly this honestly: it plants a pre-receive.d hook via the
traversal, pushes, and shows the 0644 hook does not execute. ./rce_gate_demo.sh --illustrate-gate
additionally sets +x via docker exec chmod (an out-of-band docker-root action, not attacker
capability) purely to show gitaly would run it — underscoring that the missing lever is the exec bit.
See ANALYSIS.md for the full analysis. In short: app/uploaders/gitlab_uploader.rb
validated the storage path only before :cache; at before :store the model-derived file_name
(attacker-controlled, unvalidated) was written verbatim. The fix adds before :store, :protect_from_path_traversal!.
docker-compose.yml vuln (19.2.1) + patched (19.2.2) GitLab CE
run.sh one-command harness with deterministic file oracle + PASS/FAIL
provision.rb lab setup: mint root PAT + create project (NOT part of the exploit)
exploit/poc.py the PoC sender + --verify-docker oracle
rce_gate_demo.sh honest exec-bit-gate demonstration