Module refinery.lib.scripts.js.deobfuscation.globalfinder

Resolve global-object-finder functions to globalThis.

A global-object finder is the common obfuscation idiom of a function that locates the host's global object by probing a sequence of candidates — globalThis, global, window, self, this, or closures returning them — validating each and returning the first that works. Because globalThis is the global object in every standard host, a call to such a finder evaluates to the same value as the identifier globalThis; rewriting the call as globalThis lets the surrounding reflection collapse and leaves the finder unreferenced for JsUnusedCodeRemoval to drop.

This recognition is a trusted judgment rather than a static proof: a finder's success depends on which globals the host defines, and its fallback path is not statically decidable, so "returns the global object" holds only under the standard-host assumption the obfuscator itself relies on. The trust is the same one JsReflectionInlining already applies when it rewrites the Function('return this')() idiom to globalThis. It is confined by strict gates: every return is global-object-valued, the function performs no observable write, every call it makes is rooted at one of its own locals (never console.log or any other external), and it actually references a global-object alias — so only a function that does nothing but compute the global object qualifies.

Expand source code Browse git
"""
Resolve global-object-finder functions to `globalThis`.

A *global-object finder* is the common obfuscation idiom of a function that locates the host's global
object by probing a sequence of candidates — `globalThis`, `global`, `window`, `self`, `this`, or
closures returning them — validating each and returning the first that works. Because `globalThis` is
the global object in every standard host, a call to such a finder evaluates to the same value as the
identifier `globalThis`; rewriting the call as `globalThis` lets the surrounding reflection collapse and
leaves the finder unreferenced for
`refinery.lib.scripts.js.deobfuscation.unused.JsUnusedCodeRemoval` to drop.

This recognition is a *trusted* judgment rather than a static proof: a finder's success depends on which
globals the host defines, and its fallback path is not statically decidable, so "returns the global
object" holds only under the standard-host assumption the obfuscator itself relies on. The trust is the
same one `refinery.lib.scripts.js.deobfuscation.reflection.JsReflectionInlining` already applies when it
rewrites the `Function('return this')()` idiom to `globalThis`. It is confined by strict gates: every
return is global-object-valued, the function performs no observable write, every call it makes is rooted
at one of its own locals (never `console.log` or any other external), and it actually references a
global-object alias — so only a function that does nothing but compute the global object qualifies.
"""
from __future__ import annotations

from typing import Iterator

from refinery.lib.scripts import Node, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
    Binding,
    FUNCTION_NODES,
    GLOBAL_OBJECT_ALIASES,
    SemanticModel,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    rewrite_receiver_this_to_global,
)
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsArrowFunctionExpression,
    JsAssignmentExpression,
    JsBlockStatement,
    JsCallExpression,
    JsConditionalExpression,
    JsFunctionDeclaration,
    JsFunctionExpression,
    JsIdentifier,
    JsLogicalExpression,
    JsMemberExpression,
    JsReturnStatement,
    JsScript,
    JsThisExpression,
    JsVariableDeclarator,
    strip_parens,
)

_CLOSURE_NODES = (JsFunctionExpression, JsArrowFunctionExpression)


