Skip to content

Non-deterministic ?.() miscompile drops short-circuit on captured variable in nested closure #1408

@Thorsten-Kd

Description

@Thorsten-Kd

With controlFlowFlattening: true, the obfuscator non-deterministically miscompiles optional-call expressions of the shape captured?.(arg) — where captured is an outer-scope variable referenced inside a nested closure. On some random seeds the ?. short-circuit is dropped, and the obfuscated code calls undefined as a function, throwing TypeError: <X> is not a function. The bug reproduces on roughly 5% of obfuscation runs with identical input and identical options, on both 5.4.1 and 5.4.2. Four other shapes of optional call exercised in the attached reproducer (object property, awaited, this.member, chained ?.) never reproduce — only the captured-variable-in-nested-closure shape is affected.

Expected Behavior

The obfuscated output should preserve the runtime semantics of the input. For fn?.(arg), that means evaluating to undefined when fn is undefined (the ?. short-circuit), with no call performed.

Current Behavior

On some random seeds, the obfuscator rewrites captured?.(arg) — where captured is an outer-scope variable referenced inside a nested closure — in a way that drops the ?. short-circuit and invokes the undefined value as a function. The obfuscated code throws TypeError: <obfuscated-name> is not a function at runtime instead of short-circuiting to undefined.

The miscompile is non-deterministic across runs with identical input and identical options because the obfuscator picks a fresh random seed per invocation. Only some seeds trigger it. Across 200 iterations on the attached reproducer, the failure rate is 10/200 (5.0%) on both 5.4.1 and 5.4.2.

The bug is narrowly shape-specific: across the same 200 iterations, four other optional-call shapes (obj.handler?.(), await cb?.(), this.cb?.(), chained obj.inner?.handler?.()) never reproduce. Only the captured-variable-in-nested-closure shape is affected.

Steps to Reproduce

  1. Save the file in Minimal working example below as standalone.mjs.
  2. npm install javascript-obfuscator
  3. node standalone.mjs 200

Expected output: all 200 iterations pass.
Actual output:

=== Summary over 200 iterations ===
runs with any failure: 10/200 (5.0%)
  shape1: 10/200 fail (5.0%) — example: THROW: _0x40002d is not a function
  shape2: 0/200 fail (0.0%)
  shape3: 0/200 fail (0.0%)
  shape4: 0/200 fail (0.0%)
  shape5: 0/200 fail (0.0%)

The script prints the path to a tmp directory at the end; failing iterations' obfuscated bytes are kept there for inspection.

JavaScript Obfuscator Edition

Your Environment

  • Obfuscator version used: 5.4.2 (also reproduces identically on 5.4.1)
  • Node version used: 23.x
  • OS: macOS (darwin ARM64), but the bug is not platform-specific — the obfuscator is fed deterministic input bytes and produces non-deterministic output regardless of host.

Stack trace

The throw site as it appears in the obfuscated output of a failing iteration (function names are seed-dependent, but the shape is consistent):

TypeError: _0x40002d is not a function
    at <anonymous> (source.17.mjs:1:NNNN)    // inner closure body, where `captured?.(msg)` should have short-circuited
    at main (harness.17.mjs:1:NNN)

Note: with controlFlowFlattening: true the surrounding call stack is flattened through dispatch-table dispatchers, so additional frames may appear depending on the seed. The throw itself is always at the optional-call site.

Minimal working example that will help to reproduce issue

Also available as a gist: https://gist.github.com/Thorsten-Kd/5b2a0b33932dc7534f827bd6dd5b41c5
standalone.mjs — self-contained, only depends on javascript-obfuscator:

import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

import JavaScriptObfuscator from 'javascript-obfuscator';

const ITERATIONS = parseInt(process.argv[2] ?? '200', 10);

const OBFUSCATION_OPTIONS = {
  compact: true,
  controlFlowFlattening: true,
  controlFlowFlatteningThreshold: 1.0,
  deadCodeInjection: true,
  deadCodeInjectionThreshold: 1.0,
  identifierNamesGenerator: 'hexadecimal',
  log: false,
  numbersToExpressions: true,
  selfDefending: false,
  simplify: false,
  splitStrings: true,
  splitStringsChunkLength: 5,
  stringArray: true,
  stringArrayCallsTransformThreshold: 1.0,
  stringArrayEncoding: ['base64'],
  stringArrayIndexShift: true,
  stringArrayRotate: true,
  stringArrayShuffle: true,
  stringArrayThreshold: 1,
  stringArrayWrappersChainedCalls: true,
  stringArrayWrappersCount: 5,
  target: 'node',
  unicodeEscapeSequence: true,
};

