Module refinery.lib.scripts.js.deobfuscation.stringarray

Resolve the string-array rotation pattern produced by popular JavaScript obfuscators.

The obfuscator extracts all string literals into a single array, wraps access through an accessor function and scrambles the array via a rotation IIFE that push/shifts until a checksum computed from parseInt of the array's own elements matches a target constant. This transformer detects that three-part pattern, simulates the rotation in Python, resolves every accessor call to its string literal, and removes the dead definitions.

Expand source code Browse git
"""
Resolve the string-array rotation pattern produced by popular JavaScript obfuscators.

The obfuscator extracts all string literals into a single array, wraps access through an accessor
function and scrambles the array via a rotation IIFE that push/shifts until a checksum computed
from parseInt of the array's own elements matches a target constant. This transformer detects that
three-part pattern, simulates the rotation in Python, resolves every accessor call to its string
literal, and removes the dead definitions.
"""
from __future__ import annotations

import base64
import enum
import functools
import math

from typing import Callable, NamedTuple, Sequence

from refinery.lib.scripts import (
    Node,
    _remove_from_parent,
    _replace_in_parent,
)
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.model import Binding, Role, SemanticModel, reference_role
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScopeProcessingTransformer,
    make_string_literal,
    nothing_still_names,
    remove_declarator,
    string_value,
)
from refinery.lib.scripts.js.model import (
    FUNCTION_NODES,
    JsArrayExpression,
    JsAssignmentExpression,
    JsBinaryExpression,
    JsBreakStatement,
    JsCallExpression,
    JsExpressionStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsIfStatement,
    JsNumericLiteral,
    JsParenthesizedExpression,
    JsReturnStatement,
    JsScript,
    JsSequenceExpression,
    JsStringLiteral,
    JsUnaryExpression,
    JsVariableDeclaration,
    JsVariableDeclarator,
    JsWhileStatement,
    Statement,
    is_generator_function,
    strip_parens,
    wraps_return,
)
from refinery.lib.scripts.js.numbers import exact_integer, js_parse_int, js_string_to_number
from refinery.lib.scripts.js.values import BINARY_OPS, HostApproximated


class Encoding(enum.Enum):
    NONE = 'none'
    B64 = 'base64'
    RC4 = 'rc4'


class ArrayFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    strings: list[str]


class AccessorFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    base_offset: int
    encoding: Encoding


class RotationIIFE(NamedTuple):
    node: Node
    target: int
    body: Sequence[Node]
    target_param: str | None


def _answers_a_wrapper(node: Node | None) -> bool:
    """
    Whether *node* denotes a function a call to which answers a promise or a generator object.

    A recognizer here reads the function it can see, but what the program calls is often the one a
    self-replacing shape installs in its place. The replacement is read through this, so that the
    keyword is asked of the function whose answer the pass goes on to substitute for.
    """
    node = strip_parens(node)
    return isinstance(node, FUNCTION_NODES) and wraps_return(node)


def _find_array_function(body: Sequence[Node]) -> ArrayFunction | None:
    """
    Detect the array-holder function pattern:

        function NAME() {
          var x = ['str0', 'str1', ...];
          NAME = function() { return x; };
          return NAME();
        }

    Returns (node, function_name, initial_string_list) or None.
    """
    for statement in body:
        if not isinstance(statement, JsFunctionDeclaration):
            continue
        if wraps_return(statement):
            continue
        if statement.id is None or statement.body is None:
            continue
        name = statement.id.name
        statements = statement.body.body
        if len(statements) < 2:
            continue
        array_literal: list[str] | None = None
        has_self_reassignment = False
        for s in statements:
            if isinstance(s, JsVariableDeclaration):
                for decl in s.declarations:
                    if (
                        isinstance(decl, JsVariableDeclarator)
                        and isinstance(decl.init, JsArrayExpression)
                    ):
                        elements = []
                        for element in decl.init.elements:
                            if (sv := string_value(element)) is None:
                                break
                            elements.append(sv)
                        else:
                            if elements:
                                array_literal = elements
            elif (
                isinstance(s, JsExpressionStatement)
                and isinstance(assign := s.expression, JsAssignmentExpression)
                and isinstance(assign.left, JsIdentifier)
                and assign.left.name == name
            ):
                has_self_reassignment = not _answers_a_wrapper(assign.right)
        if array_literal is not None and has_self_reassignment:
            return ArrayFunction(statement, name, array_literal)
    return None


