Module refinery.lib.scripts.js.deobfuscation.b91strings

Decode the strings an obfuscator conceals behind base91 decoders. Every string literal becomes a call of an accessor. One kind of accessor decodes an element of a shared table of encoded strings once and keeps the result in a cache:

var table = ["fOg=r", ...];
var cache = {};
function decode(str) { var alphabet = "..."; ...; return bufferToString(bytes); }
function accessor(index) {
    if (typeof cache[index] === "undefined") { return cache[index] = decode(table[index]); }
    return cache[index];
}

The other kind slices the encoded string out of one long string and keeps no cache:

var text = "...fOg=r...";
function accessor(start, length) { return decode(text.slice(start, start + length)); }

Every scope gets a decoder with an alphabet of its own. A decoder and an accessor are recognized by their templates (refinery.lib.scripts.js.deobfuscation.templates), whatever their names are and wherever they stand, through the names that hold them. A call of an accessor whose decoder, table and cache are in place before it runs becomes the string it decodes, and the machinery goes once nothing names it any longer.

Expand source code Browse git
"""
Decode the strings an obfuscator conceals behind base91 decoders. Every string literal becomes a
call of an accessor. One kind of accessor decodes an element of a shared table of encoded strings
once and keeps the result in a cache:

    var table = ["fOg=r", ...];
    var cache = {};
    function decode(str) { var alphabet = "..."; ...; return bufferToString(bytes); }
    function accessor(index) {
        if (typeof cache[index] === "undefined") { return cache[index] = decode(table[index]); }
        return cache[index];
    }

The other kind slices the encoded string out of one long string and keeps no cache:

    var text = "...fOg=r...";
    function accessor(start, length) { return decode(text.slice(start, start + length)); }

Every scope gets a decoder with an alphabet of its own. A decoder and an accessor are recognized by
their templates (`refinery.lib.scripts.js.deobfuscation.templates`), whatever their names are and
wherever they stand, through the names that hold them. A call of an accessor whose decoder, table
and cache are in place before it runs becomes the string it decodes, and the machinery goes once
nothing names it any longer.
"""
from __future__ import annotations

from typing import Callable, Iterator, NamedTuple

from refinery.lib.scripts import Node, Statement, _remove_from_parent, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.dominance import DominanceModel
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    FunctionNames,
    SemanticModel,
    enclosing_operator,
    statement_list_of,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    coerces_uninterceptably,
    denoted_value,
    integer_literal,
    make_string_literal,
    nothing_still_names,
    reaches_script_completion,
    remove_declarator,
    walk_scope,
)
from refinery.lib.scripts.js.deobfuscation.templates import (
    Shuffle,
    Template,
    match,
    matches,
)
from refinery.lib.scripts.js.model import (
    FUNCTION_NODES,
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsBlockStatement,
    JsCallExpression,
    JsExpressionStatement,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsFunctionNode,
    JsIdentifier,
    JsIfStatement,
    JsMemberExpression,
    JsNumericLiteral,
    JsObjectExpression,
    JsReturnStatement,
    JsScript,
    JsSequenceExpression,
    JsSpreadElement,
    JsStringLiteral,
    JsVariableDeclarator,
    static_property_key,
    strip_parens,
)
from refinery.lib.scripts.js.options import preserves_script_return
from refinery.lib.scripts.js.utf16 import to_code_units


def _decode_base91(encoded: str, alphabet: str) -> str:
    """
    Decode a base91-encoded string using the given 91-character alphabet. The encoding pairs
    characters from the alphabet into 13-or-14-bit values that are accumulated into a byte stream,
    which is then interpreted as UTF-8. The result is the string a program holds, one UTF-16 code
    unit to a character, so a character above the basic plane is its surrogate pair.
    """
    result: list[int] = []
    b = 0
    n = 0
    v = -1
    for ch in encoded:
        p = alphabet.find(ch)
        if p == -1:
            continue
        if v < 0:
            v = p
        else:
            v += p * 91
            b |= v << n
            n += 13 if (v & 8191) > 88 else 14
            while n > 7:
                result.append(b & 0xFF)
                b >>= 8
                n -= 8
            v = -1
    if v > -1:
        result.append((b | v << n) & 0xFF)
    return to_code_units(bytes(result).decode('utf-8'))


def _is_base91_alphabet(s: str) -> bool:
    return len(s) == 91 and len(set(s)) == 91


_DECODER_BODY = (
    'var raw = "" + (str || ""); var len = raw.length; var ret = []; var b = 0; var n = 0; var v = -1;'
    ' for (var i = 0; i < len; i++) { var p = ALPHABET.indexOf(raw[i]); if (p === -1) continue;'
    ' if (v < 0) { v = p; } else { v += p * 91; b |= v << n; n += (v & 8191) > 88 ? 13 : 14;'
    ' do { ret.push(b & 0xff); b >>= 8; n -= 8; } while (n > 7); v = -1; } }'
    ' if (v > -1) { ret.push((b | (v << n)) & 0xff); } return BUFFER(ret);'
)

_DECODER_BODY_READING_A_VARIABLE = _DECODER_BODY.replace('ALPHABET.', 'table.')