class JsGlobalFinderInlining(ScriptLevelTransformer):
    """
    Replace each call to a recognized global-object-finder function with the identifier `globalThis`.
    See the module documentation for the recognition criteria and the trust they rest on.
    """

    self_converging = True

    def _process_script(self, node: JsScript) -> None:
        cache = model_cache(self, node)
        model = cache.model
        finders = _collect_finders(node, model, cache.effects)
        if not finders:
            return
        if self._materialize_finder_receivers(model, finders):
            self.mark_changed()
            return
        finder_ids = {id(f) for f in finders}
        for call in list(node.walk()):
            if not isinstance(call, JsCallExpression) or call.arguments:
                continue
            callee = strip_parens(call.callee)
            if not isinstance(callee, JsIdentifier):
                continue
            target = cache.effects.function_of(model.resolve(callee))
            if target is None or id(target) not in finder_ids or _within(call, target):
                continue
            if not cache.dominance.established_before(target, call):
                continue
            scope = model.scope_of(call)
            if scope is not None and model.lookup('globalThis', scope) is not None:
                continue
            _replace_in_parent(call, JsIdentifier(name='globalThis'))
            self.mark_changed()

    @staticmethod
    def _materialize_finder_receivers(model: SemanticModel, finders: list[Node]) -> bool:
        """
        Rewrite the receiver `this` of each recognized finder that is stored as a namespace method
        (`NS.f = function(){ … return r || this; }`) to `globalThis`, returning whether any finder was
        changed. A finder invoked as `NS.f()` binds its object as `this`, so its `… || this` fallback
        cannot be folded through the identifier-callee path and the namespace flattening declines to
        detach the method while it observes a receiver. Materializing the belief the recognition already
        rests on — that the finder yields the global object — makes the method `this`-free, so flattening
        detaches it to a bare `f()` the fold path then resolves to `globalThis`. Only a non-arrow,
        member-assigned finder is touched, and only where `globalThis` is not shadowed in its scope: a
        name-bound finder is left to the fold path untouched (its body needs no rewrite), and an arrow
        inherits `this` lexically rather than from the call, so neither is a receiver to materialize.
        """
        changed = False
        for finder in finders:
            if isinstance(finder, JsArrowFunctionExpression):
                continue
            parent = finder.parent
            if not (
                isinstance(parent, JsAssignmentExpression)
                and parent.operator == '='
                and parent.right is finder
                and isinstance(parent.left, JsMemberExpression)
            ):
                continue
            scope = model.function_scope(finder)
            if scope is None or model.lookup('globalThis', scope) is not None:
                continue
            if rewrite_receiver_this_to_global(finder):
                changed = True
        return changed


def _collect_finders(
    root: JsScript,
    model: SemanticModel,
    effects: EffectModel,
) -> list[Node]:
    finders: list[Node] = []
    for node in root.walk():
        if not isinstance(node, FUNCTION_NODES):
            continue
        parent = node.parent
        named = (
            (isinstance(node, JsFunctionDeclaration) and node.id is not None)
            or (isinstance(parent, JsVariableDeclarator) and parent.init is node)
            or (
                isinstance(parent, JsAssignmentExpression)
                and parent.operator == '='
                and parent.right is node
            )
        )
        if named and _is_finder(node, model, effects):
            finders.append(node)
    return finders


def _is_finder(func: Node, model: SemanticModel, effects: EffectModel) -> bool:
    summary = effects.summary_of(func)
    if summary.writes_global or summary.writes_captured or summary.mutates_returned_local:
        return False
    if not any(_is_global_alias(node, model) for node in func.walk()):
        return False
    for node in func.walk():
        if isinstance(node, JsCallExpression) and not _root_is_local(node.callee, func, model):
            return False
    returns = [node for node in _own_nodes(func) if isinstance(node, JsReturnStatement)]
    if not returns:
        return False
    taint = _global_taint(func, model)
    return all(
        ret.argument is not None and _is_global_valued(ret.argument, func, model, taint, set())
        for ret in returns
    )


def _is_global_valued(
    expr: Node | None, func: Node, model: SemanticModel, taint: set[int], visiting: set[int]
) -> bool:
    expr = strip_parens(expr)
    if isinstance(expr, JsThisExpression):
        return True
    if isinstance(expr, JsIdentifier):
        if _is_global_alias(expr, model):
            return True
        binding = model.resolve(expr)
        return binding is not None and id(binding) in taint
    if isinstance(expr, JsLogicalExpression):
        return (
            _is_global_valued(expr.left, func, model, taint, visiting)
            or _is_global_valued(expr.right, func, model, taint, visiting)
        )
    if isinstance(expr, JsConditionalExpression):
        return (
            _is_global_valued(expr.consequent, func, model, taint, visiting)
            or _is_global_valued(expr.alternate, func, model, taint, visiting)
        )
    if isinstance(expr, JsCallExpression):
        closures = _callee_closures(expr.callee, func, model)
        return bool(closures) and all(
            _closure_returns_global(closure, model, visiting) for closure in closures
        )
    return False


