Reference: PDF fraud signals — the field guide
Every vendor in this space publishes what it catches. None of them publish what makes it stop working. This is the second list, for my own engine, in full.
Why publish the manual
The argument against a page like this is that it is a how-to guide for the people I am trying to catch. I do not think that survives contact with reality.
Every command below is on the first page of results for "how to remove pdf metadata". Someone building fake statements for money already owns the tools and already knows which one leaves the cleanest file — that knowledge is what they sell. The people who do not know are the ones on the receiving end: the analyst, the letting agent, the accountant who reads a clean report and hears this document is genuine.
A clean report from a byte-level parser means the bytes are clean. Those are different claims, and the entire risk lives in the gap between them. So here is the gap, drawn to scale.
The five columns
Four of these are the ones people actually run. The fifth is in the table because my own code puts it there.
1. Metadata strip
exiftool -all= statement.pdf
What most people think this does is delete the metadata. What it does to a PDF is append a new revision in which the metadata is blank. The original bytes stay exactly where they were.
/ByteRange. This is why the tool's own documentation tells you to
run a rewriter afterwards.
2. Whole-file rewrite
qpdf --linearize in.pdf out.pdf
gs -sDEVICE=pdfwrite -o out.pdf in.pdf
One revision comes out the other side, with a rebuilt cross-reference table and no history. The two tools are not equivalent, though, and the difference decides five cells in the table below.
A structural rewriter copies through what it does not need to understand — the Info dictionary, the XMP packet, the font objects. A re-renderer regenerates all of it from what it drew. Same column, opposite outcome, and I have not measured which tools sit where.
3. Print-to-PDF
Chromium's Skia backend, macOS Quartz, Microsoft's print driver. The document is rendered and a new file is written from the rendering. Nothing structural crosses over.
4. Flatten to raster
Every page becomes one image. No text, no fonts, no structure worth reading. The heaviest hammer available and the only one in this list with a real cost, which I come back to at the end.
5. Encrypt with an owner password
Not a bypass anybody markets, and the one column here that is derived purely from
my own source. An encrypted PDF has its strings and streams as ciphertext, so
five families are skipped by the engine rather than run on noise. The list is a
constant called ENCRYPTED_SUPPRESSED_FAMILIES, and it is five names
long.
An owner password does not stop anyone opening the file. It still turns off a third of the engine.
The matrix
Rows are the 12 registered families, in registry order. Eleven of them run on
every document; issuer-mismatch only runs when the caller supplies a
baseline, and it is the last row for that reason.
gone means the family cannot fire. weaker means it fires with less to say. louder means the evasion itself creates the finding. untested means I could not decide it from the code and have not run the experiment.
| Family | Metadata strip | Whole-file rewrite | Print-to-PDF | Flatten to raster | Encrypt |
|---|---|---|---|---|---|
incremental-updates |
louder — the strip is the second revision | gone — one revision out | gone | gone | fires — the xref chain is not encrypted |
metadata-mismatch |
gone — needs both stores present | untested — copied through, or regenerated? | gone — one writer fills both | gone | gone — suppressed by the engine |
date-anomalies |
gone — no timestamps left | untested — same question | gone — fresh, coherent dates | gone | gone — suppressed |
producer-fingerprint |
weaker — drops to "declares no producing tool", severity low | untested — qpdf keeps the string, a re-render replaces it | weaker — Quartz and Microsoft are on the list, Skia is not | louder — a full-page image escalates the finding to high | gone — suppressed |
id-inconsistency |
untested — does the appended trailer touch /ID[1]? | gone — one save, both halves equal | gone | gone | fires — /ID lives in the trailer |
font-anomalies |
fires — fonts are not metadata | untested — a re-subset would merge the duplicates | gone — one subset per face | gone — no fonts at all | gone — suppressed |
hybrid-page |
fires | fires — the image and the text both copy through | untested — depends whether the printer keeps one image per page | gone — no text layer left to be suspicious about | gone — suppressed |
active-content |
fires — a strip touches metadata only | untested — structural copy keeps it, a re-render drops it | gone — actions are not printed | gone | fires — read from dictionary keys, and the finding says so |
signature-coverage |
louder — appended bytes land outside the signed range | gone — signature destroyed, and nothing reports its absence | gone | gone | fires — /ByteRange is plain |
structure-warnings |
fires — an append repairs nothing | gone — repairing is what a rewriter is for | gone | gone | louder — carries the disclosure and names what was skipped |
redaction-exposure |
fires — the words under the box are content | fires — paint order copies through | untested — the marks half is gone, the covered-text half probably survives | gone — and here the evasion is also the correct fix | weaker — unapplied marks still counted, covered text unreadable |
issuer-mismatch (needs a baseline) |
louder — an absent producer mismatches too | louder | louder — two dimensions move, which is what reaches high | louder — producer and font set both move | louder — suppressed fields read as absent |
Three cells worth reading twice
A metadata strip makes two families louder
Because it is an append. The revision count goes from one to two, which is
exactly what incremental-updates looks for. And on a signed
document the appended region sits past the end of the signed
/ByteRange, which is the highest-severity finding the engine has.
Someone tidying a PDF before sending it to me makes the report worse, not better. That is not clever detection on my part. It is the file format.
Removing the producer string does not silence the producer family
With no producing tool declared anywhere, producer-fingerprint still
returns a finding: document declares no producing tool, at severity
low.
Low is the right place for it, and our own corpus is the reason. Six genuine Huntington account-analysis statements carry no producer and no creator string at all — high-volume composition engines often emit none. An absent toolchain is weakly suspicious and weakly normal at the same time, so it sits at the bottom of the ladder and stays there.
Chromium's print-to-PDF trips nothing, on purpose
The consumer-editor list has around fifty entries. Quartz PDFContext
is on it. Microsoft Print to PDF is on it. Skia/PDF,
which is what Chrome writes when you print a page, is not.
I know how to close that. I am not going to. When we parsed 213 PDFs published by
US banks, two Bank of America online-banking agreements came back as
Skia/PDF m130 and Skia/PDF m135 — printed straight out
of a browser by whoever maintained the page. Adding Skia to that list turns a real
bank's real document into a finding, on a page that is genuinely from the bank.
So printing from Chrome is the cheapest evasion in this table. It costs nothing, takes one keystroke, and my engine says nothing about the tool that did it.
What survives everything
Read the matrix down the columns and the answer is uncomfortable: nothing in the unconditional set survives all five.
The one row that survives is issuer-mismatch, and it is the row that
needs something the document cannot supply. Every other family reads the file's
own account of itself, so rewriting the file rewrites the evidence. That one
compares the file to documents from the institution it claims to come from — and
all five evasions replace the toolchain a real issuer used.
That is the whole asymmetry, in one sentence: self-description is defeated by regeneration, and comparison to an outside reference is not. The price is that you have to hold the reference. Public specimens mostly do not work for this; documents you already know are genuine do.
The raster trade-off, honestly
Flattening every page to an image silences ten of the eleven. It is the strongest move on the board and I have nothing that beats it.
What it costs is the text layer. No selection, no copy-paste, no extraction, no screen reader. If the document is going into any pipeline that reads the text — an income parser, an accounting import, a reconciliation step — a raster fails on arrival, and it fails before anyone gets to the question of whether it is real.
But that cost lands on the workflow, not on my parser. If a human is going to open the file and look at it, a flattened page sails past everything on this site.
A full-page raster is not itself reported as a finding, because scanners exist and a scanned document looks exactly like that. On a flattened file the engine is mostly silent, and silence here means I could not look, not I looked and it was fine. If your process accepts raster PDFs, assume this engine is close to blind on them and put the control somewhere else.
One more thing the columns hide: they compose. The strip tool's own documentation tells you to run a rewriter afterwards, precisely because the strip alone leaves the original bytes on disk. Run both and you have removed the metadata and the history in one go. That pair is the realistic attack, and it is cheaper than either of the last two columns.
The cells I could not fill in
Eight cells say untested. They are not hedging — they are the cells where the answer depends on which tool ran, and I have derived this table from source rather than from a bench full of files.
id-inconsistencyagainst a metadata strip — whether the appended trailer rewrites the second half of/ID.metadata-mismatch,date-anomalies,producer-fingerprint,font-anomaliesandactive-contentagainst a whole-file rewrite — all five turn on the same unmeasured question, whether the rewriter copies those objects through or regenerates them.hybrid-pageagainst print-to-PDF — whether the print path emits one image per page or tiles it, which decides whether the page still reads as a full-page image at all.redaction-exposureagainst print-to-PDF — the unapplied-marks half is certainly gone; whether the covered text is re-emitted underneath the box is the part I have not run.
Each of those is a morning's work with a fixture set, and when I run it this post gets superseded by a dated one rather than quietly corrected.
The number I will not give you
The obvious next question is what fraction of real document fraud this catches. I do not know, and neither does anyone else selling this.
Every academic benchmark I could find is pixel-level image forensics; there is no PDF-structure equivalent. No vendor publishes a false-positive rate on legitimate documents. The one public figure I have seen is an unmethodologised 80% on a competitor's site.
So the claim is high precision, unknown recall. When a family fires, the report shows the bytes it fired on and you can check them yourself. When nothing fires, you have learned that this particular set of tests came back quiet — which, after reading the table above, you now know how little that can mean.
A finding is a statement about bytes. "This document is fraudulent" is a statement about a person, and a parser does not get to make it.
Run it on a file of your own
The free checker takes a PDF and gives back the same report this table describes, with the raw evidence under every finding. No account, nothing stored.
If you want to see the table hold up, run a document twice: once as you received it, once after printing it to PDF. The difference is the point of this post.