I spent a chunk of my career in KYC/AML and document verification, mostly looking at things people had uploaded hoping nobody would look too closely — so this is the file format I have the most opinions about and the least affection for.

Here is the thing that took me embarrassingly long to internalise: a PDF is not a picture of a document. It is an append-only container with a linked list of revisions in it, and it keeps receipts about its own history whether or not anyone wanted it to. Most people editing a PDF have no idea that the thing they edited is still in the file.

Let me walk a constructed example. Imagine a bank statement, generated server-side, two pages, one credit line that says the account holder received rather less than they would like a lender to believe. They download it, drop it into a free browser-based PDF editor, white out the number, type a bigger one, hit download. Two minutes of work.

Here is what that leaves behind.

1. Count the ends of the file

$ grep -c '%%EOF' statement.pdf
2

Enter fullscreen mode Exit fullscreen mode

A PDF ends with %%EOF. Ours contains two of them, and the second one is not a typo.

PDF supports incremental update: a conforming writer, instead of rewriting the file, may append new objects, append a new cross-reference section covering only what changed, append a new trailer, and append a new startxref / %%EOF. The original bytes are left exactly where they were. A document saved three times can physically contain all three states, and you can render any of them by truncating the file at the right offset.

This is a feature, not a bug — it is how digital signatures manage to preserve the bytes they signed. Which also means the count alone is weak evidence. Signing a document is an incremental update. So is filling in a form field, or adding an annotation. Linearised ("fast web view") files legitimately have two %%EOF markers from a single save, because the first-page cross-reference section sits near the front of the file with its own trailer.

So: interesting, not conclusive. Keep going.

$ grep -abo 'startxref' statement.pdf
118201:startxref
183640:startxref

Enter fullscreen mode Exit fullscreen mode

2. Walk the /Prev chain

Every appended cross-reference section's trailer carries /Prev: the byte offset of the previous section. That is a backwards linked list. Enter it at the last startxref in the file and walk until a trailer has no /Prev; that terminal section is the original document.

Our tail:

trailer
<< /Size 41 /Root 1 0 R /Info 9 0 R
   /Prev 118201 /ID [<8f2c9a1b...> <41de77c0...>] >>
startxref
183640
%%EOF

Enter fullscreen mode Exit fullscreen mode

Now read the appended cross-reference section that 183640 points at:

xref
0 1
0000000000 65535 f
12 1
0000119004 00000 n
trailer
<< ... /Prev 118201 ... >>

Enter fullscreen mode Exit fullscreen mode

It lists exactly one live object: 12. And object 12 already existed in revision 1.

That is the whole finding. Object resolution in PDF always uses the newest section that mentions an object number, so whatever object 12 is now, it has replaced what object 12 used to be. Check what kind of object it is:

$ mutool show statement.pdf 12
<< /Length 2841 /Filter /FlateDecode >>

Enter fullscreen mode Exit fullscreen mode

A content stream. On a page. That means what the document renders changed after it was generated — and revision 1 is still sitting in the file at offset 118201, so I can extract both and diff them.

This is the distinction I would put on a wall if I were running a document-review team: "the file has two revisions" is a medium-strength observation with a dozen innocent explanations. "A later revision replaces an object that already existed and that carries page or content-stream data" is a strong one, because there is no version of that sentence which is compatible with "the bank generated this and nobody touched it."

3. The /ID pair, which the spec maintains for you

Look again at that trailer:

/ID [<8f2c9a1b...> <41de77c0...>]

Enter fullscreen mode Exit fullscreen mode

Two byte strings. The specification gives them different jobs: the first is a permanent identifier established when the document is created and must never change; the second is rewritten every time the file is written again. Two different values is the format's own record that this file was saved after it was created.

The original trailer, at offset 118201, has /ID [<8f2c9a1b...> <8f2c9a1b...>] — matching, as a freshly created file should. Watching element two drift while element one holds still across revisions is a satisfying little confirmation that you have read the chain correctly.

Its weakness matters as much as its strength: a tool that rewrites the whole file can set both elements to the same fresh value and erase the trace entirely. So divergence is informative; identity proves nothing. Almost every PDF signal is one-directional like this, which is why any honest tool in this space sells signals rather than verdicts.

4. Two metadata stores that stopped agreeing

Modern PDFs describe themselves twice: the trailer's /Info dictionary, and an XMP packet hanging off the document catalog's /Metadata key. XMP is RDF/XML and is conventionally stored unfiltered, which is why strings finds it when it finds nothing else.

$ strings statement.pdf | grep -A6 'x:xmpmeta'
<pdf:Producer>iText 7.2.5</pdf:Producer>
<xmp:CreateDate>2026-01-04T10:02:00+01:00</xmp:CreateDate>
<xmp:ModifyDate>2026-01-04T10:02:00+01:00</xmp:ModifyDate>