_DECODER_TEMPLATES = (
    Template(
        F'function DECODE(str) {{ {_DECODER_BODY} }}',
        frozenset({'ALPHABET', 'BUFFER'}),
    ),
    Template(
        F'function DECODE(str) {{ var table = ALPHABET; {_DECODER_BODY_READING_A_VARIABLE} }}',
        frozenset({'ALPHABET', 'BUFFER'}),
    ),
)
"""
The decoder as the obfuscator writes it, with its alphabet inlined and in a variable of its own.
`ALPHABET` stands for the literal holding the alphabet and `BUFFER` for the callee of the call that
turns the decoded bytes into a string.
"""

_CACHING_ACCESSOR = (
    Template(
        'function ACCESSOR(index) { if (typeof CACHE[index] === "undefined") {'
        ' return CACHE[index] = DECODE(TABLE[index]); } return CACHE[index]; }',
        frozenset({'CACHE', 'DECODE', 'TABLE'}),
    ),
    Template(
        'function ACCESSOR(index) { if (typeof CACHE[index] === "undefined") {'
        ' return CACHE[index] = DECODE(TABLE[index]); } else { return CACHE[index]; } }',
        frozenset({'CACHE', 'DECODE', 'TABLE'}),
    ),
)
"""
The accessor keeping a cache as the obfuscator writes it, its last `return` after the `if` or in an
`else`. `CACHE`, `DECODE` and `TABLE` stand for the names holding the cache, the decoder and the
table.
"""

_SLICING_ACCESSOR = (
    Template(
        'function ACCESSOR(start, length) { return DECODE(TABLE.slice(start, start + length)); }',
        frozenset({'DECODE', 'TABLE'}),
    ),
)
"""
The accessor slicing its string out of one long string as the obfuscator writes it. `DECODE` stands
for the name holding the decoder and `TABLE` for the name holding the long string or for the string
literal the accessor spells it out with.
"""


_DEFAULTS = (
    'if (typeof a === "undefined") { a = DECODE; }'
    ' if (typeof b === "undefined") { b = CACHE; }'
    ' if (x !== y) { return b[x] || (b[x] = a(TABLE[x])); }'
)

_DEFAULTED_ACCESSOR = (
    Template(
        F'function ACCESSOR(x, y, z, a, b) {{ {_DEFAULTS} }}',
        frozenset({'CACHE', 'DECODE', 'TABLE'}),
    ),
)
"""
The accessor keeping its decoder and its cache in two parameters it defaults. `CACHE`, `DECODE` and
`TABLE` stand for the names holding the cache, the decoder and the table.
"""

_DECOYED_ACCESSOR = Template(
    F'function TEMPLATE(x, y, z, a, b) {{ {_DEFAULTS}'
    ' if (z == x) { return y[CACHE[z]] = ACCESSOR(x, y); }'
    ' if (y) { [b, y] = [a(b), x || z]; return ACCESSOR(x, b, z); }'
    ' if (z && a !== DECODE) { ACCESSOR = DECODE; return ACCESSOR(x, -1, z, a, b); }'
    ' if (a === ACCESSOR) { DECODE = y; return DECODE(z); }'
    ' if (a === undefined) { ACCESSOR = b; }'
    ' if (z == a) { return y ? x[b[y]] : CACHE[x] || (z = b[x] || a, CACHE[x] = z(TABLE[x])); } }',
    frozenset({'ACCESSOR', 'CACHE', 'DECODE', 'TABLE'}),
    Shuffle(2, 1),
)
"""
The accessor of `_DEFAULTED_ACCESSOR` as the obfuscator writes it: after the two defaults, its
working branch and some of six decoys, in an order of its choosing. `ACCESSOR` stands for the name
holding the accessor.
"""


class _Held(NamedTuple):
    """
    A function each of its names holds as its one value, and the node installing it there, which
    goes when the function goes: the declaration, the declarator, or the statement assigning it.
    """
    names: FunctionNames
    function: JsFunctionNode
    definition: Node | None


class _Decoder(NamedTuple):
    held: _Held
    alphabet: str


class _Parts(NamedTuple):
    """
    What stands where an accessor template has its decoder, its table and its cache: the names
    holding the decoder and the cache, if the accessor keeps one, and the name holding the table or
    the string literal the accessor spells it out with.
    """
    decoder: Binding
    table: Binding | JsStringLiteral
    cache: Binding | None


class _StringArray(NamedTuple):
    """
    A table that is an array of encoded strings, held by *binding*. A call passes the index of the
    one it decodes.
    """
    binding: Binding
    strings: list[str]

    def encoded(self, numbers: list[int]) -> str | None:
        if len(numbers) != 1 or not 0 <= (index := numbers[0]) < len(self.strings):
            return None
        return self.strings[index]


class _LongString(NamedTuple):
    """
    A table that is one long string the encoded strings stand in, held by *binding*, or spelled out
    by the accessor where *binding* is `None`. A call passes where the one it decodes starts and how
    long it is, both counted in UTF-16 code units, which is what the string holds.
    """
    binding: Binding | None
    text: str

    def encoded(self, numbers: list[int]) -> str | None:
        if len(numbers) != 2:
            return None
        start, length = numbers
        if start < 0 or length < 0 or start + length > len(self.text):
            return None
        return self.text[start:start + length]


