From db2f5b6d8ee269e7a155c7019ad06f25dcb0ea3f Mon Sep 17 00:00:00 2001 From: Ryohei Namiki Date: Tue, 8 Sep 2026 21:34:45 -0400 Subject: [PATCH 1/3] Decline proxy inlining that would duplicate an argument replaceProxyFunctionUsages inlines every proxy call. getReplacement pastes the argument at each usage of its parameter, so a parameter the body uses twice duplicates the argument. A nested chain of such calls grows exponentially, and a side effect in the argument runs twice. Inline such a call only when every multi-use parameter receives a literal, an identifier or this. Keep the declaration of a proxy function whose call was declined, so the remaining call still resolves. --- src/modifications/proxies/proxyFunction.ts | 34 ++++++++++++++++++++++ src/modifications/proxies/proxyRemover.ts | 23 +++++++++++---- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/modifications/proxies/proxyFunction.ts b/src/modifications/proxies/proxyFunction.ts index ddaca9b..ec30a4d 100644 --- a/src/modifications/proxies/proxyFunction.ts +++ b/src/modifications/proxies/proxyFunction.ts @@ -7,6 +7,15 @@ import { v4 as uuid } from 'uuid'; import Scope from '../../scope/scope'; export default class ProxyFunction { + private static readonly duplicableArgumentTypes = new Set([ + 'IdentifierExpression', + 'LiteralNumericExpression', + 'LiteralStringExpression', + 'LiteralBooleanExpression', + 'LiteralNullExpression', + 'ThisExpression' + ]); + id: string; node: Shift.Node; parentNode: Shift.Node; @@ -65,6 +74,31 @@ export default class ProxyFunction { return expression; } + /** + * Returns whether a call of the proxy function can be inlined. + * `getReplacement` pastes an argument at every usage of its parameter, so + * a parameter that the body uses more than once duplicates the argument. + * A nested chain of such calls grows exponentially, and a side effect in + * the argument then runs more than once. + * @param args The arguments of the proxy function call. + */ + isSafeToInline(args: Shift.Expression[]): boolean { + const paramUsages = this.findParameterUsages(this.expression); + + for (const [index, usages] of paramUsages) { + const arg = args[index]; + if ( + usages.length > 1 && + arg && + !ProxyFunction.duplicableArgumentTypes.has(arg.type) + ) { + return false; + } + } + + return true; + } + /** * Finds all usages of the proxy function's parameters within a given * expression. diff --git a/src/modifications/proxies/proxyRemover.ts b/src/modifications/proxies/proxyRemover.ts index 5eff5fa..269638e 100644 --- a/src/modifications/proxies/proxyRemover.ts +++ b/src/modifications/proxies/proxyRemover.ts @@ -22,6 +22,7 @@ export default class ProxyRemover extends Modification { private proxyFunctions: ProxyFunction[]; private proxyFunctionNames: Set; private cyclicProxyFunctionIds: Set; + private retainedProxyFunctionIds: Set; private graph: Graph; /** @@ -36,6 +37,7 @@ export default class ProxyRemover extends Modification { this.proxyFunctions = []; this.proxyFunctionNames = new Set(); this.cyclicProxyFunctionIds = new Set(); + this.retainedProxyFunctionIds = new Set(); this.graph = new Graph(); } @@ -248,13 +250,19 @@ export default class ProxyRemover extends Modification { if (proxyFunction && !self.cyclicProxyFunctionIds.has(proxyFunction.id)) { const args = (node as any).arguments; - let replacement: Shift.Node = proxyFunction.getReplacement(args); - replacement = self.replaceProxyFunctionUsages(replacement, scope); - if (parent) { - TraversalHelper.replaceNode(parent, node, replacement); + if (!proxyFunction.isSafeToInline(args)) { + // the declaration must survive, because the call stays + self.retainedProxyFunctionIds.add(proxyFunction.id); } else { - replacedNode = replacement; + let replacement: Shift.Node = proxyFunction.getReplacement(args); + replacement = self.replaceProxyFunctionUsages(replacement, scope); + + if (parent) { + TraversalHelper.replaceNode(parent, node, replacement); + } else { + replacedNode = replacement; + } } } } @@ -276,7 +284,10 @@ export default class ProxyRemover extends Modification { */ private removeProxyFunctions(scope: Scope): void { for (const [_, proxyFunction] of scope.elements) { - if (!this.cyclicProxyFunctionIds.has(proxyFunction.id)) { + if ( + !this.cyclicProxyFunctionIds.has(proxyFunction.id) && + !this.retainedProxyFunctionIds.has(proxyFunction.id) + ) { TraversalHelper.removeNode(proxyFunction.parentNode, proxyFunction.node); } } From a3a3e5b6caf17e489c4d07c5c300dd7341c7f876 Mon Sep 17 00:00:00 2001 From: Ryohei Namiki Date: Tue, 8 Sep 2026 21:54:02 -0400 Subject: [PATCH 2/3] Keep the alias of a proxy function whose calls are not inlined findAliases removed an alias declaration such as var q = p before any inline decision. A call that is not inlined then names an identifier that the output no longer declares. 1.1.7 already emits that shape for an aliased cyclic proxy function. Record the alias declarations and remove them after replacement, and only for a proxy function whose calls were all inlined. --- src/modifications/proxies/proxyRemover.ts | 35 ++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/modifications/proxies/proxyRemover.ts b/src/modifications/proxies/proxyRemover.ts index 269638e..2a10654 100644 --- a/src/modifications/proxies/proxyRemover.ts +++ b/src/modifications/proxies/proxyRemover.ts @@ -23,6 +23,7 @@ export default class ProxyRemover extends Modification { private proxyFunctionNames: Set; private cyclicProxyFunctionIds: Set; private retainedProxyFunctionIds: Set; + private aliasDeclarations: AliasDeclaration[]; private graph: Graph; /** @@ -38,6 +39,7 @@ export default class ProxyRemover extends Modification { this.proxyFunctionNames = new Set(); this.cyclicProxyFunctionIds = new Set(); this.retainedProxyFunctionIds = new Set(); + this.aliasDeclarations = []; this.graph = new Graph(); } @@ -49,6 +51,7 @@ export default class ProxyRemover extends Modification { this.findAliases(); this.findCycles(); this.replaceProxyFunctionUsages(this.ast, this.globalScope); + this.removeAliasDeclarations(); if (this.shouldRemoveProxyFunctions) { this.removeProxyFunctions(this.globalScope); @@ -134,7 +137,9 @@ export default class ProxyRemover extends Modification { const proxyFunction = scope.get(name); if (proxyFunction) { scope.add(newName, proxyFunction); - TraversalHelper.removeNode(parent, node); + self.aliasDeclarations.push( + new AliasDeclaration(parent, node, proxyFunction.id) + ); if (!self.proxyFunctionNames.has(newName)) { self.proxyFunctionNames.add(newName); } @@ -278,6 +283,22 @@ export default class ProxyRemover extends Modification { return replacedNode; } + /** + * Removes the alias declarations of the proxy functions whose calls were + * all inlined. The alias of a retained or cyclic proxy function stays, + * because a call through that alias is still in the output. + */ + private removeAliasDeclarations(): void { + for (const alias of this.aliasDeclarations) { + if ( + !this.retainedProxyFunctionIds.has(alias.proxyFunctionId) && + !this.cyclicProxyFunctionIds.has(alias.proxyFunctionId) + ) { + TraversalHelper.removeNode(alias.parentNode, alias.node); + } + } + } + /** * Removes all proxy functions from a scope and its children. * @param scope The scope to remove proxy functions from. @@ -380,3 +401,15 @@ export default class ProxyRemover extends Modification { return node.type == 'CallExpression' && node.callee.type == 'IdentifierExpression'; } } + +class AliasDeclaration { + parentNode: Shift.Node; + node: Shift.Node; + proxyFunctionId: string; + + constructor(parentNode: Shift.Node, node: Shift.Node, proxyFunctionId: string) { + this.parentNode = parentNode; + this.node = node; + this.proxyFunctionId = proxyFunctionId; + } +} From b7bd9288f854a3f2721c79d3f106e85a32ce1ad5 Mon Sep 17 00:00:00 2001 From: Ryohei Namiki Date: Tue, 8 Sep 2026 23:55:26 -0400 Subject: [PATCH 3/3] Duplicate an argument only when it is pure and small The first version of this branch allowed six node types. That declined a string-array read, which is the dominant obfuscation shape, because ProxyRemover runs before ArrayUnpacker. State the real precondition instead. An argument is safe to duplicate when it evaluates with no side effect and holds at most 4 nodes. The purity half stops the exponential chain, because a chain needs a nested call. The size half bounds the duplication whatever the pass order is. Run the proxy pass again after the array unpacking, next to the second expression simplifier that exists for the same reason. A string-array argument then reaches the guard as a literal, and it inlines. --- src/index.ts | 7 +++ src/modifications/proxies/proxyFunction.ts | 57 +++++++++++++++++----- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index 689ec5f..093ca24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,13 @@ export function deobfuscate(source: string, parsedConfig?: Partial): str modifications.push(new ArrayUnpacker(ast, config.arrays.removeArrays)); } + // replace any proxy function calls that were revealed by the array unpacking + if (config.proxyFunctions.replaceProxyFunctions) { + modifications.push( + new ProxyRemover(ast, config.proxyFunctions.removeProxyFunctions) + ); + } + // simplify any expressions that were revealed by the array unpacking if (config.expressions.simplifyExpressions) { modifications.push(new ExpressionSimplifier(ast)); diff --git a/src/modifications/proxies/proxyFunction.ts b/src/modifications/proxies/proxyFunction.ts index ec30a4d..207c5db 100644 --- a/src/modifications/proxies/proxyFunction.ts +++ b/src/modifications/proxies/proxyFunction.ts @@ -7,13 +7,20 @@ import { v4 as uuid } from 'uuid'; import Scope from '../../scope/scope'; export default class ProxyFunction { - private static readonly duplicableArgumentTypes = new Set([ + // A twice-used parameter of a minified MD5 helper inlines to a 9 node + // expression, so this budget stops that expression from duplicating again. + private static readonly maxDuplicableNodes = 4; + private static readonly sideEffectFreeTypes = new Set([ + 'BinaryExpression', + 'ConditionalExpression', 'IdentifierExpression', - 'LiteralNumericExpression', - 'LiteralStringExpression', 'LiteralBooleanExpression', + 'LiteralInfinityExpression', 'LiteralNullExpression', - 'ThisExpression' + 'LiteralNumericExpression', + 'LiteralStringExpression', + 'ThisExpression', + 'UnaryExpression' ]); id: string; @@ -78,8 +85,7 @@ export default class ProxyFunction { * Returns whether a call of the proxy function can be inlined. * `getReplacement` pastes an argument at every usage of its parameter, so * a parameter that the body uses more than once duplicates the argument. - * A nested chain of such calls grows exponentially, and a side effect in - * the argument then runs more than once. + * Inlining is valid only when that argument is safe to duplicate. * @param args The arguments of the proxy function call. */ isSafeToInline(args: Shift.Expression[]): boolean { @@ -87,11 +93,7 @@ export default class ProxyFunction { for (const [index, usages] of paramUsages) { const arg = args[index]; - if ( - usages.length > 1 && - arg && - !ProxyFunction.duplicableArgumentTypes.has(arg.type) - ) { + if (usages.length > 1 && arg && !ProxyFunction.isDuplicable(arg)) { return false; } } @@ -99,6 +101,39 @@ export default class ProxyFunction { return true; } + /** + * Returns whether an expression is safe to duplicate. It must evaluate + * with no side effect, so a duplicate cannot change what the program + * does, and it must stay under `maxDuplicableNodes`, so a duplicate + * cannot compound through a chain of nested calls. + * + * A call, `new`, an assignment, `++`, `--` and `delete` each have a side + * effect. A member expression can run a getter. A regular expression, an + * array, an object and a function each have their own identity, which a + * duplicate would split. + * @param node The expression node. + */ + private static isDuplicable(node: Shift.Node): boolean { + let nodeCount = 0; + let duplicable = true; + + traverse(node, { + enter(node: Shift.Node) { + nodeCount += 1; + + if (nodeCount > ProxyFunction.maxDuplicableNodes) { + duplicable = false; + } else if (!ProxyFunction.sideEffectFreeTypes.has(node.type)) { + duplicable = false; + } else if (node.type == 'UnaryExpression' && node.operator == 'delete') { + duplicable = false; + } + } + }); + + return duplicable; + } + /** * Finds all usages of the proxy function's parameters within a given * expression.