Enter fullscreen mode Exit fullscreen mode

And the Info dictionary in the newest revision:

/Producer (iLovePDF)
/Creator (iText 7.2.5)
/CreationDate (D:20260104100200+01'00')
/ModDate (D:20260106184100+01'00')

Enter fullscreen mode Exit fullscreen mode

Nothing in the format keeps those two in sync. Whether they agree is purely a property of the tools that touched the file, and plenty of editors rewrite the dictionary while leaving the XML alone because setting a dictionary value is much less work than rewriting XML.

Note precisely what happened here. The XMP packet still says the file was made by iText and never modified. The Info dictionary says iLovePDF and carries a /ModDate two days later. The divergence is the only reason the editor is visible at all. If you were only reading XMP — which is the modern store, and the one a lot of tooling prefers — you would see a clean file.

Multi-stage publishing pipelines produce plain divergence legitimately all the time, so a bare mismatch is a question rather than an answer. The narrow, strong version is this one: the two stores disagree about the tool, and only one of them names something whose purpose is editing.

5. The tell that survives metadata cleanup

Suppose our forger reads a post like this one and strips the metadata. Then this is still there:

$ strings statement.pdf | grep -o '/BaseFont *[^ /]*' | sort -u
/BaseFont /ABCDEF+Helvetica
/BaseFont /QWERTY+Helvetica

Enter fullscreen mode Exit fullscreen mode

Embedded fonts are usually subsetted — only the glyphs the document actually uses — and the name gets a six-uppercase-letter prefix and a plus sign. The letters are arbitrary. What matters is that a generator writing a document in one pass collects every glyph it needs for a typeface into one subset with one tag.

Two tags for one typeface mean glyphs of that typeface were embedded on two separate occasions. That is the trace left when text is added to, or replaced in, an existing PDF by a different tool — and because it lives in the page resources rather than in metadata, it survives metadata stripping and it survives a whole-file rewrite that flattens the revision chain.

It is also the signal I have seen misread most often, so: merging two PDFs that both use Helvetica produces exactly this pattern, innocently. Someone combining two monthly statements into one upload will trip it. Never read it alone.

6. What a ByteRange gap actually proves

If the statement is digitally signed — routine in parts of the EU and LATAM — there is one more check, and it is the strongest structural finding available in the format because it is arithmetic.

A signature dictionary's /ByteRange is an array of offset/length pairs naming the bytes that were hashed, normally covering everything except the hole where the signature itself sits:

/ByteRange [0 8420 42840 15680]

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

Enter fullscreen mode Exit fullscreen mode

Those 2,684 bytes were appended after signing and are not protected by that signature. No certificate needed, no trust store, no crypto: you compare two integers. And it holds regardless of whether the signature validates, which is the part that catches people out. A viewer can correctly report a valid signature while displaying content the signature never covered — that is what "signed, with subsequent changes" means, and users click straight past it.

The honest caveat, because this one gets oversold: bytes outside the range are not automatically illegitimate. Multi-signature workflows produce them by construction, and PDF has a whole mechanism (DocMDP permissions) for declaring which subsequent changes the signer allowed. Reporting a coverage gap is not the same as deciding the change was forbidden.

The uncomfortable part

None of these signals is robust on its own, and I would distrust anyone who tells you otherwise.

  • Metadata cleanup is one line of code and kills signals 3 and 4.
  • A whole-file rewrite kills the revision chain and can erase the /ID divergence — but destroys any signature and leaves conspicuously fresh, coherent metadata.
  • Flattening the page to a raster image kills the font tell — and produces a document whose own shape (a full-page scan where a machine-generated statement should be vector text) is the finding.

The evasions are close to mutually exclusive. That is the actual reason a set of independent signals is worth having: not because any one of them is hard to defeat, but because defeating one tends to light up another.

It is also why I think the only defensible framing is signals, not verdicts. "This file contains two revisions, a later revision replaced a page content stream, and the Info dictionary names an online PDF editor that the XMP packet does not" is specific, true of the bytes, and something a human can act on. "Our system detected fraud" is neither of those things, and it is the sentence that ends up in a complaint.

If you want to skip the hex editor

I built Tamperlens because nothing self-serve existed between "read the bytes yourself" and "request a demo". It is a raw-byte parser — deliberately not built on a high-level PDF library, since those normalise away exactly the evidence described here — that runs ten signal families and hands back JSON.

There is a free checker on the front page: drag a PDF in, get the report, nothing is stored. The most useful thing you can do with it takes about four minutes — put a statement you know is genuine through it, then run the same file through a free online PDF editor and put that through. Watching which signals light up on a file whose history you personally know is worth more than any blog post, including this one.

Every signal family, with its benign causes documented at the same length as its malicious ones, is in the field guide.

Happy to argue about any of this in the comments — particularly if you have a case where one of these signals fired on something entirely innocent, because those are the interesting ones.