def _find_all_accessor_functions(
    model: SemanticModel,
    body: Sequence[Node],
    array_fn_name: str,
) -> list[AccessorFunction]:
    """
    Detect all accessor functions for a given array. An obfuscator can produce multiple accessors
    sharing the same array (e.g. one base64 and one RC4). Two structural variants are recognized:

    Simple pattern::

        function NAME(param, _unused) {
          param = param - BASE_OFFSET;
          var v = ARRAY_FN();
          var r = v[param];
          return r;
        }

    Self-overwriting (memoization) pattern::

        function NAME(B, I) {
          const Y = ARRAY_FN();
          NAME = function(Z, o) {
            Z = Z - BASE_OFFSET;
            ...
          };
          return NAME(B, I);
        }

    In both, subtracting the offset is the first thing the function does with its index, so the
    position a call asks for is the Number the subtraction reads from its argument, which is what
    `_decoder_index` computes. An accessor that does anything else with its index first is not
    recognized.
    """
    results: list[AccessorFunction] = []
    for stmt in body:
        if not isinstance(stmt, JsFunctionDeclaration):
            continue
        if wraps_return(stmt):
            continue
        if stmt.id is None or stmt.body is None:
            continue
        if len(stmt.params) != 2:
            continue
        fn_name = stmt.id.name
        first_param = stmt.params[0]
        if not isinstance(first_param, JsIdentifier):
            continue
        statements = stmt.body.body
        calls_array_fn = any(
            isinstance(decl, JsVariableDeclarator)
            and isinstance(decl.init, JsCallExpression)
            and isinstance(decl.init.callee, JsIdentifier)
            and decl.init.callee.name == array_fn_name
            for s in statements if isinstance(s, JsVariableDeclaration)
            for decl in s.declarations
        )
        if not calls_array_fn:
            continue
        base_offset = _index_shift(statements, first_param.name)
        if base_offset is None:
            base_offset = _extract_self_overwriting_offset(model, statements, fn_name, first_param)
        if base_offset is not None:
            results.append(AccessorFunction(stmt, fn_name, base_offset, _detect_encoding(stmt)))
    return results


def _index_shift(statements: Sequence[Node], param_name: str) -> int | None:
    """
    The offset the first of *statements* subtracts from the index parameter, when it is the plain
    assignment `param = param - OFFSET`, or `None`.
    """
    if not statements:
        return None
    first = statements[0]
    if not isinstance(first, JsExpressionStatement):
        return None
    assign = first.expression
    if not (
        isinstance(assign, JsAssignmentExpression)
        and assign.operator == '='
        and isinstance(assign.left, JsIdentifier)
        and assign.left.name == param_name
        and isinstance(assign.right, JsBinaryExpression)
        and assign.right.operator == '-'
        and isinstance(assign.right.left, JsIdentifier)
        and assign.right.left.name == param_name
        and assign.right.right is not None
    ):
        return None
    try:
        return exact_integer(_eval_arithmetic(assign.right.right))
    except _EvalError:
        return None


def _extract_self_overwriting_offset(
    model: SemanticModel,
    body: Sequence[Node],
    fn_name: str,
    param: JsIdentifier,
) -> int | None:
    """
    Detect the self-overwriting accessor variant where the function reassigns itself to an inner
    function expression containing the offset subtraction. Returns the base offset or None. The
    outer function hands its index on untouched: the only reference to its index parameter is the
    first argument of the call to its replacement that it returns. The replacement may declare a
    parameter of the same name, as a minifier spells it, and that is another binding.
    """
    if not body:
        return None
    forward = body[-1]
    if not (
        isinstance(forward, JsReturnStatement)
        and isinstance(call := forward.argument, JsCallExpression)
        and isinstance(call.callee, JsIdentifier)
        and call.callee.name == fn_name
        and call.arguments
        and isinstance(index := call.arguments[0], JsIdentifier)
        and (binding := model.binding_of(param)) is not None
    ):
        return None
    references = model.references(binding)
    if (
        len(references) != 1
        or references[0] is not index
        or model.dynamic_references(binding)
        or model.binding_maybe_reassigned_dynamically(binding)
    ):
        return None
    for s in body[:-1]:
        if not isinstance(s, JsExpressionStatement):
            continue
        if not isinstance(s.expression, JsAssignmentExpression):
            continue
        assign = s.expression
        if not isinstance(assign.left, JsIdentifier) or assign.left.name != fn_name:
            continue
        if not isinstance(assign.right, JsFunctionExpression):
            continue
        if wraps_return(assign.right):
            continue
        inner_fn = assign.right
        if inner_fn.body is None or len(inner_fn.params) < 2:
            continue
        inner_param = inner_fn.params[0]
        if not isinstance(inner_param, JsIdentifier):
            continue
        return _index_shift(inner_fn.body.body, inner_param.name)
    return None


