PDF metadata forensics

Where the format keeps its receipts, what each structure is actually specified to mean, and which evidence survives which kind of edit.

This page is about the file format, not about a product. If you are building anything that ingests PDFs you did not generate, it is worth knowing exactly which structures record a document's history, how they are specified, and how each one fails. Everything below is readable with a hex editor and patience.

Two metadata stores, and why they disagree

A PDF can describe itself in two entirely separate places, and most modern files do both.

The document information dictionary

The trailer's /Info key points at a dictionary with a small fixed vocabulary: /Title, /Author, /Subject, /Keywords, /Creator, /Producer, /CreationDate, /ModDate, /Trapped. The split between the last two tool fields is the useful part and is regularly misread: /Creator names the application that created the original document — the word processor, the reporting engine — while /Producer names whatever converted it into PDF, or most recently wrote the PDF bytes. A statement generated server-side typically carries a library string in /Producer; the interesting case is when /Producer names a tool whose purpose is editing.

Values are PDF text strings, which means either PDFDocEncoding or UTF-16BE introduced by a FEFF byte-order mark. In a file with no object streams the dictionary is usually sitting there in the clear, greppable. ISO 32000-2 (PDF 2.0) deprecates the information dictionary in favour of XMP, with the date entries retained — but deprecation in a format this long-lived means almost nothing about what you will actually receive, and the Info dictionary remains near-universal in the wild.

The XMP packet

The document catalog's /Metadata key points at a stream of /Type /Metadata /Subtype /XML holding an Adobe XMP packet: RDF/XML, wrapped in processing instructions that begin <?xpacket begin= with a fixed packet id, and — by convention and by the spec's own recommendation — stored unfiltered so that software which cannot parse PDF can still find and read it. That is why strings finds XMP but not much else.

A trimmed XMP packet

<rdf:Description rdf:about=""
    xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
    xmlns:xmp="http://ns.adobe.com/xap/1.0/"
    xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/">
  <pdf:Producer>iText 7.2.5</pdf:Producer>
  <xmp:CreatorTool>StatementRenderer</xmp:CreatorTool>
  <xmp:CreateDate>2026-01-04T10:02:00+01:00</xmp:CreateDate>
  <xmp:ModifyDate>2026-01-04T10:02:00+01:00</xmp:ModifyDate>
  <xmpMM:DocumentID>uuid:8f2c...</xmpMM:DocumentID>
  <xmpMM:InstanceID>uuid:41de...</xmpMM:InstanceID>
</rdf:Description>

The pairs that matter for cross-checking are pdf:Producer/Producer, xmp:CreatorTool/Creator, xmp:CreateDate/CreationDate and xmp:ModifyDate/ModDate.

Two XMP properties deserve attention on their own. xmpMM:DocumentID is meant to be stable across the life of a document while xmpMM:InstanceID changes on every saved instance — conceptually the same design as the trailer /ID pair, one level up. And xmpMM:History, where present, is an ordered list of stEvt: entries recording actions, software agents and timestamps. It is optional, it is written by relatively few tools, and it is trivially removable — but when a file does carry it, it is the closest thing PDF has to an edit log, and it costs nothing to read.

Why the two stores diverge

There is no mechanism in the format that keeps them in sync. Whether they agree is purely a property of the tools that touched the file. A single generator writing once fills both consistently. After that:

  • Some editors rewrite the Info dictionary and leave the XMP packet untouched, because rewriting XML is more work than setting a dictionary value.
  • Some do the opposite, updating XMP as the modern store and leaving Info stale.
  • Multi-stage pipelines — design application, distiller, imposition, post-processor — legitimately leave each store reflecting a different stage.

So divergence tells you "more than one writer", not "a forger". The strong version of the finding is narrower: the two stores disagree about the tool and only one of them names a consumer editor. Then the divergence is the sole reason that tool is visible at all, which is a meaningfully different observation from two server libraries disagreeing about a version string.

Any comparison you build needs tolerances or it will drown you. Normalise tool strings and treat one as agreeing with the other when either is a substring of the other, so iText 7.2.5 and iText match. Allow timestamps a minute of slack, because the two stores round to different precisions.

PDF date syntax, and the four ways it goes wrong

Info dictionary dates are strings in a format of the specification's own devising:

