Fixed controlFlowFlattening occasionally dropping the ?. short-circuit on foo?.(arg) calls, causing TypeError: <X> is not a function

This commit is contained in:
sanex3339
2026-05-22 01:29:30 +07:00
parent 10c763fa65
commit 1985a5d093
5 changed files with 70 additions and 2 deletions
+4
View File
@@ -1,5 +1,9 @@
Change Log
v5.4.3
---
* Fixed `controlFlowFlattening` occasionally dropping the `?.` short-circuit on `foo?.(arg)` calls, causing `TypeError: <X> is not a function`. Fixes https://github.com/javascript-obfuscator/javascript-obfuscator/issues/1408
v5.4.2
---
* Fixed obfuscated code hanging in Bun when `selfDefending` is enabled. Fixes https://github.com/javascript-obfuscator/javascript-obfuscator/issues/1404
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "javascript-obfuscator",
"version": "5.4.2",
"version": "5.4.3",
"description": "JavaScript obfuscator",
"keywords": [
"obfuscator",
@@ -64,7 +64,11 @@ export class CallExpressionControlFlowReplacer extends AbstractControlFlowReplac
const isChainExpressionParent = NodeGuards.isChainExpressionNode(parentNode);
const replacerId: number = callExpressionNode.arguments.length;
// Bucket reuse-eligible wrappers by both arg count AND optional-ness, so an
// optional `foo?.(arg)` call never reuses the wrapper of a non-optional `bar(arg)`
// that happens to share `arguments.length` — which would drop the `?.` short-circuit
// and crash on undefined callees (issue #1408).
const replacerId: string = `${callExpressionNode.arguments.length}-${isChainExpressionParent ? 'optional' : 'standard'}`;
const callExpressionFunctionCustomNode: ICustomNode<TInitialData<CallExpressionFunctionNode>> =
this.controlFlowCustomNodeFactory(ControlFlowCustomNode.CallExpressionFunctionNode);
const expressionArguments: (ESTree.Expression | ESTree.SpreadElement)[] = callExpressionNode.arguments;
@@ -248,5 +248,33 @@ describe('CallExpressionControlFlowReplacer', function () {
assert.match(obfuscatedCode, controlFlowStorageNodeRegExp);
});
});
describe('Variant #8 - optional `?.()` call must not reuse a non-optional wrapper (issue #1408)', () => {
const samplesCount: number = 200;
it('should preserve the `?.` short-circuit on every obfuscation when an optional call and a non-optional call share the same arity', () => {
const code: string = readFileAsString(
__dirname + '/fixtures/issue-1408-mixed-optional-and-plain-calls.js'
);
for (let i = 0; i < samplesCount; i++) {
const obfuscatedCode: string = JavaScriptObfuscator.obfuscate(code, {
...NO_ADDITIONAL_NODES_PRESET,
controlFlowFlattening: true,
controlFlowFlatteningThreshold: 1,
deadCodeInjection: true,
deadCodeInjectionThreshold: 1
}).getObfuscatedCode();
const result: unknown = eval(obfuscatedCode);
assert.strictEqual(
result,
'ok',
`iteration ${i}: optional ?.() call lost its short-circuit (obfuscated code threw or returned wrong value)`
);
}
});
});
});
});
@@ -0,0 +1,32 @@
(function () {
function noise (n) {
var acc = 0;
for (var i = 0; i < n; i++) {
if (i % 2 === 0) {
acc += i;
} else {
acc ^= i;
}
}
return acc;
}
function bump (v) {
return v + 1;
}
function outer () {
var captured = undefined;
var inner = function (msg) {
captured?.(msg);
return 'ok';
};
return inner('payload');
}
noise(50);
bump(7);
noise(25);
bump(13);
return outer();
})();