_Table = _StringArray | _LongString


class _Kind(NamedTuple):
    """
    A kind of accessor: its templates, the reader of what stands where they have their decoder,
    table and cache, the reader of what its table holds, and the question whether the program calls
    an accessor of the kind only as its templates assume, asked of the names holding it.
    """
    templates: tuple[Template, ...]
    parts: Callable[[SemanticModel, JsFunctionNode], _Parts | None]
    table: Callable[[EffectModel, Binding | JsStringLiteral], _Table | None]
    called: Callable[[SemanticModel, FunctionNames | None], bool]


class _Accessor(NamedTuple):
    """
    An accessor, the decoder it calls and the name it calls it through, the table it reads, and the
    cache it keeps the decoded strings in, if it keeps any.
    """
    held: _Held
    decoder: _Decoder
    decoder_name: Binding
    table: _Table
    cache: Binding | None

    def stores(self) -> list[Binding]:
        """
        The names holding the table and the cache.
        """
        return [store for store in (self.table.binding, self.cache) if store is not None]


def _definition(function: JsFunctionNode) -> Node | None:
    if isinstance(function, JsFunctionDeclaration):
        return function
    parent = enclosing_operator(function)
    if isinstance(parent, JsVariableDeclarator):
        return parent
    if isinstance(parent, JsAssignmentExpression) and isinstance(parent.parent, JsExpressionStatement):
        return parent.parent
    return None


def _held_functions(model: SemanticModel, root: Node) -> Iterator[_Held]:
    """
    Every function that each name invoking it holds as its only value.
    """
    for node in root.walk():
        if not isinstance(node, FUNCTION_NODES):
            continue
        names = model.function_names(node)
        if names is None or not all(
            len(binding.declarations) == 1 and strip_parens(model.singular_value(binding)) is node
            for binding in names.bindings
        ):
            continue
        yield _Held(names, node, _definition(node))


def _declared_outside(model: SemanticModel, function: Node, callee: Node | None) -> bool:
    """
    Whether *callee* names something *function* does not declare: a name or fixed keys read off
    one, possibly behind numbers a comma operator discards, as in `(0, Buffer.from)`.
    """
    callee = strip_parens(callee)
    if isinstance(callee, JsSequenceExpression):
        *discarded, callee = callee.expressions
        if not all(isinstance(strip_parens(part), JsNumericLiteral) for part in discarded):
            return False
        callee = strip_parens(callee)
    while isinstance(callee, JsMemberExpression) and not callee.computed:
        callee = strip_parens(callee.object)
    if not isinstance(callee, JsIdentifier):
        return False
    binding = model.resolve(callee)
    return binding is None or not binding.declared_within(function)


def _decoder(model: SemanticModel, held: _Held) -> _Decoder | None:
    """
    The decoder *held* is, when it is the decoder template: its alphabet is the one string literal
    of its own that is a base91 alphabet, and the call it returns through calls a function it does
    not declare, which turns the decoded bytes into a string. The name holding it is asked about
    reflection as the name of a table is (`_table_value`): an accessor reading a table it spells out
    has no such name to ask, and code reaching the scope of the machinery may rebind the decoder as
    well as the table.
    """
    function = held.function
    if len(function.params) != 1:
        return None
    if any(model.reflection_can_reach_from_its_scope(name) for name in held.names.bindings):
        return None
    alphabets = [
        node for node in walk_scope(function, include_root_body=True)
        if isinstance(node, JsStringLiteral) and node.value is not None and _is_base91_alphabet(node.value)
    ]
    values = {node.value for node in alphabets if node.value is not None}
    if len(values) != 1:
        return None
    statements = statement_list_of(function)
    if not statements or not isinstance(last := statements[-1], JsReturnStatement):
        return None
    call = strip_parens(last.argument)
    if not isinstance(call, JsCallExpression) or len(call.arguments) != 1:
        return None
    if not _declared_outside(model, function, call.callee):
        return None
    nodes = {id(node): 'ALPHABET' for node in alphabets}
    nodes[id(call.callee)] = 'BUFFER'
    if not matches(model, function, _DECODER_TEMPLATES, {}, nodes):
        return None
    return _Decoder(held, values.pop())


def _outer(model: SemanticModel, function: Node, node: Node | None) -> Binding | None:
    """
    The binding *node* names when it is a name *function* does not declare.
    """
    node = strip_parens(node)
    if not isinstance(node, JsIdentifier) or (binding := model.resolve(node)) is None:
        return None
    if binding.declared_within(function):
        return None
    return binding


