Skip to content

Commit ca1cbdb

Browse files
authored
Merge pull request #2 from ActiveState/1.28.6-sec-filters-lzw-hex
Security (2/5): LZW + ASCIIHex limits — CVE-2026-28804, CVE-2025-62708, CVE-2025-66019
2 parents 9b5452f + bbb064e commit ca1cbdb

2 files changed

Lines changed: 88 additions & 20 deletions

File tree

PyPDF2/filters.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
else:
5151
from io import StringIO
5252

53+
import binascii
5354
import struct
5455
import zlib
5556

@@ -169,25 +170,29 @@ def decode(data, decodeParms=None):
169170
:return: a string conversion in base-7 ASCII, where each of its values
170171
v is such that 0 <= ord(v) <= 127.
171172
"""
172-
retval = ""
173-
hex_pair = ""
174-
index = 0
175-
while True:
176-
if index >= len(data):
177-
raise PdfStreamError("Unexpected EOD in ASCIIHexDecode")
178-
char = data[index]
179-
if char == ">":
180-
break
181-
elif char.isspace():
182-
index += 1
183-
continue
184-
hex_pair += char
185-
if len(hex_pair) == 2:
186-
retval += chr(int(hex_pair, base=16))
187-
hex_pair = ""
188-
index += 1
189-
assert hex_pair == ""
190-
return retval
173+
# CVE-2026-28804: the previous character-by-character accumulation
174+
# (retval += ..., hex_pair += ...) is quadratic, so a large
175+
# /ASCIIHexDecode stream caused excessive CPU time. Locate the EOD
176+
# marker once, strip whitespace, and bulk-decode with binascii.
177+
eod = data.find(">")
178+
if eod == -1:
179+
raise PdfStreamError("Unexpected EOD in ASCIIHexDecode")
180+
hex_str = b"".join(data[:eod].split()) if isinstance(
181+
data, bytes
182+
) else "".join(data[:eod].split())
183+
# Per ISO 32000 §7.4.2, a final odd hex digit is assumed to be
184+
# followed by a "0".
185+
if len(hex_str) % 2 == 1:
186+
hex_str += b"0" if isinstance(hex_str, bytes) else "0"
187+
try:
188+
return binascii.unhexlify(hex_str)
189+
except (binascii.Error, TypeError):
190+
raise PdfStreamError("Invalid hexadecimal data in ASCIIHexDecode")
191+
192+
193+
# CVE-2025-62708 / CVE-2025-66019: bound LZWDecode output so a small stream
194+
# cannot amplify into gigabytes of memory. Set to 0 to disable (trusted input).
195+
LZW_MAX_OUTPUT_LENGTH = 75000000 # 75 MB
191196

192197

193198
class LZWDecode(object):
@@ -196,10 +201,11 @@ class LZWDecode(object):
196201
"""
197202

198203
class Decoder(object):
199-
def __init__(self, data):
204+
def __init__(self, data, max_output_length=LZW_MAX_OUTPUT_LENGTH):
200205
self.STOP = 257
201206
self.CLEARDICT = 256
202207
self.data = data
208+
self.max_output_length = max_output_length
203209
self.bytepos = 0
204210
self.bitpos = 0
205211
self.dict = [""] * 4096
@@ -246,6 +252,11 @@ def decode(self):
246252
cW = self.CLEARDICT
247253
baos = ""
248254
while True:
255+
if self.max_output_length and len(baos) > self.max_output_length:
256+
raise PdfReadError(
257+
"Output exceeds maximum allowed length (%d bytes) "
258+
"while decoding LZW stream." % self.max_output_length
259+
)
249260
pW = cW
250261
cW = self.next_code()
251262
if cW == -1:

Tests/test_security_lzw_hex.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Regression tests for the LZW and ASCIIHex decoder hardening backports:
4+
5+
- CVE-2026-28804 : ASCIIHexDecode quadratic decoding -> bulk decode.
6+
- CVE-2025-62708/66019 : bound LZWDecode output (decompression bomb).
7+
"""
8+
import pytest
9+
10+
from PyPDF2 import filters
11+
from PyPDF2.errors import PdfReadError, PdfStreamError
12+
13+
14+
# --- CVE-2026-28804: ASCIIHexDecode ---------------------------------------
15+
16+
def test_asciihex_basic():
17+
assert filters.ASCIIHexDecode.decode("48656c6c6f>") == b"Hello"
18+
19+
20+
def test_asciihex_ignores_whitespace():
21+
assert filters.ASCIIHexDecode.decode("48 65 6c\n6c\t6f >") == b"Hello"
22+
23+
24+
def test_asciihex_odd_length_padded():
25+
# ISO 32000 §7.4.2: a trailing odd digit is treated as followed by "0".
26+
assert filters.ASCIIHexDecode.decode("4>") == b"@" # 0x40
27+
28+
29+
def test_asciihex_missing_eod_raises():
30+
with pytest.raises(PdfStreamError):
31+
filters.ASCIIHexDecode.decode("48656c6c6f") # no '>'
32+
33+
34+
# --- CVE-2025-62708 / CVE-2025-66019: LZWDecode output cap -----------------
35+
36+
def _pack_lzw(codes, width=9):
37+
"""Pack a list of fixed-width LZW codes MSB-first into bytes.
38+
39+
Kept small so the code width stays at the initial 9 bits (dictlen < 511).
40+
"""
41+
bits = "".join(format(c, "0%db" % width) for c in codes)
42+
while len(bits) % 8:
43+
bits += "0"
44+
return bytes(bytearray(int(bits[i : i + 8], 2) for i in range(0, len(bits), 8)))
45+
46+
47+
def test_lzw_decodes_normally():
48+
# Three literal 'A' (65) codes then STOP (257) -> "AAA".
49+
data = _pack_lzw([65, 65, 65, 257])
50+
assert filters.LZWDecode.Decoder(data).decode() == b"AAA"
51+
52+
53+
def test_lzw_output_is_capped():
54+
# Many literal codes with no STOP; a tiny cap must abort before exhaustion.
55+
data = _pack_lzw([65] * 200)
56+
with pytest.raises(PdfReadError):
57+
filters.LZWDecode.Decoder(data, max_output_length=5).decode()

0 commit comments

Comments
 (0)