D:YYYYMMDDHHmmSSOHH'mm'
D:20260104100200+01'00'     PDF 1.7 form, trailing apostrophe
D:20260104100200+01'00      also seen; ISO 32000-2 dropped the trailing quote
D:20260104100200Z           UTC
D:20260104                  legal — everything after the year may be truncated

Everything after the year is optional, so D:2026 is a valid PDF date. The offset indicator O is +, - or Z. XMP dates, by contrast, are ISO 8601. Any cross-check has to normalise both into instants before comparing, and has to cope with a legitimate absence of timezone information on either side.

Four failure modes are worth testing for:

  • Modification before creation. Compare within a single store, not across the two — cross-store skew is a metadata-divergence question, not a chronology question. Within one store, a correctly running generator cannot produce it.
  • Future timestamps. Use a tolerance of a day, not a second. Clock skew and timezone-handling bugs are real and boring.
  • Impossible UTC offsets. No real timezone exceeds ±14:00. An offset outside that range was typed, not computed.
  • Unparseable strings. Common enough from legacy and niche producers that it deserves its own, lower weight. Treat "this generator is sloppy" separately from "these timestamps are inconsistent".

One asymmetry to internalise: the presence of a bad date is evidence; the absence of dates is not. Stripping metadata is one line of code in any PDF library.

The trailer /ID pair

The trailer's /ID is an array of exactly two byte strings, conventionally 16 bytes each and conventionally rendered in hex. The specification gives them distinct, useful roles: the first element is a permanent identifier established when the document is created and must not change for the life of that document; the second is updated whenever the file is written again. It is, in other words, a change marker the format maintains for you.

trailer
<< /Size 41 /Root 1 0 R /Info 9 0 R
   /ID [<8f2c9a1b...> <41de77c0...>] >>

Implementations typically derive the value by hashing the current time, the file path, the file size and the Info dictionary contents, which is why the two elements are usually MD5-length. That derivation is a recommendation, not a requirement, and nothing prevents a tool from writing whatever it likes.

Three practical notes:

  • /ID is required when the trailer has an /Encrypt entry, and for the standard security handler at the older revisions the first element participates in deriving the encryption key. So encrypted files essentially always have one.
  • Some minimal generators omit /ID entirely. That removes the check; it does not indicate anything about the document.
  • A tool that rewrites the whole file can set both elements to the same fresh value, erasing the evidence completely. A matching pair therefore proves nothing. The signal is one-directional: divergence is informative, identity is not.

When a file has several revisions it has several trailers, and each carries its own /ID. Collecting all of them shows the second element evolving while the first stays fixed — a small, satisfying confirmation that you are reading the revision chain correctly.

Incremental updates and the /Prev chain

This is the structure that makes PDF forensics possible at all, and it is a deliberate feature rather than an oversight. Instead of rewriting a file, a conforming writer may append: new and replacement objects, then a new cross-reference section covering only what changed, then a new trailer, then startxref with the offset of that new section, then %%EOF. The original bytes are untouched. A document saved three times can physically contain all three states.

The trailer of each appended section carries /Prev, the byte offset of the previous cross-reference section. That is the chain. You enter it at the last startxref in the file and walk backwards until a trailer has no /Prev; that terminal section is the original document.

%PDF-1.7
… original body …
xref
0 41
0000000000 65535 f
0000000015 00000 n
…
trailer
<< /Size 41 /Root 1 0 R /Info 9 0 R /ID [<8f2c…> <8f2c…>] >>
startxref
118201
%%EOF                       <-- end of revision 1
… appended objects: 12 0 obj (replacement content stream) …
xref
0 1
0000000000 65535 f
12 1
0000119004 00000 n
trailer
<< /Size 41 /Root 1 0 R /Info 9 0 R
   /Prev 118201 /ID [<8f2c…> <41de…>] >>
startxref
183640
%%EOF                       <-- end of revision 2

Read that appended section carefully: it lists object 12 and nothing else. Object 12 already existed in revision 1. The new entry wins, because resolution always uses the newest section that mentions an object number. If object 12 is a page's content stream, then what the document renders changed between revision 1 and revision 2 — and revision 1 is still in the file, so you can render both.

That distinction is the whole game. "The file has two revisions" is weak: signing a document is an incremental update, so is filling a form field, adding an annotation, or applying a comment. "A later revision replaces an object that already existed and that carries page, content-stream or image data" is strong, because it means the visible document changed after generation.

Classic cross-reference tables

