145 lines
5.5 KiB
Python
Executable File
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()
|