Module refinery.lib.scripts.js.deobfuscation.unpassed

Put back the declarations an obfuscation moved into the parameter list of a function. It gives a function no call passes more arguments to than it declares a parameter for each declaration of its body, and writes the declaration as a store the function makes when it starts:

function parse(source) {               function parse(source, decode) {
  function decode(n) { ... }             if (!decode) {
  ...                                      decode = function (n) { ... };
}                                        }
                                         ...
                                       }

A var whose value is made of literals and stands first in the body becomes a parameter with that value as its default, function parse(source, limit = 8), and any other var a parameter the body assigns where the declaration stood.

No call passes such a parameter (SemanticModel.parameter_never_passed()), so when the function starts it holds undefined or its default, as the local it was. The pass makes it a local again: the store of a function becomes the declaration of that function, a default a var with that value at the top of the body, and a plain parameter a var without one. Only parameters that end the list go, since the position of every other one is what a call passes to it.

Expand source code Browse git
"""
Put back the declarations an obfuscation moved into the parameter list of a function. It gives a
function no call passes more arguments to than it declares a parameter for each declaration of its
body, and writes the declaration as a store the function makes when it starts:

    function parse(source) {               function parse(source, decode) {
      function decode(n) { ... }             if (!decode) {
      ...                                      decode = function (n) { ... };
    }                                        }
                                             ...
                                           }

A `var` whose value is made of literals and stands first in the body becomes a parameter with that
value as its default, `function parse(source, limit = 8)`, and any other `var` a parameter the body
assigns where the declaration stood.

No call passes such a parameter
(`refinery.lib.scripts.js.analysis.model.SemanticModel.parameter_never_passed`), so when the
function starts it holds `undefined` or its default, as the local it was. The pass makes it a local
again: the store of a function becomes the declaration of that function, a default a `var` with
that value at the top of the body, and a plain parameter a `var` without one. Only parameters that
end the list go, since the position of every other one is what a call passes to it.
"""
from __future__ import annotations

from typing import NamedTuple

from refinery.lib.scripts import Expression, Node, Statement, set_child_list
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    arguments_aliasing_changes,
    pattern_identifiers,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    is_literal,
    walk_scope,
)
from refinery.lib.scripts.js.model import (
    FUNCTION_NODES,
    JsArrayExpression,
    JsAssignmentExpression,
    JsAssignmentPattern,
    JsBigIntLiteral,
    JsBinaryExpression,
    JsBlockStatement,
    JsCatchClause,
    JsClassDeclaration,
    JsConditionalExpression,
    JsExpressionStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsFunctionNode,
    JsIdentifier,
    JsIfStatement,
    JsLogicalExpression,
    JsObjectExpression,
    JsProperty,
    JsPropertyKind,
    JsScript,
    JsUnaryExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsVarKind,
)
from refinery.lib.scripts.js.strict import directive_prologue, strict_mode_at


def _made_of_literals(node: Node | None) -> bool:
    """
    Whether *node* computes its value from literals alone and can neither throw nor run code of the
    program: an array or object literal of such values with plain keys, a conditional or logical
    operator over them, `!` or `void` of one, or a primitive made of literals
    (`_primitive_literal`). Such a value is the same wherever and whenever it is computed, so a
    generator, which computes its defaults when it is called and its body when it first resumes,
    computes it the same in either.
    """
    if isinstance(node, JsArrayExpression):
        return all(_made_of_literals(element) for element in node.elements)
    if isinstance(node, JsObjectExpression):
        return all(
            isinstance(prop, JsProperty)
            and not prop.computed
            and not prop.method
            and prop.kind is JsPropertyKind.INIT
            and _made_of_literals(prop.value)
            for prop in node.properties
        )
    if isinstance(node, JsConditionalExpression):
        return all(_made_of_literals(part) for part in (node.test, node.consequent, node.alternate))
    if isinstance(node, JsLogicalExpression):
        return _made_of_literals(node.left) and _made_of_literals(node.right)
    if isinstance(node, JsUnaryExpression) and node.operator in ('!', 'void'):
        return _made_of_literals(node.operand)
    return _primitive_literal(node)


def _primitive_literal(node: Node | None) -> bool:
    """
    Whether *node* is a primitive made of literals that no operator over it can throw on: a literal
    that is no `BigInt`, or `-`, `+` or `~` of one, or a binary operator but `in` and `instanceof`
    over two of them.
    """
    if node is None or isinstance(node, JsBigIntLiteral):
        return False
    if isinstance(node, JsUnaryExpression) and node.operator in ('-', '+', '~'):
        return _primitive_literal(node.operand)
    if isinstance(node, JsBinaryExpression):
        if node.operator in ('in', 'instanceof'):
            return False
        return _primitive_literal(node.left) and _primitive_literal(node.right)
    return is_literal(node)


