| --- |
| tags: |
| - security |
| - vulnerability |
| - poc |
| - parquet |
| - fastparquet |
| - decompression-bomb |
| - cwe-789 |
| license: mit |
| --- |
| |
| # fastparquet β Parquet PageHeader Decompression Bomb (PoC) |
|
|
| **Repo:** `MBM7/fastparquet-parquet-decompression-bomb-poc` |
| **Status:** Responsible disclosure β submitted to Huntr |
| **Severity:** High / CWE-789 (Uncontrolled Memory Allocation) |
|
|
| --- |
|
|
| ## Summary |
|
|
| A crafted **492-byte** `.parquet` file causes `fastparquet` to allocate |
| **gigabytes of memory** before failing β enabling OOM-kill on any service |
| that loads user-supplied Parquet files. |
|
|
| | File size | Fake `uncompressed_page_size` | Peak allocation | Amplification | |
| |-----------|-------------------------------|-----------------|---------------| |
| | 492 bytes | 500,000,000 | **500 MB** | 1 : 1,016,260 | |
| | 492 bytes | 2,000,000,000 | **2,000 MB** | 1 : 4,065,040 | |
|
|
| --- |
|
|
| ## Root Cause |
|
|
| `fastparquet/compression.py` β `decompress_data()`: |
|
|
| ```python |
| if algorithm.upper() in decom_into: |
| x = np.empty(uncompressed_size, dtype='uint8') # β pre-allocates upfront |
| decom_into[algorithm.upper()]( |
| np.frombuffer(data, dtype=np.uint8), x |
| ) |
| return x |
| ``` |
|
|
| `uncompressed_size` is passed directly from `ph.uncompressed_page_size` |
| (Thrift compact i32, PageHeader field 2) **with no bound check** at any |
| point in the call chain: |
|
|
| ``` |
| fastparquet.ParquetFile.to_pandas() |
| β read_col_group() [core.py ~291] |
| β _read_page() [core.py ~20] |
| β decompress_data( |
| data, |
| page_header.uncompressed_page_size, β from Thrift, unchecked |
| codec |
| ) |
| β np.empty(uncompressed_size) β GB allocation here |
| ``` |
|
|
| `max_header_size` or equivalent limit: **does not exist** in fastparquet. |
|
|
| --- |
|
|
| ## Attack |
|
|
| Parquet format: `PAR1 | PageHeader (Thrift compact) | compressed_data | ... | PAR1` |
|
|
| PageHeader field 2 (`uncompressed_page_size`, type i32) is a |
| zigzag-encoded varint at byte offset 7 in a minimal single-column file. |
|
|
| Replacing the 1-byte varint (value `4`) with a 5-byte varint |
| (value `500_000_000`) produces a valid-looking file that triggers 500 MB |
| of allocation on any codec in `decom_into` (GZIP, SNAPPY, ZSTD, BROTLI). |
|
|
| --- |
|
|
| ## Reproduce |
|
|
| ```bash |
| pip install fastparquet pyarrow |
| python poc_fastparquet_parquet_bomb.py |
| ``` |
|
|
| Expected output: |
| ``` |
| Crafted file size : 492 bytes |
| Expected allocation : 500,000,000 bytes |
| Amplification : 1:1,016,260 |
| Result : DecompressionError: unexpected end of file |
| Peak memory : 500 MB β allocation happened |
| ``` |
|
|
| --- |
|
|
| ## Suggested Fix |
|
|
| `fastparquet/compression.py`, before `np.empty()`: |
|
|
| ```python |
| MAX_UNCOMPRESSED = 256 * 1024 * 1024 # 256 MB configurable limit |
| if uncompressed_size > MAX_UNCOMPRESSED: |
| raise ValueError( |
| f"uncompressed_size {uncompressed_size} exceeds safety limit " |
| f"{MAX_UNCOMPRESSED}. Possible decompression bomb." |
| ) |
| ``` |
|
|
| --- |
|
|
| ## Environment |
|
|
| | Package | Version | |
| |---------------|-----------| |
| | fastparquet | 2026.5.0 | |
| | pyarrow | 25.0.0 | |
| | Python | 3.12 | |
|
|
| --- |
|
|
| ## Files |
|
|
| | File | Description | |
| |---------------------------------------|--------------------------| |
| | `poc_fastparquet_parquet_bomb.py` | PoC script (self-contained) | |
| | `bomb_500mb.parquet` | Crafted payload (492 bytes) | |
|
|
| --- |
|
|
| *Discovered via empirical security research using UBDAF (Universal Bug |
| Discovery & Analysis Framework) β Q2 CRITICAL pattern: arithmetic on |
| externally-controlled value without bound check.* |
|
|