def _find_rotation_iife(
    body: Sequence[Node],
    array_fn_name: str,
) -> RotationIIFE | None:
    """
    Detect the rotation IIFE pattern:

        (function(getArray, target) {
          var arr = getArray();
          while (true) { try { ... parseInt ... push(shift) } catch { push(shift) } }
        })(ARRAY_FN, TARGET_NUMBER);

    A generator is refused and an `async` function is not, which is where the two kinds a call wraps
    the answer of part company: this IIFE is read for its effect on the array and never for what the
    call answered, and calling a generator runs no body at all, where an `async` body with no
    `await` in it runs to completion before the call returns.

    Returns (statement_node, target_checksum, iife_body_statements) or None. Also handles the case
    where the rotation call is an element of a comma-separated sequence expression.
    """
    for stmt in body:
        if not isinstance(stmt, JsExpressionStatement):
            continue
        expr = stmt.expression
        if isinstance(expr, JsParenthesizedExpression):
            expr = expr.expression
        candidates: list[tuple[Node, JsCallExpression]] = []
        if isinstance(expr, JsCallExpression):
            candidates.append((stmt, expr))
        elif isinstance(expr, JsSequenceExpression):
            for sub in expr.expressions:
                if isinstance(sub, JsCallExpression):
                    candidates.append((sub, sub))
        for removable, call in candidates:
            fn = call.callee
            if isinstance(fn, JsParenthesizedExpression):
                fn = fn.expression
            if not isinstance(fn, JsFunctionExpression):
                continue
            if is_generator_function(fn):
                continue
            if len(call.arguments) != 2:
                continue
            first_arg = call.arguments[0]
            second_arg = call.arguments[1]
            if not (isinstance(first_arg, JsIdentifier) and first_arg.name == array_fn_name):
                continue
            try:
                target = _as_position(_eval_arithmetic(second_arg))
            except _EvalError:
                continue
            fn_body = fn.body
            if fn_body is None:
                continue
            if not any(isinstance(s, JsWhileStatement) for s in fn_body.body):
                continue
            target_param = None
            if len(fn.params) >= 2 and isinstance(fn.params[1], JsIdentifier):
                target_param = fn.params[1].name
            return RotationIIFE(removable, target, fn_body.body, target_param)
    return None


class _EvalError(Exception):
    pass


def _array_order_is_literal(
    model: SemanticModel,
    array: ArrayFunction,
    accessors: Sequence[AccessorFunction],
) -> bool:
    """
    Whether the strings are in the order the array literal spells them. The model names every way
    the program reaches the array function — a reference, a write through an alias, a `with`-body
    name, an export, a global handed to a call — and the literal order is the order the accessors
    read only when each of those ways belongs to the array function itself or to an accessor. A
    rotation has to reach the array function before it can reorder the array, so a reference
    anywhere else is a hand this resolver cannot see the moves of.
    """
    binding = model.binding_of(array.node.id)
    if binding is None:
        return False
    if binding.exported or binding.reachable_through_a_handed_object:
        return False
    inside = {
        id(node)
        for root in (array.node, *(accessor.node for accessor in accessors))
        for node in root.walk()
    }
    references = [
        *model.references(binding),
        *model.dynamic_references(binding),
        *binding.indefinite_writes,
    ]
    return all(id(reference) in inside for reference in references)


def _as_position(value: float) -> int:
    """
    Read an evaluated Number as the whole number a pattern can use — an index, an offset, a rotation
    target, a checksum — truncated toward zero. `NaN` and the infinities name no position, and `int`
    raises on them rather than answering, so they are refused the way every other unusable
    expression in this module is refused: an uncaught `OverflowError` would abort the whole run
    instead.
    """
    if not math.isfinite(value):
        raise _EvalError
    return int(value)


def _extract_checksum_expression(
    iife_body: Sequence[Node],
    target_param: str | None = None,
) -> Node | None:
    """
    Extract the checksum expression AST node from the rotation IIFE body. Handles two structural
    variants:

    1. Variable declaration: `var x = CHECKSUM; if (x === target) break;`
    2. Inline if-condition: `if (CHECKSUM === target) break;`

    Variant 2 occurs when earlier passes eliminate the intermediate variable or constant-fold the
    checksum expression to a value equal to the rotation target.
    """
    checksum_node: Node | None = None
    for s in iife_body:
        if not isinstance(s, JsWhileStatement) or s.body is None:
            continue
        for ws in s.walk():
            if isinstance(ws, JsVariableDeclaration):
                for decl in ws.declarations:
                    if isinstance(decl, JsVariableDeclarator) and decl.init is not None:
                        checksum_node = decl.init
                        break
                if checksum_node is not None:
                    break
        if checksum_node is None and target_param is not None:
            for ws in s.walk():
                if (
                    isinstance(ws, JsIfStatement)
                    and isinstance(ws.test, JsBinaryExpression)
                    and ws.test.operator == '==='
                    and isinstance(ws.consequent, JsBreakStatement)
                ):
                    left, right = ws.test.left, ws.test.right
                    if isinstance(right, JsIdentifier) and right.name == target_param:
                        checksum_node = left
                    elif isinstance(left, JsIdentifier) and left.name == target_param:
                        checksum_node = right
                    if checksum_node is not None:
                        break
        break
    return checksum_node


def _decode_string(raw: str, encoding: Encoding, key: str | None = None) -> str:
    """
    Decode a raw string from the array according to the encoding mode. For RC4, a key must be
    supplied. Raises _EvalError when decoding is not possible.
    """
    if encoding == Encoding.NONE:
        return raw
    try:
        if encoding == Encoding.B64:
            return _decode_base64(raw)
        if encoding == Encoding.RC4:
            if key is None:
                raise _EvalError
            return _decrypt_rc4(raw, key)
    except _EvalError:
        raise
    except (UnicodeDecodeError, ValueError):
        raise _EvalError
    raise _EvalError


