You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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:

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_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):

========================================================
 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
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support