From 6aa69af7b37afe3cf0302f5e662fe480d73e9752 Mon Sep 17 00:00:00 2001 From: gray Date: Tue, 10 Feb 2026 17:35:39 +0800 Subject: [PATCH 1/6] fix: include sandbox ID in evalInVm cache key to prevent cross-scope collisions --- src/modules/utils/evalInVm.js | 2 +- src/modules/utils/sandbox.js | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/utils/evalInVm.js b/src/modules/utils/evalInVm.js index 8dc4f84..119cd57 100644 --- a/src/modules/utils/evalInVm.js +++ b/src/modules/utils/evalInVm.js @@ -58,7 +58,7 @@ const MAX_CACHE_SIZE = 100; * // evalInVm('[1,2,3].length') => {type: 'Literal', value: 3, raw: '3'} */ export function evalInVm(stringToEval, sb) { - const cacheName = `eval-${generateHash(stringToEval)}`; + const cacheName = `eval-${sb?.id || 'no-sb'}-${generateHash(stringToEval)}`; if (CACHE[cacheName] === undefined) { // Simple cache eviction: clear all when hitting size limit if (Object.keys(CACHE).length >= MAX_CACHE_SIZE) CACHE = {}; diff --git a/src/modules/utils/sandbox.js b/src/modules/utils/sandbox.js index a52d74e..dc7cd96 100644 --- a/src/modules/utils/sandbox.js +++ b/src/modules/utils/sandbox.js @@ -19,6 +19,9 @@ const DEFAULT_MEMORY_LIMIT = 128; // Default execution timeout (in milliseconds) const DEFAULT_TIMEOUT = 1000; +// Auto-incrementing ID for unique sandbox identification in evalInVm cache +let sandboxIdCounter = 0; + /** * Isolated sandbox environment for executing untrusted JavaScript code during deobfuscation. * @@ -52,6 +55,7 @@ export class Sandbox { * The sandbox is configured with memory limits, execution timeouts, and blocked APIs. */ constructor() { + this.id = ++sandboxIdCounter; this.replacedItems = {...BLOCKED_APIS}; this.replacedItemsNames = Object.keys(BLOCKED_APIS); this.timeout = DEFAULT_TIMEOUT; From 48bdda163072813216df994deb8cd72f695505b8 Mon Sep 17 00:00:00 2001 From: gray Date: Tue, 10 Feb 2026 17:36:24 +0800 Subject: [PATCH 2/6] fix: use nodeId-based cache key in getDeclarationWithContext to prevent cross-scope collisions --- src/modules/utils/getDeclarationWithContext.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/modules/utils/getDeclarationWithContext.js b/src/modules/utils/getDeclarationWithContext.js index f4cfe7d..279776d 100644 --- a/src/modules/utils/getDeclarationWithContext.js +++ b/src/modules/utils/getDeclarationWithContext.js @@ -159,8 +159,7 @@ export function getDeclarationWithContext(originNode, excludeOriginNode = false) const cache = getCache(originNode.scriptHash); const srcHash = generateHash(originNode.src); const cacheNameId = `context-${originNode.nodeId}-${srcHash}`; - const cacheNameSrc = `context-${srcHash}`; - let cached = cache[cacheNameId] || cache[cacheNameSrc]; + let cached = cache[cacheNameId]; if (!cached) { while (stack.length) { const node = stack.shift(); @@ -274,8 +273,7 @@ export function getDeclarationWithContext(originNode, excludeOriginNode = false) } // Convert to array and remove redundant nodes cached = removeRedundantNodes([...filteredNodes]); - cache[cacheNameId] = cached; // Caching context for the same node - cache[cacheNameSrc] = cached; // Caching context for a different node with similar content + cache[cacheNameId] = cached; } return cached; } \ No newline at end of file From 3c079ae5ce760e5296ed738c68bd1fb047fcc007 Mon Sep 17 00:00:00 2001 From: gray Date: Tue, 10 Feb 2026 18:34:18 +0800 Subject: [PATCH 3/6] fix: check target variable shadowing and modification before proxy replacement --- src/modules/safe/resolveProxyReferences.js | 43 ++++++++++- src/modules/safe/resolveProxyVariables.js | 83 +++++++++++++++++++++- 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/modules/safe/resolveProxyReferences.js b/src/modules/safe/resolveProxyReferences.js index 5d30f1b..b8a7f4a 100644 --- a/src/modules/safe/resolveProxyReferences.js +++ b/src/modules/safe/resolveProxyReferences.js @@ -2,6 +2,30 @@ import {areReferencesModified} from '../utils/areReferencesModified.js'; import {doesDescendantMatchCondition} from '../utils/doesDescendantMatchCondition.js'; import {getMainDeclaredObjectOfMemberExpression} from '../utils/getMainDeclaredObjectOfMemberExpression.js'; +/** + * Checks if the target identifier name is shadowed by a different declaration + * at the reference's scope, which would make replacement incorrect. + */ +function isTargetShadowedAtReference(ref, targetName, targetDeclNode) { + let scope = ref.scope; + while (scope) { + const vars = scope.variables || []; + for (let j = 0; j < vars.length; j++) { + if (vars[j].name === targetName) { + const ids = vars[j].identifiers || []; + for (let k = 0; k < ids.length; k++) { + if (ids[k] === targetDeclNode) { + return false; // Same declaration = not shadowed + } + } + return true; // Different declaration = shadowed + } + } + scope = scope.upper; + } + return false; +} + // Static array for supported node types to avoid recreation overhead const SUPPORTED_REFERENCE_TYPES = ['Identifier', 'MemberExpression']; @@ -126,7 +150,8 @@ export function resolveProxyReferencesMatch(arb, candidateFilter = () => true) { } // Both the proxy and target must not be modified - if (areReferencesModified(arb.ast, refs) || areReferencesModified(arb.ast, [n.init])) { + const initRefs = n.init.declNode?.references || [n.init]; + if (areReferencesModified(arb.ast, refs) || areReferencesModified(arb.ast, initRefs)) { continue; } @@ -157,7 +182,21 @@ export function resolveProxyReferencesTransform(arb, match) { // Replace each reference to the proxy with the target for (let i = 0; i < references.length; i++) { - arb.markNode(references[i], targetNode); + const ref = references[i]; + // Determine which name to check for shadowing + let checkName, checkDeclNode; + if (targetNode.type === 'Identifier') { + checkName = targetNode.name; + checkDeclNode = targetNode.declNode; + } else if (targetNode.type === 'MemberExpression') { + const rootObj = getMainDeclaredObjectOfMemberExpression(targetNode); + checkName = rootObj?.name; + checkDeclNode = rootObj?.declNode; + } + if (checkName && isTargetShadowedAtReference(ref, checkName, checkDeclNode)) { + continue; + } + arb.markNode(ref, targetNode); } return arb; diff --git a/src/modules/safe/resolveProxyVariables.js b/src/modules/safe/resolveProxyVariables.js index 95236df..5373c1d 100644 --- a/src/modules/safe/resolveProxyVariables.js +++ b/src/modules/safe/resolveProxyVariables.js @@ -1,5 +1,29 @@ import {areReferencesModified} from '../utils/areReferencesModified.js'; +/** + * Checks if the target identifier name is shadowed by a different declaration + * at the reference's scope, which would make replacement incorrect. + */ +function isTargetShadowedAtReference(ref, targetName, targetDeclNode) { + let scope = ref.scope; + while (scope) { + const vars = scope.variables || []; + for (let j = 0; j < vars.length; j++) { + if (vars[j].name === targetName) { + const ids = vars[j].identifiers || []; + for (let k = 0; k < ids.length; k++) { + if (ids[k] === targetDeclNode) { + return false; // Same declaration = not shadowed + } + } + return true; // Different declaration = shadowed + } + } + scope = scope.upper; + } + return false; +} + /** * Validates that a VariableDeclarator represents a proxy variable assignment. * @@ -87,14 +111,23 @@ export function resolveProxyVariablesTransform(arb, match) { if (areReferencesModified(arb.ast, references)) { return arb; } - + // Also check if the TARGET variable's references are modified + const targetRefs = targetIdentifier.declNode?.references || []; + if (areReferencesModified(arb.ast, targetRefs)) { + return arb; + } + // Replace all references with the target identifier for (let i = 0; i < references.length; i++) { const ref = references[i]; + // Skip if target name is shadowed by a different declaration at this reference + if (isTargetShadowedAtReference(ref, targetIdentifier.name, targetIdentifier.declNode)) { + continue; + } arb.markNode(ref, targetIdentifier); } } - + return arb; } @@ -122,8 +155,52 @@ export function resolveProxyVariablesTransform(arb, match) { export default function resolveProxyVariables(arb, candidateFilter = () => true) { const matches = resolveProxyVariablesMatch(arb, candidateFilter); + // Separate matches into safe and unsafe batches + const safeMatches = []; + const unsafeMatches = []; + + // Pre-validate all matches before applying any changes for (let i = 0; i < matches.length; i++) { - arb = resolveProxyVariablesTransform(arb, matches[i]); + const match = matches[i]; + const {declaratorNode, targetIdentifier, references, shouldRemove} = match; + const proxyName = declaratorNode.id?.name || '?'; + const targetName = targetIdentifier?.name || '?'; + + // Skip self-replacements (proxy name === target name) + // These are no-ops and waste processing time + if (proxyName === targetName) { + continue; + } + + if (shouldRemove) { + // Unused proxies are always safe to remove + safeMatches.push(match); + } else { + // Check safety conditions using original AST state + if (!areReferencesModified(arb.ast, references)) { + const targetRefs = targetIdentifier.declNode?.references || []; + if (!areReferencesModified(arb.ast, targetRefs)) { + // Check shadowing for each reference + let allRefsSafe = true; + for (let j = 0; j < references.length; j++) { + if (isTargetShadowedAtReference(references[j], targetIdentifier.name, targetIdentifier.declNode)) { + allRefsSafe = false; + break; + } + } + if (allRefsSafe) { + safeMatches.push(match); + continue; + } + } + } + unsafeMatches.push(match); + } + } + + // Apply all safe replacements in one batch + for (let i = 0; i < safeMatches.length; i++) { + arb = resolveProxyVariablesTransform(arb, safeMatches[i]); } return arb; From f024b7e91118757a9d697a3b550c94275bf69e98 Mon Sep 17 00:00:00 2001 From: gray Date: Tue, 10 Feb 2026 18:56:34 +0800 Subject: [PATCH 4/6] fix: add missing value property to Null literals and validate nested replacement nodes --- src/modules/unsafe/resolveLocalCalls.js | 113 ++++++++++++++++++++++-- src/modules/utils/createNewNode.js | 23 +++-- 2 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/modules/unsafe/resolveLocalCalls.js b/src/modules/unsafe/resolveLocalCalls.js index dfe2646..876e47d 100644 --- a/src/modules/unsafe/resolveLocalCalls.js +++ b/src/modules/unsafe/resolveLocalCalls.js @@ -35,6 +35,77 @@ function countAppearances(n) { return count; } +/** + * Validates a single Literal AST node for correctness. + * Ensures the node won't crash escodegen during code generation. + * @param {ASTNode} node - The Literal node to validate + * @return {boolean} True if valid, false otherwise + */ +function isValidLiteral(node) { + // If it has regex property, ensure pattern and flags are valid strings + if (node.regex) { + if (node.regex.pattern === undefined || node.regex.pattern === null || + node.regex.flags === undefined || node.regex.flags === null || + typeof node.regex.pattern !== 'string' || typeof node.regex.flags !== 'string') { + return false; + } + return true; + } + // BigInt literals use bigint+raw, value can be anything + if (typeof node.bigint === 'string' && node.raw) { + return true; + } + // If value is a RegExp object but no regex property, it's malformed + if (node.value && typeof node.value === 'object' && node.value.constructor === RegExp) { + return false; + } + // Reject Literal nodes where value is undefined — escodegen falls through + // to generateRegExp(undefined) which crashes with toString() on undefined + if (node.value === undefined) { + return false; + } + return true; +} + +/** + * Recursively validates that a replacement AST node tree is safe to use. + * Walks into ObjectExpression properties, ArrayExpression elements, and + * UnaryExpression arguments to check all nested Literal nodes. + * @param {ASTNode} node - The AST node to validate + * @return {boolean} True if node and all descendants are valid, false otherwise + */ +function isValidReplacementNode(node) { + if (!node || node === evalInVm.BAD_VALUE) return false; + + // Validate Literal nodes + if (node.type === 'Literal') { + return isValidLiteral(node); + } + + // Recurse into ObjectExpression properties + if (node.type === 'ObjectExpression' && Array.isArray(node.properties)) { + for (let i = 0; i < node.properties.length; i++) { + const prop = node.properties[i]; + if (prop.value && !isValidReplacementNode(prop.value)) return false; + if (prop.key && !isValidReplacementNode(prop.key)) return false; + } + } + + // Recurse into ArrayExpression elements + if (node.type === 'ArrayExpression' && Array.isArray(node.elements)) { + for (let i = 0; i < node.elements.length; i++) { + if (node.elements[i] && !isValidReplacementNode(node.elements[i])) return false; + } + } + + // Recurse into UnaryExpression argument (e.g. -0, +5) + if (node.type === 'UnaryExpression' && node.argument) { + if (!isValidReplacementNode(node.argument)) return false; + } + + return true; +} + /** * Identifies CallExpression nodes that can be resolved through local function definitions. * Collects call expressions where the callee has a declaration node and meets specific criteria. @@ -52,6 +123,7 @@ export function resolveLocalCallsMatch(arb, candidateFilter = () => true) { // Check if call expression has proper declaration context if ((n.callee?.declNode || + (n.callee?.type === 'AssignmentExpression' && n.callee.right?.declNode) || (n.callee?.object?.declNode && !SKIP_PROPERTIES.includes(n.callee.property?.value || n.callee.property?.name)) || n.callee?.object?.type === 'Literal') && @@ -90,9 +162,20 @@ export function resolveLocalCallsTransform(arb, matches) { if (c.arguments[j].type === 'ThisExpression') continue candidateLoop; } - const callee = c.callee?.object || c.callee; + const rawCallee = c.callee?.type === 'AssignmentExpression' ? c.callee.right : c.callee; + const callee = rawCallee?.object || rawCallee; const declNode = callee?.declNode || callee?.object?.declNode; - + + // Guard for AssignmentExpression callees: only resolve if the assignment + // left side has no remaining references (the assignment is effectively dead). + // This ensures safe modules have already inlined the variable's other usages. + if (c.callee?.type === 'AssignmentExpression') { + const leftDecl = c.callee.left?.declNode; + if (!leftDecl) continue; // Can't verify without declaration + const leftRefs = leftDecl.references || []; + if (leftRefs.some(ref => ref !== c.callee.left)) continue; + } + // Skip simple wrappers that should be handled by safe modules if (declNode?.parentNode?.body?.body?.[0]?.type === 'ReturnStatement') { const returnArg = declNode.parentNode.body.body[0].argument; @@ -118,32 +201,44 @@ export function resolveLocalCallsTransform(arb, matches) { // Skip simple function wrappers (handled by safe modules) if (declNode.parentNode.type === 'FunctionDeclaration' && VALID_UNWRAP_TYPES.includes(declNode.parentNode?.body?.body?.[0]?.argument?.type)) continue; - + // Build execution context in sandbox const contextSb = new Sandbox(); try { contextSb.run(createOrderedSrc(getDeclarationWithContext(declNode.parentNode))); if (Object.keys(cache) >= CACHE_LIMIT) cache.flush(); cache[cacheName] = contextSb; - } catch {} + } catch { + // Context build failed; cacheName stays BAD_VALUE + continue; + } } } // Evaluate call expression in appropriate context const contextVM = cache[cacheName]; - const nodeSrc = createOrderedSrc([c]); + let nodeSrc; + try { + nodeSrc = createOrderedSrc([c]); + } catch { + continue; + } const replacementNode = contextVM === evalInVm.BAD_VALUE ? evalInVm(nodeSrc) : evalInVm(nodeSrc, contextVM); - - if (replacementNode !== evalInVm.BAD_VALUE && replacementNode.type !== 'FunctionDeclaration' && replacementNode.name !== 'undefined') { + + if (replacementNode !== evalInVm.BAD_VALUE && + replacementNode.type !== 'FunctionDeclaration' && + replacementNode.name !== 'undefined' && + isValidReplacementNode(replacementNode)) { // Anti-debugging protection: avoid resolving function toString that might trigger detection - if (c.callee.type === 'MemberExpression' && + if (c.callee.type === 'MemberExpression' && (c.callee.property?.name || c.callee.property?.value) === 'toString' && replacementNode?.value?.substring(0, 8) === 'function') continue; - + arb.markNode(c, replacementNode); modifiedRanges.push(c.range); } } + return arb; } diff --git a/src/modules/utils/createNewNode.js b/src/modules/utils/createNewNode.js index 88984d1..4c0fcb5 100644 --- a/src/modules/utils/createNewNode.js +++ b/src/modules/utils/createNewNode.js @@ -114,12 +114,13 @@ export function createNewNode(value) { name: 'undefined', }; break; - case 'Null': - newNode = { - type: 'Literal', - raw: 'null', - }; - break; + case 'Null': + newNode = { + type: 'Literal', + value: null, + raw: 'null', + }; + break; case 'BigInt': newNode = { type: 'Literal', @@ -159,8 +160,18 @@ export function createNewNode(value) { } break; case 'RegExp': + // Validate that RegExp has required properties + // Some RegExp objects copied from isolated-vm may have undefined/null source/flags + if (!value || typeof value !== 'object' || + value.source === undefined || value.source === null || + value.flags === undefined || value.flags === null || + typeof value.source !== 'string' || typeof value.flags !== 'string') { + + break; // Return BAD_VALUE (newNode is already BAD_VALUE) + } newNode = { type: 'Literal', + value: null, // Explicitly set to null to prevent escodegen from using value regex: { pattern: value.source, flags: value.flags, From cdc4082faf7f399dc4d740d96a0437bb860ed357 Mon Sep 17 00:00:00 2001 From: gray Date: Tue, 10 Feb 2026 18:58:20 +0800 Subject: [PATCH 5/6] fix: wrap Function constructor body in function for parsing return statements --- .../safe/replaceNewFuncCallsWithLiteralContent.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js b/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js index 2517201..4512173 100644 --- a/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js +++ b/src/modules/safe/replaceNewFuncCallsWithLiteralContent.js @@ -21,7 +21,20 @@ function parseCodeStringToAST(codeStr) { }; } - const body = generateFlatAST(codeStr, {detailed: false, includeSrc: false})[0].body; + let body; + const flatAST = generateFlatAST(codeStr, {detailed: false, includeSrc: false}); + if (flatAST.length && flatAST[0].body) { + body = flatAST[0].body; + } else { + // Code string comes from new Function("code") where "code" is a function body. + // Statements like "return this" are only valid inside a function, so wrap and re-parse. + const wrappedAST = generateFlatAST(`(function(){${codeStr}})`, {detailed: false, includeSrc: false}); + const funcBody = wrappedAST.length && wrappedAST[0].body[0]?.expression?.body?.body; + if (!funcBody) { + throw new Error(`Failed to parse code string: "${codeStr.substring(0, 80)}..."`); + } + body = funcBody; + } if (body.length > 1) { return { From 6b2affa69fdf4f7f801ef25eab955705f7ae912b Mon Sep 17 00:00:00 2001 From: gray Date: Tue, 10 Feb 2026 18:59:23 +0800 Subject: [PATCH 6/6] fix: support Identifier assignments and only check conditional context for writes --- ...rWithFixedValueNotAssignedAtDeclaration.js | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js b/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js index b97b594..5a6b563 100644 --- a/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js +++ b/src/modules/safe/replaceIdentifierWithFixedValueNotAssignedAtDeclaration.js @@ -92,11 +92,15 @@ export function replaceIdentifierWithFixedValueNotAssignedAtDeclarationMatch(arb // Check for exactly one assignment to a literal value const assignmentRef = getSingleAssignmentReference(n); - if (assignmentRef && assignmentRef.parentNode.right.type === 'Literal') { - + if (assignmentRef && (assignmentRef.parentNode.right.type === 'Literal' || assignmentRef.parentNode.right.type === 'Identifier')) { + // Ensure no unsafe usage patterns exist + // Only check conditional context for write (assignment) references - + // a read reference inside a conditional is safe to inline + const isWriteReference = (r) => + r.parentNode?.type === 'AssignmentExpression' && r.parentKey === 'left'; const hasUnsafeReferences = n.references.some(r => - isForLoopIterator(r) || isInConditionalContext(r) + isForLoopIterator(r) || (isWriteReference(r) && isInConditionalContext(r)) ); if (!hasUnsafeReferences) { @@ -130,12 +134,23 @@ export function replaceIdentifierWithFixedValueNotAssignedAtDeclarationTransform // Additional safety check: ensure references aren't modified in complex ways if (!areReferencesModified(arb.ast, referencesToReplace)) { + // For Identifier values (e.g., te = O), verify the target is safe to inline + if (valueNode.type === 'Identifier') { + if (!valueNode.declNode) { + return arb; // Can't verify target safety without declaration node + } + const targetRefs = valueNode.declNode.references || []; + if (areReferencesModified(arb.ast, targetRefs)) { + return arb; + } + } for (let i = 0; i < referencesToReplace.length; i++) { const ref = referencesToReplace[i]; - - // Skip function calls where identifier is the callee - // Example: let func; func = someFunction; func(); // Don't replace func() - if (ref.parentNode.type === 'CallExpression' && ref.parentKey === 'callee') { + + // Skip function calls where identifier is the callee and value is a Literal + // Example: let x; x = 3; x(); // Don't replace with 3() + // But allow: let te; te = O; te(123); // Replace with O(123) + if (ref.parentNode.type === 'CallExpression' && ref.parentKey === 'callee' && valueNode.type === 'Literal') { continue; }