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
- Save the file in Minimal working example below as
standalone.mjs.
npm install javascript-obfuscator
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.
With
controlFlowFlattening: true, the obfuscator non-deterministically miscompiles optional-call expressions of the shapecaptured?.(arg)— wherecapturedis an outer-scope variable referenced inside a nested closure. On some random seeds the?.short-circuit is dropped, and the obfuscated code callsundefinedas a function, throwingTypeError: <X> is not a function. The bug reproduces on roughly 5% of obfuscation runs with identical input and identical options, on both5.4.1and5.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 toundefinedwhenfnisundefined(the?.short-circuit), with no call performed.Current Behavior
On some random seeds, the obfuscator rewrites
captured?.(arg)— wherecapturedis 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 throwsTypeError: <obfuscated-name> is not a functionat runtime instead of short-circuiting toundefined.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.1and5.4.2.The bug is narrowly shape-specific: across the same 200 iterations, four other optional-call shapes (
obj.handler?.(),await cb?.(),this.cb?.(), chainedobj.inner?.handler?.()) never reproduce. Only the captured-variable-in-nested-closure shape is affected.Steps to Reproduce
standalone.mjs.npm install javascript-obfuscatornode standalone.mjs 200Expected output: all 200 iterations pass.
Actual output:
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
5.4.1)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):
Note: with
controlFlowFlattening: truethe 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 onjavascript-obfuscator:Suspected cause (speculation)
TypeScript and modern JS engines lower
f?.(arg)to a guarded call shape roughly equivalent to:controlFlowFlatteningrewrites the ternary into a dispatch-table-keyed control flow graph. Under some random keyings, the dispatch branch corresponding to the=== void 0guard 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 thatsimplify: falsedoes not prevent the miscompile rules out the simplify pass as the cause; the problem is in (or interacts with)controlFlowFlatteningitself.