corbel/scripts/gen_zip_bomb_fixtures.py

145 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate a "lying" zip-bomb test fixture for CorbelPurge.
This crafts a ZIP archive where the central directory declares a small
uncompressed size (100 bytes) but the actual decompressed content is
much larger (1 MiB). This simulates a malicious archive that tries to
bypass size-header-based caps.
The ZIP format is hand-crafted (not via the `zip` library) so we can
lie about the size. The structure is:
[Local File Header][file data][Central Directory][End of Central Dir]
Each file header has both a "compressed size" and "uncompressed size"
field. We set the central directory's "uncompressed size" to 100, but
write 1 MiB of actual data. A naive reader that trusts the header
would only allocate 100 bytes; a streaming reader counts actual bytes
and detects the lie.
"""
import struct
import zlib
from pathlib import Path
FIXTURES_DIR = Path(__file__).parent.parent / "tests" / "fixtures"
FIXTURES_DIR.mkdir(parents=True, exist_ok=True)
def make_lying_zip_bomb():
"""Create a ZIP where the declared uncompressed size is 100 bytes
but the actual decompressed content is 1 MiB.
The ZIP is structurally valid (decompressors can read it) but the
central directory lies about the size. CorbelPurge's streaming
`read_with_cap` should detect this by counting actual bytes.
"""
# The actual content: 1 MiB of 'A' characters.
actual_content = b"A" * (1024 * 1024)
# Compress it with DEFLATE.
compressed = zlib.compress(actual_content, 9)
# The "lie": declare the uncompressed size as 100 bytes.
declared_uncompressed_size = 100
declared_compressed_size = len(compressed) # we don't lie about this
# CRC32 of the actual content (the decompressor will compute this
# and we need to match it for the CRC check to pass).
crc = zlib.crc32(actual_content) & 0xFFFFFFFF
# --- Local File Header ---
local_header = struct.pack(
"<IHHHHHIIIHH",
0x04034b50, # Local file header signature
20, # Version needed to extract (2.0)
0, # General purpose bit flag
8, # Compression method (DEFLATE)
0, # File last modification time
0, # File last modification date
crc, # CRC-32 of uncompressed data
declared_compressed_size, # Compressed size
declared_uncompressed_size, # Uncompressed size (THE LIE)
12, # File name length
0, # Extra field length
)
file_name = b"bomb.txt"
# --- Central Directory File Header ---
cd_header = struct.pack(
"<IHHHHHHIIIHHHHHII",
0x02014b50, # Central directory file header signature
20, # Version made by
20, # Version needed to extract
0, # General purpose bit flag
8, # Compression method (DEFLATE)
0, # File last modification time
0, # File last modification date
crc, # CRC-32
declared_compressed_size, # Compressed size
declared_uncompressed_size, # Uncompressed size (THE LIE)
12, # File name length
0, # Extra field length
0, # File comment length
0, # Disk number where file starts
0, # Internal file attributes
0, # External file attributes
0, # Relative offset of local file header
)
# --- End of Central Directory Record ---
local_header_size = len(local_header) + len(file_name) + len(compressed)
cd_size = len(cd_header) + len(file_name)
eocd = struct.pack(
"<IHHHHIIH",
0x06054b50, # End of central directory signature
0, # Number of this disk
0, # Disk where central directory starts
1, # Number of central directory records on this disk
1, # Total number of central directory records
cd_size, # Size of central directory (bytes)
local_header_size, # Offset of start of central directory
0, # Comment length
)
# Assemble the ZIP.
zip_bytes = (
local_header
+ file_name
+ compressed
+ cd_header
+ file_name
+ eocd
)
path = FIXTURES_DIR / "lying_zip_bomb.zip"
path.write_bytes(zip_bytes)
return path, len(actual_content), declared_uncompressed_size
def make_honest_zip_bomb():
"""Create a ZIP where the declared size is honest (1 MiB) but the
content is 1 MiB of 'B' characters. This tests the "honest but
oversized" case — the cap should still trigger based on the
declared size alone (the old behavior).
"""
import zipfile
path = FIXTURES_DIR / "honest_zip_bomb.zip"
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("bomb.txt", b"B" * (1024 * 1024))
return path
def main():
lying_path, actual, declared = make_lying_zip_bomb()
print(f" wrote {lying_path} ({lying_path.stat().st_size} bytes)")
print(f" declared uncompressed size: {declared} bytes")
print(f" actual uncompressed size: {actual} bytes")
print(f" ratio: {actual / declared:.0f}x")
honest_path = make_honest_zip_bomb()
print(f" wrote {honest_path} ({honest_path.stat().st_size} bytes)")
if __name__ == "__main__":
main()