def _caching_parts(model: SemanticModel, function: JsFunctionNode) -> _Parts | None:
    """
    The names *function* reads an element of at its one parameter, the one of them it hands to a
    call taking one argument, and that call's callee: the cache, the table and the decoder, if
    *function* is a caching accessor and the cache holds an empty object.
    """
    if len(function.params) != 1 or not isinstance(index := function.params[0], JsIdentifier):
        return None
    if not 0 < len(statement_list_of(function) or ()) <= 2:
        return None
    parameter = model.binding_of(index)
    owners: dict[int, Binding] = {}
    decoder = table = None
    for node in walk_scope(function, include_root_body=True):
        if not isinstance(node, JsMemberExpression) or not node.computed:
            continue
        owner = strip_parens(node.object)
        key = strip_parens(node.property)
        if not isinstance(owner, JsIdentifier) or not isinstance(key, JsIdentifier):
            continue
        if model.resolve(key) is not parameter or (binding := model.resolve(owner)) is None:
            continue
        owners[id(binding)] = binding
        call = enclosing_operator(node)
        if not isinstance(call, JsCallExpression) or len(call.arguments) != 1:
            continue
        callee = strip_parens(call.callee)
        if strip_parens(call.arguments[0]) is node and isinstance(callee, JsIdentifier):
            decoder = model.resolve(callee)
            table = binding
    if decoder is None or table is None or len(owners) != 2:
        return None
    cache = next(binding for binding in owners.values() if binding is not table)
    if decoder is cache or decoder is table:
        return None
    empty = strip_parens(model.singular_value(cache))
    if not isinstance(empty, JsObjectExpression) or empty.properties:
        return None
    return _Parts(decoder, table, cache)


def _slicing_parts(model: SemanticModel, function: JsFunctionNode) -> _Parts | None:
    """
    The callee of the call *function* returns and what it slices the argument of that call out of:
    the decoder and the table, if *function* is a slicing accessor.
    """
    if len(function.params) != 2 or len(statements := statement_list_of(function) or ()) != 1:
        return None
    if not isinstance(body := statements[0], JsReturnStatement):
        return None
    call = strip_parens(body.argument)
    if not isinstance(call, JsCallExpression) or len(call.arguments) != 1:
        return None
    sliced = strip_parens(call.arguments[0])
    if not isinstance(sliced, JsCallExpression):
        return None
    if not isinstance(member := strip_parens(sliced.callee), JsMemberExpression):
        return None
    decoder = _outer(model, function, call.callee)
    text = strip_parens(member.object)
    table = text if isinstance(text, JsStringLiteral) else _outer(model, function, text)
    if decoder is None or table is None or decoder is table:
        return None
    return _Parts(decoder, table, None)


def _default(model: SemanticModel, function: Node, statement: Node | None) -> Binding | None:
    """
    The name *statement* stores into a parameter of *function* when it is
    `if (...) { parameter = name; }` and *function* does not declare the name.
    """
    if not isinstance(statement, JsIfStatement) or statement.alternate is not None:
        return None
    consequent = statement.consequent
    if isinstance(consequent, JsBlockStatement):
        if len(consequent.body) != 1:
            return None
        consequent = consequent.body[0]
    if not isinstance(consequent, JsExpressionStatement):
        return None
    assignment = strip_parens(consequent.expression)
    if not isinstance(assignment, JsAssignmentExpression):
        return None
    return _outer(model, function, assignment.right)


def _defaulted_parts(model: SemanticModel, function: JsFunctionNode) -> _Parts | None:
    """
    The names the first two statements of *function* store into its fourth and fifth parameter, the
    decoder and the cache, and the other name it reads an element of at its first parameter, the
    table, if *function* is a defaulted accessor and the cache holds an empty array.
    """
    params = function.params
    if len(params) != 5 or not all(isinstance(parameter, JsIdentifier) for parameter in params):
        return None
    statements = statement_list_of(function) or []
    if len(statements) < 3:
        return None
    decoder = _default(model, function, statements[0])
    cache = _default(model, function, statements[1])
    if decoder is None or cache is None or decoder is cache:
        return None
    first = params[0]
    assert isinstance(first, JsIdentifier)
    index = model.binding_of(first)
    tables: dict[int, Binding] = {}
    for node in walk_scope(function, include_root_body=True):
        if not isinstance(node, JsMemberExpression) or not node.computed:
            continue
        key = strip_parens(node.property)
        if not isinstance(key, JsIdentifier) or model.resolve(key) is not index:
            continue
        if (owner := _outer(model, function, node.object)) is not None and owner is not cache:
            tables[id(owner)] = owner
    if len(tables) != 1 or (table := next(iter(tables.values()))) is decoder:
        return None
    empty = strip_parens(model.singular_value(cache))
    if not isinstance(empty, JsArrayExpression) or empty.elements:
        return None
    return _Parts(decoder, table, cache)


def _called_with_one_argument(model: SemanticModel, names: FunctionNames | None) -> bool:
    """
    Whether every reference of the names of an accessor but their declarations is the callee of a
    call passing one argument, so the accessor is never handed a decoder or a cache of a caller's
    choosing. An accessor no name holds may be called any way.
    """
    if names is None:
        return False
    for binding in names.bindings:
        if binding.dynamic_refs or binding.has_indefinite_write:
            return False
        declarations = {id(node) for node in binding.declarations}
        for reference in names.references_of(binding):
            if id(reference) in declarations:
                continue
            call = enclosing_operator(reference)
            if not isinstance(call, JsCallExpression) or strip_parens(call.callee) is not reference:
                return False
            if len(call.arguments) != 1 or isinstance(call.arguments[0], JsSpreadElement):
                return False
    return True


def _called_any_way(model: SemanticModel, names: FunctionNames | None) -> bool:
    """
    Whether the program calls an accessor only as the templates of a kind assume, for a kind whose
    templates assume nothing about the calls.
    """
    return True


