| --- |
| 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 | |
|
|
|
|