dukesp69's picture
Triager-ergonomics: ship package.json + one-line npm install; updated PoC sections
969e64d verified
|
Raw
History Blame Contribute Delete
7.67 kB
---
license: other
license_name: huntr-security-research-poc
tags:
- security
- poc
- tensorflow.js
- tensorflowjs-converter
- dos
- cwe-674
- watchdog-bypass
---
# F-23 β€” Mutual function recursion in `tensorflowjs_converter` `StatelessIf` β†’ in-process watchdog never fires (sister of F-17)
**Authorized security research artifact** disclosed via huntr.com's
[TensorFlow.js Model Format Vulnerability program](https://huntr.com/bounties/disclose/models?target=tensorflow.js).
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)
**Sister of F-17 via `StatelessIf` mutual recursion β€” same watchdog-resistance signature**
- 8 s wall-clock burn β†’ in-process `setTimeout(1500ms)` never fired
- External SIGKILL is the only termination path
- Distinct trigger from F-17 (recursive function table, not while-loop)
All proof data above was captured against a synthetic CI-runner lab at `/tmp/victim_host/` (no real PII present). Full capture: [`F23_REAL_IMPACT_PROOF_2026-06-11.txt`](./F23_REAL_IMPACT_PROOF_2026-06-11.txt).
---
## Summary
A Node.js service that executes an attacker-supplied GraphModel containing
mutually-recursive functions in `library.function` (or a single function
that calls itself) will become **permanently unresponsive** β€” the process
hangs and only an external SIGKILL recovers it. The vulnerable
`StatelessIf` / `If` dispatch at
`tfjs-converter/src/operations/executors/control_executor.ts:32-47` invokes
the chosen branch via `context.functionMap[fn].executeFunctionAsync(...)`
with **no recursion-depth counter and no cycle detection**.
The end-to-end PoC captured 8 seconds of hang followed by parent-watchdog
SIGKILL: `exit code=null sig=SIGKILL`.
## Root Cause
**Lines of Code:**
- [tfjs-converter/src/operations/executors/control_executor.ts L32-L47 (`StatelessIf` / `If`)](https://github.com/tensorflow/tfjs/blob/7f5309fef0a47545e34049903dbdae0f97285f7e/tfjs-converter/src/operations/executors/control_executor.ts#L32-L47)
- Function dispatch via `context.functionMap[fn].executeFunctionAsync(...)` at L42 and L45.
In `control_executor.ts:32-47`:
```ts
case 'If':
case 'StatelessIf': {
const thenFunc = getParamValue('thenBranch', node, tensorMap, context) as string;
const elseFunc = getParamValue('elseBranch', node, tensorMap, context) as string;
const cond = getParamValue('cond', node, tensorMap, context) as Tensor;
const args = getParamValue('args', node, tensorMap, context) as Tensor[];
const data = await cond.data();
if (data[0]) {
return context.functionMap[thenFunc].executeFunctionAsync( // ← no depth counter
args, context.tensorArrayMap, context.tensorListMap);
} else {
return context.functionMap[elseFunc].executeFunctionAsync( // ← no cycle check
args, context.tensorArrayMap, context.tensorListMap);
}
}
```
Attacker GraphDef declares functions that call each other unboundedly:
- `f_a` body: `StatelessIf(true, f_b, f_b)` β€” always dispatches to `f_b`.
- `f_b` body: `StatelessIf(true, f_a, f_a)` β€” always dispatches to `f_a`.
- top-level: `StatelessIf(true, f_a, f_a)`.
Each invocation pushes a fresh `ExecutionContext` frame and a `tidy()`
scope; recursion runs `f_a ↔ f_b ↔ f_a …` forever. Because all calls are
microtask-chained (per the sister F-17 finding's event-loop analysis), the
event loop is also blocked β€” in-process watchdogs cannot fire.
**Why this is NOT a duplicate of F-17 (StatelessWhile)**: F-17 uses
**`StatelessWhile`** to exhaust **iteration count** in a flat loop; the fix
is to read `maximum_iterations` from the node attr (already parsed by the
operation mapper). F-23 uses **`StatelessIf`** + `library.function` to
exhaust **stack frames** through recursion; the fix is to add a
recursion-depth counter on `ExecutionContext`. Different op, different
attack shape, **independent fixes**.
## Internal Pre-conditions
1. Victim service calls `tf.loadGraphModel(<attacker URL>)` then
`model.executeAsync(...)`.
2. Service uses `@tensorflow/tfjs-converter` ≀ 4.22.0.
## External Pre-conditions
None.
## Attack Path
1. Attacker authors a `model.json` GraphDef containing:
- `node[0]` β€” `Const 'true_cond' = true` (always-true predicate)
- `node[1]` β€” `StatelessIf(true_cond, f_a, f_a)` (top-level)
- `library.function`:
- `f_a` whose body is `StatelessIf(true_cond, f_b, f_b)`
- `f_b` whose body is `StatelessIf(true_cond, f_a, f_a)`
2. Attacker delivers the file.
3. Victim service calls `tf.loadGraphModel(file://…)` then
`model.executeAsync({}, ['out'])`.
4. `control_executor.ts:32-47` enters `StatelessIf`, dispatches to `f_a`.
5. `f_a` enters `StatelessIf`, dispatches to `f_b`.
6. `f_b` enters `StatelessIf`, dispatches to `f_a`.
7. Recursion continues unbounded. Promise never resolves. Microtask queue
monopolised; `setTimeout` watchdogs cannot fire.
8. Only external SIGKILL recovers the worker.
## Impact
Captured PoC (`F23_REAL_IMPACT_PROOF_2026-06-11.txt`):
```text
========================================================
PoC F-23 β€” Mutual function recursion via StatelessIf
========================================================
top-level: StatelessIf(true, f_a, f_a)
f_a body : StatelessIf(true, f_b, f_b)
f_b body : StatelessIf(true, f_a, f_a)
β†’ unbounded mutual recursion across function-call frames
model loaded; invoking executeAsync …
[watchdog 8s SIGKILL]
[exit c=null s=SIGKILL]
```
`exit c=null s=SIGKILL` is conclusive: `executeAsync` never returned; the
in-process `setTimeout` watchdog never fired; the only path to recover the
process was an external supervisor kill.
## Mitigation
In `control_executor.ts` `StatelessIf` / `If` (and `StatelessCase`,
`StatelessWhile` per F-17):
```ts
const RECURSION_LIMIT = 64;
case 'If':
case 'StatelessIf': {
context.recursionDepth = (context.recursionDepth ?? 0) + 1;
if (context.recursionDepth > RECURSION_LIMIT) {
throw new ValueError(
`Function-call recursion exceeds limit (${RECURSION_LIMIT}); ` +
`current path: ${context.callStack.join(' β†’ ')}`);
}
try {
const thenFunc = getParamValue('thenBranch', node, tensorMap, context) as string;
const elseFunc = getParamValue('elseBranch', node, tensorMap, context) as string;
const cond = await (getParamValue('cond', …) as Tensor).data();
const args = getParamValue('args', …) as Tensor[];
return cond[0]
? context.functionMap[thenFunc].executeFunctionAsync(args, …)
: context.functionMap[elseFunc].executeFunctionAsync(args, …);
} finally {
context.recursionDepth--;
}
}
```
Optional defence-in-depth: maintain a `callStack: string[]` on
`ExecutionContext` and refuse to enter a function already in the stack
(strict cycle detection).
## 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-674 (Uncontrolled Recursion)
- CWE-770 (Allocation of Resources Without Limits or Throttling)
## 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 β€” `StatelessIf` mutual recursion through `functions[]` table |
| `reproduce_real_impact.js` | watchdog-bypass demo (same harness as F-17, distinct trigger) |
| `F23_REAL_IMPACT_PROOF_2026-06-11.txt` | captured 8 s wall-clock burn with in-process watchdog never firing |