def _table_value(effects: EffectModel, table: Binding | JsStringLiteral) -> tuple[bool, object]:
    """
    What *table* denotes as `refinery.lib.scripts.js.deobfuscation.helpers.denoted_value` reads it:
    the string a literal spells, or the one value the name *table* holds wherever it is in place,
    unless code the model cannot read may reach that name.

    The table is machinery under a name the obfuscator makes up, like the decoder, the accessor and
    the cache. A constant of the program is also asked whether a host or code running in the global
    scope may reach it (`refinery.lib.scripts.js.deobfuscation.helpers.binding_constant`), while
    the table is asked only about code in its own scope the model cannot read: a direct `eval`
    there, or a span the parser could not read
    (`refinery.lib.scripts.js.analysis.model.SemanticModel.reflection_can_reach_from_its_scope`).
    A script the obfuscator writes stores on the global object under keys this very table holds,
    and the wider question would refuse the table of every script.
    """
    model = effects.model
    if isinstance(table, JsStringLiteral):
        return denoted_value(
            table,
            model,
            lambda converted: coerces_uninterceptably(effects, converted),
        )
    if model.reflection_can_reach_from_its_scope(table):
        return False, None
    return denoted_value(
        model.singular_value(table),
        model,
        lambda converted: coerces_uninterceptably(effects, converted),
    )


def _string_array(effects: EffectModel, table: Binding | JsStringLiteral) -> _StringArray | None:
    """
    The strings the name *table* holds (`_table_value`): the one array of at least ten strings the
    text gives it, in a container nothing changes or hands out
    (`refinery.lib.scripts.js.analysis.effects.EffectModel.binding_is_immutable_container`).
    """
    if not isinstance(table, Binding):
        return None
    known, value = _table_value(effects, table)
    if not known or not isinstance(value, list) or len(value) < 10:
        return None
    strings = [string for string in value if isinstance(string, str)]
    if len(strings) != len(value) or not effects.binding_is_immutable_container(table):
        return None
    return _StringArray(table, strings)


def _long_string(effects: EffectModel, table: Binding | JsStringLiteral) -> _LongString | None:
    """
    The string *table* spells out, or the string the name *table* holds (`_table_value`).
    """
    known, value = _table_value(effects, table)
    if not known or not isinstance(value, str):
        return None
    return _LongString(table if isinstance(table, Binding) else None, value)


_KINDS = (
    _Kind(_CACHING_ACCESSOR, _caching_parts, _string_array, _called_any_way),
    _Kind(_SLICING_ACCESSOR, _slicing_parts, _long_string, _called_any_way),
    _Kind(_DEFAULTED_ACCESSOR, _defaulted_parts, _string_array, _called_with_one_argument),
)


def _parts(model: SemanticModel, function: JsFunctionNode) -> tuple[_Kind, _Parts] | None:
    """
    The first kind of accessor whose reader of parts recognizes *function*, and what stands where
    the templates of that kind have their decoder, table and cache. Whether *function* has the
    normal form of one of those templates is asked apart from this (`_is_accessor`), after the
    cheaper questions, since building that form copies the whole function.
    """
    for kind in _KINDS:
        if (parts := kind.parts(model, function)) is not None:
            return kind, parts
    return None


def _is_accessor(
    model: SemanticModel,
    function: JsFunctionNode,
    kind: _Kind,
    parts: _Parts,
) -> bool:
    """
    Whether *function* has the normal form of a template of *kind*, with the names and the literal
    of *parts* standing for the placeholders they fill.
    """
    placeholders = {id(parts.decoder): 'DECODE'}
    nodes: dict[int, str] = {}
    if isinstance(parts.table, Binding):
        placeholders[id(parts.table)] = 'TABLE'
    else:
        nodes[id(parts.table)] = 'TABLE'
    if parts.cache is not None:
        placeholders[id(parts.cache)] = 'CACHE'
    return matches(model, function, kind.templates, placeholders, nodes)


def _accessor(
    effects: EffectModel,
    held: _Held,
    decoders: dict[int, _Decoder],
    tables: dict[tuple[int, int], _Table | None],
) -> _Accessor | None:
    """
    The accessor *held* is, when it is an accessor template over a decoder of *decoders* and a table
    holding strings. What a table holds is kept in *tables* by the identity of the kind reading it
    and of what stands for it, since every accessor of a kind reads the one table.
    """
    model = effects.model
    if (found := _parts(model, held.function)) is None:
        return None
    kind, parts = found
    if (decoder := decoders.get(id(parts.decoder))) is None:
        return None
    if not _is_accessor(model, held.function, kind, parts):
        return None
    if not kind.called(model, held.names):
        return None
    if (key := (id(kind), id(parts.table))) not in tables:
        tables[key] = kind.table(effects, parts.table)
    if (table := tables[key]) is None:
        return None
    return _Accessor(held, decoder, parts.decoder, table, parts.cache)