def _arithmetic(operator: str, left: float, right: float) -> float:
    """
    The number *operator* computes from two numbers, refusing an operator whose result is no number,
    a power the engine approximates, and a division by zero.
    """
    apply = BINARY_OPS.get(operator)
    if apply is None or operator == '/' and right == 0:
        raise _EvalError
    try:
        result = apply(left, right)
    except HostApproximated:
        raise _EvalError
    if not isinstance(result, float):
        raise _EvalError
    return result


def _eval_arithmetic(node: Node) -> float:
    """
    Evaluate a pure arithmetic expression AST to a float. Handles numeric literals, unary `+`/`-`,
    binary operators, and parenthesized expressions. Raises `_EvalError` on any node that is not
    statically computable.
    """
    if isinstance(node, JsNumericLiteral):
        return float(node.value)
    if isinstance(node, JsParenthesizedExpression) and node.expression:
        return _eval_arithmetic(node.expression)
    if isinstance(node, JsUnaryExpression) and node.operand:
        if node.operator == '-':
            return -_eval_arithmetic(node.operand)
        if node.operator == '+':
            return _eval_arithmetic(node.operand)
    if isinstance(node, JsBinaryExpression) and node.left and node.right:
        left = _eval_arithmetic(node.left)
        right = _eval_arithmetic(node.right)
        return _arithmetic(node.operator, left, right)
    raise _EvalError


def _eval_checksum(
    node: Node,
    strings: list[str],
    decoder_calls: dict[int, AccessorFunction],
) -> float:
    """
    Evaluate a checksum expression against the current array state. Handles the arithmetic
    operators (`+`, `-`, `*`, `/`), unary negation, parentheses, `parseInt` calls on accessor
    lookups, and numeric literals. The accessor lookups are the calls in *decoder_calls*, which
    maps the `id` of each to the accessor it reaches.

    Raises `_EvalError` on any unrecognized pattern.
    """
    recurse = functools.partial(
        _eval_checksum,
        strings=strings,
        decoder_calls=decoder_calls,
    )
    if isinstance(node, JsNumericLiteral):
        return float(node.value)
    if isinstance(node, JsParenthesizedExpression) and node.expression:
        return recurse(node.expression)
    if isinstance(node, JsUnaryExpression) and node.operand:
        if node.operator == '-':
            return -recurse(node.operand)
        if node.operator == '+':
            return recurse(node.operand)
    if isinstance(node, JsBinaryExpression) and node.left and node.right:
        lhs = recurse(node.left)
        rhs = recurse(node.right)
        return _arithmetic(node.operator, lhs, rhs)
    if isinstance(node, JsCallExpression) and isinstance(node.callee, JsIdentifier):
        if node.callee.name == 'parseInt' and len(node.arguments) >= 1:
            radix = _checksum_radix(node.arguments[1:], recurse)
            inner = node.arguments[0]
            if isinstance(inner, JsStringLiteral):
                if inner.value is None:
                    raise _EvalError
                result = js_parse_int(inner.value, radix)
                if result is None:
                    raise _EvalError
                return result
            if (accessor := decoder_calls.get(id(inner))) is not None:
                assert isinstance(inner, JsCallExpression)
                decoded = _decode_call(inner, accessor, strings)
                if (result := js_parse_int(decoded, radix)) is None:
                    raise _EvalError
                return result
            raise _EvalError
    raise _EvalError


def _checksum_radix(
    rest: Sequence[Node],
    recurse: Callable[[Node], float],
) -> int:
    """
    The radix a `parseInt` call in a checksum expression names, or `0` for the language's "not
    supplied". Reading it is what keeps the simulated checksum equal to the one the engine computes:
    an unsupplied radix takes a `0x` prefix as base 16, so ignoring a written `10` would read
    `'0x1F'` as `31` where the engine reads `0`. A radix that does not evaluate to an integer is not
    a checksum this module can simulate.
    """
    if not rest:
        return 0
    radix = exact_integer(recurse(rest[0]))
    if radix is None:
        raise _EvalError
    return radix


def _decoder_index(node: Node) -> int | None:
    """
    The position a call to the decoder asks for, as the decoder's subtraction reads it from the
    argument: the Number the argument evaluates to, where it is a whole number. The obfuscator
    spells an index as a numeric string as often as a number, and the subtraction reads a string
    through StringToNumber, so `'0x13d'` asks for 317 where `'1_72'` asks for no position at all.
    """
    if isinstance(node, JsStringLiteral) and (text := string_value(node)) is not None:
        return exact_integer(js_string_to_number(text))
    try:
        return exact_integer(_eval_arithmetic(node))
    except _EvalError:
        return None


def _read_decoder_call(call: JsCallExpression) -> tuple[int, str | None]:
    """
    The position and the RC4 key a call to the decoder passes. The key is `None` where the call
    passes none, which the encodings without one never read. Raises `_EvalError` when the position
    is not one `_decoder_index` can read.
    """
    if not call.arguments:
        raise _EvalError
    index = _decoder_index(call.arguments[0])
    if index is None:
        raise _EvalError
    key = string_value(call.arguments[1]) if len(call.arguments) >= 2 else None
    return index, key


