diff --git a/packages/webcrack/src/ast-utils/matcher.ts b/packages/webcrack/src/ast-utils/matcher.ts index 910ef756..700d5fb4 100644 --- a/packages/webcrack/src/ast-utils/matcher.ts +++ b/packages/webcrack/src/ast-utils/matcher.ts @@ -155,8 +155,7 @@ export function isReadonlyObject( memberAccess: m.Matcher, ): boolean { // Workaround because sometimes babel treats the VariableDeclarator/binding itself as a violation - if (!binding.constant && binding.constantViolations[0] !== binding.path) - return false; + if (!isConstantBinding(binding)) return false; function isPatternAssignment(member: NodePath) { const { parentPath } = member; @@ -195,6 +194,69 @@ export function isReadonlyObject( ); } +/** + * Returns true if the binding is constant (never re-assigned). + */ +export function isConstantBinding(binding: Binding) { + // Workaround because sometimes babel treats the VariableDeclarator/binding itself as a violation + if (binding.constant || binding.constantViolations[0] === binding.path) + return true; + + // if there is only a single assignment to the variable and it is a param + // then consider it a constant (this may not be very safe) + if (binding.kind === 'param' && binding.constantViolations.length === 1) { + const [path] = binding.constantViolations; + if ( + path.isAssignmentExpression() && + !isBindingPossiblyUsedBefore(binding, path) + ) + return true; + } + + return false; +} + +/** + * Checks if the binding could have possibly been used before the specified path. + * We use *possibly* because we don't know for sure (due to FunctionExpressions) if + * the binding was *actually* used, but it *could have* been. + */ +export function isBindingPossiblyUsedBefore( + binding: Binding, + before: NodePath, + includeParent = true, +) { + return binding.referencePaths.some((rp) => { + // this can be a cause for false-positives in FunctionExpressions + // think: + // var x; + // function getX() { + // return x; + // } + // x = 10; // x isn't used before because the function is never called, but we'll return true anyway + // while we could trace referenes to the function and rule out false-positives, it's really not worth it + if (rp.node.start != null && before.node.start != null) { + if (rp.node.start <= before.node.start) return true; + } else { + // if we dont have access to node positioning info, the reference was likely generated by us + // as I'm too lazy to implement an alternative way to check (and afraid of performance issues) + // we'll just pretend it was used + return true; + } + + if (includeParent) { + // check if the reference is a child of "before" + // for example: + // x = x + 10; + // this is accessing "x" before the Assignment, so it should count + for (let p = rp.parentPath; p; p = p.parentPath) { + if (p === before) return true; + } + } + return false; + }); +} + /** * Checks if the binding is a temporary variable that is only assigned * once and has limited references. Often created by transpilers. @@ -253,3 +315,23 @@ export function anySubList( ): m.Matcher> { return new AnySubListMatcher(elements); } + +export function declarationOrAssignment( + name: m.Matcher, + matcher: m.Matcher, +): m.Matcher { + return m.or( + m.variableDeclaration(m.anything(), [m.variableDeclarator(name, matcher)]), + m.expressionStatement(m.assignmentExpression('=', name, matcher)), + ); +} + +export function declaratorOrAssignmentExpression( + name: m.Matcher, + matcher: m.Matcher, +): m.Matcher { + return m.or( + m.variableDeclarator(name, matcher), + m.assignmentExpression('=', name, matcher), + ); +} diff --git a/packages/webcrack/src/ast-utils/test/matcher.test.ts b/packages/webcrack/src/ast-utils/test/matcher.test.ts index f955f21a..00ed1fff 100644 --- a/packages/webcrack/src/ast-utils/test/matcher.test.ts +++ b/packages/webcrack/src/ast-utils/test/matcher.test.ts @@ -1,6 +1,13 @@ import * as m from '@codemod/matchers'; +import * as t from '@babel/types'; +import { parse } from '@babel/parser'; +import traverse, { NodePath } from '@babel/traverse'; import { expect, test } from 'vitest'; -import { anySubList } from '../matcher.js'; +import { + anySubList, + isBindingPossiblyUsedBefore, + isConstantBinding, +} from '../matcher.js'; test('any sub list', () => { const a = m.capture(m.matcher((x) => x === 2)); @@ -10,3 +17,45 @@ test('any sub list', () => { expect(a.currentKeys).toEqual([1]); expect(b.currentKeys).toEqual([3]); }); + +test('isConstantBinding', () => { + const ast = parse('const a = 1; let b = 2, c = 3; c = 4'); + traverse(ast, { + Program(path) { + const a = path.scope.getBinding('a')!; + expect(isConstantBinding(a)).toBe(true); + + const b = path.scope.getBinding('b')!; + expect(isConstantBinding(b)).toBe(true); + + const c = path.scope.getBinding('c')!; + expect(isConstantBinding(c)).toBe(false); + }, + }); +}); + +test('isConstantBinding - function', () => { + const ast = parse('function a(b, c) { b = 10; console.log(c); c = 20 }'); + traverse(ast, { + Program(path) { + const func = path.get('body.0') as NodePath; + + const b = func.scope.getBinding('b')!; + expect(isConstantBinding(b)).toBe(true); + + const c = func.scope.getBinding('c')!; + expect(isConstantBinding(c)).toBe(false); + }, + }); +}); + +test('isBindingPossiblyUsedBefore', () => { + const ast = parse('var a = 1; console.log(a); a = 2;'); + traverse(ast, { + Program(path) { + const a = path.scope.getBinding('a')!; + const assign = a.constantViolations[0]; + expect(isBindingPossiblyUsedBefore(a, assign)).toBe(true); + }, + }); +}); diff --git a/packages/webcrack/src/deobfuscate/control-flow-object.ts b/packages/webcrack/src/deobfuscate/control-flow-object.ts index bf0ef96f..a70eae13 100644 --- a/packages/webcrack/src/deobfuscate/control-flow-object.ts +++ b/packages/webcrack/src/deobfuscate/control-flow-object.ts @@ -8,9 +8,12 @@ import { constKey, constMemberExpression, createFunctionMatcher, + declarationOrAssignment, + declaratorOrAssignmentExpression, findParent, getPropName, inlineFunctionCall, + isConstantBinding, isReadonlyObject, } from '../ast-utils'; import mergeStrings from '../unminify/transforms/merge-strings'; @@ -73,9 +76,7 @@ export default { m.arrayOf(m.objectProperty(propertyKey, propertyValue)), ); const aliasId = m.capture(m.identifier()); - const aliasVar = m.variableDeclaration(m.anything(), [ - m.variableDeclarator(aliasId, m.fromCapture(varId)), - ]); + const aliasVar = declarationOrAssignment(aliasId, m.fromCapture(varId)); // E.g. "rLxJs" const assignedKey = m.capture(propertyName); // E.g. "6|0|4|3|1|5|2" @@ -99,7 +100,7 @@ export default { m.or(m.fromCapture(varId), m.fromCapture(aliasId)), propertyName, ); - const varMatcher = m.variableDeclarator( + const varMatcher = declaratorOrAssignmentExpression( varId, m.objectExpression(objectProperties), ); @@ -109,12 +110,7 @@ export default { propertyName, ); - function isConstantBinding(binding: Binding) { - // Workaround because sometimes babel treats the VariableDeclarator/binding itself as a violation - return binding.constant || binding.constantViolations[0] === binding.path; - } - - function transform(path: NodePath) { + function transform(path: NodePath) { let changes = 0; if (varMatcher.match(path.node)) { // Verify all references to make sure they match how the obfuscator @@ -177,8 +173,12 @@ export default { * and others are assigned later. */ function transformObjectKeys(objBinding: Binding): boolean { - const container = objBinding.path.parentPath!.container as t.Statement[]; - const startIndex = (objBinding.path.parentPath!.key as number) + 1; + const path = + objBinding.kind === 'param' + ? objBinding.constantViolations[0].parentPath! + : objBinding.path.parentPath!; + const container = path.container as t.Statement[]; + const startIndex = (path.key as number) + 1; const properties: t.ObjectProperty[] = []; for (let i = startIndex; i < container.length; i++) { @@ -222,6 +222,11 @@ export default { } return { + Expression: { + exit(path) { + this.changes += transform(path); + }, + }, VariableDeclarator: { exit(path) { this.changes += transform(path); diff --git a/packages/webcrack/src/deobfuscate/control-flow-switch.ts b/packages/webcrack/src/deobfuscate/control-flow-switch.ts index 0a7b7bf8..40c8488d 100644 --- a/packages/webcrack/src/deobfuscate/control-flow-switch.ts +++ b/packages/webcrack/src/deobfuscate/control-flow-switch.ts @@ -1,7 +1,11 @@ import * as t from '@babel/types'; import * as m from '@codemod/matchers'; import type { Transform } from '../ast-utils'; -import { constMemberExpression, infiniteLoop } from '../ast-utils'; +import { + constMemberExpression, + declarationOrAssignment, + infiniteLoop, +} from '../ast-utils'; export default { name: 'control-flow-switch', @@ -28,17 +32,15 @@ export default { const matcher = m.blockStatement( m.anyList( // E.g. const sequence = "2|4|3|0|1".split("|") - m.variableDeclaration(undefined, [ - m.variableDeclarator( - sequenceName, - m.callExpression( - constMemberExpression(m.stringLiteral(sequenceString), 'split'), - [m.stringLiteral('|')], - ), + declarationOrAssignment( + sequenceName, + m.callExpression( + constMemberExpression(m.stringLiteral(sequenceString), 'split'), + [m.stringLiteral('|')], ), - ]), + ), // E.g. let iterator = 0 or -0x1a70 + 0x93d + 0x275 * 0x7 - m.variableDeclaration(undefined, [m.variableDeclarator(iterator)]), + declarationOrAssignment(iterator, m.anything()), infiniteLoop( m.blockStatement([ m.switchStatement( diff --git a/packages/webcrack/src/deobfuscate/decoder.ts b/packages/webcrack/src/deobfuscate/decoder.ts index 1bd9d844..07f764b6 100644 --- a/packages/webcrack/src/deobfuscate/decoder.ts +++ b/packages/webcrack/src/deobfuscate/decoder.ts @@ -4,6 +4,7 @@ import type * as t from '@babel/types'; import * as m from '@codemod/matchers'; import { anySubList, + declarationOrAssignment, findParent, inlineVariable, renameFast, @@ -112,12 +113,10 @@ export function findDecoders(stringArray: StringArray): Decoder[] { m.blockStatement( anySubList( // var array = getStringArray(); - m.variableDeclaration(undefined, [ - m.variableDeclarator( - arrayIdentifier, - m.callExpression(m.identifier(stringArray.name)), - ), - ]), + declarationOrAssignment( + arrayIdentifier, + m.callExpression(m.identifier(stringArray.name)), + ), // var h = array[e]; return h; // or return array[e -= 254]; m.containerOf( diff --git a/packages/webcrack/src/deobfuscate/string-array.ts b/packages/webcrack/src/deobfuscate/string-array.ts index f0f41631..3b834cf7 100644 --- a/packages/webcrack/src/deobfuscate/string-array.ts +++ b/packages/webcrack/src/deobfuscate/string-array.ts @@ -3,6 +3,7 @@ import traverse from '@babel/traverse'; import type * as t from '@babel/types'; import * as m from '@codemod/matchers'; import { + declarationOrAssignment, inlineArrayElements, isReadonlyObject, renameFast, @@ -34,9 +35,10 @@ export function findStringArray(ast: t.Node): StringArray | undefined { m.blockStatement([m.returnStatement(m.fromCapture(arrayIdentifier))]), ), ); - const variableDeclaration = m.variableDeclaration(undefined, [ - m.variableDeclarator(arrayIdentifier, arrayExpression), - ]); + const variableDeclaration = declarationOrAssignment( + arrayIdentifier, + arrayExpression, + ); // function getStringArray() { ... } const matcher = m.functionDeclaration( m.identifier(functionName),