def _cache_confined(model: SemanticModel, cache: Binding) -> bool:
    """
    Whether nothing but an accessor keeping *cache* reads or writes it: every reference, a `with`
    body's included, lies in its declarator, in a write establishing its value, or in a function
    that is an accessor template over it. Such a function need not be held by a name: the
    moved-declaration setting hands an accessor to a function through a parameter.
    """
    declarator = cache.declarations[0].parent if len(cache.declarations) == 1 else None
    sites = model.binding_establishment_sites(cache) or []
    accessors: dict[int, bool] = {}
    for reference in [*model.references(cache), *model.dynamic_references(cache)]:
        if declarator is not None and reference.is_descendant_of(declarator):
            continue
        if any(reference is site or reference.is_descendant_of(site) for site in sites):
            continue
        function = reference.parent
        while function is not None and not isinstance(function, FUNCTION_NODES):
            function = function.parent
        if function is None:
            return False
        if (known := accessors.get(id(function))) is None:
            found = _parts(model, function)
            known = accessors[id(function)] = (
                found is not None
                and found[1].cache is cache
                and _is_accessor(model, function, *found)
                and found[0].called(model, model.function_names(function))
            )
        if not known:
            return False
    return True


def _decoded_calls(dominance: DominanceModel, accessor: _Accessor) -> Iterator[tuple[JsCallExpression, str]]:
    """
    Every call of *accessor* passing integer literals that say which encoded string of its table to
    decode, after the accessor, the decoder, the table and the cache are in place, with the string
    the call returns. A string that does not decode or starts with a byte order mark is left a call:
    the function turning the bytes into a string strips a byte order mark where the host has a
    `TextDecoder` and keeps it elsewhere.
    """
    model = dominance.model
    needed = [accessor.decoder_name, *accessor.stores()]
    for read in accessor.held.names.reads:
        if not isinstance(read, JsIdentifier):
            continue
        call = enclosing_operator(read)
        if not isinstance(call, JsCallExpression) or strip_parens(call.callee) is not read:
            continue
        numbers = [number for argument in call.arguments if (number := integer_literal(argument)) is not None]
        if len(numbers) != len(call.arguments) or (encoded := accessor.table.encoded(numbers)) is None:
            continue
        bindings = [model.resolve(read), *needed]
        if not all(dominance.binding_established_before(binding, call) for binding in bindings):
            continue
        try:
            decoded = _decode_base91(encoded, accessor.decoder.alphabet)
        except (UnicodeDecodeError, ValueError):
            continue
        if decoded.startswith('\N{BYTE ORDER MARK}'):
            continue
        yield call, decoded


def _stands_in_a_list(definition: Node) -> bool:
    """
    Whether *definition* can be taken out of the tree without leaving a hole: a statement standing
    in a statement list, or a declarator of a declaration that does. One written under an `export`
    or a label stands in a slot of its own, which nothing may be left empty in.
    """
    statement = definition.parent if isinstance(definition, JsVariableDeclarator) else definition
    holder = None if statement is None else statement.parent
    return holder is not None and any(item is statement for item in statement_list_of(holder) or ())


def _remove_machinery(
    model: SemanticModel,
    accessors: list[_Accessor],
    replaced: list[Node],
    root: JsScript,
    keeps_the_end_value: bool,
) -> None:
    """
    Remove the accessors, then the decoders, then the tables and caches whose every reference, a
    `with` body's included, lies in a replaced call or in a definition removed with them, when
    `refinery.lib.scripts.js.deobfuscation.helpers.nothing_still_names` agrees for all of them. A
    name an importer reads stays, and so does a definition that does not stand in a statement list
    (`_stands_in_a_list`) or, where *keeps_the_end_value* asks for the value the script ends on, a
    statement assigning one that may set that value. Each decoder, table and cache is asked about
    once, however many accessors share it.
    """
    inside: set[int] = {id(node) for call in replaced for node in call.walk()}
    removed: dict[int, Node] = {}

    def remove(definition: Node | None, *bindings: Binding) -> None:
        if definition is None or id(definition) in removed:
            return
        if any(binding.exported for binding in bindings) or not _stands_in_a_list(definition):
            return
        if (
            keeps_the_end_value
            and isinstance(definition, JsExpressionStatement)
            and reaches_script_completion(definition, root)
        ):
            return
        own = {id(node) for node in definition.walk()}
        for binding in bindings:
            for reference in [*model.references(binding), *model.dynamic_references(binding)]:
                if id(reference) not in inside and id(reference) not in own:
                    return
        removed[id(definition)] = definition
        inside.update(own)

    for accessor in accessors:
        remove(accessor.held.definition, *accessor.held.names.bindings)
    decoders = {id(accessor.decoder.held.function): accessor.decoder.held for accessor in accessors}
    for held in decoders.values():
        remove(held.definition, *held.names.bindings)
    stores = {id(store): store for accessor in accessors for store in accessor.stores()}
    for store in stores.values():
        declarator = store.declarations[0].parent if len(store.declarations) == 1 else None
        if isinstance(declarator, JsVariableDeclarator):
            remove(declarator, store)
    if not removed or not nothing_still_names(model, [*removed.values(), *replaced]):
        return
    for node in removed.values():
        if isinstance(node, JsVariableDeclarator):
            remove_declarator(node)
        else:
            _remove_from_parent(node)


