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.
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.
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) - Function dispatch via
context.functionMap[fn].executeFunctionAsync(...)at L42 and L45.
In control_executor.ts:32-47:
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_abody:StatelessIf(true, f_b, f_b)β always dispatches tof_b.f_bbody:StatelessIf(true, f_a, f_a)β always dispatches tof_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
- 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:node[0]βConst 'true_cond' = true(always-true predicate)node[1]βStatelessIf(true_cond, f_a, f_a)(top-level)library.function:f_awhose body isStatelessIf(true_cond, f_b, f_b)f_bwhose body isStatelessIf(true_cond, f_a, f_a)
- Attacker delivers the file.
- Victim service calls
tf.loadGraphModel(file://β¦)thenmodel.executeAsync({}, ['out']). control_executor.ts:32-47entersStatelessIf, dispatches tof_a.f_aentersStatelessIf, dispatches tof_b.f_bentersStatelessIf, dispatches tof_a.- Recursion continues unbounded. Promise never resolves. Microtask queue
monopolised;
setTimeoutwatchdogs cannot fire. - Only external SIGKILL recovers the worker.
Impact
Captured PoC (F23_REAL_IMPACT_PROOF_2026-06-11.txt):
========================================================
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):
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 |