June 17, 2026
Solving YWH Dojo #51 — DeadBolt: From Zip Slip to Remote Code Execution
Introduction
0xZeus
7 min read
Introduction
YWH Dojo #51, "DeadBolt", was one of those challenges where the solution was not hidden behind a single vulnerability. Instead, it required understanding how several seemingly harmless components interacted with each other.
At a high level, the application allowed users to upload plugins packaged as ZIP and base64 archives. Before using the functionality, users had to provide a valid license key. Uploaded plugins would then be extracted and loaded dynamically by the application.
The challenge ultimately boiled down to answering three questions:
- How can I obtain a valid license?
- Can I escape the intended ZIP extraction directory?
- If I can write files, is there a way to get them executed?
The answer to all three turned out to be yes.
Understanding the Target
The application was built using Node.js and exposed a plugin upload functionality.
process.chdir('/tmp/app')
const require_plugin = module.createRequire('file:///tmp/app/plugins/');
const indexTemplate = ejs.compile(
fs.readFileSync('/tmp/app/views/index.ejs', 'utf-8'),
{ filename: '/tmp/app/views/index.ejs' }
);
// Validate license key
function validateKey(key) {
// Validate key format
const raw = key.replace(/-/g, '').toUpperCase();
if (raw.length !== 16 || !/^[0-9A-F]{16}$/.test(raw)) return false;
// Extract each part of the key
const [A, B, C, D] = [0, 4, 8, 12].map(i => parseInt(raw.slice(i, i + 4), 16));
// Get the first character from A
const f = (A >> 12) & 0xF;
// Prepare seed and convert to 16 bits
const seed = (A ^ B ^ C) & 0xFFFF;
// Validate final key
return (f === 0xA || f === 0xC)
&& ( (B ^ (A & 0xFF)) & 0xFF ) === 0x37
&& C % 7 === 0
&& D === ((Math.imul(seed, 1103515245) + 1337) & 0xFFFF);
}
// Unzip a zip file to a specific destination
function unzip(filename, dest) {
const options = {
lazyEntries: true,
strictFileNames: true,
decodeStrings: false
};
return new Promise((resolve, reject) => {
yauzl.open(filename, options, (err, zipfile) => {
if (err) return reject(err);
zipfile.readEntry();
zipfile.on('entry', (entry) => {
const filenameStr = entry.fileName.toString();
const destpath = path.join(dest, filenameStr);
fs.mkdirSync(path.dirname(destpath), { recursive: true });
zipfile.openReadStream(entry, (err, readStream) => {
if (err) return reject(err);
const writeStream = fs.createWriteStream(destpath);
readStream.on("error", reject);
writeStream.on("error", reject);
writeStream.on("finish", () => {
zipfile.readEntry();
});
readStream.pipe(writeStream);
});
});
zipfile.on("end", resolve);
zipfile.on("error", reject);
});
});
}
// Get a plugin from a given system directory
function getPlugins(directory) {
const entries = fs.readdirSync(directory, { withFileTypes: true });
const plugins = entries
.filter(entry => entry.isFile() && entry.name.endsWith('.js'))
.map(entry => loadPlugin(path.join(directory, entry.name)));
return plugins;
}
// Load a given plugin from a given filename
function loadPlugin(pluginFilename) {
const resolved = path.resolve(pluginFilename);
if (!fs.existsSync(resolved)) {
throw new Error(`Plugin not found: ${resolved}`);
}
const plugin = require_plugin(resolved);
if (!plugin || typeof plugin.get !== "function") {
throw new Error("Invalid plugin: must export an object with a get() method");
}
return plugin;
}
async function main() {
const zipData = decodeURIComponent("").trim();
const pluginToRun = decodeURIComponent("").trim();
const key = decodeURIComponent("").trim();
const destZip = `/tmp/upload_${crypto.randomUUID()}.zip`;
const destPublic = '/tmp/app/plugins';
const destArchive = '/tmp/app/plugins/archive';
var plugins = []
var message = ""
try {
if ( validateKey(key) ) {
if ( zipData.length > 0 ) {
// Write zip file
fs.writeFileSync(destZip, Buffer.from(zipData, "base64"));
await unzip(destZip, destArchive);
// Clean up zip
fs.rmSync(destZip, { force: true });
}
// Load all plugins
plugins = getPlugins(destPublic)
if ( pluginToRun != "" ) {
plugins.map((p) => {
if ( pluginToRun == p.getName() ) {
p.run()
}
});
} else {
message = "invalid plugin given!"
}
} else {
message = "invalid license key!"
}
} catch (error) {
console.error('[-] Error:', error.message);
}
console.log(indexTemplate({message: message, plugins: plugins}))
}
main();process.chdir('/tmp/app')
const require_plugin = module.createRequire('file:///tmp/app/plugins/');
const indexTemplate = ejs.compile(
fs.readFileSync('/tmp/app/views/index.ejs', 'utf-8'),
{ filename: '/tmp/app/views/index.ejs' }
);
// Validate license key
function validateKey(key) {
// Validate key format
const raw = key.replace(/-/g, '').toUpperCase();
if (raw.length !== 16 || !/^[0-9A-F]{16}$/.test(raw)) return false;
// Extract each part of the key
const [A, B, C, D] = [0, 4, 8, 12].map(i => parseInt(raw.slice(i, i + 4), 16));
// Get the first character from A
const f = (A >> 12) & 0xF;
// Prepare seed and convert to 16 bits
const seed = (A ^ B ^ C) & 0xFFFF;
// Validate final key
return (f === 0xA || f === 0xC)
&& ( (B ^ (A & 0xFF)) & 0xFF ) === 0x37
&& C % 7 === 0
&& D === ((Math.imul(seed, 1103515245) + 1337) & 0xFFFF);
}
// Unzip a zip file to a specific destination
function unzip(filename, dest) {
const options = {
lazyEntries: true,
strictFileNames: true,
decodeStrings: false
};
return new Promise((resolve, reject) => {
yauzl.open(filename, options, (err, zipfile) => {
if (err) return reject(err);
zipfile.readEntry();
zipfile.on('entry', (entry) => {
const filenameStr = entry.fileName.toString();
const destpath = path.join(dest, filenameStr);
fs.mkdirSync(path.dirname(destpath), { recursive: true });
zipfile.openReadStream(entry, (err, readStream) => {
if (err) return reject(err);
const writeStream = fs.createWriteStream(destpath);
readStream.on("error", reject);
writeStream.on("error", reject);
writeStream.on("finish", () => {
zipfile.readEntry();
});
readStream.pipe(writeStream);
});
});
zipfile.on("end", resolve);
zipfile.on("error", reject);
});
});
}
// Get a plugin from a given system directory
function getPlugins(directory) {
const entries = fs.readdirSync(directory, { withFileTypes: true });
const plugins = entries
.filter(entry => entry.isFile() && entry.name.endsWith('.js'))
.map(entry => loadPlugin(path.join(directory, entry.name)));
return plugins;
}
// Load a given plugin from a given filename
function loadPlugin(pluginFilename) {
const resolved = path.resolve(pluginFilename);
if (!fs.existsSync(resolved)) {
throw new Error(`Plugin not found: ${resolved}`);
}
const plugin = require_plugin(resolved);
if (!plugin || typeof plugin.get !== "function") {
throw new Error("Invalid plugin: must export an object with a get() method");
}
return plugin;
}
async function main() {
const zipData = decodeURIComponent("").trim();
const pluginToRun = decodeURIComponent("").trim();
const key = decodeURIComponent("").trim();
const destZip = `/tmp/upload_${crypto.randomUUID()}.zip`;
const destPublic = '/tmp/app/plugins';
const destArchive = '/tmp/app/plugins/archive';
var plugins = []
var message = ""
try {
if ( validateKey(key) ) {
if ( zipData.length > 0 ) {
// Write zip file
fs.writeFileSync(destZip, Buffer.from(zipData, "base64"));
await unzip(destZip, destArchive);
// Clean up zip
fs.rmSync(destZip, { force: true });
}
// Load all plugins
plugins = getPlugins(destPublic)
if ( pluginToRun != "" ) {
plugins.map((p) => {
if ( pluginToRun == p.getName() ) {
p.run()
}
});
} else {
message = "invalid plugin given!"
}
} else {
message = "invalid license key!"
}
} catch (error) {
console.error('[-] Error:', error.message);
}
console.log(indexTemplate({message: message, plugins: plugins}))
}
main();From the source code and application behavior, the workflow looked roughly like this:
- User submits a license key.
- Application validates the key.
- User uploads a ZIP archive.
- ZIP contents are extracted into a plugin directory.
- Extracted plugins are loaded dynamically.
- Plugin information is displayed through the web interface.
At first glance, the ZIP extraction logic looked relatively secure. There were even protections intended to prevent path traversal attacks. However, security mechanisms are only as strong as their assumptions, and that became the central theme of this challenge.
Stage 1 — Defeating the License System
Before reaching any interesting functionality, a valid license key was required.
Rather than attempting to bypass the check directly, I began analyzing the validation routine itself.
The license verification process relied entirely on deterministic mathematical operations performed on several hexadecimal values. There was no cryptography involved and no server-side secret.
Because the algorithm was fully reversible, I was able to recreate the validation logic locally and generate a key that satisfied every condition enforced by the application.
This is the code i wrote :
function genKey() {
// A must start with A or C
const A = 0xABCD;
// low byte condition
const Blow = (A & 0xFF) ^ 0x37;
// arbitrary high byte
const B = (0x12 << 8) | Blow;
// divisible by 7
const C = 0x1337 - (0x1337 % 7);
const seed = (A ^ B ^ C) & 0xFFFF;
const D =
(Math.imul(seed, 1103515245) + 1337) & 0xFFFF;
const hex = n => n.toString(16).toUpperCase().padStart(4, '0');
return `${hex(A)}-${hex(B)}-${hex(C)}-${hex(D)}`;
}
console.log(genKey());function genKey() {
// A must start with A or C
const A = 0xABCD;
// low byte condition
const Blow = (A & 0xFF) ^ 0x37;
// arbitrary high byte
const B = (0x12 << 8) | Blow;
// divisible by 7
const C = 0x1337 - (0x1337 % 7);
const seed = (A ^ B ^ C) & 0xFFFF;
const D =
(Math.imul(seed, 1103515245) + 1337) & 0xFFFF;
const hex = n => n.toString(16).toUpperCase().padStart(4, '0');
return `${hex(A)}-${hex(B)}-${hex(C)}-${hex(D)}`;
}
console.log(genKey());This immediately granted access to the plugin upload functionality.
At this stage, I did not yet have code execution, but I had successfully crossed the first barrier.
Stage 2 — Looking at ZIP Extraction
Once plugin uploads were available, the next step was investigating how ZIP files were processed.
The extraction code constructed output paths by joining the extraction directory with the filename stored inside the archive.
Conceptually, the logic looked similar to:
const destpath = path.join(destination, filename);
fs.createWriteStream(destpath);const destpath = path.join(destination, filename);
fs.createWriteStream(destpath);This pattern is dangerous because it often leads to path traversal vulnerabilities if archive entries contain sequences such as:
../../../etc/passwd../../../etc/passwdHowever, simple traversal attempts did not work.
The ZIP library was configured with protections that rejected suspicious filenames.
At this point, it appeared that the application was protected against traditional Zip Slip attacks.
But the keyword here is traditional.
Stage 3 — Discovering the Symlink Bypass
After spending more time studying ZIP internals, I realized the application treated symbolic links differently from regular files.
This distinction became critical.
The extraction process validated filenames but did not adequately account for symbolic links.
A symbolic link can point to another location on the filesystem while still having a perfectly valid filename.
For example:
link -> ../../pluginslink -> ../../pluginsThe filename itself is harmless.
The problem occurs later when files are written through that link.
Imagine the following archive structure:
link/
└── shell.jslink/
└── shell.jsIf link is actually a symbolic link pointing outside the extraction directory, writing shell.js through that path results in the file being created somewhere completely different.
From the application's perspective:
archive/link/shell.jsarchive/link/shell.jsFrom the operating system's perspective:
plugins/shell.jsplugins/shell.jsThis effectively bypassed the extraction boundary and gave me an arbitrary file write primitive.
This was the first major vulnerability in the challenge.
Stage 4 — Finding a Valuable Write Location
Arbitrary file write is powerful, but by itself it does not guarantee code execution.
The next task was identifying a location where a written file would later be interpreted or executed by the application.
The answer was hiding in the plugin system.
The application dynamically loaded JavaScript files from the plugins directory.
This immediately transformed the plugins directory into a high-value target.
If I could place a JavaScript file there, there was a good chance the application would execute it.
The remaining question was how plugin loading was implemented.
Stage 5 — Unsafe Dynamic Module Loading
The plugin loader used Node.js module loading functionality to load uploaded plugins.
The relevant workflow looked approximately like this:
const plugin = require_plugin(path);const plugin = require_plugin(path);After loading the module, the application performed validation:
if (!plugin || typeof plugin.get !== "function") {
throw new Error("Invalid plugin");
}if (!plugin || typeof plugin.get !== "function") {
throw new Error("Invalid plugin");
}At first glance this appears reasonable.
However, there is a subtle but critical issue.
Node.js executes a module immediately when require() is called.
That means the order of operations is actually:
- Execute the module.
- Return exported object.
- Validate exported object.
The validation occurs too late.
Any malicious code placed at the top level of the module runs before the application decides whether the plugin is valid.
This was the second major vulnerability.
Once I realized this, the attack path became clear.
Stage 6 — Achieving Code Execution
Using the Zip Slip primitive, I placed a JavaScript file inside the application's plugin directory.
The plugin contained code that executed immediately when loaded.
The application eventually scanned the plugin directory and called require() on the file.
At that moment, my code executed inside the Node.js process.
The challenge was effectively solved.
From an attacker's perspective, this was full Remote Code Execution.
The server was now running attacker-controlled JavaScript with the same privileges as the application itself.
Alternative Observation — Data Exfiltration Through Templates
An interesting aspect of this challenge is that direct command execution was not strictly necessary.
The application's frontend rendered plugin information using EJS templates.
For every plugin, the application invoked:
plugin.get()plugin.get()and serialized the returned object into the HTML response.
Because plugins could access process variables, sensitive information could be embedded directly into the rendered page.
This meant that even without establishing a shell, an attacker could exfiltrate secrets such as environment variables through normal application responses.
In a real-world scenario, this could include:
- API keys
- Database credentials
- Session secrets
- Internal tokens
- Environment configuration
and, in this challenge, the flag.
Vulnerability Chain
The final attack chain looked like this:
Reverse License Algorithm
↓
Generate Valid License
↓
Upload ZIP Archive
↓
Symlink-Based Zip Slip
↓
Arbitrary File Write
↓
Write Malicious Plugin
↓
Unsafe require()
↓
Remote Code ExecutionReverse License Algorithm
↓
Generate Valid License
↓
Upload ZIP Archive
↓
Symlink-Based Zip Slip
↓
Arbitrary File Write
↓
Write Malicious Plugin
↓
Unsafe require()
↓
Remote Code ExecutionIndividually, each weakness might not appear catastrophic.
Combined together, they resulted in complete compromise of the application.
Lessons Learned
This challenge demonstrates several important security lessons.
1. Archive Extraction Is Dangerous
ZIP extraction should always validate canonical paths and verify that every extracted file remains inside the intended destination directory.
2. Symlinks Are Frequently Overlooked
Many developers block path traversal but forget that symbolic links can bypass those protections entirely.
3. Dynamic Code Loading Is High Risk
Loading user-controlled files through require() effectively means executing user-controlled code.
4. Validation Must Happen Before Execution
Any security check performed after a module has already been loaded is fundamentally ineffective.
5. Vulnerabilities Rarely Exist Alone
The most dangerous attacks often emerge when multiple low- or medium-severity issues are chained together.
Conclusion
DeadBolt was an excellent example of modern vulnerability chaining. The challenge required moving beyond individual bugs and understanding the complete application workflow.
A reversible license algorithm granted access to the upload functionality. A symlink-based Zip Slip vulnerability provided arbitrary file write. Unsafe dynamic module loading transformed that primitive into Remote Code Execution.
None of these issues alone were particularly novel, but together they formed a realistic and highly impactful attack path, ultimately resulting in full compromise of the application and retrieval of the flag.