Reference: PDF fraud signals, the field guide
The matrix was re-run, and the thing it caught was the bench. A
fixture the evasion's own tool refused had no report to read, and the table
printed that absence as gone: nine cells overstated how much an evasion
silences, and one was published as gone on a fixture that was never
successfully derived at all. Failures are counted separately now and shown as
unmeasured. One verdict moved on its merits,
embedded-image-anomalies under a qpdf rewrite.
What did not move: the laundering ranking, every number in the four-state forgery table, and the conclusion, which now holds across all eighteen unconditional families rather than the eleven the figure used to draw. The engine went 1.29.0 to 1.32.0 in the meantime and moved nothing: all four builds print the same table over the same fixtures.
This post claimed to cover every signal family and covered
eleven of eighteen. The seven it left out are
signature-integrity, signature-permissions,
embedded-image-anomalies,
document-injection-markers,
br-identifier-checksum, running-balance-break
and glyph-anomalies. Most of them did not exist when the
table was first written and the sentence above it was never revisited,
which is the ordinary way a completeness claim goes false.
The whole matrix has been re-run on one engine, 1.29.0, over the 92
PDF fixtures in the repository, by one checked-in script. Six of the
eleven published rows moved. The largest is
signature-coverage under a qpdf rewrite, published as
gone: signature destroyed and measured as high on all
thirteen signed fixtures: the rewrite invalidates the
signature rather than removing it, which is the loudest thing this
engine reports. Every change is listed in
what I had wrong.
Rows now carry n, the number of fixtures the family fires
on as built. Four of the eighteen fire on three fixtures or fewer, and
"fires" over n=1 is not the same claim as "fires" over n=16.
A seventh column, and a correction to the six beside it. The post already
said the columns compose and already named this pair, then never measured it.
Measuring it meant re-running all seven on one engine, which moved the other
six as well.
exiftool -all= followed by qpdf --linearize
takes an unsigned forgery from high to low,
because the rewrite erases the revision the strip manufactures and the strip
empties the stores the rewrite would carry through. Measured on engine 1.29.0
over 92 fixtures: strip, then rewrite.
It costs the forger two things. Every laundered file lands on the same two
residual signals, so the result does not look untouched, it looks rewritten.
And on a signed document it is a disaster: 13 of 13 signed fixtures came out
high, including one that reads 0 low before.
This post first shipped with eight cells marked untested and a promise to run them. They have now been run on a bench (qpdf, Ghostscript, exiftool and a Quartz print path), and the matrix below is measured rather than derived.
Three things changed beyond filling the blanks, and I have left the record of all three rather than quietly swapping the table. The whole-file rewrite column has been split in two, because qpdf and Ghostscript disagree on three of the five families it covered. The single column was a wrong question, not just a missing answer. Six cells I had published as settled were wrong. And the run found a bug in the engine itself, which had to be fixed before three of the cells could be filled at all. All of it is in what I had wrong.
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 six columns
Four of these are the ones people actually run. The last is in the table because my own code puts it there. The rewrite is two columns rather than one, for a reason the bench decided rather than I did.
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. They disagree on three of those five families, which is why this is two columns now: there is no answer to "what does a whole-file rewrite do" that does not name the tool.
The interesting half is where Ghostscript fails to launder. It rewrites
/Producer and copies /Creator through verbatim, so an editor
that named itself in both survives a full re-render. It re-subsets fonts without
merging them, so two subsets of one typeface stay two subsets. And it does not remove
active content so much as move it: /OpenAction came out the far side as
an /A action on the catalog, which would satisfy anyone grepping for the
old key.
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.
What it cannot throw away is the picture, which is why the four families that read the picture rather than the paperwork come through it intact, the redaction one still carrying the words under the box.
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 the
engine skips those families outright rather than running them on noise. The list is
a constant called ENCRYPTED_SUPPRESSED_FAMILIES, and it is
nine names long: five of them are rows in the matrix below, and
three are families this post never covered
(document-injection-markers, embedded-image-anomalies and
signature-integrity, added in engine 1.13.5, 1.14.0 and 1.15.0).
An owner password does not stop anyone opening the file. It still turns off nine of the nineteen families.
This section said five in both places. That was true the day it was written and stopped being true three engine releases later. Unlike every other number in this post it is not a bench measurement: it is the length of a list in my source, so correcting it took a read rather than a re-run, and leaving it wrong would have been the one kind of stale number this post has no excuse for. A test now derives it from that constant, so it cannot drift again quietly. The matrix below was left untouched that day, because only a bench run may move it. The run happened the next day and is dated where it landed.
The matrix
Rows are the 19 families that run on every document, in registry order.
issuer-mismatch is the twentieth and is not here: it only runs when
the caller supplies a baseline, so the bench cannot reach it, and
what survives everything treats it separately 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. split means it goes quiet on some fixtures and fires on the rest. unmeasured means the evasion's own tool refused the file, which is not the same as the family going quiet and is no longer printed as though it were.
Three of these cells could not be filled the first time this table was measured, because the run turned up a bug in my own parser. It was reading metadata out of superseded revisions and reporting it as current, so a stripped file still looked like it carried everything the strip removed. That is fixed in engine 1.13.4 and the cells below are the fixed engine's answers. The story is in what I had wrong, because it is the more interesting half.
The shape, without the reading. The detail, and the reasons, are in the table below.
| Family | Strip | Rewrite (qpdf) | Rewrite (gs) | Raster | Encrypt | |
|---|---|---|---|---|---|---|
incremental-updates n=16 |
louder: the strip is the second revision | gone | gone | gone on the 15 it could print | gone | gone: a re-save |
metadata-mismatch n=2 |
gone: needs both stores present | fires: high and medium | gone: one writer fills both | gone: one writer fills both | gone | gone: suppressed |
date-anomalies n=1 |
gone: no timestamps left | fires: medium | gone: fresh dates | gone: fresh dates | gone | gone: suppressed |
producer-fingerprint n=24 |
fires: 21 of 24 unchanged, and 72 silent files now declare no producing tool | fires: 21 of 24 unchanged, 2 weaker | split: gone on 20 of 24 | weaker: info on 22, and 69 silent files now fire | fires: 21 of 24 unchanged, and 73 silent files now fire | gone on all 23 it could encrypt |
id-inconsistency n=14 |
louder: a new /ID[1], medium, on 70 files that had none | weaker: gone on 9 of 14, and 80 silent files gain a fired pair | gone | gone on the 13 it could print | gone on the 12 it could raster | weaker: gone on 9 of 14, and 80 silent files gain a fired pair |
font-anomalies n=6 |
louder: info to high on 5 of 6 | weaker: info | split: gone on 3 of 6, info on the rest | split: gone on 3 of 6, info on the rest | gone: no fonts | gone: suppressed |
hybrid-page n=3 |
fires | fires | gone: re-rendered | fires: one image per page | gone: no text layer | gone: suppressed |
active-content n=4 |
fires | fires | fires | gone on the 2 of 4 it could print | gone | fires: dictionary keys are plain |
signature-coverage n=13 |
louder: appended past the signed range | louder: high on all 13, from info on 6 | gone: signature destroyed | gone | gone | louder: high on all 13 |
signature-integrity n=13 |
fires: info on 10 of 13 | louder: info to high on 10 of 13 | gone: signature destroyed | gone | gone | gone |
signature-permissions n=13 |
fires: info on 9 of 13 | louder: info to high on 4, medium to high on 1 | gone: signature destroyed | gone | gone | louder: same as the qpdf column |
br-identifier-checksum n=3 |
fires: the digits are content | fires | split: fires on 2 of 3 | fires | gone: no text layer | gone: suppressed |
running-balance-break n=2 |
fires | fires | gone: re-rendered | fires | gone: no text layer | gone: suppressed |
glyph-anomalies n=1 |
fires: low | fires: low | gone: re-rendered | fires: low | gone: no text layer | gone: suppressed |
extraction-anomalies n=4 |
fires | fires | split: gone on 3 of 4 | split: gone on 3 of 4 | gone: no text layer | gone: suppressed |
structure-warnings n=7 |
fires: low on 3 and medium on 1, of the 4 it could strip | split: gone on 2 of the 5 it rewrote, low on the rest | split: gone on 6 of 7 | split: gone on 4 of the 5 it could print | gone on the 5 it could raster | weaker: low, and NEW low on 85 files that name what encryption hid |
redaction-exposure n=9 |
fires: the words are content | fires: paint order survives | split: fires on 7 of 9, one of them louder | split: fires on 8 of 9, gone on one | gone, and it is the correct fix | split: gone on 8 of 9 |
document-injection-markers n=1 |
fires: high | fires: high | fires: high | unmeasured: Quartz refuses the only fixture | gone | gone: suppressed |
embedded-image-anomalies n=6 |
fires | split: fires on 4 of 6, one weaker, one gone | gone: images re-encoded | gone | gone | gone: suppressed |
Engine 1.37.0, qpdf 12.4.0, exiftool 13.55, Ghostscript 10.07.1 and the Quartz
print path, over the 98 PDF fixtures in the repository, 31 August 2026.
n is the number of those fixtures the family fires on as built.
Reproduce with
npx tsx scripts/evasion-bench.mjs --in test/fixtures; the same run
with --record docs/evasion-bench-last-run.json writes the summary a
test checks this table against.
Run twice the same day, on two engine builds. The correction below was found on 1.31.0. The bench was then run again on 1.32.0, which publishes what each revision of a document wrote and adds no detection, and the recorded summary came back identical to the 1.31.0 one in every field except the version number it stamps. The table above is the second run.
The table is a re-run, not a reprint, and it moved for a reason worth
stating plainly: the bench was counting its own failures as
successes. When a tool ran and failed on one fixture, that fixture had
no report to read, and the cell summary read the absence as the family
stopped firing. So a gone count could be six real silences and two files
exiftool had refused. Nine cells were inflated by it, and one,
document-injection-markers under print-to-PDF, was published as
gone on the strength of a single fixture Quartz will not open at all.
That cell now reads unmeasured, because that is what it is.
The irony is that the script already carried this rule for a missing tool: "absent, not zero", it says, and "a silently missing column is how a matrix starts lying". It applied the rule per column and not per file. Fixed at the source, so the next run cannot repeat it.
One verdict changed on its merits.
embedded-image-anomalies under a qpdf rewrite was published as
fires on 5 of 6, one weaker and measures fires on 4 of 6, one
weaker, one gone. The fixture that goes quiet is
certified-page-image-added.pdf, where the finding is an image
was added in a later revision: a rewrite collapses the revision chain, so
there is no later revision left for the image to have arrived in. That is
structural, which means the published cell was mis-transcribed on 15 August
rather than overtaken by anything.
Nothing else moved, and one thing that could have did not. The engine went 1.29.0 to 1.32.0 in between; running the whole bench on all four builds over the same fixtures produces byte-identical tables, so not one cell here is attributable to the engine. The laundering counts below reproduce exactly. And the conclusion, re-checked over all eighteen unconditional families rather than the eleven the old figure drew, still holds.
Four cells worth reading twice
A metadata strip makes four 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, on the bench a signed file went from low to high on that
alone.
The third was the cell I had marked untested, and it is the neatest result of the
run. The appended trailer keeps the original /ID[0] and writes a
new /ID[1]: a file whose two halves matched before the strip,
b6060606… and b6060606…, comes out carrying
b6060606… and b7060606…. That mismatch is precisely what
id-inconsistency exists to find. The strip manufactures the
finding it was run to remove.
The fourth arrived with the eighteen-family table and belongs to the same
mechanism. font-anomalies reads info on a
single-revision file and something worse on a file that has been edited, so the
revision the strip appends escalates it: info to high on
five of the six fixtures it fires on. Nothing about the fonts changed. The file
simply stopped being a one-save document.
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.
Destroying a signature is quieter than invalidating it
Read the three signature-* rows across instead of down and the
usual direction reverses. The evasions that leave the signature object in the
file make the report louder: a qpdf rewrite takes
signature-coverage to high on all thirteen signed
fixtures, because invalidating a signature is the loudest thing this engine
reports. The three that re-render the page take all three families to
gone.
On the nine signed fixtures that read above low as built, the four
evasions that keep the signature launder 0 of 9. Ghostscript,
print-to-PDF and a raster flatten launder 9 of 9. A re-render
does not break the signature, it removes it, and there is nothing left to
report.
Which is the uncomfortable part. This engine has no signal for "the
document should carry a signature and carries none." An invalidated
signature is a finding; an absent one is an ordinary PDF, and most PDFs are
unsigned. So against a signed document the forger's answer is not to compose
evasions harder, it is to print. That is an issuer-baseline question, the same
shape as issuer-mismatch, and it is the largest hole this matrix
has exposed. It is recorded here as a gap and not as a result, because nothing
about it is fixed.
The column I should have run: strip, then rewrite
This post has said since August that the columns compose, and named this exact pair while doing it: the strip tool's own documentation tells you to run a rewriter afterwards. It said so and then did not measure it, which is the same mistake as the eight cells marked untested in the first version. Every column above is one command, so the matrix answers "what does this tool do to my signals" rather than the question a forger actually asks. Here is the pair, run.
exiftool -all= is an incremental edit on a PDF. It
appends a revision and leaves the freed metadata objects sitting in the byte
stream, which is why the strip column above reads louder rather than
gone. qpdf --linearize re-serialises from the object
graph, so it drops unreferenced objects and collapses the revision chain, but
run on its own it preserves the metadata disagreements it finds. Each is a
bad evasion against this engine. Composed, the rewrite erases the revision
the strip just manufactured, and the strip empties the stores the rewrite
would otherwise carry through.
This is not a thought experiment I constructed. watermarks-remover, an MIT-licensed tool published to remove AI provenance marks, runs exactly this pair behind one command, and warns in its own source that exiftool alone leaves "the original metadata bytes recoverable". The recipe is packaged, in a Docker image, with a README.
| Family | Strip | Rewrite (qpdf) | Strip, then rewrite |
|---|---|---|---|
incremental-updates |
louder | gone | gone |
metadata-mismatch |
gone | fires | gone |
date-anomalies |
gone | fires | gone |
id-inconsistency |
louder: medium on a file that had none |
fires: info |
fires: info |
producer-fingerprint |
weaker: drops to "declares no producing tool" | fires | weaker: same "declares no producing tool" |
font-anomalies |
louder | fires: info |
fires: info |
the three signature-* families |
louder: appended past the signed range | signature-coverage high on 13 of 13;
the other two louder on 10 and on 5 |
the same, on the same fixtures |
| everything that reads the page | fires | fires | fires |
Engine 1.30.0, qpdf 12.4.0, exiftool 13.55, over the 92 PDF fixtures in the
repository, re-run 24 August 2026 and unchanged from the 1.29.0 run of 15 August
except where the callout below says otherwise. "Everything that reads the page"
is redaction-exposure, hybrid-page,
active-content, running-balance-break,
br-identifier-checksum and
document-injection-markers: unchanged in severity across all three
columns. embedded-image-anomalies was in that list and is not any
more, because a rewrite silences one of its six fixtures and weakens another.
What it does to a forgery
The clearest reading is one document in four states. This is
showcase-br-comprovante-edited.pdf, a fictional proof of address
whose page content was replaced after generation, and its clean twin.
| File | Score | What the report says |
|---|---|---|
| clean, untouched | 0 low |
no signals at all |
| forged, untouched | 70 high |
incremental-updates: page content replaced after generation |
| forged, strip only | 100 high |
the above, plus the two the strip manufactured |
| forged, strip then rewrite | 10 low |
producer-fingerprint low, id-inconsistency info |
| clean, strip then rewrite | 10 low |
the same two, identically |
These five rows are not in the fixture set the rest of this page runs on:
showcase-br-comprovante is generated by
npm run corpus:showcase and deliberately not committed. They were
regenerated and re-measured on engine 1.30.0 on 24 August 2026, and every score
and every signal above came back the same.
So the honest answer is the one I would rather not print: on an
unsigned document, the two-step takes a real forgery from
high to low, and the laundered forgery and
the laundered clean file are indistinguishable to this engine.
This section first closed by calling the two-step the strongest evasion on this page. That was wrong, and wrong in the way this post exists to warn about: the new column was measured on engine 1.29.0 while the six beside it still carried prose written against 1.13.4, so a fresh number was being ranked against stale text.
Re-run on one engine, over the 35 unsigned fixtures that read above
low as built, the ranking is: a raster flatten launders
34, qpdf --encrypt 32, a
Ghostscript rewrite 24, print-to-PDF 18,
and the two-step 13. It is fifth of seven, not first. What
stands is the mechanism, and the fact that it beats either half alone:
qpdf --linearize by itself launders 11, and
exiftool -all= by itself launders none.
Every number in this paragraph was re-measured on engine 1.30.0 on 24 August 2026 and came back identical, down to the split of the 44 loud fixtures into 35 unsigned and 9 signed. It is the only block of figures on this page the re-run did not touch.
What it cannot do
Three things, and they are the reason the row above is not the end of the story.
It does not make a document look untouched. Both laundered
files land on 10 low carrying the same two signals, and neither
resembles the clean original, which carries none. A PDF that declares no
producing tool and has mismatched /ID halves has been
through a rewriter. For document classes where the issuer always names its
software, and bank statements and payroll systems are exactly that, "declares
no producing tool" is not a quiet result. It is the anomaly.
It is catastrophic on anything signed. All 13 signed
fixtures came out high, 11 of them at 100 and the other
two at 90, including certified-intact.pdf, which reads
0 low untouched.
The rewrite does not remove the signature, it invalidates it, and an
invalidated signature is the loudest thing the engine reports. In a market
where the document arrives signed, ICP-Brasil being the case I know best,
this evasion is worse than doing nothing.
It does not touch the page. Every family that reads content rather than bookkeeping fires exactly as before. The text under a black box is still there, the balance column still fails to add up, the check digit is still wrong. That is the same conclusion the bottom of the matrix already reached, and this column does not move it.
Reproduce with npx tsx scripts/evasion-bench.mjs --in test/fixtures,
which builds the derived files into a temp directory and prints the table. It is
checked in this time; the 2026-08-03 bench was not, which is why the severities
above it went two engine versions without a re-run. Being checked in is not the
same as being run, so the run is checked in too: adding
--record docs/evasion-bench-last-run.json writes the summary, and a
test fails once the engine moves past the version in it.
What survives everything
Read the matrix down the columns and the answer is uncomfortable: nothing in the unconditional set survives all six. That was written when the set was eleven families and the figure below drew eleven. It has been re-checked over the whole set at every re-run since, most recently over all nineteen on 31 August 2026, and it is still true: every one of the nineteen has at least one column in which every firing the bench could measure went gone.
A family counts as silenced when every firing the bench could measure went gone. Fixtures the evasion's own tool refused are excluded rather than counted as silences, which is the correction of 24 August 2026 and is why the qpdf bar is 1 and not 3. Cost is what the evasion takes away from the person sending the document, which is the column that decides whether anyone actually does it.
The one family that never goes quiet is issuer-mismatch, and it is
the one the grid above cannot draw: it fires only when the caller supplies a
baseline, so a bench that inspects a file on its own can never reach it. Its row
comes from the 2026-08-03 run, which did supply baselines, and it is stated here
rather than pictured for that reason. 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 every
evasion here replaces the toolchain a real issuer used.
It does not survive them all equally, though, and that run corrected me on the direction. I had assumed that the more an evasion destroyed, the louder this family would get, because suppressed fields read as absent. The opposite is true: the comparison scores against the dimensions it can still see, and flattening to raster cuts that from nine of nine to one of six. The more an evasion destroys, the less there is left to disagree with a baseline about. Only the two evasions that rewrite while preserving structure make it louder.
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 seventeen of the eighteen. The one
that survives is producer-fingerprint, and it survives by getting
louder: a rasteriser that declares no producing tool is itself the
finding. 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. It is now measured rather than asserted: strip, then rewrite, added 15 August 2026.
What I had wrong
The third pass, 24 August 2026: the instrument
The 15 August pass ended by saying that re-running the whole table was now one
command. This is the first time anyone ran it, and what it caught was not a
family, it was scripts/evasion-bench.mjs itself.
A failed derivation was being published as a silenced family.
Nine of the 92 fixtures are things a tool can legitimately refuse:
garbage.pdf is not a PDF, encrypted.pdf cannot be
re-encrypted, Quartz will not open six files whose whole purpose is to be
malformed. When the tool failed there was no report to read, and the cell summary
treated a missing report exactly as it treats a family that stopped firing. So
the strip column claimed structure-warnings went
gone on 3 of 7 when it went gone on none of them and
exiftool had simply refused three files.
The script had the right rule written down and applied it one level too high. Its own header says a variant whose tool is missing is UNMEASURED, absent, not zero, and that "a silently missing column is how a matrix starts lying". It enforced that per column and not per file.
| Cell | Published | Measured 1.30.0 |
|---|---|---|
document-injection-markers × print |
gone: not printed | unmeasured: the family has one fixture and Quartz refuses it |
structure-warnings × strip |
gone on 3 of 7, low on the rest | gone on none: those three are files exiftool refused |
structure-warnings × encrypt |
weaker: low, gone on 2 of 7 | weaker: low, gone on none |
structure-warnings × rewrite, print, raster |
gone on 4, 6 and 7 of 7 | gone on 2 of 5, 4 of 5 and 5 of 5 measured |
id-inconsistency × rewrite and encrypt |
weaker: info, and gone on 5 of 8 | gone on 3 of the 6 either tool could process |
active-content × print |
gone: not printed | gone on the 2 of 4 Quartz would open |
embedded-image-anomalies × rewrite (qpdf) |
fires on 5 of 6, one weaker | fires on 4 of 6, one weaker, one gone |
The last row is the only one that is not about the instrument. The fixture that
goes quiet is certified-page-image-added.pdf, where the finding is
that an image arrived in a later revision; a rewrite collapses the revision chain,
so there is no later revision left for it to have arrived in. That is structural
and was true in August, which makes it a transcription error rather than drift.
The engine is not responsible for any of this. It went 1.29.0 to 1.30.0 between the two runs, and running the entire bench on both builds over the same fixtures produces byte-identical output. That is the first time this post has been able to say a number moved for a reason other than the engine, and it is only sayable because the run is cheap enough to do twice.
The second pass, 15 August 2026
Two kinds of wrong, and the first is the worse one.
The table claimed to be complete and was not. It said
"every signal family" and carried eleven of eighteen. Five of the seven
missing families shipped after the table was written
(br-identifier-checksum,
running-balance-break, glyph-anomalies,
embedded-image-anomalies,
signature-permissions); two did not
(signature-integrity,
document-injection-markers). Either way the sentence above
the table went on saying "every" while the engine grew underneath it.
A page whose argument is that other vendors publish what they catch and
not what defeats them cannot leave seven families out of the second
list.
Six published cells moved once the whole matrix ran on one engine. They were measured on 1.13.4 in August and read as current ever since; 1.17.0 re-specified four families four days later.
| Cell | Published | Measured 1.29.0 | Why |
|---|---|---|---|
signature-coverage × rewrite (qpdf) |
gone: signature destroyed | louder: high on all 13 | The rewrite invalidates the signature, it does not remove it. An invalidated signature is the loudest finding this engine has, so the cell was inverted |
producer-fingerprint × raster |
louder: the full-page image escalates it to high | fires: low | 1.17.0 capped this family at medium and stopped full-page imagery escalating anything. The evasion doc flagged this on 2026-08-04 and the post was not updated |
id-inconsistency × rewrite (qpdf) |
louder: qpdf does the same, always | weaker: info, and gone on 5 of 8 | 1.17.0 made this info on any single-revision file, which is what a rewrite produces |
redaction-exposure × encrypt |
weaker: marks counted, text not read | gone on 8 of 9 | Encryption suppresses the content families outright rather than degrading them |
font-anomalies × rewrite (gs) |
fires: re-subset, not merged | gone on 3 of 6, info on the rest | Both halves are true of different fixtures, and the row said only the louder half |
structure-warnings × encrypt |
fires: names what was skipped | weaker: low, gone on 2 of 7 | Same shape: true of most fixtures, not of all, and the severity dropped |
The pattern in five of the six is one thing: a cell measured on
one engine and read as current on a later one. The bench was
not checked in, so re-running it was a manual job nobody did, and the
matrix drifted for eleven engine releases. It is
scripts/evasion-bench.mjs now, and re-running the whole
table is one command.
The first pass, 3 August 2026
This post originally carried eight cells marked untested and twelve rows derived by reading my own code. The bench filled the eight. It also caught six cells that were not marked untested at all (they were published as settled, and they were wrong), and then a seventh thing that was not a cell at all, but a bug in the engine this whole table describes. Deleting any of it and moving on would make this page a worse document than the one that admitted it did not know, so here it is.
| Cell | Originally published | Measured |
|---|---|---|
id-inconsistency × rewrite |
gone: "one save, both halves equal" | louder under qpdf |
font-anomalies × print-to-PDF |
gone: "one subset per face" | fires |
incremental-updates × encrypt |
fires: "the xref chain is not encrypted" | gone |
structure-warnings × encrypt |
louder | fires: severity unchanged |
issuer-mismatch × raster |
louder | weaker |
issuer-mismatch × encrypt |
louder | weaker |
qpdf does not equalise the two halves of /ID. It keeps
/ID[0] from the original and writes a fresh /ID[1], which is correct behaviour. That is what the second half is for, and it means running qpdf
over any file at all creates an id-inconsistency finding that was not
there before. So does exiftool -all=. The metadata strip manufactures the
finding it was meant to remove.
A printer emits one font subset per source resource, not per typeface. It has no idea two resources name the same face, so two resources naming Arial come out the far side as two fresh subsets and the conflict survives. I had reasoned that a printer normalises fonts; it normalises glyph programs, not resource slots.
Encryption cannot preserve an incremental update, because there is no way to encrypt a file without rewriting it whole, and a rewrite collapses the revision chain. I had written that cell as though encryption were a filter applied over existing bytes. It is a re-save, which also means this column is always "rewrite plus encryption" and never encryption on its own.
Two things the run did not overturn, for balance: the print path really does
leave badly redacted text readable underneath the box, Jonathan Archer and
Salary: 184,000.00 came back out of a printed file verbatim, and a raster
flatten really does escalate producer-fingerprint rather than silencing it,
exactly as the table said before anyone ran it.
The seventh thing I had wrong was the engine
Three cells could not be filled at all on the first pass, and the reason turned out not
to be the fixtures. Running a metadata strip through my own parser produced a result I
did not believe: metadata-mismatch still fired, naming two producers, on a
file that no longer declared either. So did date-anomalies.
A strip is an append, which this post already said, but that cuts both ways, and I had
only followed it in one direction. exiftool -all= removes metadata three
ways at once: the new trailer omits /Info, the Info dictionary and the XMP
stream are marked free in the new cross-reference section, and the
catalog is rewritten without its /Metadata reference. The original bytes
stay in the file, untouched. My parser defeated all three of those: it
inherited /Info from the previous trailer, it discarded free entries so a
deletion could never shadow the object it deleted, and it found XMP by searching the raw
file for <x:xmpmeta, which returns the first match, so the packet it
reported was guaranteed to be the oldest one in the file.
The blast radius was wider than stripped files. Any document with an incremental update that corrected its metadata was being judged against the superseded values. It failed towards over-reporting, which is exactly why nothing caught it: a finding that should not be there looks like the tool working.
Engine 1.13.4 fixes it: the newest trailer decides /Info, free entries
delete, and document XMP is read from the catalog's /Metadata stream, with
the old byte scan kept only for files whose catalog cannot be reached at all. With that
in, the three cells fill in as gone, gone and weaker, which
is what this post predicted from reading the code, before the bench proved the code was
not doing it.
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.
Or start with the “Signed, then
modified” sample: a precomputed report on a PDF whose appended bytes fall
outside the signed /ByteRange, which is the louder cell in
the signature-coverage row above. It loads without an account and
without an upload. Your own file goes in the same checker: the file is sent over
HTTPS, parsed in memory and never written to disk.