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 key | Why it matters |
|---|---|
| /JavaScript, /JS | The document runs script. Extract and read it - this is the payload in most malicious PDFs. |
| /OpenAction, /AA | Something happens on open, without a click. Pair it with the above. |
| /Launch | Attempts to run an external program. |
| /URI | The document fetches or links out. Follow it. |
| /EmbeddedFile | There is a whole other file inside. Extract it. |
| /ObjStm | Objects are packed inside compressed streams and will not appear in a raw grep. |
| /AcroForm, /XFA | Form data, which may hold values the rendering does not show. |
| /Encrypt | Encrypted. Often with the empty owner password, which qpdf removes in one command. |
- Decompress everything first.
qpdf --qdf --object-streams=disable in.pdf out.pdfrewrites the file with uncompressed streams and expanded object streams, after whichgrepand 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
%%EOFmarkers 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.
pdftotextreads straight through it. - Images are separate objects.
pdfimages -allextracts them, and then it is an image steganography problem. - Encrypted with a user password means cracking it -
pdf2johnand 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:delelements, and comments are all indocument.xmleven 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
olevbaneeds--deobfand a look atxlmdeobfuscatorto 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 indocument.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 for | Why |
|---|---|
| AutoOpen, Document_Open, Workbook_Open, AutoExec | Where execution starts, without user interaction. |
| Shell, WScript.Shell, Run, Exec | Command execution. |
| CreateObject("MSXML2.XMLHTTP"), URLDownloadToFile | The download of a second stage. Follow the URL. |
| Environ, GetObject("winmgmts:") | Environment reconnaissance, often used as an anti-analysis check. |
| Chr(), Asc(), StrReverse, Mid | String obfuscation. Evaluate rather than read. |
| A very long string constant | Base64 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
\objdatablobs.rtfobjextracts them. RTF's parser is famously lenient, which is why exploits arrive in it. - Email (.eml, .msg) carries headers worth reading in full -
Receivedchains,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 inmeta.xml. - CHM, LNK and OneNote files all embed executables or command lines and all turn up in challenges.
lnkinfoandpyOneNoteread the latter two. - A document with no macros and nothing embedded is usually a metadata or a deleted-content challenge. Go back to
docPropsand to tracked changes.
The order for any document
fileandbinwalk. Confirm the format and check for a second file inside.- Normalise:
qpdf --qdffor a PDF,unzipfor OOXML,oledumpfor OLE. - Enumerate structure before content - object types for a PDF, the archive listing for OOXML, the stream list for OLE.
- Read the metadata. It is one command and it answers a surprising share of these outright.
- Extract everything embedded: attachments, images, OLE objects, unreferenced zip entries.
- If there is script or a macro, find the auto-execution entry point and the network or shell sink, then evaluate rather than read.
- If nothing is embedded, look at what the rendering hides: revisions, tracked changes, hidden sheets, and text under a black rectangle.