Detect a Tampered PDF in Python Without the Original
DEV Community

Detect a Tampered PDF in Python Without the Original

Originally published at htpbe.tech. The version on htpbe.tech stays in sync with the latest detection algorithm - refer to it for the canonical text. A PDF lands in your intake queue - a bank statement, an invoice, a signed offer letter - and you have to decide whether to trust it. There is no “original” sitting in a database to diff against. The applicant emailed you one file. The vendor uploaded one file. That single PDF is all the evidence you have. The usual advice is blunt: without the original, you cannot tell. That is half true. A PDF carries evidence of its own history inside the file itself, so you do not always need the original to spot that something was changed after the document was created. This tutorial reads those internal signals in Python with pypdf and pikepdf , explains what each one means, and is honest about where do-it-yourself heuristics break down. One scope note before we start. This is tamper detection - finding evidence that a file was modified after it was first created. That is a different problem from confirming an identity or fact-checking the numbers on the page. We inspect the document’s structure; we do not ask a bank whether the balance is real. This post is the by-hand, no-original angle. If you would rather skip straight to a hosted integration - submit a URL, get a verdict, route on it - the companion post PDF Tamper Detection in Python: Integrate in Under 50 Lines walks through the API instead. Can you check if a PDF was edited without the original? The common objection is that a single file has no baseline, so any change is invisible. That holds for one specific question: did the visible text change from some earlier draft? Without the earlier draft, you genuinely cannot diff pixel to pixel. But that is not the only question worth asking. A more useful one is: does this file show evidence of having been written to more than once after it was created? That question does not need a baseline. The answer is recorded inside the PDF, because of how the format saves changes. A PDF is not a flat picture of a page - it is a structured container with several layers of bookkeeping, and editing tools leave fingerprints in that bookkeeping. Three layers carry most of the signal: - Metadata - who made the file, with what software, and when. - Structure - the cross-reference machinery that records how many times the file was saved. - Digital signatures - cryptographic seals, and whether anything happened after they were applied. We read all three with mainstream libraries: pypdf for the friendly high-level metadata, and pikepdf (a binding over the battle-tested QPDF engine) when we need the lower-level structure. pip install pypdf pikepdf Layer 1: Metadata - Creator, Producer, and the two dates Every PDF can carry an Info dictionary and an XMP metadata packet. Both describe the document’s provenance, and two pairs of fields are especially telling. Creator vs Producer. The creator is the application a human used to author the document - Word, InDesign, a payroll system. The producer is the library or engine that actually wrote the PDF bytes - a PDF library, a print-to-PDF driver, a conversion tool. On a clean, institutionally generated document these two tell a coherent story. When a file has passed through an editor, the producer often changes to name that editor while the creator still claims something else. CreationDate vs ModDate. The creation date is when the document was first made; the modification date is when it was last saved. On a file generated in one shot, these are effectively the same instant. When they diverge - or worse, when the modification date is earlier than the creation date - you are looking at a file that was touched after it was born. Here is how to read all four with pypdf : from pypdf import PdfReader reader = PdfReader("statement.pdf") info = reader.metadata or {} print("Creator: ", info.get("/Creator")) print("Producer:", info.get("/Producer")) print("Created: ", info.get("/CreationDate")) print("Modified:", info.get("/ModDate")) PDF dates look like D:20240213120000+00'00' . A small helper makes them comparable: from datetime import datetime, timezone import re def parse_pdf_date(raw): if not raw: return None m = re.match(r"D:(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?", str(raw)) if not m: return None y, mo, d, hh, mm, ss = (int(g or 0) for g in m.groups()) return datetime(y, mo, d, hh, mm, ss, tzinfo=timezone.utc) created = parse_pdf_date(info.get("/CreationDate")) modified = parse_pdf_date(info.get("/ModDate")) if created and modified: if modified created: print("Document was saved after it was created.") A modification timestamp that precedes the creation timestamp is one of the cleaner signals you will find - there is no honest workflow in which a file is saved before it exists. Do not forget the XMP packet, which sometimes carries history the Info dictionary does not: xmp = reader.xmp_metadata if xmp: print("XMP CreateDate:", xmp.xmp_createDate) print("XMP ModifyDate:", xmp.xmp_modifyDate) When the Info dictionary dates and the XMP dates disagree, that is itself worth a closer look - two layers that contradict each other about when the document was made suggest one of them was rewritten. One important warning about metadata on its own: it is the easiest layer to forge. A one-line script can overwrite the producer field or backdate a timestamp without touching a single visible pixel. Metadata is a lead, not a verdict - which is exactly why the structural layer below matters more. Layer 2: Structure - xref tables and incremental updates This is where the “you need the original” objection falls apart. A PDF locates its internal objects through a cross-reference table - the xref. When you save a PDF, a writer can append changes to the end of the file rather than rewriting it from scratch. This is called an incremental update, and it adds a new xref section pointing at the appended objects. The original bytes stay where they were; the edits are bolted on after them. The consequence is forensically useful: each save generation leaves its own xref layer. A file that was generated once and never touched typically has a single xref section. A file that was opened, edited, and re-saved several times accumulates a chain of them. Counting those layers tells you roughly how many times the document was written to - no original required, because the history is in the file. pikepdf gives you a clean handle on this. Counting startxref markers in the raw bytes is a simple first approximation of how many save generations the file has: import pikepdf with open("statement.pdf", "rb") as f: raw = f.read() # Each save generation appends a startxref pointer. save_generations = raw.count(b"startxref") print("Approx. save generations:", save_generations) # pikepdf reads the document version and structure. pdf = pikepdf.open("statement.pdf") print("PDF version:", pdf.pdf_version) Now the caveat, and it is a theme we will keep hammering: more than one save generation is not proof of fraud. A bank’s own system might linearize a file (a legitimate optimization that adds structure), or a document-management pipeline might re-stamp every file it ingests. Incremental updates are how PDFs are supposed to grow. The signal is “this file was written to after creation,” not “this file was edited maliciously.” What that fact means depends entirely on what kind of document you expected and what software an honest issuer would have used. Layer 3: Digital signatures and “modified after signing” If a PDF is digitally signed, the signature covers a specific byte range of the file. Anything appended after that signed range was added after the signing - and the cryptographic guarantee no longer covers the whole document. You can detect the presence of signatures structurally. Signature fields live in the AcroForm dictionary with a /Sig field type: import pikepdf pdf = pikepdf.open("contract.pdf") root = pdf.Root sig_fields = 0 if "/AcroForm" in root and "/Fields" in root.AcroForm: for field in root.AcroForm.Fields: if field.get("/FT") == pikepdf.Name("/Sig") and "/V" in field: sig_fields += 1 print("Signature fields present:", sig_fields) Two findings here carry real weight: - Modifications after signing. If the signed byte range stops short of the end of the file, content was added after the seal was applied. The signature no longer covers the whole document. - Signature removal. A document that was signed and then had its signature stripped - leaving behind the scaffolding of a signature workflow without the seal - is a strong tampering signal. Detecting these reliably means parsing the signature’s byte range, walking the structure that follows it, and reconstructing what the document looked like at signing time. That is considerably more code than reading a metadata field - which is a good segue into the limits of the DIY approach. Where do-it-yourself runs out of road Everything above is real and useful. But ship it as your fraud check and you will drown in false positives. Here is why. Legitimate tools touch the same fields fraudsters do. A perfectly honest invoice might be generated, then optimized by a server-side tool that adds an xref layer and rewrites the producer. If your rule is “producer changed, therefore tampered,” you will reject a large slice of genuine traffic. The hard part is not reading the fields - it is knowing which combinations of creator, producer, structure, and dates are normal for a given kind of document and which are anomalous. You need a corpus of known tools. Telling a bank’s statement engine apart from a consumer PDF editor apart from an online merge tool requires a maintained database of software fingerprints and how each one behaves. That database is the actual product; the byte-reading is the easy ten percent. Building it from scratch, and keeping it current as tools ship new versions, is a full-time job on its own. Some forgeries have no modification to

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.