Skip to content

Commit 1b03d0c

Browse files
dvlinuxx-maxclaude
andcommitted
Reject ZIP parser-differential archives in safe_extract (#780)
A wheel/zip can be crafted so Python's zipfile reads it as empty (e.g. an End-Of-Central-Directory record with size-of-central-directory set to 0, a trailing second EOCD with 0 entries, or a cd_offset pointing at an empty central directory) while installers still unpack the payload from the local file headers. GuardDog enumerates members from the central directory, so such a package extracts zero files and scans as clean ("Found 0 potentially malicious indicators") even though it carries executable code. safe_extract now walks the local file headers independently of the EOCD and rejects the archive when more members are visible there than zipfile enumerates. The walk parses each header's compressed size and seeks over the data (instead of scanning for the PK signature, which false-positives on compressed bytes), and stops conservatively on data descriptors / ZIP64 size markers to avoid false positives. Tested: flags the cd_size=0 and trailing-EOCD framings; no false positive on a real DEFLATE wheel (107 members incl. a directory entry). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4b1982a commit 1b03d0c

2 files changed

Lines changed: 148 additions & 1 deletion

File tree

guarddog/utils/archives.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import pathlib
44
import stat
5+
import struct
56
import zipfile
67

78
import tarsafe # type: ignore
@@ -39,6 +40,59 @@ def is_zip_archive(path: str) -> bool:
3940
return is_tar_archive(path) or is_zip_archive(path)
4041

4142

43+
_ZIP_LOCAL_FILE_HEADER = b"PK\x03\x04"
44+
45+
46+
def _count_local_file_headers(path: str) -> int:
47+
"""
48+
Count members by walking the ZIP local file headers, independent of the
49+
End-Of-Central-Directory record and central directory that zipfile trusts.
50+
51+
Each header is parsed for its compressed size so we can seek over the data
52+
rather than scanning for the next signature (which would false-positive on
53+
compressed bytes). Returns the number of headers walked. The walk stops
54+
conservatively (undercounting) when a member cannot be followed cheaply: a
55+
data descriptor with no inline sizes (general-purpose bit 3) or a ZIP64 size
56+
marker. Undercounting is safe here; it only avoids false positives.
57+
"""
58+
count = 0
59+
with open(path, "rb") as f:
60+
while True:
61+
header = f.read(30)
62+
if len(header) < 30 or header[:4] != _ZIP_LOCAL_FILE_HEADER:
63+
break
64+
flags = struct.unpack("<H", header[6:8])[0]
65+
compressed_size = struct.unpack("<I", header[18:22])[0]
66+
name_len = struct.unpack("<H", header[26:28])[0]
67+
extra_len = struct.unpack("<H", header[28:30])[0]
68+
if (flags & 0x08 and compressed_size == 0) or compressed_size == 0xFFFFFFFF:
69+
break
70+
f.seek(name_len + extra_len + compressed_size, os.SEEK_CUR)
71+
count += 1
72+
return count
73+
74+
75+
def _assert_zip_fully_enumerated(source_archive: str, enumerated: int) -> None:
76+
"""
77+
Guard against ZIP parser-differential evasion (e.g. an End-Of-Central-Directory
78+
record with size-of-central-directory set to 0, which makes zipfile read an
79+
empty archive while installers still unpack the payload from the local file
80+
headers). Raises if the local file headers expose more members than zipfile
81+
enumerated from the central directory.
82+
83+
See https://github.com/DataDog/guarddog/issues/780 and the ZIP
84+
parser-differential class described in USENIX Security 2025, "My ZIP isn't
85+
your ZIP".
86+
"""
87+
walked = _count_local_file_headers(source_archive)
88+
if walked > enumerated:
89+
raise ValueError(
90+
f"archive parser anomaly: {walked} ZIP local file headers but zipfile "
91+
f"enumerates {enumerated} members from the central directory "
92+
f"(possible scan evasion via EOCD size/offset differential)"
93+
)
94+
95+
4296
def safe_extract(
4397
source_archive: str,
4498
target_directory: str,
@@ -181,8 +235,15 @@ def recurse_add_perms(path):
181235

182236
elif zipfile.is_zipfile(source_archive):
183237
with zipfile.ZipFile(source_archive, "r") as zip_file:
238+
members = zip_file.infolist()
239+
240+
# Reject archives where the central directory zipfile reads hides
241+
# members that are still present as local file headers (and unpacked
242+
# by installers). Otherwise such an archive scans as if it were empty.
243+
_assert_zip_fully_enumerated(source_archive, len(members))
244+
184245
# Check uncompressed size for zip archives
185-
files = [info for info in zip_file.infolist() if not info.is_dir()]
246+
files = [info for info in members if not info.is_dir()]
186247
file_count = len(files)
187248
total_size = sum(info.file_size for info in files)
188249

tests/core/test_archives.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import binascii
12
import os
3+
import struct
24

35
import pytest
46

@@ -10,6 +12,67 @@
1012
ZIP_PASSWORD = b"hunter2"
1113

1214

15+
def _build_zip(members: dict[str, bytes], cd_size: int | None = None) -> bytes:
16+
"""
17+
Build a stored (uncompressed) ZIP by hand so the End-Of-Central-Directory
18+
size-of-central-directory field can be overridden. With ``cd_size=0`` the
19+
archive reproduces the parser-differential from issue #780: zipfile reads it
20+
as empty while the local file headers still carry every member.
21+
"""
22+
body = bytearray()
23+
central = bytearray()
24+
offsets = []
25+
for name, data in members.items():
26+
raw = name.encode()
27+
crc = binascii.crc32(data) & 0xFFFFFFFF
28+
offsets.append(len(body))
29+
body += b"PK\x03\x04" + struct.pack(
30+
"<HHHHHIIIHH", 20, 0, 0, 0, 0x21, crc, len(data), len(data), len(raw), 0
31+
)
32+
body += raw + data
33+
real_cd_size = 0
34+
for (name, data), offset in zip(members.items(), offsets):
35+
raw = name.encode()
36+
crc = binascii.crc32(data) & 0xFFFFFFFF
37+
record = (
38+
b"PK\x01\x02"
39+
+ struct.pack(
40+
"<HHHHHHIIIHHHHHII",
41+
20,
42+
20,
43+
0,
44+
0,
45+
0,
46+
0x21,
47+
crc,
48+
len(data),
49+
len(data),
50+
len(raw),
51+
0,
52+
0,
53+
0,
54+
0,
55+
(0o100644) << 16,
56+
offset,
57+
)
58+
+ raw
59+
)
60+
central += record
61+
real_cd_size += len(record)
62+
count = len(members)
63+
eocd_cd_size = real_cd_size if cd_size is None else cd_size
64+
eocd = b"PK\x05\x06" + struct.pack(
65+
"<HHHHIIH", 0, 0, count, count, eocd_cd_size, len(body), 0
66+
)
67+
return bytes(body) + bytes(central) + eocd
68+
69+
70+
_WHL_MEMBERS = {
71+
"pkg/__init__.py": b"print('hello')\n",
72+
"pkg-1.0.dist-info/METADATA": b"Metadata-Version: 2.1\nName: pkg\nVersion: 1.0\n",
73+
}
74+
75+
1376
def test_encrypted_zip_extracts_with_correct_password(tmp_path):
1477
safe_extract(ENCRYPTED_ZIP, str(tmp_path), zip_password=ZIP_PASSWORD)
1578
assert (tmp_path / "index.js").read_text() == 'console.log("hi")\n'
@@ -29,3 +92,26 @@ def test_encrypted_zip_wrong_password_raises(tmp_path):
2992
def test_tar_archive_rejects_password(tmp_path):
3093
with pytest.raises(ValueError, match="only supported for ZIP"):
3194
safe_extract(PLAIN_TARGZ, str(tmp_path), zip_password=ZIP_PASSWORD)
95+
96+
97+
def test_well_formed_zip_extracts(tmp_path):
98+
archive = tmp_path / "pkg-1.0-py3-none-any.whl"
99+
archive.write_bytes(_build_zip(_WHL_MEMBERS))
100+
out = tmp_path / "out"
101+
out.mkdir()
102+
safe_extract(str(archive), str(out))
103+
assert (out / "pkg" / "__init__.py").read_bytes() == _WHL_MEMBERS["pkg/__init__.py"]
104+
105+
106+
def test_cd_size_zero_eocd_differential_rejected(tmp_path):
107+
# zipfile reads this as empty (namelist() == []) but the payload is still
108+
# present in the local file headers; safe_extract must refuse it (issue #780).
109+
archive = tmp_path / "crafted-1.0-py3-none-any.whl"
110+
archive.write_bytes(_build_zip(_WHL_MEMBERS, cd_size=0))
111+
112+
import zipfile
113+
114+
assert zipfile.ZipFile(str(archive)).namelist() == []
115+
116+
with pytest.raises(ValueError, match="parser anomaly"):
117+
safe_extract(str(archive), str(tmp_path / "out"))

0 commit comments

Comments
 (0)