Module refinery.lib.scripts.js.deobfuscation.flatfunctions

Undo function flattening. The obfuscation moves the body of a function into a flat function at the top of the program and leaves the function a wrapper that calls the flat function with its arguments and with an object whose members stand for every name the body read, wrote or called:

function flat([value], O) {
  O.total += value;
  return O.c(value);
}
function add(...args) {
  var O = {
    get total() { return total; },
    set total(v) { total = v; },
    c(...a) { return twice(...a); },
  };
  return flat(args, O);
}

A newer form passes the object first, flat(O, args), and in strict code the flat function has no parameters and unpacks its arguments instead:

var [[value], O] = arguments;

Where the program was also flattened into a machine, its recovery leaves every function a statement storing it into a name, flat = function ([value], O) {...};, which the pass reads as the flat function wherever the store has run before the wrapper calls it.

The pass reads the wrapper, the object and the flat function together, writes every access through the object as the access on the name its member stands for (refinery.lib.scripts.js.deobfuscation.nameproxy), and puts the body back into the wrapper, with the parameters the flat function unpacked:

function add(...[value]) {
  total += value;
  return twice(value);
}

The wrapper keeps its length of 0 where something may read it; where nothing can (SemanticModel.reached_only_by_shown_calls()), the parameters are written (value) right away. An async function keeps a function of its own, whose promise settles when it did, and the flat function moves into the wrapper without the object instead:

async function delayed(...args) {
  async function flat([value]) { await null; return twice(value); }
  return flat(args);
}

A function may also be left a forwarder to a function kept in an array, which takes the array and the arguments and hands the arguments on to the function it declares:

var T = [function () {
  var [t, args] = arguments;
  function body(value) { return twice(value); }
  return body.apply(this, args);
}];
function add() { return T[0].apply(this, [T, arguments]); }

The forwarder gets the parameters and the body of the declared function, which keeps neither, and the array goes once nothing reads it.

Expand source code Browse git
"""
Undo function flattening. The obfuscation moves the body of a function into a flat function at the
top of the program and leaves the function a wrapper that calls the flat function with its
arguments and with an object whose members stand for every name the body read, wrote or called:

    function flat([value], O) {
      O.total += value;
      return O.c(value);
    }
    function add(...args) {
      var O = {
        get total() { return total; },
        set total(v) { total = v; },
        c(...a) { return twice(...a); },
      };
      return flat(args, O);
    }

A newer form passes the object first, `flat(O, args)`, and in strict code the flat function has no
parameters and unpacks its `arguments` instead:

    var [[value], O] = arguments;

Where the program was also flattened into a machine, its recovery leaves every function a statement
storing it into a name, `flat = function ([value], O) {...};`, which the pass reads as the flat
function wherever the store has run before the wrapper calls it.

The pass reads the wrapper, the object and the flat function together, writes every access through
the object as the access on the name its member stands for
(`refinery.lib.scripts.js.deobfuscation.nameproxy`), and puts the body back into the wrapper, with
the parameters the flat function unpacked:

    function add(...[value]) {
      total += value;
      return twice(value);
    }

The wrapper keeps its `length` of 0 where something may read it; where nothing can
(`refinery.lib.scripts.js.analysis.model.SemanticModel.reached_only_by_shown_calls`), the
parameters are written `(value)` right away. An async function keeps a function of its own, whose
promise settles when it did, and the flat function moves into the wrapper without the object
instead:

    async function delayed(...args) {
      async function flat([value]) { await null; return twice(value); }
      return flat(args);
    }

A function may also be left a forwarder to a function kept in an array, which takes the array
and the arguments and hands the arguments on to the function it declares:

    var T = [function () {
      var [t, args] = arguments;
      function body(value) { return twice(value); }
      return body.apply(this, args);
    }];
    function add() { return T[0].apply(this, [T, arguments]); }

The forwarder gets the parameters and the body of the declared function, which keeps neither, and
the array goes once nothing reads it.
"""
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Iterator, NamedTuple

from refinery.lib.scripts import (
    Expression,
    Node,
    Statement,
    _clone_node,
    _remove_from_parent,
    _replace_in_parent,
    set_child_list,
)
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    Scope,
    SemanticModel,
    aliases_arguments,
    arguments_name_their_function,
    crosses_dynamic_scope,
    enclosing_operator,
    is_direct_eval_call,
    observes_arguments_aliasing,
    references_new_target,
    walk_receiver_scope,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    references_receiver_this,
)
from refinery.lib.scripts.js.deobfuscation.nameproxy import NameProxy
from refinery.lib.scripts.js.deobfuscation.parameters import (
    ParameterList,
    parameter_list,
    unpacking_moves_into_parameters,
)
from refinery.lib.scripts.js.model import (
    FUNCTION_NODES,
    JsArrayExpression,
    JsArrayPattern,
    JsAssignmentExpression,
    JsCallExpression,
    JsExpressionStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsMemberExpression,
    JsMethodDefinition,
    JsNumericLiteral,
    JsObjectExpression,
    JsProperty,
    JsPropertyKind,
    JsRestElement,
    JsReturnStatement,
    JsScript,
    JsSpreadElement,
    JsThisExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsVarKind,
    JsWithStatement,
    static_property_key,
    strip_parens,
)
from refinery.lib.scripts.js.numbers import exact_integer
from refinery.lib.scripts.js.strict import declares_use_strict, directive_prologue, strict_mode_at