def _decode_call(call: JsCallExpression, accessor: AccessorFunction, strings: Sequence[str]) -> str:
    """
    The string *call*, a call to *accessor*, answers when the accessor reads *strings*: the element
    at the position the call asks for less the accessor's own offset, decoded the way the accessor
    decodes it. Raises `_EvalError` when the call asks for no position this module can read, the
    position names no element, or the element does not decode.
    """
    index, key = _read_decoder_call(call)
    position = index - accessor.base_offset
    if not 0 <= position < len(strings):
        raise _EvalError
    return _decode_string(strings[position], accessor.encoding, key)


def _simulate_rotation(
    strings: list[str],
    checksum_node: Node,
    decoder_calls: dict[int, AccessorFunction],
    target: int,
) -> list[str] | None:
    """
    Simulate the array rotation loop. For each rotation position, evaluate the checksum
    expression against the current array state. Stop when the checksum matches the target,
    or bail after len(strings) attempts.
    """
    array = list(strings)
    n = len(array)
    for _ in range(n):
        try:
            if _as_position(_eval_checksum(checksum_node, array, decoder_calls)) == target:
                return array
        except _EvalError:
            pass
        array.append(array.pop(0))
    return None


class _Decoders(NamedTuple):
    """
    The bindings a call names an accessor through, each mapped to the accessor it denotes, and the
    declarators that make the aliases among them.
    """
    accessors: dict[Binding, AccessorFunction]
    alias_declarators: list[JsVariableDeclarator]

    def called_by(self, model: SemanticModel, node: Node) -> AccessorFunction | None:
        """
        The accessor *node* calls, where it is a call whose callee resolves to one of these
        bindings, or `None`.
        """
        if not isinstance(node, JsCallExpression) or not isinstance(node.callee, JsIdentifier):
            return None
        if (binding := model.resolve(node.callee)) is None:
            return None
        return self.accessors.get(binding)


def _is_written_only_by_itself(
    model: SemanticModel,
    binding: Binding,
    function: JsFunctionDeclaration,
) -> bool:
    """
    Whether every store into *binding* happens inside *function*, the one it declares. The
    self-overwriting decoder replaces itself with the function that does the work, and a store
    anywhere else means a call may reach some other function under the same name. A second
    declaration of the name is such a store: a later function declaration of it is the one the
    name holds, and a `var` of it with a value replaces the decoder where it runs.
    """
    if binding.exported or binding.reachable_through_a_handed_object:
        return False
    if len(binding.declarations) != 1 or binding.declarations[0] is not function.id:
        return False
    writes = [
        *binding.writes,
        *binding.indefinite_writes,
        *(ref for ref in model.dynamic_references(binding) if reference_role(ref) is not Role.READ),
    ]
    return all(write.is_descendant_of(function) for write in writes)


def _find_decoders(
    model: SemanticModel,
    scope: Node,
    accessors: Sequence[AccessorFunction],
) -> _Decoders:
    """
    Every binding through which a call reaches one of the *accessors*: the binding each declares,
    where nothing else stores into it, and every name declared with one of those as its only value,
    such as `const a0x = a0d` and the chains an obfuscator builds from it. A call is read as a call
    to the decoder when its callee resolves to one of these bindings, never by spelling alone: a
    parameter or a local of the same name inside some function is another binding.
    """
    found: dict[Binding, AccessorFunction] = {}
    for accessor in accessors:
        assert accessor.node.id is not None
        binding = model.binding_of(accessor.node.id)
        if binding is not None and _is_written_only_by_itself(model, binding, accessor.node):
            found[binding] = accessor
    copies: dict[Binding, list[tuple[Binding, JsVariableDeclarator]]] = {}
    for node in scope.walk():
        if (
            isinstance(node, JsVariableDeclarator)
            and isinstance(node.id, JsIdentifier)
            and isinstance(node.init, JsIdentifier)
            and (alias := model.binding_of(node.id)) is not None
            and (source := model.resolve(node.init)) is not None
        ):
            copies.setdefault(source, []).append((alias, node))
    aliases: list[JsVariableDeclarator] = []
    pending = list(found)
    while pending:
        source = pending.pop()
        for alias, declarator in copies.get(source, ()):
            if alias in found or model.singular_value(alias) is not declarator.init:
                continue
            found[alias] = found[source]
            aliases.append(declarator)
            pending.append(alias)
    return _Decoders(found, aliases)


_B64_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
_B64_STANDARD = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
_B64_TRANSLATE = str.maketrans(_B64_ALPHABET, _B64_STANDARD)