def _global_taint(func: Node, model: SemanticModel) -> set[int]:
    defs: list[tuple[Binding | None, Node | None]] = []
    for node in _own_nodes(func):
        if (
            isinstance(node, JsVariableDeclarator)
            and node.init is not None
            and isinstance(node.id, JsIdentifier)
        ):
            defs.append((model.binding_of(node.id), node.init))
        elif (
            isinstance(node, JsAssignmentExpression)
            and node.operator == '='
            and isinstance(node.left, JsIdentifier)
        ):
            defs.append((model.resolve(node.left), node.right))
    taint: set[int] = set()
    changed = True
    while changed:
        changed = False
        for binding, value in defs:
            if binding is None or id(binding) in taint or not _binding_within(binding, func, model):
                continue
            if _is_global_valued(value, func, model, taint, set()):
                taint.add(id(binding))
                changed = True
    return taint


def _callee_closures(callee: Node | None, func: Node, model: SemanticModel) -> list[Node]:
    callee = strip_parens(callee)
    if isinstance(callee, _CLOSURE_NODES):
        return [callee]
    if isinstance(callee, JsIdentifier):
        return _function_declarations(model.resolve(callee))
    if isinstance(callee, JsMemberExpression):
        base = strip_parens(callee.object)
        if isinstance(base, JsIdentifier):
            return _array_closures(model.resolve(base), func, model)
    return []


def _array_closures(binding: Binding | None, func: Node, model: SemanticModel) -> list[Node]:
    value = strip_parens(_sole_value(binding, func, model))
    if not isinstance(value, JsArrayExpression):
        return []
    closures: list[Node] = []
    for element in value.elements:
        element = strip_parens(element)
        if not isinstance(element, _CLOSURE_NODES):
            return []
        closures.append(element)
    return closures


def _closure_returns_global(closure: Node, model: SemanticModel, visiting: set[int]) -> bool:
    if id(closure) in visiting:
        return False
    visiting = visiting | {id(closure)}
    body = getattr(closure, 'body', None)
    taint = _global_taint(closure, model)
    if isinstance(closure, JsArrowFunctionExpression) and not isinstance(body, JsBlockStatement):
        return _is_global_valued(body, closure, model, taint, visiting)
    returns = [node for node in _own_nodes(closure) if isinstance(node, JsReturnStatement)]
    return bool(returns) and all(
        ret.argument is not None and _is_global_valued(ret.argument, closure, model, taint, visiting)
        for ret in returns
    )


def _sole_value(binding: Binding | None, func: Node, model: SemanticModel) -> Node | None:
    if binding is None:
        return None
    values: list[Node] = []
    for decl in binding.declarations:
        parent = decl.parent
        if isinstance(parent, JsVariableDeclarator) and parent.init is not None:
            values.append(parent.init)
    for node in _own_nodes(func):
        if (
            isinstance(node, JsAssignmentExpression)
            and node.operator == '='
            and isinstance(node.left, JsIdentifier)
            and node.right is not None
            and model.resolve(node.left) is binding
        ):
            values.append(node.right)
    return values[0] if len(values) == 1 else None


def _root_is_local(expr: Node | None, func: Node, model: SemanticModel) -> bool:
    expr = strip_parens(expr)
    if isinstance(expr, _CLOSURE_NODES):
        return True
    if isinstance(expr, JsIdentifier):
        return _binding_within(model.resolve(expr), func, model)
    if isinstance(expr, JsMemberExpression):
        return _root_is_local(expr.object, func, model)
    if isinstance(expr, JsCallExpression):
        return _root_is_local(expr.callee, func, model)
    return False


def _binding_within(binding: Binding | None, func: Node, model: SemanticModel) -> bool:
    if binding is None:
        return False
    func_scope = model.function_scope(func)
    return func_scope is not None and func_scope.contains(binding.scope)