class _Held(NamedTuple):
    """
    A function a name holds as its one value (`_moving_function`): the function, the statement at
    the top of the program that holds it, which is the function itself where it is declared and
    the store `name = function …;` otherwise, and the name.
    """
    function: JsFunctionDeclaration | JsFunctionExpression
    holder: Statement
    name: str


@dataclass
class _Flattened:
    """
    A wrapper read with its flat function, the statement holding that function and the name it is
    called by: the call between the two, the declaration of the object literal, the literal and
    whether the call passes it first, the array pattern the flat function binds the arguments to
    and its parameter for the object, the unpacking of `arguments` that stands for the two in
    strict code together with its read of `arguments`, the statements of the flat body after that
    unpacking, the scope of the wrapper, whether the flat function is nested into the wrapper rather
    than merged with it, and the bindings of the wrapper's scope the flat body does not see there:
    the object, and where it is merged, the rest parameter and the wrapper's own `arguments`.
    """
    wrapper: JsFunctionDeclaration | JsFunctionExpression
    flat: JsFunctionDeclaration | JsFunctionExpression
    flat_holder: Statement
    flat_name: str
    call: JsCallExpression
    declaration: JsVariableDeclaration
    literal: JsObjectExpression
    object_first: bool
    pattern: JsArrayPattern
    parameter: JsIdentifier
    pair: JsArrayPattern | None
    source: JsIdentifier | None
    statements: list[Statement]
    wrapper_scope: Scope
    nested: bool
    removed: list[Binding]


class _Restoration(ABC):
    """
    An edit that puts the body of a function moved out of a wrapper back into the wrapper. The
    statement holding the function it was moved into, the holder, stays in the body of the program
    until every edit of the pass is made, which then removes all of them at once; a holder that is
    the element of an array goes with the array, once nothing reads it.
    """

    @property
    @abstractmethod
    def wrapper(self) -> JsFunctionDeclaration | JsFunctionExpression:
        ...

    @property
    @abstractmethod
    def holder(self) -> Statement | JsFunctionExpression:
        ...

    @abstractmethod
    def apply(self) -> None:
        ...


@dataclass
class _Forwarded(_Restoration):
    """
    A function the obfuscation left a forwarder, `function F() { return G(...arguments); }`, with
    the parameters it takes over from `G`, which are made of the parameters of `G` only when the
    edit is made.
    """
    forwarder: JsFunctionDeclaration
    target: JsFunctionDeclaration | JsFunctionExpression
    target_holder: Statement
    parameters: ParameterList

    @property
    def wrapper(self) -> JsFunctionDeclaration:
        return self.forwarder

    @property
    def holder(self) -> Statement:
        return self.target_holder

    def apply(self) -> None:
        body = self.forwarder.body
        moved = self.target.body
        assert body is not None and moved is not None
        set_child_list(self.forwarder, 'params', self.parameters.build())
        set_child_list(body, 'body', list(moved.body))


@dataclass
class _Plan(_Restoration):
    """
    The edit decided for one wrapper: each access through the object with the expression that
    takes its place, and how the flat body moves.
    """
    reading: _Flattened
    substitutions: list[tuple[JsMemberExpression, Expression]]

    @property
    def wrapper(self) -> JsFunctionDeclaration | JsFunctionExpression:
        return self.reading.wrapper

    @property
    def holder(self) -> Statement:
        return self.reading.flat_holder

    def apply(self) -> None:
        """
        Make the edit. The flat function stays in the body of the program until every plan of the
        pass is made, which then removes all of them at once.
        """
        for member, stand_in in self.substitutions:
            _replace_in_parent(member, stand_in)
        self._move()

    @abstractmethod
    def _move(self) -> None:
        ...


@dataclass
class _Merge(_Plan):
    """
    The flat body becomes the body of the wrapper, whose parameter list becomes *parameters*.
    """
    parameters: ParameterList

    def _move(self) -> None:
        reading = self.reading
        body = reading.wrapper.body
        assert body is not None
        set_child_list(reading.wrapper, 'params', self.parameters.build())
        set_child_list(body, 'body', reading.statements)


@dataclass
class _Nest(_Plan):
    """
    The flat function moves into the wrapper in place of the object, and neither it nor the call
    passes the object any more. A function a store held is declared there under the name the call
    reads.
    """

    def _move(self) -> None:
        reading = self.reading
        body = reading.wrapper.body
        assert body is not None
        call = reading.call
        set_child_list(call, 'arguments', [call.arguments[1 if reading.object_first else 0]])
        flat = reading.flat
        if reading.pair is not None:
            set_child_list(reading.pair, 'elements', [reading.pattern])
        else:
            set_child_list(flat, 'params', [reading.pattern])
        _remove_from_parent(reading.declaration)
        if isinstance(flat, JsFunctionExpression):
            flat = JsFunctionDeclaration(
                id=JsIdentifier(name=reading.flat_name),
                params=list(flat.params),
                body=flat.body,
                is_async=flat.is_async,
            )
        set_child_list(body, 'body', [flat, *body.body])


def _ancestry(node: Node | None) -> Iterator[Node]:
    while node is not None:
        yield node
        node = node.parent


def _apart(plans: list[_Restoration]) -> list[_Restoration]:
    """
    The plans to make in one pass: each in turn, unless its wrapper or its flat function holds one
    of a plan taken before it or lies inside one. A plan was decided on the tree as it stands, and
    an edit inside what another plan moves or reads waits for the next pass.
    """
    taken: list[_Restoration] = []
    ends: set[int] = set()
    around: set[int] = set()
    for plan in plans:
        own = (plan.wrapper, plan.holder)
        if any(id(end) in around for end in own):
            continue
        if any(id(above) in ends for end in own for above in _ancestry(end)):
            continue
        for end in own:
            ends.add(id(end))
            around.update(id(above) for above in _ancestry(end))
        taken.append(plan)
    return taken


def _stored_function(binding: Binding, cache: ModelCache) -> _Held | None:
    """
    The function expression *binding* holds as its one value
    (`refinery.lib.scripts.js.analysis.model.SemanticModel.singular_value`) where a statement
    `name = function …;` stores it, the one write of the name, and nothing reads the function's
    own name, which it loses where the body moves.
    """
    model = cache.model
    function = model.singular_value(binding)
    if not isinstance(function, JsFunctionExpression):
        return None
    store = enclosing_operator(function)
    if (
        not isinstance(store, JsAssignmentExpression)
        or store.operator != '='
        or strip_parens(store.right) is not function
        or not isinstance(target := strip_parens(store.left), JsIdentifier)
        or binding.writes != [target]
        or not isinstance(holder := store.parent, JsExpressionStatement)
    ):
        return None
    if function.id is not None:
        own = model.binding_of(function.id)
        if own is None or model.references(own) or own.dynamic_refs:
            return None
    return _Held(function, holder, target.name)


def _moving_function(
    binding: Binding | None,
    callee: JsIdentifier,
    wrapper: JsFunctionDeclaration | JsFunctionExpression,
    root: JsScript,
    cache: ModelCache,
) -> _Held | None:
    """
    The function *binding* names, where its body may move into *wrapper* and *callee* is the only
    way to it: a function declared once, at the top of the program, under that name, or one a
    statement at the top of the program stores into the name (`_stored_function`), which has run
    before *callee* is read (`refinery.lib.scripts.js.analysis.dominance.DominanceModel`, its
    `binding_established_before`); no generator, and async where the wrapper is; nothing else
    writes the name, exports it or reaches it by reflection, and no host reaches it; it holds no
    `with` and no direct `eval`, and runs in the mode of the wrapper.
    """
    model = cache.model
    if binding is None or len(binding.declarations) != 1:
        return None
    name = binding.declarations[0]
    declared = name.parent
    if isinstance(declared, JsFunctionDeclaration) and declared.id is name:
        if binding.writes:
            return None
        held = _Held(declared, declared, name.name)
    elif (held := _stored_function(binding, cache)) is None:
        return None
    elif not cache.dominance.binding_established_before(binding, callee):
        return None
    function = held.function
    if (
        held.holder.parent is not root
        or function.generator
        or function.is_async != wrapper.is_async
        or function.body is None
        or wrapper.body is None
    ):
        return None
    if (
        binding.reads != [callee]
        or binding.dynamic_refs
        or binding.exported
        or model.reflection_can_reach(binding)
        or model.reached_from_outside(binding)
    ):
        return None
    if strict_mode_at(function.body) != strict_mode_at(wrapper.body):
        return None
    for node in function.walk():
        if is_direct_eval_call(node) or isinstance(node, JsWithStatement):
            return None
    return held


def _wrapper_scope_holds(
    wrapper: JsFunctionDeclaration | JsFunctionExpression,
    wrapper_scope: Scope,
    model: SemanticModel,
    reframed: bool,
) -> bool:
    """
    Whether a body moved into *wrapper* finds its names where the text says: no `with` and no direct
    `eval` reach the wrapper's scope. A body *reframed* runs in a frame other than its own: merged
    with the wrapper it has none, and nested under a name other than its own its function has
    another `name`. Only `caller` can tell either, so the program spells none
    (`refinery.lib.scripts.js.analysis.model.SemanticModel.spells_caller`).
    """
    if crosses_dynamic_scope(wrapper_scope):
        return False
    if model.direct_eval_declares_below(wrapper, model.root_scope):
        return False
    return not reframed or not model.spells_caller()


def _names_land(
    moving: JsFunctionDeclaration | JsFunctionExpression,
    wrapper_scope: Scope,
    model: SemanticModel,
    removed: list[Binding],
    own: Binding | None = None,
    skipped: JsIdentifier | None = None,
    barred: str | None = None,
) -> bool:
    """
    Whether every name the function *moving* reads and does not declare, but *own* and the read
    *skipped*, finds from inside the wrapper, whose bindings *removed* it no longer sees, the
    binding it finds where it stands; none spells *barred*.
    """
    for node in moving.walk():
        if not isinstance(node, JsIdentifier) or node is skipped or not model.is_reference(node):
            continue
        found = model.resolve(node)
        if found is not None and (found is own or found.declared_within(moving)):
            continue
        if node.name == barred:
            return False
        if model.lookup(node.name, wrapper_scope, exclude=removed) is not found:
            return False
    return True


def _read(
    wrapper: JsFunctionDeclaration | JsFunctionExpression,
    root: JsScript,
    cache: ModelCache,
) -> _Flattened | None:
    """
    The wrapper *wrapper* read with its flat function, or `None` where it is not one. The wrapper
    takes one rest parameter, and its body declares the object and returns the call of the flat
    function with the two, which is the only use of each; the flat function is one a name holds
    at the top of the program (`_moving_function`), and that call is the only way to it. Neither
    is a generator, and both are async or neither is.
    """
    model = cache.model
    params = wrapper.params
    body = wrapper.body
    if (
        wrapper.generator
        or len(params) != 1
        or not isinstance(rest := params[0], JsRestElement)
        or not isinstance(rest_name := rest.argument, JsIdentifier)
        or body is None
        or len(statements := body.body) != 2
        or not isinstance(declaration := statements[0], JsVariableDeclaration)
        or declaration.kind is not JsVarKind.VAR
        or len(declaration.declarations) != 1
        or not isinstance(declarator := declaration.declarations[0], JsVariableDeclarator)
        or not isinstance(object_name := declarator.id, JsIdentifier)
        or not isinstance(literal := declarator.init, JsObjectExpression)
        or not isinstance(statement := statements[1], JsReturnStatement)
        or not isinstance(call := statement.argument, JsCallExpression)
        or call.optional
        or not isinstance(callee := call.callee, JsIdentifier)
        or len(call.arguments) != 2
    ):
        return None
    first, second = call.arguments
    if not isinstance(first, JsIdentifier) or not isinstance(second, JsIdentifier):
        return None
    rest_binding = model.binding_of(rest_name)
    object_binding = model.binding_of(object_name)
    if rest_binding is None or object_binding is None:
        return None
    if model.resolve(first) is rest_binding and model.resolve(second) is object_binding:
        object_first = False
    elif model.resolve(first) is object_binding and model.resolve(second) is rest_binding:
        object_first = True
    else:
        return None
    passed_object, passed_rest = (first, second) if object_first else (second, first)
    if (
        model.references(rest_binding) != [passed_rest]
        or model.references(object_binding) != [passed_object]
        or rest_binding.declarations != [rest_name]
        or object_binding.declarations != [object_name]
    ):
        return None
    held = _moving_function(model.resolve(callee), callee, wrapper, root, cache)
    if held is None or (flat := held.function).body is None:
        return None
    flat_statements = list(flat.body.body)
    pair = None
    source = None
    if flat.params:
        if len(flat.params) != 2:
            return None
        parameter, pattern = flat.params if object_first else reversed(flat.params)
    else:
        rest_of_body = flat_statements[len(directive_prologue(flat.body)):]
        if (
            not rest_of_body
            or not isinstance(unpacking := rest_of_body[0], JsVariableDeclaration)
            or unpacking.kind is not JsVarKind.VAR
            or len(unpacking.declarations) != 1
            or not isinstance(declarator := unpacking.declarations[0], JsVariableDeclarator)
            or not isinstance(pair := declarator.id, JsArrayPattern)
            or not isinstance(source := declarator.init, JsIdentifier)
            or source.name != 'arguments'
            or len(pair.elements) != 2
        ):
            return None
        parameter, pattern = pair.elements if object_first else reversed(pair.elements)
        flat_statements = rest_of_body[1:]
    if not isinstance(pattern, JsArrayPattern) or not isinstance(parameter, JsIdentifier):
        return None
    if (wrapper_scope := model.function_scope(wrapper)) is None:
        return None
    nested = flat.is_async
    removed = [object_binding]
    if not nested:
        removed.append(rest_binding)
        if (own_arguments := wrapper_scope.bindings.get('arguments')) is not None:
            removed.append(own_arguments)
    return _Flattened(
        wrapper=wrapper,
        flat=flat,
        flat_holder=held.holder,
        flat_name=held.name,
        call=call,
        declaration=declaration,
        literal=literal,
        object_first=object_first,
        pattern=pattern,
        parameter=parameter,
        pair=pair,
        source=source,
        statements=flat_statements,
        wrapper_scope=wrapper_scope,
        nested=nested,
        removed=removed,
    )


def _lands(
    name: str,
    accessor: JsIdentifier,
    member: Node,
    reading: _Flattened,
    model: SemanticModel,
) -> bool:
    """
    Whether the name *name*, written in place of the access *member*, means what the identifier
    *accessor* in the member of the object means: no binding of the flat function takes it, it is
    not the name of a flat function nested into the wrapper, and from inside the wrapper it finds
    the binding the member finds.
    """
    landed = model.lookup(name, model.scope_of(member))
    if landed is not None and landed.declared_within(reading.flat):
        return False
    if reading.nested and name == reading.flat_name:
        return False
    found = model.lookup(name, reading.wrapper_scope, exclude=reading.removed)
    return found is model.resolve(accessor)


def _call_order_holds(accessor: JsIdentifier, call: JsCallExpression, cache: ModelCache) -> bool:
    """
    Whether calling the name *accessor* names where *call* calls the member standing for it computes
    the same: the member reads the name after the arguments run and the call on the name before
    them, so either the name holds its one value before the call, or no argument can change it.
    """
    target = cache.model.resolve(accessor)
    if (
        target is not None
        and not target.dynamic_refs
        and cache.dominance.binding_established_before(target, call)
    ):
        return True
    return all(
        cache.effects.is_side_effect_free(
            argument,
            None,
            call_established=cache.call_established,
            discarded=True,
            reads_may_throw=True,
            read_established=cache.read_established,
            coercions_may_write=True,
        )
        for argument in call.arguments
    )


def _plan(
    wrapper: JsFunctionDeclaration | JsFunctionExpression,
    root: JsScript,
    cache: ModelCache,
) -> _Plan | None:
    """
    The edit that puts the flat function of *wrapper* back into it, or `None` where the body would
    not compute in the wrapper what it computes where it stands. The flat function runs in the mode
    of the wrapper; it reads no `arguments` but the unpacking of the strict form, and holds no
    `with` and no direct `eval`; no `with` and no direct `eval` reach the wrapper either. A body
    merged with the wrapper reads no `this` and no `new.target`, which the call of the wrapper gives
    it, where a nested flat function is called as it was. Every name the flat function reads that
    it does not declare finds from inside the wrapper the binding it finds where it stands, and none
    is the name of a nested flat function. The object parameter is declared once, and every use of
    it is an access through the object that
    `refinery.lib.scripts.js.deobfuscation.nameproxy.NameProxy.stand_in` writes on a name that
    lands where the member's name is (`_lands`), with no call through a getter, which would pass
    the object as `this`, and with a call through a method only where the order of the name and
    the arguments does not matter (`_call_order_holds`). Where the call passes the arguments first,
    the pattern binds them before the object exists, and an access in the pattern stays. For a
    merge, the unpacking of the strict form has to mean what the parameter list means
    (`refinery.lib.scripts.js.deobfuscation.parameters.unpacking_moves_into_parameters`), and the
    parameter list keeps the `length` of 0 the wrapper has wherever something may read it.
    """
    model = cache.model
    reading = _read(wrapper, root, cache)
    if reading is None:
        return None
    flat = reading.flat
    proxy = NameProxy.read(reading.literal, model)
    if proxy is None:
        return None
    if not reading.nested and (references_receiver_this(flat) or references_new_target(flat)):
        return None
    for node in walk_receiver_scope(flat):
        if (
            isinstance(node, JsIdentifier)
            and node.name == 'arguments'
            and node is not reading.source
            and model.is_reference(node)
        ):
            return None
    own_name = reading.flat_name if flat.id is None else flat.id.name
    reframed = not reading.nested or own_name != reading.flat_name
    if not _wrapper_scope_holds(wrapper, reading.wrapper_scope, model, reframed):
        return None
    parameter = model.binding_of(reading.parameter)
    if parameter is None or parameter.declarations != [reading.parameter]:
        return None
    if not _names_land(
        flat,
        reading.wrapper_scope,
        model,
        reading.removed,
        own=parameter,
        skipped=reading.source,
        barred=reading.flat_name if reading.nested else None,
    ):
        return None
    substitutions: list[tuple[JsMemberExpression, Expression]] = []
    for reference in model.references(parameter):
        member = reference.parent
        if not isinstance(member, JsMemberExpression) or member.object is not reference:
            return None
        if not reading.object_first and member.is_descendant_of(reading.pattern):
            return None
        stand_in = proxy.stand_in(member)
        if stand_in is None or stand_in.receiver:
            return None
        late = stand_in.late
        if late is not None and not _call_order_holds(stand_in.names[0][1], late, cache):
            return None
        if not all(
            _lands(written.name, accessor, member, reading, model)
            for written, accessor in stand_in.names
        ):
            return None
        substitutions.append((member, stand_in.node))
    if reading.nested:
        return _Nest(reading, substitutions)
    if (
        reading.pair is not None
        and not unpacking_moves_into_parameters(model, reading.pattern, flat, [parameter])
    ):
        return None
    length = None if model.reached_only_by_shown_calls(wrapper) else 0
    parameters = parameter_list(reading.pattern.elements, wrapper, length)
    if parameters is None:
        return None
    return _Merge(reading, substitutions, parameters)


def _forwarded(
    wrapper: JsFunctionDeclaration | JsFunctionExpression,
    root: JsScript,
    cache: ModelCache,
) -> _Forwarded | None:
    """
    The edit that puts the parameters and the body of `G` back into the forwarder *wrapper*,
    `function F() { return G(...arguments); }`, or `None` where `F` would not compute what it
    computes now. `F` is a plain function declared at the top of the program, and `G` may move into
    it (`_moving_function`, `_wrapper_scope_holds`, `_names_land`). The call hands `G` the arguments
    of `F` and no receiver, so `G` may read its own `arguments` but no `this` and no `new.target`,
    and may not reach itself through `arguments`. The spread runs the iteration of an array, which
    the program must leave as the language has it. `F` takes the parameters of `G` as they are
    where every call of `F` is shown, and otherwise keeps its `length` of 0
    (`refinery.lib.scripts.js.deobfuscation.parameters.parameter_list`), where its `arguments`
    object still aliases the parameters exactly where that of `G` did, or `G` cannot tell
    (`refinery.lib.scripts.js.analysis.model.observes_arguments_aliasing`). The list is decided
    without touching the parameters of `G`, which the edit alone moves.
    """
    model = cache.model
    body = wrapper.body
    if (
        not isinstance(wrapper, JsFunctionDeclaration)
        or wrapper.parent is not root
        or wrapper.generator
        or wrapper.is_async
        or wrapper.params
        or body is None
    ):
        return None
    statements = body.body[len(directive_prologue(body)):]
    if (
        len(statements) != 1
        or not isinstance(statement := statements[0], JsReturnStatement)
        or not isinstance(call := statement.argument, JsCallExpression)
        or call.optional
        or not isinstance(callee := call.callee, JsIdentifier)
        or len(call.arguments) != 1
        or not isinstance(spread := call.arguments[0], JsSpreadElement)
        or not isinstance(source := spread.argument, JsIdentifier)
        or source.name != 'arguments'
    ):
        return None
    wrapper_scope = model.function_scope(wrapper)
    if wrapper_scope is None:
        return None
    own_arguments = wrapper_scope.bindings.get('arguments')
    if own_arguments is None or model.resolve(source) is not own_arguments:
        return None
    if not cache.trust_at(call).chain(list):
        return None
    held = _moving_function(model.resolve(callee), callee, wrapper, root, cache)
    if held is None or (target := held.function).body is None:
        return None
    if references_receiver_this(target):
        return None
    parameters = _forwarded_parameters(wrapper, wrapper_scope, own_arguments, target, model)
    if parameters is None:
        return None
    return _Forwarded(wrapper, target, held.holder, parameters)


def _forwarded_parameters(
    wrapper: JsFunctionDeclaration | JsFunctionExpression,
    wrapper_scope: Scope,
    own_arguments: Binding,
    target: JsFunctionDeclaration | JsFunctionExpression,
    model: SemanticModel,
) -> ParameterList | None:
    """
    The parameters a forwarder *wrapper* takes from *target*, the function it hands its own
    `arguments` to, where the body of *target* can run as the body of *wrapper*: nothing reaches
    *target* through `new.target` or its own `arguments`; the body finds its names in the scope
    of *wrapper* (`_wrapper_scope_holds`, `_names_land`), whose `arguments` stands for the one
    *target* had; *wrapper* takes the parameters as they are where every call of it is shown, and
    otherwise keeps its `length` of 0
    (`refinery.lib.scripts.js.deobfuscation.parameters.parameter_list`); and its `arguments`
    object aliases the parameters exactly where that of *target* did, or *target* cannot tell
    (`refinery.lib.scripts.js.analysis.model.observes_arguments_aliasing`). The receiver and the
    modes of the two are the caller's to settle.
    """
    assert target.body is not None
    if references_new_target(target) or arguments_name_their_function(target):
        return None
    if not _wrapper_scope_holds(wrapper, wrapper_scope, model, reframed=True):
        return None
    if not _names_land(target, wrapper_scope, model, [own_arguments]):
        return None
    if model.reached_only_by_shown_calls(wrapper):
        parameters: ParameterList | None = ParameterList(list(target.params), [])
    else:
        parameters = parameter_list(target.params, target, 0)
    if parameters is None:
        return None
    strict = strict_mode_at(target.body)
    before = aliases_arguments(target.params, strict=strict)
    after = parameters.aliases_arguments(strict=strict)
    if before != after and observes_arguments_aliasing(target):
        return None
    return parameters


@dataclass
class _TableForwarded(_Restoration):
    """
    A function the obfuscation left a forwarder to the element of an array that holds `G`'s
    declaration, with the parameters it takes over from `G` and the directive it takes along where
    `G` is strict and the forwarder is not. The forwarder keeps its own directive prologue, and a
    copy of the directive it takes along follows it, since the element keeps its own. The edit
    leaves `G` with no parameters and an empty body; nothing reaches it any more.
    """
    forwarder: JsFunctionDeclaration | JsFunctionExpression
    element: JsFunctionExpression
    target: JsFunctionDeclaration
    parameters: ParameterList
    directive: Statement | None

    @property
    def wrapper(self) -> JsFunctionDeclaration | JsFunctionExpression:
        return self.forwarder

    @property
    def holder(self) -> JsFunctionExpression:
        return self.element

    def apply(self) -> None:
        body = self.forwarder.body
        moved = self.target.body
        assert body is not None and moved is not None
        statements: list[Statement] = [*directive_prologue(body)]
        if self.directive is not None:
            statements.append(_clone_node(self.directive))
        statements.extend(moved.body)
        set_child_list(self.forwarder, 'params', self.parameters.build())
        set_child_list(body, 'body', statements)
        set_child_list(self.target, 'params', [])
        set_child_list(moved, 'body', [])


def _table_forwarding(
    wrapper: Node,
    model: SemanticModel,
) -> tuple[JsCallExpression, JsMemberExpression, Binding, int] | None:
    """
    For a forwarder *wrapper*, `function F() { return T[k].apply(this, [T, arguments]); }`, the
    call, the read of the element, the binding of `T` and the index `k`; `None` for any other
    function. Both names `T` find the one binding, and `arguments` is the forwarder's own.
    """
    if not isinstance(wrapper, (JsFunctionDeclaration, JsFunctionExpression)):
        return None
    body = wrapper.body
    if wrapper.generator or wrapper.is_async or wrapper.params or body is None:
        return None
    statements = body.body[len(directive_prologue(body)):]
    if (
        len(statements) != 1
        or not isinstance(statement := statements[0], JsReturnStatement)
        or not isinstance(call := statement.argument, JsCallExpression)
        or call.optional
        or not isinstance(method := call.callee, JsMemberExpression)
        or method.optional
        or static_property_key(method) != 'apply'
        or not isinstance(element := method.object, JsMemberExpression)
        or element.optional
        or not element.computed
        or not isinstance(index := element.property, JsNumericLiteral)
        or not isinstance(table := element.object, JsIdentifier)
        or len(call.arguments) != 2
        or not isinstance(call.arguments[0], JsThisExpression)
        or not isinstance(handed := call.arguments[1], JsArrayExpression)
        or len(handed.elements) != 2
        or not isinstance(again := handed.elements[0], JsIdentifier)
        or not isinstance(source := handed.elements[1], JsIdentifier)
        or source.name != 'arguments'
    ):
        return None
    k = exact_integer(index.value)
    if k is None or k < 0:
        return None
    binding = model.resolve(table)
    if binding is None or model.resolve(again) is not binding:
        return None
    scope = model.function_scope(wrapper)
    if scope is None or model.resolve(source) is not scope.bindings.get('arguments'):
        return None
    return call, element, binding, k