const SOURCE = `
// Shape 1: captured outer-scope variable called via \`?.()\` inside a
// nested closure. This is the shape that reliably reproduces the bug.
export function shape1() {
  const captured = undefined;
  const wrap = (msg) => {
    captured?.(msg);
    return 'ok';
  };
  return wrap('payload');
}

export function shape2() {
  const obj = {};
  obj.handler?.('arg');
  return 'ok';
}

export async function shape3() {
  const cb = undefined;
  await cb?.();
  return 'ok';
}

export class Foo { run() { this.cb?.('arg'); return 'ok'; } }
export function shape4Run() { return new Foo().run(); }

export function shape5() {
  const obj = { inner: undefined };
  obj.inner?.handler?.();
  return 'ok';
}

// Padding so controlFlowFlattening has enough basic blocks to do
// non-trivial rewiring. Tiny inputs sometimes don't trigger the
// miscompile because the dispatch table CFF builds is too small.
function noise(n) {
  let acc = 0;
  for (let i = 0; i < n; i++) {
    if (i % 2 === 0) acc += i;
    else if (i % 3 === 0) acc -= i;
    else acc ^= i;
    if (acc > 1000000) acc = 0;
  }
  return acc;
}
function processItems(items) {
  const out = [];
  for (const item of items) {
    const tag = item.tag ?? 'untagged';
    const value = item.value ?? 0;
    if (tag === 'a') out.push(value * 2);
    else if (tag === 'b') out.push(value + 10);
    else out.push(value);
  }
  return out;
}
class Store {
  constructor() { this.data = new Map(); }
  set(k, v) { this.data.set(k, v); }
  get(k) { return this.data.get(k); }
  has(k) { return this.data.has(k); }
  *values() { for (const v of this.data.values()) yield v; }
}
export function exercisePadding() {
  noise(100);
  processItems([{ tag: 'a', value: 1 }, { tag: 'b', value: 2 }, { value: 3 }]);
  const s = new Store();
  s.set('x', 1);
  return s.has('x');
}
`;

const HARNESS_TEMPLATE = `
import { shape1, shape2, shape3, shape4Run, shape5, exercisePadding } from './SOURCE_PLACEHOLDER';
async function main() {
  exercisePadding();
  const results = { shape1: 'unset', shape2: 'unset', shape3: 'unset', shape4: 'unset', shape5: 'unset' };
  try { results.shape1 = shape1(); } catch (e) { results.shape1 = 'THROW: ' + e.message; }
  try { results.shape2 = shape2(); } catch (e) { results.shape2 = 'THROW: ' + e.message; }
  try { results.shape3 = await shape3(); } catch (e) { results.shape3 = 'THROW: ' + e.message; }
  try { results.shape4 = shape4Run(); } catch (e) { results.shape4 = 'THROW: ' + e.message; }
  try { results.shape5 = shape5(); } catch (e) { results.shape5 = 'THROW: ' + e.message; }
  console.log(JSON.stringify(results));
}
main();
`;

const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'obf-repro-'));
console.log(`workdir: ${tmpDir}`);
console.log(`node: ${process.version}`);

const SHAPES = ['shape1', 'shape2', 'shape3', 'shape4', 'shape5'];
const failsByShape = Object.fromEntries(SHAPES.map((s) => [s, 0]));
let runsWithAnyFail = 0;
const examples = {};

for (let i = 1; i <= ITERATIONS; i++) {
  const obfPath = path.join(tmpDir, `source.${i}.mjs`);
  const harnessPath = path.join(tmpDir, `harness.${i}.mjs`);
  const obfuscated = JavaScriptObfuscator.obfuscate(SOURCE, OBFUSCATION_OPTIONS).getObfuscatedCode();
  await fs.promises.writeFile(obfPath, obfuscated);
  const harness = HARNESS_TEMPLATE.replace('SOURCE_PLACEHOLDER', `./source.${i}.mjs`);
  await fs.promises.writeFile(harnessPath, harness);

  const proc = spawnSync(process.execPath, [harnessPath], { encoding: 'utf-8', timeout: 10_000 });
  let results;
  try { results = JSON.parse(proc.stdout?.trim() ?? ''); }
  catch {
    runsWithAnyFail++;
    console.log(`[${i}/${ITERATIONS}] FAIL (no JSON) stderr=${(proc.stderr ?? '').slice(0, 200)}`);
    continue;
  }
  const failed = SHAPES.filter((s) => results[s] !== 'ok');
  if (failed.length > 0) {
    runsWithAnyFail++;
    for (const s of failed) {
      failsByShape[s]++;
      examples[s] ??= results[s];
    }
    console.log(`[${i}/${ITERATIONS}] FAIL ${failed.join(',')}`);
  } else {
    console.log(`[${i}/${ITERATIONS}] PASS`);
    await fs.promises.unlink(obfPath);
    await fs.promises.unlink(harnessPath);
  }
}

console.log(`\n=== Summary over ${ITERATIONS} iterations ===`);
console.log(`runs with any failure: ${runsWithAnyFail}/${ITERATIONS} (${((runsWithAnyFail / ITERATIONS) * 100).toFixed(1)}%)`);
for (const s of SHAPES) {
  const n = failsByShape[s];
  console.log(`  ${s}: ${n}/${ITERATIONS} fail (${((n / ITERATIONS) * 100).toFixed(1)}%)${n > 0 ? ` — example: ${examples[s].slice(0, 120)}` : ''}`);
}
console.log(`\nbroken artifacts (if any) kept in: ${tmpDir}`);

Suspected cause (speculation)

TypeScript and modern JS engines lower f?.(arg) to a guarded call shape roughly equivalent to:

(f === null || f === void 0) ? void 0 : f.call(undefined, arg)

controlFlowFlattening rewrites the ternary into a dispatch-table-keyed control flow graph. Under some random keyings, the dispatch branch corresponding to the === void 0 guard appears to collapse into the unguarded call — possibly during a dead-branch elimination pass that proves one arm "unreachable" using the wrong invariant. The fact that simplify: false does not prevent the miscompile rules out the simplify pass as the cause; the problem is in (or interacts with) controlFlowFlattening itself.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions