Skip to content
All posts
forensicsrevised August 14, 20266 min read

Document forensics: taking apart a PDF and an Office file

A PDF is a graph of objects and an Office document is a zip of XML. Both hide things in places a viewer never renders. How to enumerate the structure, pull out streams and macros, and follow what the document tries to fetch.

Documents are containers pretending to be pages. The thing you see rendered is one interpretation of the bytes, and the parts a viewer never shows - object streams, removed layers, macro projects, embedded files, incremental-update history - are where a challenge puts the flag and where a malicious document puts its payload.

The two formats behave completely differently and need different tools, so this splits cleanly. What they share is the first instinct: enumerate the structure before you look at the content.

PDF: a graph of numbered objects

A PDF is a set of objects, each numbered, referencing each other, with a cross-reference table at the end saying where each one starts and a trailer pointing at the root. Objects are dictionaries, and streams are dictionaries with a compressed blob attached.

# Structure first: object counts by type, and what is unusual.
pdfid chal.pdf

# Then the object graph, with the suspicious ones named.
pdf-parser.py --stats chal.pdf
pdf-parser.py --object 12 --filter --raw chal.pdf   # decompress one stream

# Text and metadata.
pdftotext -layout chal.pdf -
exiftool chal.pdf

# Anything attached.
pdfdetach -list chal.pdf && pdfdetach -saveall chal.pdf
pdfid keyWhy it matters
/JavaScript, /JSThe document runs script. Extract and read it - this is the payload in most malicious PDFs.
/OpenAction, /AASomething happens on open, without a click. Pair it with the above.
/LaunchAttempts to run an external program.
/URIThe document fetches or links out. Follow it.
/EmbeddedFileThere is a whole other file inside. Extract it.
/ObjStmObjects are packed inside compressed streams and will not appear in a raw grep.
/AcroForm, /XFAForm data, which may hold values the rendering does not show.
/EncryptEncrypted. Often with the empty owner password, which qpdf removes in one command.
  • Decompress everything first. qpdf --qdf --object-streams=disable in.pdf out.pdf rewrites the file with uncompressed streams and expanded object streams, after which grep and a text editor work. This is the single highest-value command on any PDF.
  • Look for incremental updates. A PDF can be appended to, and older versions of every object remain in the file. Multiple %%EOF markers mean multiple revisions - and the redaction someone applied in the last one did not remove what the earlier one contains.
  • Redaction that is a black rectangle is a drawing on top of text that is still in the content stream. pdftotext reads straight through it.
  • Images are separate objects. pdfimages -all extracts them, and then it is an image steganography problem.
  • Encrypted with a user password means cracking it - pdf2john and then hashcat.

Office: OOXML is a zip, and OLE is a filesystem

There are two generations and the distinction decides your tooling. .docx, .xlsx, .pptx are ZIP archives of XML (OOXML). .doc, .xls, .ppt are OLE compound files - a small FAT-like filesystem inside a single file. Macro-enabled formats (.docm, .xlsm) are OOXML archives with an OLE file inside them holding the VBA project.

# OOXML: it is a zip, so treat it like one.
unzip -l chal.docx
unzip -o chal.docx -d doc/ && grep -ri 'flag\|http' doc/ | head

# OLE: enumerate the streams.
oledump.py chal.doc
oledump.py -s 8 -v chal.doc          # decompress stream 8 (a VBA module)

# The one-shot triage for either.
oleid chal.docm
olevba --deobf --reveal chal.docm
  • `docProps/core.xml` and `app.xml` hold the author, the revision count, the total editing time, and the template. That is OSINT material and frequently the actual objective.
  • `word/_rels/document.xml.rels` lists every external relationship - remote templates, linked images, OLE objects. A relationship with TargetMode="External" pointing at a URL is a remote-template injection, which fetches and runs something when the document opens.
  • Deleted text survives. Tracked changes, w:del elements, and comments are all in document.xml even when the rendering hides them.
  • Extra files in the zip. An OOXML package can carry any file; anything not referenced by a relationship is there deliberately.
  • Excel 4.0 (XLM) macros live in hidden sheets rather than a VBA project, so olevba needs --deobf and a look at xlmdeobfuscator to catch them. Very hidden sheets do not appear in Excel's own unhide dialog.
  • DDE fields - DDEAUTO c:\\windows\\system32\\cmd.exe - execute without any macro at all, and appear as field codes in document.xml.

Reading a macro

VBA in a challenge is obfuscated but not sophisticated: string concatenation, Chr() arithmetic, base64, and occasionally an XOR loop. It is deliberately readable once you know the entry points and the sinks.

What to grep forWhy
AutoOpen, Document_Open, Workbook_Open, AutoExecWhere execution starts, without user interaction.
Shell, WScript.Shell, Run, ExecCommand execution.
CreateObject("MSXML2.XMLHTTP"), URLDownloadToFileThe download of a second stage. Follow the URL.
Environ, GetObject("winmgmts:")Environment reconnaissance, often used as an anti-analysis check.
Chr(), Asc(), StrReverse, MidString obfuscation. Evaluate rather than read.
A very long string constantBase64 or hex of the actual payload.

The productive move with an obfuscated macro is the same as with obfuscated managed code: do not deobfuscate it by hand, evaluate it. Replace the sink - the Shell call - with a MsgBox or a write to a file, and let the macro decode its own payload for you. ViperMonkey automates exactly this by emulating the VBA rather than executing it, which is safer and needs no Windows.

The other document formats

  • RTF is text, not a container, and it hides objects as hex-encoded \objdata blobs. rtfobj extracts them. RTF's parser is famously lenient, which is why exploits arrive in it.
  • Email (.eml, .msg) carries headers worth reading in full - Received chains, Authentication-Results, and the boundary structure. Attachments are base64 MIME parts and extract with any mail library.
  • OpenDocument (.odt, .ods) is a zip like OOXML, with macros in Basic/ and metadata in meta.xml.
  • CHM, LNK and OneNote files all embed executables or command lines and all turn up in challenges. lnkinfo and pyOneNote read the latter two.
  • A document with no macros and nothing embedded is usually a metadata or a deleted-content challenge. Go back to docProps and to tracked changes.

The order for any document

  1. file and binwalk. Confirm the format and check for a second file inside.
  2. Normalise: qpdf --qdf for a PDF, unzip for OOXML, oledump for OLE.
  3. Enumerate structure before content - object types for a PDF, the archive listing for OOXML, the stream list for OLE.
  4. Read the metadata. It is one command and it answers a surprising share of these outright.
  5. Extract everything embedded: attachments, images, OLE objects, unreferenced zip entries.
  6. If there is script or a macro, find the auto-execution entry point and the network or shell sink, then evaluate rather than read.
  7. If nothing is embedded, look at what the rendering hides: revisions, tracked changes, hidden sheets, and text under a black rectangle.