F-26 β Unbounded HashTableV2.LookupTableImportV2 N-keys in tensorflowjs_converter (425 MB RSS amplification)
Authorized security research artifact disclosed via huntr.com's
TensorFlow.js Model Format Vulnerability program.
Source commit 7f5309fef0a47545e34049903dbdae0f97285f7e. All capture data was
collected against a synthetic /tmp/victim_host/ CI-runner lab β no real PII present.
Real impact captured (sanitized)
425 MB RSS from unbounded LookupTableImportV2 N-keys
- Measured
425 MBpeak RSS in the child prlimit --as=2/4 GBβ exit 134 (SIGABRT)- Trigger: attacker-controlled
NinHashTableV2.LookupTableImportV2
All proof data above was captured against a synthetic CI-runner lab at /tmp/victim_host/ (no real PII present). Full capture: F26_REAL_IMPACT_PROOF_2026-06-11.txt.
Summary
A Node.js service that executes an attacker-supplied GraphModel containing
the sequence HashTableV2 β Const keys[N] β Const values[N] β LookupTableImportV2(ht, keys, values) allocates N tensor references +
N Map entries for any attacker-chosen N. Captured run at N = 2^18
(262,144) consumed 425 MB RSS from a 1 KB model.json and a 2 MB
weight shard. Linear scaling β N = 4 M requests ~7 GB β OOM-kill on
commodity hosts.
The root cause is in
tfjs-converter/src/executor/hash_table.ts:76-109 (HashTable.import),
which iterates keysLength with no upper bound, retains each unpacked
value Tensor via keep() (preventing surrounding tidy() from disposing
it), and inserts one entry per iteration into the table's internal Map.
Root Cause
Lines of Code:
- tfjs-converter/src/executor/hash_table.ts L24 (
HashTableclass) - tfjs-converter/src/executor/hash_table.ts L76 (
async import) - tfjs-converter/src/executor/hash_table.ts L104 (
this.tensorMap.set(key, value)per iteration) - tfjs-converter/src/operations/executors/hash_table_executor.ts L53 (
case 'LookupTableImportV2')
In hash_table.ts:76-109:
async import(keys: Tensor, values: Tensor): Promise<Tensor> {
this.checkKeyAndValueTensor(keys, values);
const $keys = await keys.data(); // β TypedArray of attacker length
...
return tidy(() => {
const $values = unstack(values); // β N retained Tensors
const keysLength = $keys.length; // β no cap
...
for (let i = 0; i < keysLength; i++) { // β no upper bound
const key = $keys[i];
const value = $values[i];
keep(value); // β prevents disposal
this.tensorMap.set(key, value); // β Map.set per iteration
}
...
});
}
End state: N Tensors + N Map entries, all retained by keep().
hash_table_executor.ts:53 dispatches LookupTableImportV2 (and
InitializeTableV2) straight to hashTable.import(keys, values) β no
intermediate guard.
Why this is NOT a duplicate of F-24: F-24 is the TensorList sparse-index amplification
(tensor_list.ts setItem(huge_idx)). F-26 is the HashTable
unbounded-keys amplification (hash_table.ts import(N, β¦)). Different
file, different attacker payload (single huge index vs huge tensor
length), different retention mechanism (V8 sparse array vs explicit
keep() + Map). Independent fixes.
Internal Pre-conditions
- Victim service calls
tf.loadGraphModel(<attacker URL>)thenmodel.executeAsync(...). - Service uses
@tensorflow/tfjs-converterβ€ 4.22.0.
External Pre-conditions
None.
Attack Path
- Attacker authors a
model.jsonGraphDef containing:HashTableV2(creates the table β key_dtype=DT_INT32, value_dtype=DT_INT32).Const keysandConst valuesβ shape[N], dtype int32.LookupTableImportV2(ht, keys, values).LookupTableSizeV2(import)β input wired throughimportto force the executor to actually runimport(otherwise the executor prunes it as dead code).
- Attacker delivers the file.
- Victim service loads + executes.
HashTable.importiteratesN = 262144keys (the PoC's value),unstacks the values tensor into N Tensors, callskeep()on each, andtensorMap.seton each.- RSS climbs by 425 MB; scaling N forces OOM-kill.
Impact
Captured PoC (F26_REAL_IMPACT_PROOF_2026-06-11.txt):
attacker N keys/values : 262144
RSS delta : 425 MB
HASHTABLE FLOOD DOS : YES βββ
- Attacker model.json: 1 KB + 2 MB shard.
- N = 2^18 = 262,144 β 425 MB RSS.
- Linear scaling: N = 4 M β ~7 GB β OOM-kill.
Mitigation
In hash_table.ts::import, cap keysLength at a configurable budget:
const MAX_HASHTABLE_IMPORT = 1 << 16; // 65 536
async import(keys: Tensor, values: Tensor): Promise<Tensor> {
this.checkKeyAndValueTensor(keys, values);
const $keys = await keys.data();
if ($keys.length > MAX_HASHTABLE_IMPORT) {
throw new ValueError(
`LookupTableImportV2 refuses keys.length=${$keys.length} ` +
`(limit ${MAX_HASHTABLE_IMPORT}); enable explicit opt-in to override.`);
}
...
}
Optional optimisation: drop the per-entry keep() and rely on the tensor
map holding the only reference β that lets tidy() clean up disposed
entries when erase() is called.
CVSS
CVSS 3.1 7.5 / High β AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:H.
Bug classification
- CWE-770 (Allocation of Resources Without Limits or Throttling)
- CWE-789 (Memory Allocation with Excessive Size Value)
Affected versions
@tensorflow/tfjs-converter β€ 4.22.0.
Files in this repository
| File | Purpose |
|---|---|
README.md |
this disclosure |
package.json |
npm dependencies for one-step npm install |
reproduce.js |
minimal PoC β HashTableV2.LookupTableImportV2 with attacker-controlled N |
reproduce_real_impact.sh |
host-OOM emulation + RSS measurement under prlimit --as=... |
F26_REAL_IMPACT_PROOF_2026-06-11.txt |
captured 425 MB peak RSS + exit-code 134 at 2/4 GB caps |