def _detect_encoding(accessor_node: JsFunctionDeclaration) -> Encoding:
    """
    Detect the string encoding mode by inspecting the accessor function body. The base64 and RC4
    variants inject an init guard:

        if (NAME['...'] === undefined)

    that contains the base64 alphabet string and one (base64) or two (RC4) inner function
    definitions. For the self-overwriting variant, the guard is inside the inner function expression.
    """
    if accessor_node.body is None:
        return Encoding.NONE
    search_bodies: list[Sequence[Node]] = [accessor_node.body.body]
    if accessor_node.id is not None:
        fn_name = accessor_node.id.name
        for stmt in accessor_node.body.body:
            if (
                isinstance(stmt, JsExpressionStatement)
                and isinstance(stmt.expression, JsAssignmentExpression)
                and isinstance(stmt.expression.left, JsIdentifier)
                and stmt.expression.left.name == fn_name
                and isinstance(stmt.expression.right, JsFunctionExpression)
                and stmt.expression.right.body is not None
            ):
                search_bodies.append(stmt.expression.right.body.body)
    for body in search_bodies:
        for stmt in body:
            if not isinstance(stmt, JsIfStatement):
                continue
            inner_functions = 0
            has_alphabet = False
            for child in stmt.walk():
                if isinstance(child, JsStringLiteral) and child.value == _B64_ALPHABET:
                    has_alphabet = True
                if (
                    isinstance(child, JsVariableDeclarator)
                    and isinstance(child.init, JsFunctionExpression)
                ):
                    inner_functions += 1
            if has_alphabet:
                return Encoding.RC4 if inner_functions >= 2 else Encoding.B64
    return Encoding.NONE


def _custom_b64decode(s: str) -> bytes:
    """
    Decode a string using the obfuscator's custom base64 alphabet (lowercase letters first)
    and tolerate missing padding.
    """
    translated = s.translate(_B64_TRANSLATE)
    pad = len(translated) % 4
    if pad:
        translated += '=' * (4 - pad)
    return base64.b64decode(translated)


def _decode_base64(s: str) -> str:
    """
    Decode a base64-encoded string as produced by the obfuscator.
    """
    return _custom_b64decode(s).decode('utf-8')


def _decrypt_rc4(s: str, key: str) -> str:
    """
    Base64-decode, UTF-8-decode, then RC4-decrypt a string using the given key. The obfuscator's
    RC4 operates on Unicode character codes (after UTF-8 decode), not raw bytes, because its
    inline atob uses `decodeURIComponent` which interprets the base64 output as UTF-8.
    """
    data = _decode_base64(s)
    sbox = list(range(256))
    j = 0
    for i in range(256):
        j = (j + sbox[i] + ord(key[i % len(key)])) % 256
        sbox[i], sbox[j] = sbox[j], sbox[i]
    i = j = 0
    out: list[str] = []
    for ch in data:
        i = (i + 1) % 256
        j = (j + sbox[i]) % 256
        sbox[i], sbox[j] = sbox[j], sbox[i]
        out.append(chr(ord(ch) ^ sbox[(sbox[i] + sbox[j]) % 256]))
    return ''.join(out)


def _replace_accessor_calls(
    root: Node,
    model: SemanticModel,
    decoders: _Decoders,
    strings: Sequence[str],
    infrastructure: Sequence[Node],
) -> int:
    """
    Replace every call to a decoder below *root* with the string `_decode_call` reads for it from
    *strings*, the table in the order the accessors read it. A call is one to a decoder when its
    callee resolves to a binding of *decoders*. A call inside the *infrastructure* stays: the
    rotation reads the array it is still turning, and answering those reads with the strings the
    turning ends on stops the loop before it turns, so a call the pass leaves standing would read
    the array unturned. Returns the number of replaced calls.
    """
    machinery = {id(node) for part in infrastructure for node in part.walk()}
    calls: list[tuple[JsCallExpression, AccessorFunction]] = []
    for node in root.walk():
        if id(node) in machinery:
            continue
        if (accessor := decoders.called_by(model, node)) is not None:
            assert isinstance(node, JsCallExpression)
            calls.append((node, accessor))
    count = 0
    for call, accessor in calls:
        try:
            value = _decode_call(call, accessor, strings)
        except _EvalError:
            continue
        _replace_in_parent(call, make_string_literal(value))
        count += 1
    return count


def _infrastructure_nodes(
    array: ArrayFunction,
    accessors: list[AccessorFunction],
    rotation: RotationIIFE | None,
    decoders: _Decoders,
) -> list[Node]:
    """
    Every node the string-array infrastructure occupies: the array function, the accessor functions,
    the rotation IIFE that puts the strings in order, and the declarators that alias an accessor.
    They are collected before anything is removed, so that the names they declare can be asked about
    together — a reference from one of them into another goes with the removal, and only a reference
    from outside all of them keeps the infrastructure alive.
    """
    nodes: list[Node] = [array.node]
    nodes.extend(accessor.node for accessor in accessors)
    if rotation is not None:
        nodes.append(rotation.node)
    nodes.extend(decoders.alias_declarators)
    return nodes