A classic xref section is subsection headers (first object number, count) followed by fixed-width 20-byte entries: a 10-digit offset, a 5-digit generation number, a type character n (in use) or f (free), and a two-character end-of-line. The fixed width is why the tables are trivially machine-readable and why hand-edited PDFs so often break them — insert a byte anywhere earlier in the file and every offset after it is wrong.

The free entries form a linked list whose head is object 0 with generation 65535. Generation numbers exist so that a freed object number can be reused safely, and in practice almost nothing bumps them: a nonzero generation is unusual enough to be worth noticing.

Counting revisions honestly

Counting %%EOF markers is the folk method and it is a decent floor, but it is not the revision count. It disagrees with reality in both directions:

  • It over-counts on linearised files. A linearised ("fast web view") PDF legitimately places a first-page cross-reference section near the start of the file, with its own trailer and %%EOF, whose /Prev points at the main section at the end. One save, two markers.
  • It over-counts on files with trailing junk — a %%EOF-looking byte sequence inside a stream, or content appended by a broken transfer.
  • It under-counts when a marker is missing, which non-conforming producers manage regularly.
  • It says nothing about what changed, which is the only part that carries weight.

The defensible method is to walk the /Prev chain and count sections, keeping %%EOF offsets as corroboration and as a fallback when the chain is broken. Guard the walk: a loop in /Prev is a real thing to find in a hostile file, so track visited offsets and cap the chain length.

Cross-reference streams, object streams and hybrid files

PDF 1.5 introduced two features that together make naive text-based forensics much weaker.

Cross-reference streams replace the plain-text table with a compressed binary stream object. Its /W array gives the field widths of each entry and /Index gives the subsections. Entry type 0 is free, type 1 is a normal object with a byte offset and generation, and type 2 is an object living inside an object stream, identified by the containing stream's object number plus an index within it. Trailer keys such as /Root, /Info, /ID and /Prev move into the stream's own dictionary — so a file can have a full revision chain and no literal trailer keyword anywhere.

Object streams (/Type /ObjStm) pack many non-stream objects into one Flate-compressed stream. This is why strings on a modern PDF often shows you almost nothing: the Info dictionary, the page tree and the font dictionaries are all inside compressed containers. Any real forensic parser has to inflate them.

Hybrid-reference files are the wrinkle worth knowing about. To stay readable by pre-1.5 software, a file may carry a classic table and a companion cross-reference stream, pointed at by the classic trailer's /XRefStm. Old readers see one view of the file; new readers see another. The two views can legitimately differ in coverage — and can be made to differ in substance, which is a documented technique for building a PDF that shows different content to different viewers. Any parser that means to be thorough has to read both and merge them rather than picking one.

Subset font prefixes

When a producer embeds only the glyphs a document actually uses, the convention — specified, not folklore — is to prefix the font name with six uppercase ASCII letters and a plus sign:

/BaseFont /ABCDEF+Helvetica
/FontDescriptor 22 0 R
  /FontName /ABCDEF+Helvetica

The six letters are arbitrary; the specification asks only that they be chosen so that different subsets are unlikely to collide. For simple fonts the /FontName in the descriptor must match the /BaseFont, so you have two places to read the same tag.

The forensic value comes from a property of generators, not of the format: a tool writing a document in one pass collects every glyph it needs for a typeface into one subset with one tag. Two different tags for the same base font mean glyphs of that typeface were embedded on two separate occasions — the trace left when text is added to, or replaced in, an existing PDF by a different tool.

Its real strength is durability. Subset tags live in the page resources, not in metadata, so they survive metadata stripping, and they survive a whole-file rewrite that flattens the revision chain. Its weakness is that document assembly produces the same pattern innocently: merge two PDFs that both use Helvetica and you get two subsets. Read it together with the revision history, never alone.

Signature /ByteRange

A signature dictionary's /Contents holds the PKCS#7 blob as a hex string, and /ByteRange is an array of offset/length pairs naming the bytes that were hashed. The standard arrangement is two pairs that cover everything except the hole where /Contents itself sits — the signature cannot sign itself.

/ByteRange [0 8420 42840 15680]
             ^ ^    ^     ^
             | |    |     +-- length of the second covered span
             | |    +-------- start of the second span (just after /Contents)
             | +------------- length of the first covered span
             +--------------- start of the first span