class JsBase91StringDecoder(ScriptLevelTransformer):
    """
    Replace every call of a base91 string accessor the module describes by the string it returns,
    and remove the machinery nothing names any longer.
    """

    def _process_script(self, node: JsScript):
        cache = model_cache(self, node)
        model = cache.model
        held = list(_held_functions(model, node))
        decoders: dict[int, _Decoder] = {}
        for item in held:
            if (decoder := _decoder(model, item)) is not None:
                decoders.update((id(name), decoder) for name in item.names.bindings)
        if not decoders:
            return
        accessors: list[_Accessor] = []
        tables: dict[tuple[int, int], _Table | None] = {}
        confined: dict[int, bool] = {}
        replacements: list[tuple[JsCallExpression, str]] = []
        for item in held:
            if id(item.names.own) in decoders:
                continue
            if (accessor := _accessor(cache.effects, item, decoders, tables)) is None:
                continue
            accessors.append(accessor)
            if accessor.cache is not None:
                if (known := confined.get(id(accessor.cache))) is None:
                    known = confined[id(accessor.cache)] = _cache_confined(model, accessor.cache)
                if not known:
                    continue
            replacements.extend(_decoded_calls(cache.dominance, accessor))
        if not replacements:
            return
        for call, decoded in replacements:
            _replace_in_parent(call, make_string_literal(decoded))
        replaced: list[Node] = [call for call, _ in replacements]
        _remove_machinery(model, accessors, replaced, node, preserves_script_return(self.options))
        self.mark_changed()


def _index_call(
    effects: EffectModel,
    reference: Node,
) -> tuple[JsCallExpression, JsNumericLiteral] | None:
    """
    The call *reference* is the callee of, passing one number literal as its only argument, and that
    literal: `f(7)`, `f.call(undefined, 7)` or `f.apply(undefined, [7])`, the receiver `undefined`
    spelled as the name or as `void` of a number. The last two only where the program writes no
    member of `Function.prototype`, and neither through an optional chain, which calls nothing where
    `f` holds no function yet. Code the model cannot read is not asked about, for the reason the
    table is not (`_table_value`): every script the obfuscator writes reads the global object
    through a reflective surface of its own.
    """
    model = effects.model
    parent = enclosing_operator(reference)
    if isinstance(parent, JsCallExpression) and strip_parens(parent.callee) is reference:
        arguments = parent.arguments
        if len(arguments) == 1 and isinstance(number := arguments[0], JsNumericLiteral):
            return parent, number
        return None
    if not isinstance(parent, JsMemberExpression) or strip_parens(parent.object) is not reference:
        return None
    method = static_property_key(parent)
    call = enclosing_operator(parent)
    if method not in ('call', 'apply') or not isinstance(call, JsCallExpression):
        return None
    if strip_parens(call.callee) is not parent or len(call.arguments) != 2:
        return None
    if parent.optional or call.optional:
        return None
    if not effects.prototype_name_unwritten(JsFunctionExpression):
        return None
    receiver, argument = call.arguments
    known, value = denoted_value(
        receiver,
        model,
        lambda converted: coerces_uninterceptably(effects, converted),
    )
    if not known or value is not None:
        return None
    argument = strip_parens(argument)
    if method == 'apply':
        if not isinstance(argument, JsArrayExpression) or len(argument.elements) != 1:
            return None
        argument = argument.elements[0]
    if not isinstance(argument, JsNumericLiteral):
        return None
    return call, argument


def _written_only_inside(
    model: SemanticModel,
    binding: Binding,
    function: Node,
    *,
    besides: Node | None = None,
) -> bool:
    """
    Whether every write of *binding* lies in *function* or in *besides*, and nothing writes it in a
    way no reference records.
    """
    if binding.dynamic_refs or binding.has_indefinite_write or binding.exported:
        return False
    if binding.reachable_through_a_handed_object:
        return False
    if model.reflection_can_reach_from_its_scope(binding):
        return False
    for write in binding.writes:
        if besides is not None and (write is besides or write.is_descendant_of(besides)):
            continue
        if not write.is_descendant_of(function):
            return False
    return True


class _Decoys(NamedTuple):
    """
    The decoy branches of a decoyed accessor, and each call of it written through `call` or `apply`
    with the name it calls and the number it passes.
    """
    decoys: list[Statement]
    calls: list[tuple[JsCallExpression, JsIdentifier, JsNumericLiteral]]