class _Store(NamedTuple):
    """
    A statement `if (!p) { p = function () {...}; }`, the read of `p` it tests, and the function.
    """
    statement: JsIfStatement
    test: JsIdentifier
    function: JsFunctionExpression


class _Local(NamedTuple):
    """
    A parameter no call passes, by its name: its default, and the store of the function it holds
    where it holds one. It becomes the declaration of that function in place of the store, or a
    `var` at the top of the body.
    """
    name: str
    default: Expression | None
    store: _Store | None

    def declaration(self) -> Statement:
        """
        The declaration the parameter becomes, made of the nodes of the tree when the edit is made.
        """
        if (store := self.store) is not None:
            function = store.function
            return JsFunctionDeclaration(
                id=JsIdentifier(name=self.name),
                params=list(function.params),
                body=function.body,
                generator=function.generator,
                is_async=function.is_async,
            )
        declarator = JsVariableDeclarator(id=JsIdentifier(name=self.name), init=self.default)
        return JsVariableDeclaration(kind=JsVarKind.VAR, declarations=[declarator])


def _function_store(statement: Statement, binding: Binding, cache: ModelCache) -> _Store | None:
    """
    The function *statement* stores into the parameter *binding* when the parameter is falsy,
    `if (!p) { p = function () {...}; }`, where that store is the only write of the parameter.
    """
    model = cache.model
    if not isinstance(statement, JsIfStatement) or statement.alternate is not None:
        return None
    test = statement.test
    if (
        not isinstance(test, JsUnaryExpression)
        or test.operator != '!'
        or not isinstance(read := test.operand, JsIdentifier)
        or model.resolve(read) is not binding
    ):
        return None
    body = statement.consequent
    if isinstance(body, JsBlockStatement):
        if len(body.body) != 1:
            return None
        body = body.body[0]
    if (
        not isinstance(body, JsExpressionStatement)
        or not isinstance(store := body.expression, JsAssignmentExpression)
        or store.operator != '='
        or not isinstance(target := store.left, JsIdentifier)
        or not isinstance(function := store.right, JsFunctionExpression)
        or function.id is not None
        or binding.writes != [target]
    ):
        return None
    return _Store(statement, read, function)


def _tested_names(statements: list[Statement]) -> dict[str, list[JsIfStatement]]:
    """
    The statements among *statements* that test a plain name for being falsy and have no `else`,
    `if (!p) ...`, by the name they test: the only statements that can store a function into a
    parameter (`_function_store`).
    """
    tested: dict[str, list[JsIfStatement]] = {}
    for statement in statements:
        if (
            isinstance(statement, JsIfStatement)
            and statement.alternate is None
            and isinstance(test := statement.test, JsUnaryExpression)
            and test.operator == '!'
            and isinstance(read := test.operand, JsIdentifier)
        ):
            tested.setdefault(read.name, []).append(statement)
    return tested


def _names_declared_in_the_body(function: JsFunctionNode) -> set[str]:
    """
    The names the body of *function* declares itself, in any of its blocks or clauses. Annex B
    copies a block function to the body only where its name is no parameter, and with a default in
    the list the body's own `var`s and functions live apart from the parameters; a parameter made a
    declaration of the body would meet either.
    """
    declared: set[str] = set()
    if function.body is None:
        return declared
    for node in walk_scope(function.body):
        if isinstance(node, (JsFunctionDeclaration, JsClassDeclaration)):
            if node.id is not None:
                declared.add(node.id.name)
        elif isinstance(node, JsVariableDeclarator):
            declared.update(identifier.name for identifier in pattern_identifiers(node.id))
        elif isinstance(node, JsCatchClause):
            declared.update(identifier.name for identifier in pattern_identifiers(node.param))
    return declared