covered through byte 42840 + 15680 = 58520
file length                        = 61204
                                     -------
bytes outside the signature        =  2684

If the last covered byte is not the last byte of the file, the difference was appended after signing and is not protected by that signature. That is arithmetic on two integers. No certificate, no trust store, no cryptography — and it holds whether or not the signature's mathematics validate.

Keep the two questions separate, because viewers routinely blur them:

  • Does the signature verify? Requires the certificate, a trust store, revocation checking, and a policy about what counts as trusted.
  • Does anything exist outside the signed range? Requires the file.

A viewer can legitimately report a valid signature while displaying content the signature never covered. That is what "signed, with subsequent changes" means in the UI, and it is why coverage is worth reporting as its own fact.

The honest caveat: bytes outside the range are not automatically illegitimate. Multi-signature workflows produce them by construction, and PDF has a whole mechanism — DocMDP permissions, expressed through a signature reference with /TransformMethod /DocMDP and a /P level — for declaring which subsequent changes the signer allowed. Reporting a coverage gap is not the same as evaluating whether the change was permitted.

What survives what

The most useful mental model when triaging a file: different edits destroy different evidence. Nothing here is a guarantee — behaviour varies by tool — but the shape holds.

Evidence Metadata stripped Whole file rewritten Flattened to image
Producer / Creator strings gone replaced replaced
Info vs XMP divergence gone usually gone gone
Date anomalies gone reset reset
Revision chain (/Prev) survives gone gone
Trailer /ID divergence survives can be erased can be erased
Duplicate font subsets survives often survives gone (no text)
Full-page image + visible text survives survives becomes the signal
Signature coverage gap survives signature destroyed signature destroyed

Read the columns rather than the rows. Metadata-only cleanup is the cheapest evasion and leaves every structural signal intact. A whole-file rewrite is more effective but destroys any signature and tends to leave a fresh, coherent, and conspicuously recent metadata set. Flattening to an image defeats text-level analysis entirely and creates a document whose own shape — a full-page raster where a machine-generated statement should be vector text — is the finding.

The general lesson: no single signal is robust, and the evasions are mutually exclusive enough that a set of independent signals is much harder to defeat than any one of them. That is also why the honest framing is signals, not detection.

Reading it yourself

You can get a long way with a shell. Start with the tail of the file and the markers:

tail -c 500 statement.pdf                     # trailer, startxref, %%EOF
grep -c '%%EOF' statement.pdf                 # a floor on the revision count
grep -abo 'startxref' statement.pdf           # every revision's entry point
strings statement.pdf | grep -A40 'x:xmpmeta' # the XMP packet, usually uncompressed
strings statement.pdf | grep -o '/BaseFont *[^ /]*' | sort -u

Beyond that you need a real parser, and the choice of tool matters more than it looks:

  • qpdf is the honest one. qpdf --qdf --object-streams=disable rewrites a file into an uncompressed, human-readable normal form, which is the single most useful thing you can do to a PDF you want to understand. --show-xref dumps the resolved cross-reference table. Note what it does not preserve: the output is a new file with one revision. Analyse the original, use the normalised copy for reading.
  • mutool (mutool show file.pdf trailer, … grep) is excellent for poking at individual objects and dictionaries.
  • pikepdf (Python, on top of qpdf) is the pleasant scripting option, and unlike most high-level bindings it will let you at /Prev and the raw trailer.
  • exiftool reads both metadata stores and will happily show you Info and XMP side by side, which is exactly the comparison you want.

The load-and-save trap. Most high-level PDF libraries — the ones you would reach for to make a PDF — normalise on read. Load a file and write it back out and you typically get: one revision, a rebuilt cross-reference table, a fresh /ID pair, re-subset fonts and new metadata. Every structural signal is destroyed by the act of examining the file. If you are building anything forensic, parse the bytes; do not round-trip them.

A related warning about evidence handling: never analyse the copy that a conversion step, a mail gateway, an antivirus scanner or a storage layer with server-side processing has touched. Hash the original on receipt and analyse the original.

Or let something else do the walking

Tamperlens implements everything on this page as a raw-byte parser — no high-level PDF library in the forensic path, because those libraries normalise away exactly the evidence described above. Eleven signal families, plain-English findings with machine-readable evidence, deterministic output. The free checker runs it in your browser against your own file with nothing stored; the API is one POST, with 25 documents a month free.

Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic.

Related reading