def _remove_infrastructure(nodes: Sequence[Node]) -> None:
    """
    Delete the nodes `_infrastructure_nodes` collected, each the way its kind is deleted: a
    declarator through `refinery.lib.scripts.js.deobfuscation.helpers.remove_declarator`, so that the
    declaration holding it goes too once it holds nothing else, and every other node out of the
    statement list it sits in.
    """
    for node in nodes:
        if isinstance(node, JsVariableDeclarator):
            remove_declarator(node)
        else:
            _remove_from_parent(node)


class _CachedResolution(NamedTuple):
    """
    Cached result of a successful array rotation simulation, stored on the scope root node to
    survive across pipeline iterations. This prevents re-simulation failures when the simplifier
    modifies the checksum expression in the rotation IIFE between string array passes. The result
    holds for the accessors `_accessor_signature` describes.
    """
    resolved: list[str]
    accessors: list[tuple[str, int, Encoding]]


def _accessor_signature(accessors: Sequence[AccessorFunction]) -> list[tuple[str, int, Encoding]]:
    """
    The name, offset and encoding of each of the *accessors*, which is what a cached rotation was
    simulated with.
    """
    return [(accessor.name, accessor.base_offset, accessor.encoding) for accessor in accessors]


_CACHE_ATTR = '_stringarray_cache'


class JsStringArrayResolver(ScopeProcessingTransformer):

    def __init__(self):
        super().__init__()
        self._root: JsScript | None = None

    def visit_JsScript(self, node: JsScript):
        self._root = node
        return super().visit_JsScript(node)

    def _strings_in_read_order(
        self,
        scope: Node,
        model: SemanticModel,
        array: ArrayFunction,
        accessors: Sequence[AccessorFunction],
        decoders: _Decoders,
        rotation: RotationIIFE | None,
    ) -> list[str] | None:
        """
        The string table in the order the accessors read it, or `None` when that order cannot be
        established. A rotation reorders the table until a checksum matches, so a found one is
        simulated and its result cached on the scope — a later pass may simplify the checksum
        expression into something this resolver can no longer read. The checksum reads the table
        through the calls in it that *decoders* says reach an accessor. Where no rotation is found,
        the literal already spells the order the accessors read, unless the model can name a hand
        that reaches the array besides them.
        """
        if rotation is None:
            if not _array_order_is_literal(model, array, accessors):
                return None
            return array.strings
        signature = _accessor_signature(accessors)
        cache: _CachedResolution | None = getattr(scope, _CACHE_ATTR, None)
        if cache is not None and cache.accessors == signature:
            return cache.resolved
        checksum = _extract_checksum_expression(rotation.body, rotation.target_param)
        if checksum is None:
            return None
        decoder_calls = {
            id(node): accessor for node in checksum.walk()
            if (accessor := decoders.called_by(model, node)) is not None
        }
        resolved = _simulate_rotation(array.strings, checksum, decoder_calls, rotation.target)
        if resolved is None:
            return None
        setattr(scope, _CACHE_ATTR, _CachedResolution(resolved, signature))
        return resolved

    def _process_scope_body(self, scope: Node, body: list[Statement]) -> None:
        array = _find_array_function(body)
        if array is None:
            return
        assert self._root is not None
        model = model_cache(self, self._root).model
        accessors = _find_all_accessor_functions(model, body, array.name)
        if not accessors:
            return
        decoders = _find_decoders(model, scope, accessors)
        rotation = _find_rotation_iife(body, array.name)
        resolved = self._strings_in_read_order(scope, model, array, accessors, decoders, rotation)
        if resolved is None:
            return
        infrastructure = _infrastructure_nodes(array, accessors, rotation, decoders)
        replaced = _replace_accessor_calls(scope, model, decoders, resolved, infrastructure)
        if nothing_still_names(model_cache(self, self._root).model, infrastructure):
            _remove_infrastructure(infrastructure)
            self.mark_changed()
        elif replaced > 0:
            self.mark_changed()

Classes

class Encoding (*args, **kwds)

Create a collection of name/value pairs.

Example enumeration:

>>> class Color(Enum):
...     RED = 1
...     BLUE = 2
...     GREEN = 3

Access them by:

  • attribute access:

Color.RED

  • value lookup:

Color(1)

  • name lookup:

Color['RED']

Enumerations can be iterated over, and know how many members they have:

>>> len(Color)
3
>>> list(Color)
[<Color.RED: 1>, <Color.BLUE: 2>, <Color.GREEN: 3>]

Methods can be added to enumerations, and members can have their own attributes – see the documentation for details.

Expand source code Browse git
class Encoding(enum.Enum):
    NONE = 'none'
    B64 = 'base64'
    RC4 = 'rc4'

Ancestors

  • enum.Enum

Class variables

var NONE

The type of the None singleton.

var B64

The type of the None singleton.

var RC4

The type of the None singleton.

class ArrayFunction (node, name, strings)

ArrayFunction(node, name, strings)

Expand source code Browse git
class ArrayFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    strings: list[str]

Ancestors

  • builtins.tuple

Instance variables

var node

Alias for field number 0

Expand source code Browse git
class ArrayFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    strings: list[str]
var name

Alias for field number 1

Expand source code Browse git
class ArrayFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    strings: list[str]
var strings