def _locals(function: JsFunctionNode, cache: ModelCache) -> list[_Local] | None:
    """
    The parameters that end the list of *function* and become declarations of its body, last first,
    or `None` where none does. Every call of the function is one the text shows, the host's included
    (`refinery.lib.scripts.js.analysis.model.SemanticModel.reached_only_by_shown_calls`), so
    nothing else passes them or reads the `length`; a call through a table under a key only the run
    knows is taken for a call of one of the table's own entries (`own_keys`), which is how a router
    of the obfuscation selects them by random keys, while a key that names what every object
    inherits there is a program made up for it
    (`TestAnEntryOfATableHandedBackUnderAKeyKnownOnlyAtRuntimeKeepsWhatItIsHanded`). The run is
    the obfuscation's only where one of
    them holds a function stored under `if (!p)`, a shape no author writes; a run of plain or
    defaulted parameters alone is the shape an optional argument has, and stays. A function whose
    body tests none of its parameters that way is therefore passed over before the model is asked
    anything.
    """
    body = function.body
    if not isinstance(body, JsBlockStatement):
        return None
    statements = body.body[len(directive_prologue(body)):]
    tested = _tested_names(statements)
    if not any(
        isinstance(parameter, JsIdentifier) and parameter.name in tested
        for parameter in function.params
    ):
        return None
    model = cache.model
    if not model.reached_only_by_shown_calls(function, own_keys=True):
        return None
    declared = _names_declared_in_the_body(function)
    found: list[_Local] = []
    for parameter in reversed(function.params):
        default = None
        name = parameter
        if isinstance(parameter, JsAssignmentPattern):
            name, default = parameter.left, parameter.right
            if not _made_of_literals(default):
                break
        if not isinstance(name, JsIdentifier) or name.name in declared:
            break
        binding = model.binding_of(name)
        if (
            binding is None
            or not model.parameter_never_passed(binding, own_keys=True)
            or model.reflection_can_reach(binding)
            or model.references(binding, exclude=body)
        ):
            break
        stored = None
        if default is None:
            candidates = tested.get(name.name, [])
            stores = (_function_store(statement, binding, cache) for statement in candidates)
            stored = next((store for store in stores if store is not None), None)
        if stored is not None:
            reads = [read for read in binding.reads if read is not stored.test]
            if not all(cache.dominance.runs_before(stored.statement, read) for read in reads):
                break
        found.append(_Local(name.name, default, stored))
    if not any(local.store is not None for local in found):
        return None
    kept = function.params[:len(function.params) - len(found)]
    if (
        kept
        and arguments_aliasing_changes(function, function.params, kept, strict=strict_mode_at(body))
    ):
        return None
    return found


class JsUnpassedParameters(ScriptLevelTransformer):
    """
    Make the parameters no call passes that end a parameter list the declarations of the body they
    were moved out of.
    """

    def _process_script(self, node: JsScript) -> None:
        cache = model_cache(self, node)
        edits: list[tuple[JsFunctionNode, list[_Local]]] = []
        with cache.pinned():
            for function in node.walk():
                if not isinstance(function, FUNCTION_NODES) or not function.params:
                    continue
                if (found := _locals(function, cache)) is not None:
                    edits.append((function, found))
        for function, found in edits:
            body = function.body
            assert isinstance(body, JsBlockStatement)
            set_child_list(function, 'params', function.params[:len(function.params) - len(found)])
            statements = list(body.body)
            prologue = len(directive_prologue(body))
            placed = [local.declaration() for local in reversed(found) if local.store is None]
            for local in found:
                if local.store is not None:
                    statements[statements.index(local.store.statement)] = local.declaration()
            statements[prologue:prologue] = placed
            set_child_list(body, 'body', statements)
        if edits:
            self.mark_changed()

Classes

class JsUnpassedParameters

Make the parameters no call passes that end a parameter list the declarations of the body they were moved out of.

Expand source code Browse git
class JsUnpassedParameters(ScriptLevelTransformer):
    """
    Make the parameters no call passes that end a parameter list the declarations of the body they
    were moved out of.
    """

    def _process_script(self, node: JsScript) -> None:
        cache = model_cache(self, node)
        edits: list[tuple[JsFunctionNode, list[_Local]]] = []
        with cache.pinned():
            for function in node.walk():
                if not isinstance(function, FUNCTION_NODES) or not function.params:
                    continue
                if (found := _locals(function, cache)) is not None:
                    edits.append((function, found))
        for function, found in edits:
            body = function.body
            assert isinstance(body, JsBlockStatement)
            set_child_list(function, 'params', function.params[:len(function.params) - len(found)])
            statements = list(body.body)
            prologue = len(directive_prologue(body))
            placed = [local.declaration() for local in reversed(found) if local.store is None]
            for local in found:
                if local.store is not None:
                    statements[statements.index(local.store.statement)] = local.declaration()
            statements[prologue:prologue] = placed
            set_child_list(body, 'body', statements)
        if edits:
            self.mark_changed()

Ancestors

Inherited members