def _forwarding_element(
    element: Node | None,
    model: SemanticModel,
) -> tuple[JsFunctionDeclaration, Statement | None, JsVariableDeclaration] | None:
    """
    For an array element that is a function forwarding its second argument to a function it
    declares, `function () { var [t, args] = arguments; function G(...) {...} return G.apply(this,
    args); }`, the declaration of `G`, the element's `use strict` directive where it has one, and
    the unpacking of the arguments; `None` for anything else. The element does nothing else, and
    `G` is reached by the call alone: nothing reads `t`, `args` is read by the call only, and
    neither `G` nor the element is named anywhere but there.
    """
    if (
        not isinstance(element, JsFunctionExpression)
        or element.generator
        or element.is_async
        or element.params
        or (body := element.body) is None
    ):
        return None
    prologue = directive_prologue(body)
    strict = declares_use_strict(body)
    if len(prologue) != int(strict):
        return None
    statements = body.body[len(prologue):]
    if len(statements) != 3:
        return None
    unpacking, target, forward = statements
    if (
        not isinstance(unpacking, JsVariableDeclaration)
        or unpacking.kind is not JsVarKind.VAR
        or len(unpacking.declarations) != 1
        or not isinstance(declarator := unpacking.declarations[0], JsVariableDeclarator)
        or not isinstance(pattern := declarator.id, JsArrayPattern)
        or len(pattern.elements) != 2
        or not isinstance(table := pattern.elements[0], JsIdentifier)
        or not isinstance(args := pattern.elements[1], JsIdentifier)
        or not isinstance(source := declarator.init, JsIdentifier)
        or source.name != 'arguments'
        or not isinstance(target, JsFunctionDeclaration)
        or target.id is None
        or target.generator
        or target.is_async
        or target.body is None
        or not isinstance(forward, JsReturnStatement)
        or not isinstance(call := forward.argument, JsCallExpression)
        or call.optional
        or not isinstance(method := call.callee, JsMemberExpression)
        or method.optional
        or static_property_key(method) != 'apply'
        or not isinstance(callee := method.object, JsIdentifier)
        or len(call.arguments) != 2
        or not isinstance(call.arguments[0], JsThisExpression)
        or not isinstance(handed := call.arguments[1], JsIdentifier)
    ):
        return None
    scope = model.function_scope(element)
    if scope is None or model.resolve(source) is not scope.bindings.get('arguments'):
        return None
    unread = model.binding_of(table)
    passed = model.binding_of(args)
    declared = model.binding_of(target.id)
    if unread is None or passed is None or declared is None:
        return None
    if model.references(unread) or model.references(passed) != [handed]:
        return None
    if model.references(declared) != [callee] or declared.dynamic_refs:
        return None
    if element.id is not None:
        own = model.binding_of(element.id)
        if own is None or model.references(own):
            return None
    return target, prologue[0] if strict else None, unpacking


def _is_a_method(function: Node) -> bool:
    """
    Whether *function* is written as a method, a getter or a setter, of a class or of an object
    literal, whose parameters the language fixes or which no `new` constructs.
    """
    owner = function.parent
    if isinstance(owner, JsMethodDefinition):
        return True
    return isinstance(owner, JsProperty) and (owner.method or owner.kind is not JsPropertyKind.INIT)


