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