Everything Tamperlens detects, and exactly what defeats each one

All nineteen signal families against six evasions, measured on one engine, and three dated passes of corrections to the cells this post got wrong.

Reference: PDF fraud signals, the field guide

Updated 24 August 2026, the bench re-run

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.

Updated 15 August 2026, second pass

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.

Updated 15 August 2026, twice

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.

Updated 3 August 2026

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.

A metadata strip on a PDF is an append, not a delete. The revision count goes up by one, and on a signed document the appended bytes fall outside the signed /ByteRange.
Byte-layout diagram: before the strip the file is one revision; after the strip the original bytes are unchanged and a second revision is appended carrying a blanked Info dictionary. before objects + /Info xref trailer /ID %%EOF after objects + /Info xref trailer /ID %%EOF blank /Info xref /Prev trailer, %%EOF revision 1: byte-identical to the original revision 2: the strip Any signature signed only what is left of the dashed line. Everything right of it is now uncovered.

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.

The reason print-to-PDF beats most of this table is not subtlety. It throws the evidence away and writes a new file that has never been edited, because it genuinely has not been.
Diagram: printing to PDF discards the old file's account of itself (metadata, identifiers, revision history, signature, annotations), and writes a fresh structure from the rendered page. Only the appearance of the page crosses over. the old file's account of itself Info dictionary · XMP packet trailer /ID pair revision chain (/Prev) font subset prefixes signature /ByteRange annotations · JavaScript all discarded render then write anew what the new file says one revision, no /Prev fresh /ID, both halves equal producer = the printer one font subset per face no signature, no annotations coherent, recent timestamps a pristine document What does cross over: the page as it looks. Including, in paint order, the text that somebody hid under a black rectangle.

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.

Corrected 23 August 2026

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.

Raster silences almost everything and no family survives all six, but the two rewriter columns are the point: they are the same evasion described two ways, and they disagree on fifteen of the nineteen rows.
Heat grid: all nineteen unconditional signal families against the six evasions, re-measured on 31 August 2026 against engine 1.37.0. The raster column silences eighteen of the nineteen; the only family it does not silence is producer-fingerprint, which it makes louder. No family survives all six. strip qpdf gs print raster encrypt incremental-updates louder gone gone gone gone gone metadata-mismatch gone fires gone gone gone gone date-anomalies gone fires gone gone gone gone producer-fingerprint weaker fires split weaker fires gone id-inconsistency louder weaker gone gone gone weaker font-anomalies louder weaker split split gone gone hybrid-page fires fires gone fires gone gone active-content fires fires fires gone gone fires signature-coverage louder louder gone gone gone louder signature-integrity fires louder gone gone gone gone signature-permissions fires louder gone gone gone louder br-identifier-checksum fires fires split fires gone gone running-balance-break fires fires gone fires gone gone glyph-anomalies fires fires gone fires gone gone extraction-anomalies fires fires split split gone gone structure-warnings fires split split split gone weaker redaction-exposure fires fires split split gone split document-injection-markers fires fires fires unmeasured gone gone embedded-image-anomalies fires split gone gone gone gone gone unmeasured weaker or split fires louder

The shape, without the reading. The detail, and the reasons, are in the table below.

Family Strip Rewrite (qpdf) Rewrite (gs) Print 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.

Re-run 24 August 2026, engines 1.31.0 then 1.32.0

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.

Corrected 15 August 2026, hours after publishing

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.

The two rewriters share a cost and are not the same evasion: Ghostscript silences eleven families and qpdf silences one.
Bar chart: how many of the nineteen unconditional signal families each evasion silences, and what the evasion costs the person sending the document. Flattening to raster silences eighteen; encrypting with an owner password silences thirteen and costs the sender nothing. families silenced, of 19 what it costs the sender metadata strip 2 costs nothing, and appends a revision rewrite (qpdf) 1 costs any signature print-to-PDF 9 costs the whole revision history rewrite (Ghostscript) 11 costs any signature encrypt (owner password) 13 costs nothing: the report says so out loud flatten to raster 18 costs the text layer

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.

The concession

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.

CellPublishedMeasured 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.

CellOriginally publishedMeasured
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.