Alias for field number 2

Expand source code Browse git
class ArrayFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    strings: list[str]
class AccessorFunction (node, name, base_offset, encoding)

AccessorFunction(node, name, base_offset, encoding)

Expand source code Browse git
class AccessorFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    base_offset: int
    encoding: Encoding

Ancestors

  • builtins.tuple

Instance variables

var node

Alias for field number 0

Expand source code Browse git
class AccessorFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    base_offset: int
    encoding: Encoding
var name

Alias for field number 1

Expand source code Browse git
class AccessorFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    base_offset: int
    encoding: Encoding
var base_offset

Alias for field number 2

Expand source code Browse git
class AccessorFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    base_offset: int
    encoding: Encoding
var encoding

Alias for field number 3

Expand source code Browse git
class AccessorFunction(NamedTuple):
    node: JsFunctionDeclaration
    name: str
    base_offset: int
    encoding: Encoding
class RotationIIFE (node, target, body, target_param)

RotationIIFE(node, target, body, target_param)

Expand source code Browse git
class RotationIIFE(NamedTuple):
    node: Node
    target: int
    body: Sequence[Node]
    target_param: str | None

Ancestors

  • builtins.tuple

Instance variables

var node

Alias for field number 0

Expand source code Browse git
class RotationIIFE(NamedTuple):
    node: Node
    target: int
    body: Sequence[Node]
    target_param: str | None
var target

Alias for field number 1

Expand source code Browse git
class RotationIIFE(NamedTuple):
    node: Node
    target: int
    body: Sequence[Node]
    target_param: str | None
var body

Alias for field number 2

Expand source code Browse git
class RotationIIFE(NamedTuple):
    node: Node
    target: int
    body: Sequence[Node]
    target_param: str | None
var target_param

Alias for field number 3

Expand source code Browse git
class RotationIIFE(NamedTuple):
    node: Node
    target: int
    body: Sequence[Node]
    target_param: str | None
class JsStringArrayResolver

Base for transforms that process at function-scope boundaries. Visits JsScript and each function body (JsFunctionDeclaration, JsFunctionExpression, JsArrowFunctionExpression). Subclasses may override either _process_scope or _process_scope_body.

Expand source code Browse git
class JsStringArrayResolver(ScopeProcessingTransformer):

    def __init__(self):
        super().__init__()
        self._root: JsScript | None = None

    def visit_JsScript(self, node: JsScript):
        self._root = node
        return super().visit_JsScript(node)

    def _strings_in_read_order(
        self,
        scope: Node,
        model: SemanticModel,
        array: ArrayFunction,
        accessors: Sequence[AccessorFunction],
        decoders: _Decoders,
        rotation: RotationIIFE | None,
    ) -> list[str] | None:
        """
        The string table in the order the accessors read it, or `None` when that order cannot be
        established. A rotation reorders the table until a checksum matches, so a found one is
        simulated and its result cached on the scope — a later pass may simplify the checksum
        expression into something this resolver can no longer read. The checksum reads the table
        through the calls in it that *decoders* says reach an accessor. Where no rotation is found,
        the literal already spells the order the accessors read, unless the model can name a hand
        that reaches the array besides them.
        """
        if rotation is None:
            if not _array_order_is_literal(model, array, accessors):
                return None
            return array.strings
        signature = _accessor_signature(accessors)
        cache: _CachedResolution | None = getattr(scope, _CACHE_ATTR, None)
        if cache is not None and cache.accessors == signature:
            return cache.resolved
        checksum = _extract_checksum_expression(rotation.body, rotation.target_param)
        if checksum is None:
            return None
        decoder_calls = {
            id(node): accessor for node in checksum.walk()
            if (accessor := decoders.called_by(model, node)) is not None
        }
        resolved = _simulate_rotation(array.strings, checksum, decoder_calls, rotation.target)
        if resolved is None:
            return None
        setattr(scope, _CACHE_ATTR, _CachedResolution(resolved, signature))
        return resolved

    def _process_scope_body(self, scope: Node, body: list[Statement]) -> None:
        array = _find_array_function(body)
        if array is None:
            return
        assert self._root is not None
        model = model_cache(self, self._root).model
        accessors = _find_all_accessor_functions(model, body, array.name)
        if not accessors:
            return
        decoders = _find_decoders(model, scope, accessors)
        rotation = _find_rotation_iife(body, array.name)
        resolved = self._strings_in_read_order(scope, model, array, accessors, decoders, rotation)
        if resolved is None:
            return
        infrastructure = _infrastructure_nodes(array, accessors, rotation, decoders)
        replaced = _replace_accessor_calls(scope, model, decoders, resolved, infrastructure)
        if nothing_still_names(model_cache(self, self._root).model, infrastructure):
            _remove_infrastructure(infrastructure)
            self.mark_changed()
        elif replaced > 0:
            self.mark_changed()

Ancestors

Methods

def visit_JsScript(self, node)
Expand source code Browse git
def visit_JsScript(self, node: JsScript):
    self._root = node
    return super().visit_JsScript(node)

Inherited members