def _forwarded_through_a_table(
    wrapper: JsFunctionDeclaration | JsFunctionExpression,
    cache: ModelCache,
) -> _TableForwarded | None:
    """
    The edit that puts the parameters and the body of `G` back into the forwarder *wrapper*, `F`,
    which hands its receiver and its arguments to the element `T[k]` of an array, which hands them
    on to the function `G` it declares (`_table_forwarding`, `_forwarding_element`), or `None`
    where `F` would not compute what it computes now.

    `T[k]` must hold that element whenever `F` runs: `T` is one array of such elements, declared
    once, established before `F` reads it and never written, and every reference to it is a
    forwarder's, which hands the array to an element that does not read it, so nothing changes
    an element; no other forwarder reads the element `k`, which the edit leaves empty. Each `apply`
    must find the built-in
    (`refinery.lib.scripts.js.analysis.model.SemanticModel.intrinsic_dispatch_trusted`), and the
    unpacking of the arguments must still be the language's at the call.

    `F` is a plain function and no method (`_is_a_method`). The receiver and the arguments reach
    `G` as they reach `F`, so `G` may read its own `arguments` but no `new.target`, and may not
    reach itself through `arguments`; `G` holds no `with` and no direct `eval`, and every name it
    reads finds from inside `F` the binding it finds where it stands (`_wrapper_scope_holds`,
    `_names_land`). `F` runs in the mode of `G`: where `G` is strict and `F` is not, `F` takes the
    directive along, which requires a `G` that reads no `this`, since `F` no longer coerces the
    receiver it hands on. A strict `F` cannot run a sloppy `G`. The parameters are taken as
    `_forwarded` takes them, and `F` keeps its own directive prologue, so where it says or takes
    along `use strict`, the parameters it takes have to be plain names, which the language demands
    of a function whose body says so.
    """
    model = cache.model
    forwarding = _table_forwarding(wrapper, model)
    if forwarding is None or _is_a_method(wrapper):
        return None
    _, read, table, k = forwarding
    if (
        len(table.declarations) != 1
        or table.writes
        or table.dynamic_refs
        or table.exported
        or model.reflection_can_reach(table)
        or model.reached_from_outside(table)
    ):
        return None
    declarator = table.declarations[0].parent
    if (
        not isinstance(declarator, JsVariableDeclarator)
        or declarator.id is not table.declarations[0]
        or not isinstance(array := declarator.init, JsArrayExpression)
        or k >= len(array.elements)
    ):
        return None
    elements = [_forwarding_element(element, model) for element in array.elements]
    if not all(elements):
        return None
    readers: list[int] = []
    for reference in model.references(table):
        holder = reference.parent
        while holder is not None and not isinstance(holder, FUNCTION_NODES):
            holder = holder.parent
        other = _table_forwarding(holder, model) if holder is not None else None
        if other is None or other[2] is not table:
            return None
        if reference is other[1].object:
            readers.append(other[3])
    if readers.count(k) != 1:
        return None
    if not cache.dominance.binding_established_before(table, read):
        return None
    forwarded = elements[k]
    assert forwarded is not None
    target, directive, unpacking = forwarded
    if not model.intrinsic_dispatch_trusted():
        return None
    if not cache.trust_at(unpacking).chain(list):
        return None
    for node in target.walk():
        if is_direct_eval_call(node) or isinstance(node, JsWithStatement):
            return None
    body = wrapper.body
    assert body is not None and target.body is not None
    target_is_strict = strict_mode_at(target.body)
    wrapper_is_strict = strict_mode_at(body)
    if wrapper_is_strict and not target_is_strict:
        return None
    moved_directive = None
    if target_is_strict and not wrapper_is_strict:
        if directive is None or references_receiver_this(target):
            return None
        moved_directive = directive
    wrapper_scope = model.function_scope(wrapper)
    if wrapper_scope is None:
        return None
    own_arguments = wrapper_scope.bindings.get('arguments')
    if own_arguments is None:
        return None
    parameters = _forwarded_parameters(wrapper, wrapper_scope, own_arguments, target, model)
    if parameters is None:
        return None
    says_use_strict = moved_directive is not None or declares_use_strict(body)
    plain = not parameters.tail and all(isinstance(param, JsIdentifier) for param in parameters.head)
    if says_use_strict and not plain:
        return None
    element = array.elements[k]
    assert isinstance(element, JsFunctionExpression)
    return _TableForwarded(wrapper, element, target, parameters, moved_directive)


class JsFunctionUnflattening(ScriptLevelTransformer):
    """
    Put the body of a flattened function back into its wrapper.
    """

    def _process_script(self, node: JsScript) -> None:
        cache = model_cache(self, node)
        plans: list[_Restoration] = []
        with cache.pinned():
            for function in node.walk():
                if not isinstance(function, (JsFunctionDeclaration, JsFunctionExpression)):
                    continue
                plan = _plan(function, node, cache)
                if plan is None:
                    plan = _forwarded(function, node, cache)
                if plan is None:
                    plan = _forwarded_through_a_table(function, cache)
                if plan is not None:
                    plans.append(plan)
        taken = _apart(plans)
        if not taken:
            return
        for plan in taken:
            plan.apply()
        moved = {id(plan.holder) for plan in taken}
        kept = [statement for statement in node.body if id(statement) not in moved]
        set_child_list(node, 'body', kept)
        self.mark_changed()

Classes

class JsFunctionUnflattening

Put the body of a flattened function back into its wrapper.

Expand source code Browse git
class JsFunctionUnflattening(ScriptLevelTransformer):
    """
    Put the body of a flattened function back into its wrapper.
    """

    def _process_script(self, node: JsScript) -> None:
        cache = model_cache(self, node)
        plans: list[_Restoration] = []
        with cache.pinned():
            for function in node.walk():
                if not isinstance(function, (JsFunctionDeclaration, JsFunctionExpression)):
                    continue
                plan = _plan(function, node, cache)
                if plan is None:
                    plan = _forwarded(function, node, cache)
                if plan is None:
                    plan = _forwarded_through_a_table(function, cache)
                if plan is not None:
                    plans.append(plan)
        taken = _apart(plans)
        if not taken:
            return
        for plan in taken:
            plan.apply()
        moved = {id(plan.holder) for plan in taken}
        kept = [statement for statement in node.body if id(statement) not in moved]
        set_child_list(node, 'body', kept)
        self.mark_changed()

Ancestors

Inherited members