pwneglyph logo
web web-server nodejs express broken-access-control registration-logic sanitize-html html-sanitizer-bypass node-html-markdown entity-decoding markdown-pdf remarkable html-to-pdf iframe local-file-read lfi

No admin row is seeded, so registering username 'admin' grants isAdmin. The PDF export pipeline stores HTML through sanitize-html (strips <iframe>) but node-html-markdown later decodes HTML entities, so a double-encoded &lt;iframe src=file:///flag.txt&gt; is reborn as a live tag and markdown-pdf (remarkable html:true) renders it, reading local files into the PDF.

Dark Runes — register-as-admin → entity-encoded iframe → markdown-pdf LFI

Platform: HackTheBox · Category: Web (server-side) · Stack: Node/Express, EJS, sanitize-html, node-html-markdown, markdown-pdf

Challenge overview

A document app where admins can export documents to PDF. The flag is at /flag.txt. There's a 4-digit "access pass" for a debug export, but it's a dead end — every failed attempt rotates it:

router.post("/document/debug/export", isAuthenticated, isAdmin, async (req, res) => {
  const { access_pass, content } = req.body;
  if (!verifyPass(access_pass)) { rotatePass(); return res.status(403).send("BAD PASS..."); }
  ...
});

So brute force is impossible. The real path is the normal export route plus an HTML-sanitizer quirk.

Step 1 — become admin (no admin is seeded)

Authorization is purely username === "admin":

const isAdmin = (req, res, next) =>
  req.user.username === "admin" ? next() : res.status(403).send("Forbidden");

The cookie is base64(JSON.stringify({username,id}))-sha256(body+SECRET) and registration never reserves adminfindUser("admin") returns nothing because no such account exists in the seed. So just register a user literally named admin; the server signs a perfectly valid admin cookie for you. (No need to forge the HMAC — the app does it.)

Step 2 — the sanitizer/markdown round-trip that revives <iframe>

Creating a document runs the content through sanitize-html, which drops disallowed tags like <iframe>:

const sanitizedContent = sanitizeHtml(content, { allowedAttributes: { a: ["style"], ... } });
addDocument(user.id, sanitizedContent, integrity);

But the export route doesn't render the stored HTML directly — it first converts it back with node-html-markdown, then renders the markdown to PDF with HTML enabled:

const content = nhm.translate(document.content);          // node-html-markdown
const generatedPDF = await generatePDF(content);          // markdown-pdf, remarkable { html: true }

The trick: submit the iframe HTML-entity-encoded so sanitize-html sees harmless text, not a tag, and leaves it intact:

&lt;iframe src="file:///flag.txt"&gt;&lt;/iframe&gt;

sanitize-html keeps the text node as-is. Then nhm.translate decodes the entities back to real < / > while emitting markdown, and markdown-pdf (with remarkable { html: true }) renders the result as live HTML — so the <iframe> is reconstituted and points at a local file.

Step 3 — read the flag into the PDF

1. register username=admin  -> app issues a valid admin cookie
2. POST /documents  content = &lt;iframe src="file:///flag.txt"&gt;&lt;/iframe&gt;
3. GET  /document/export/<id>   (admin)  -> PDF embeds the iframe -> renders /flag.txt

Flag: HTB{F0rs33_3num3r3t3_F!nd_3XplOit}

Takeaways (generalized techniques)

  • Authorization by username with no seeded privileged account = register the privileged name. Always test whether admin/root can simply be registered when the check is user.username === "admin".
  • Sanitize-then-transform is a sanitizer bypass. A sanitizer only protects the representation it sees. If a later stage decodes entities / re-parses (node-html-markdown, a markdown→HTML step, another HTML pass), feed it entity-encoded markup (&lt;iframe&gt;) that is inert at sanitize time but becomes a live tag downstream. The vulnerable shape is sanitize(x) stored, then decode/translate(x) rendered.
  • HTML→PDF renderers are LFI/SSRF engines. Headless HTML-to-PDF (markdown-pdf/remarkable, wkhtmltopdf, Puppeteer) with HTML enabled will happily resolve <iframe src="file:///..."> or remote URLs from the server's context — local file read / SSRF without any browser of your own.

Sources & references