def _is_global_alias(node: Node, model: SemanticModel) -> bool:
    if not isinstance(node, JsIdentifier) or node.name not in GLOBAL_OBJECT_ALIASES:
        return False
    scope = model.scope_of(node)
    return scope is not None and model.lookup(node.name, scope) is None


def _function_declarations(binding: Binding | None) -> list[Node]:
    if binding is None:
        return []
    return [decl.parent for decl in binding.declarations if isinstance(decl.parent, FUNCTION_NODES)]


def _own_nodes(func: Node) -> Iterator[Node]:
    body = getattr(func, 'body', None)
    if not isinstance(body, JsBlockStatement):
        return
    stack: list[Node] = list(body.body)
    while stack:
        node = stack.pop()
        yield node
        if not isinstance(node, FUNCTION_NODES):
            stack.extend(node.children())


def _within(node: Node, ancestor: Node) -> bool:
    current = node.parent
    while current is not None:
        if current is ancestor:
            return True
        current = current.parent
    return False

Classes

class JsGlobalFinderInlining

Replace each call to a recognized global-object-finder function with the identifier globalThis. See the module documentation for the recognition criteria and the trust they rest on.

Expand source code Browse git
class JsGlobalFinderInlining(ScriptLevelTransformer):
    """
    Replace each call to a recognized global-object-finder function with the identifier `globalThis`.
    See the module documentation for the recognition criteria and the trust they rest on.
    """

    self_converging = True

    def _process_script(self, node: JsScript) -> None:
        cache = model_cache(self, node)
        model = cache.model
        finders = _collect_finders(node, model, cache.effects)
        if not finders:
            return
        if self._materialize_finder_receivers(model, finders):
            self.mark_changed()
            return
        finder_ids = {id(f) for f in finders}
        for call in list(node.walk()):
            if not isinstance(call, JsCallExpression) or call.arguments:
                continue
            callee = strip_parens(call.callee)
            if not isinstance(callee, JsIdentifier):
                continue
            target = cache.effects.function_of(model.resolve(callee))
            if target is None or id(target) not in finder_ids or _within(call, target):
                continue
            if not cache.dominance.established_before(target, call):
                continue
            scope = model.scope_of(call)
            if scope is not None and model.lookup('globalThis', scope) is not None:
                continue
            _replace_in_parent(call, JsIdentifier(name='globalThis'))
            self.mark_changed()

    @staticmethod
    def _materialize_finder_receivers(model: SemanticModel, finders: list[Node]) -> bool:
        """
        Rewrite the receiver `this` of each recognized finder that is stored as a namespace method
        (`NS.f = function(){ … return r || this; }`) to `globalThis`, returning whether any finder was
        changed. A finder invoked as `NS.f()` binds its object as `this`, so its `… || this` fallback
        cannot be folded through the identifier-callee path and the namespace flattening declines to
        detach the method while it observes a receiver. Materializing the belief the recognition already
        rests on — that the finder yields the global object — makes the method `this`-free, so flattening
        detaches it to a bare `f()` the fold path then resolves to `globalThis`. Only a non-arrow,
        member-assigned finder is touched, and only where `globalThis` is not shadowed in its scope: a
        name-bound finder is left to the fold path untouched (its body needs no rewrite), and an arrow
        inherits `this` lexically rather than from the call, so neither is a receiver to materialize.
        """
        changed = False
        for finder in finders:
            if isinstance(finder, JsArrowFunctionExpression):
                continue
            parent = finder.parent
            if not (
                isinstance(parent, JsAssignmentExpression)
                and parent.operator == '='
                and parent.right is finder
                and isinstance(parent.left, JsMemberExpression)
            ):
                continue
            scope = model.function_scope(finder)
            if scope is None or model.lookup('globalThis', scope) is not None:
                continue
            if rewrite_receiver_this_to_global(finder):
                changed = True
        return changed

Ancestors

Inherited members