def _decoys(effects: EffectModel, function: JsFunctionNode) -> _Decoys | None:
    """
    The decoys of *function* and its calls written through `call` or `apply`, when it is the decoyed
    accessor and no decoy can run: the name holding it holds nothing but what a decoy stores, the
    name of its decoder holds a function declared in the scope it names and nothing but what a decoy
    stores, and every reference of the accessor outside it passes one number as its only argument.
    """
    model = effects.model
    if not isinstance(function, (JsArrowFunctionExpression, JsFunctionExpression)):
        return None
    declarator = enclosing_operator(function)
    if not isinstance(declarator, JsVariableDeclarator):
        return None
    if strip_parens(declarator.init) is not function:
        return None
    names = model.function_names(function)
    if names is None or len(names.own.declarations) != 1:
        return None
    holder = names.own
    if (parts := _defaulted_parts(model, function)) is None or parts.cache is None:
        return None
    decoder = parts.decoder
    assert isinstance(parts.table, Binding)
    placeholders = {
        id(holder): 'ACCESSOR',
        id(decoder): 'DECODE',
        id(parts.cache): 'CACHE',
        id(parts.table): 'TABLE',
    }
    if (found := match(model, function, (_DECOYED_ACCESSOR,), placeholders)) is None:
        return None
    _, fit = found
    if len(decoder.declarations) != 1:
        return None
    if not isinstance(decoder.declarations[0].parent, JsFunctionDeclaration):
        return None
    if model.binding_of(decoder.declarations[0]) is not decoder:
        return None
    if not _written_only_inside(model, decoder, function):
        return None
    if not _written_only_inside(model, holder, function, besides=declarator):
        return None
    calls: list[tuple[JsCallExpression, JsIdentifier, JsNumericLiteral]] = []
    for reference in holder.reads:
        if reference.is_descendant_of(function):
            continue
        if not isinstance(reference, JsIdentifier):
            return None
        if (indexed := _index_call(effects, reference)) is None:
            return None
        call, number = indexed
        if strip_parens(call.callee) is not reference:
            calls.append((call, reference, number))
    statements = statement_list_of(function) or []
    if len(statements) != len(fit) or (shuffled := _DECOYED_ACCESSOR.shuffled) is None:
        return None
    always = shuffled.start + shuffled.kept
    decoys = [statement for statement, position in zip(statements, fit) if position >= always]
    if not decoys and not calls:
        return None
    return _Decoys(decoys, calls)


class JsBase91AccessorDecoys(ScriptLevelTransformer):
    """
    Remove the decoy branches of a defaulted base91 accessor that no call runs, and write each call
    of it through `call` or `apply` as a plain call.
    """

    def _process_script(self, node: JsScript):
        candidates = [
            function for function in node.walk()
            if isinstance(function, (JsArrowFunctionExpression, JsFunctionExpression))
            and len(function.params) == 5
        ]
        if not candidates:
            return
        effects = model_cache(self, node).effects
        found: list[_Decoys] = []
        for function in candidates:
            if (decoys := _decoys(effects, function)) is not None:
                found.append(decoys)
        if not found:
            return
        for item in found:
            for statement in item.decoys:
                _remove_from_parent(statement)
            for call, callee, argument in item.calls:
                plain = JsCallExpression(
                    callee=JsIdentifier(name=callee.name),
                    arguments=[argument],
                )
                _replace_in_parent(call, plain)
        self.mark_changed()

Classes

class JsBase91StringDecoder

Replace every call of a base91 string accessor the module describes by the string it returns, and remove the machinery nothing names any longer.

Expand source code Browse git
class JsBase91StringDecoder(ScriptLevelTransformer):
    """
    Replace every call of a base91 string accessor the module describes by the string it returns,
    and remove the machinery nothing names any longer.
    """

    def _process_script(self, node: JsScript):
        cache = model_cache(self, node)
        model = cache.model
        held = list(_held_functions(model, node))
        decoders: dict[int, _Decoder] = {}
        for item in held:
            if (decoder := _decoder(model, item)) is not None:
                decoders.update((id(name), decoder) for name in item.names.bindings)
        if not decoders:
            return
        accessors: list[_Accessor] = []
        tables: dict[tuple[int, int], _Table | None] = {}
        confined: dict[int, bool] = {}
        replacements: list[tuple[JsCallExpression, str]] = []
        for item in held:
            if id(item.names.own) in decoders:
                continue
            if (accessor := _accessor(cache.effects, item, decoders, tables)) is None:
                continue
            accessors.append(accessor)
            if accessor.cache is not None:
                if (known := confined.get(id(accessor.cache))) is None:
                    known = confined[id(accessor.cache)] = _cache_confined(model, accessor.cache)
                if not known:
                    continue
            replacements.extend(_decoded_calls(cache.dominance, accessor))
        if not replacements:
            return
        for call, decoded in replacements:
            _replace_in_parent(call, make_string_literal(decoded))
        replaced: list[Node] = [call for call, _ in replacements]
        _remove_machinery(model, accessors, replaced, node, preserves_script_return(self.options))
        self.mark_changed()

Ancestors

Inherited members

class JsBase91AccessorDecoys

Remove the decoy branches of a defaulted base91 accessor that no call runs, and write each call of it through call or apply as a plain call.

Expand source code Browse git
class JsBase91AccessorDecoys(ScriptLevelTransformer):
    """
    Remove the decoy branches of a defaulted base91 accessor that no call runs, and write each call
    of it through `call` or `apply` as a plain call.
    """

    def _process_script(self, node: JsScript):
        candidates = [
            function for function in node.walk()
            if isinstance(function, (JsArrowFunctionExpression, JsFunctionExpression))
            and len(function.params) == 5
        ]
        if not candidates:
            return
        effects = model_cache(self, node).effects
        found: list[_Decoys] = []
        for function in candidates:
            if (decoys := _decoys(effects, function)) is not None:
                found.append(decoys)
        if not found:
            return
        for item in found:
            for statement in item.decoys:
                _remove_from_parent(statement)
            for call, callee, argument in item.calls:
                plain = JsCallExpression(
                    callee=JsIdentifier(name=callee.name),
                    arguments=[argument],
                )
                _replace_in_parent(call, plain)
        self.mark_changed()

Ancestors

Inherited members