Module refinery.lib.scripts.js.deobfuscation.evaluator
Evaluate pure JavaScript functions called with constant arguments and replace call sites with computed results.
Expand source code Browse git
"""
Evaluate pure JavaScript functions called with constant arguments and replace call sites with
computed results.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from refinery.lib.scripts.js.deobfuscation.helpers import Value
from refinery.lib.scripts import Node, Transformer, _remove_from_parent, _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,
Scope,
SemanticModel,
call_supplies_an_arguments_object,
pattern_identifiers,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
GLOBAL_VALUE_NAMES,
ScriptLevelTransformer,
a_host_reaches_the_binding,
binding_constant,
binding_has_references,
extract_literal_value,
is_reference,
names_global_value,
references_receiver_this,
remove_declarator,
replace_with_value,
substitute_params,
value_to_node,
walk_scope,
)
from refinery.lib.scripts.js.deobfuscation.interpreter import (
InterpreterError,
IrreducibleExpression,
JsInterpreter,
_ThrowSignal,
is_runtime_name,
names_runtime_builtin,
)
from refinery.lib.scripts.js.model import (
JsArrowFunctionExpression,
JsAssignmentExpression,
JsBlockStatement,
JsCallExpression,
JsCatchClause,
JsFunctionDeclaration,
JsFunctionExpression,
JsIdentifier,
JsMemberExpression,
JsNumericLiteral,
JsReturnStatement,
JsScript,
JsStringLiteral,
JsSwitchCase,
JsSwitchStatement,
JsVariableDeclaration,
JsVariableDeclarator,
strip_parens,
)
_FuncNode = JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression
def _is_value_closed(
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
known: set[str],
model: SemanticModel,
) -> bool:
"""
Check whether a function's body is closed enough for the interpreter to evaluate a call to
it: it references only its own parameters, names declared within its body, the names in
*known* (a function already proven pure or a binding holding a constant), registry built-ins,
and well-known globals. This is the *value* precondition — every name the interpreter needs is
resolvable — and is deliberately separate from the *effect* precondition that a call writes no
observable state, which `refinery.lib.scripts.js.analysis.effects.EffectModel` decides. A body
that is a single switch-return (globalConcealing shape) qualifies even when its return
expressions reference external names, because the irreducible fallback substitutes the
parameters into them.
"""
for param in func.params:
if not isinstance(param, JsIdentifier):
return False
body = func.body
if body is None:
return True
local_names = {p.name for p in func.params if isinstance(p, JsIdentifier)}
_collect_declared_names(body, local_names)
if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
elif isinstance(func, JsFunctionExpression) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
if references_receiver_this(body):
return False
if _is_switch_return_pattern(body, local_names):
return True
for node in walk_scope(body):
if isinstance(node, JsIdentifier) and is_reference(node) and node.name not in local_names:
name = node.name
if name in known:
continue
if names_runtime_builtin(node, model) or names_global_value(node, model):
continue
if name == 'arguments' and call_supplies_an_arguments_object(model, func):
continue
return False
return True
def _is_switch_return_pattern(body, local_names: set[str]) -> bool:
"""
Check whether the function body is a switch statement where every case returns an expression.
This is the globalConcealing pattern where return expressions may reference external names
but the dispatch logic itself is pure (switch on a parameter).
"""
if not isinstance(body, JsBlockStatement):
return False
stmts = body.body
if not stmts:
return False
switch = stmts[0]
if not isinstance(switch, JsSwitchStatement):
return False
for remaining in stmts[1:]:
if isinstance(remaining, JsReturnStatement) and remaining.argument is None:
continue
return False
if switch.discriminant is None:
return False
if isinstance(switch.discriminant, JsIdentifier):
if switch.discriminant.name not in local_names:
return False
elif not _all_refs_local(switch.discriminant, local_names):
return False
if not switch.cases:
return False
for case in switch.cases:
if not isinstance(case, JsSwitchCase):
return False
if case.test is not None and not isinstance(case.test, (JsStringLiteral, JsNumericLiteral)):
return False
case_body = case.body
if len(case_body) != 1:
return False
stmt = case_body[0]
if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
return False
return True
def _all_refs_local(node: Node, local_names: set[str]) -> bool:
for child in node.walk():
if isinstance(child, JsIdentifier) and is_reference(child):
if child.name not in local_names:
return False
return True
def _collect_declared_names(body, names: set[str]) -> None:
if not isinstance(body, JsBlockStatement):
return
for node in walk_scope(body):
if isinstance(node, JsVariableDeclaration):
for decl in node.declarations:
if isinstance(decl, JsVariableDeclarator) and isinstance(decl.id, JsIdentifier):
names.add(decl.id.name)
if isinstance(node, JsFunctionDeclaration) and isinstance(node.id, JsIdentifier):
names.add(node.id.name)
if isinstance(node, JsCatchClause) and isinstance(node.param, JsIdentifier):
names.add(node.param.name)
def _unresolved_names(
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
known: set[str],
model: SemanticModel,
) -> set[str]:
"""
Return the set of external names referenced by *func* that are not locally declared, not in
*known*, and not well-known globals or runtime names. Names that are plain-assigned (`=`)
within the function body AND also read within the same body are treated as implicit locals
(obfuscator temporaries like `rr = expr; ... use(rr)`) and excluded. Names that are only
plain-assigned but never read are also excluded (write-only temps). Compound-assigned names
(`+=`, `|=`, etc.) that were NOT also plain-initialized are always retained — they perform a
read-modify-write of the external binding and are never a local temp.
"""
body = func.body
if body is None:
return set()
local_names = {p.name for p in func.params if isinstance(p, JsIdentifier)}
_collect_declared_names(body, local_names)
if isinstance(func, JsFunctionDeclaration) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
elif isinstance(func, JsFunctionExpression) and isinstance(func.id, JsIdentifier):
local_names.add(func.id.name)
plain_assigned: set[str] = set()
compound_assigned: set[str] = set()
read: set[str] = set()
host_supplied: set[str] = set()
claimed: set[str] = set()
for node in walk_scope(body, include_root_body=True):
if not isinstance(node, JsIdentifier) or not is_reference(node):
continue
name = node.name
if name in local_names:
continue
if isinstance(node.parent, JsAssignmentExpression) and node.parent.left is node:
if node.parent.operator == '=':
plain_assigned.add(name)
else:
compound_assigned.add(name)
read.add(name)
else:
read.add(name)
if names_runtime_builtin(node, model) or names_global_value(node, model):
host_supplied.add(name)
elif name == 'arguments' and call_supplies_an_arguments_object(model, func):
host_supplied.add(name)
elif name in GLOBAL_VALUE_NAMES or is_runtime_name(name):
claimed.add(name)
external_names: set[str] = set()
for name in read - plain_assigned:
if name in known:
continue
if name in host_supplied and name not in claimed:
continue
external_names.add(name)
return external_names
def _is_evaluable(
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
known: set[str],
model: SemanticModel,
) -> bool:
"""
Whether every name a call to *func* reads has an answer, given the *known* names: its body is
value-closed (`_is_value_closed`), or it is a function expression or an arrow that reads no
receiver `this` and whose other free names are only temporaries it assigns before reading them
(`_unresolved_names`).
"""
if _is_value_closed(func, known, model):
return True
return (
not isinstance(func, JsFunctionDeclaration)
and func.body is not None
and not references_receiver_this(func.body)
and not _unresolved_names(func, known, model)
)
class JsFunctionEvaluator(ScriptLevelTransformer):
"""
Evaluate pure JavaScript functions called with constant arguments, replacing call sites with
computed results. Handles named function calls and IIFEs.
"""
def __init__(self):
super().__init__()
self._script: JsScript | None = None
self._effects: EffectModel | None = None
self._functions: list[_FuncNode] = []
self._pure_nodes: set[int] = set()
self._binding_constants: dict[Binding, tuple[bool, Value]] = {}
self._call_counts: dict[int, int] = {}
self._resolved_counts: dict[int, int] = {}
self._failed_counts: dict[int, int] = {}
def _process_script(self, node: JsScript) -> None:
"""
Decide every fold of one invocation against the model snapshot it opened with, holding the
cache pinned through analysis and evaluation. The pin's obligation holds in one direction
only, and every read obeys it: a fold replaces a call with its value or a substituted clone
of a body expression, which only ever deletes — a callee reference, the effects the call
carried, a tampering site it contained — so each held answer stays the stricter one and a
later fold declines where a fresh model might have proceeded. The dominance a fold reads to
know the callee's value is established before the call is a statement-order fact, which no
expression replacement moves, and a spliced clone lands exactly where the call stood and
carries only names `_substitution_would_break` has already bound identically at that spot,
so no held fact is revealed more permissive by the splice. The held model has never
resolved the names of such a clone, so a later fold running the code that holds it reads
none of them off the model and declines instead.
The removal of resolved definitions runs after the pin is released, against the model the
cache rebuilds over the post-evaluation tree: whether anything still names a function is a
structural fact the folds themselves change — each one deletes the references that kept
the callee alive, and a spliced clone can name a binding the entry snapshot never saw —
so this decision cannot be made against the held model without deleting a live function
or holding a dead one back forever.
"""
self._script = node
self._effects = None
self._functions = []
self._pure_nodes.clear()
self._binding_constants.clear()
self._call_counts.clear()
self._resolved_counts.clear()
self._failed_counts.clear()
cache = model_cache(self, node)
with cache.pinned():
self._analyze_purity(node)
self._evaluate_calls(node)
self._remove_resolved_definitions(node)
def _collect_named_functions(self, script: JsScript) -> list[_FuncNode]:
"""
Every function the model resolves a name to unambiguously: a function declaration, a
`var`/`let`/`const` declarator initializer, or a hoisted `var` assigned a function exactly once
(`var f; f = function(){}`, the form namespace flattening leaves). This is
`EffectModel.unambiguous_function`, the same filter the interpreter resolves nested callees
through, so every name a call site can reach is a candidate for purity analysis here. Anonymous
functions and names that held a value and were then reassigned are excluded, because the model
resolves no single function for them.
"""
effects = self._effects
if effects is None:
return []
model = effects.model
functions: list[_FuncNode] = []
for node in script.walk():
if not isinstance(node, _FuncNode):
continue
if effects.unambiguous_function(model.invocation_binding(node)) is node:
functions.append(node)
return functions
def _known_names(self, scope: Scope | None, func: _FuncNode) -> set[str]:
"""
The names *func*'s body reads that resolve, from *scope* outward, to something a call to
*func* can be evaluated with: a function already proven pure, or a binding that holds a
constant (`_binding_constant`) no write inside *func* establishes. A nearer binding wins: a
name rebound below the scope that holds the pure function or the constant — a parameter or
a local — shadows it and is not known, matching how the name resolves inside the body being
analyzed. Only the names the body reads are asked about: the question is asked for every
function on every round of `_analyze_purity`, and a scope may hold thousands of names that
no body reads. Whether the constant is in place when a call runs is asked for each call,
through the callback of the interpreter `_interpreter_at` builds.
"""
effects = self._effects
body = func.body
if effects is None or body is None:
return set()
model = effects.model
names: set[str] = set()
read = {
node.name for node in walk_scope(body, include_root_body=True)
if isinstance(node, JsIdentifier) and is_reference(node)
}
for name in read:
current = scope
while current is not None and name not in current.bindings:
current = current.parent
if current is None:
continue
binding = current.bindings[name]
function = effects.unambiguous_function(binding)
if function is not None and id(function) in self._pure_nodes:
names.add(name)
continue
if not self._binding_constant(binding)[0]:
continue
sites = model.binding_establishment_sites(binding)
if sites is None or any(site is func or site.is_descendant_of(func) for site in sites):
continue
names.add(name)
return names
def _binding_constant(self, binding: Binding) -> tuple[bool, Value]:
"""
The constant *binding* holds once established, as
`refinery.lib.scripts.js.deobfuscation.helpers.binding_constant` answers it, asked once per
binding for each invocation.
"""
effects = self._effects
if effects is None:
return False, None
cached = self._binding_constants.get(binding)
if cached is None:
cached = binding_constant(effects, binding, self.options)
self._binding_constants[binding] = cached
return cached
def _constant_at(self, binding: Binding | None, call: JsCallExpression) -> tuple[bool, Value]:
"""
The constant *binding* holds while *call* runs, as `(True, value)`, or `(False, None)`: the
binding must hold a constant (`_binding_constant`) that is in place before *call* runs. This
is the question the interpreter asks through its *constant* callback for a free name the
folded body reads, and the one a constant argument of *call* is resolved by.
"""
if binding is None:
return False, None
known, value = self._binding_constant(binding)
if not known:
return False, None
cache = self._cache_for(call)
if cache is None or not cache.dominance.binding_established_before(binding, call):
return False, None
return True, value
def _analyze_purity(self, script: JsScript) -> None:
self._effects = model_cache(self, script).effects
self._functions = self._collect_named_functions(script)
changed = True
while changed:
changed = False
for func in self._functions:
if id(func) in self._pure_nodes:
continue
if not self._effects.summary_of(func).is_literal_replaceable:
continue
known = self._known_names(self._effects.model.function_scope(func), func)
if _is_evaluable(func, known, self._effects.model):
self._pure_nodes.add(id(func))
changed = True
def _evaluate_calls(self, script: JsScript) -> None:
for node in list(script.walk_in_order()):
if not isinstance(node, JsCallExpression):
continue
if node.callee is None:
continue
callee = strip_parens(node.callee)
if isinstance(callee, JsIdentifier):
self._try_named_call(node)
elif isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
self._try_iife(node, callee)
elif isinstance(callee, JsMemberExpression):
self._try_method_chain(node)
def _try_method_chain(self, node: JsCallExpression) -> None:
"""
Evaluate a method call whose value the interpreter can compute — the `[66, 79].map(f).join('')`
decoder shape and everything it composes from. Unlike a call to a named function, there is no
function node to run with arguments: the call *is* the expression, so it goes to
`eval_expression` and the result replaces it.
Walking outermost-first means the longest chain is attempted before any of its links, so a whole
decoder collapses in one step and the inner links are gone before they are reached. Admission is
the shared gate's decision alone; this adds no question of its own, because every one it would ask
— is the prototype intact, does a callback write outside itself, is an argument effectful — the gate
already asks.
"""
effects = self._effects
if effects is None or not effects.call_is_foldable(node):
return
self._evaluate_expression_and_replace(node)
def _evaluate_expression_and_replace(self, node: JsCallExpression) -> bool:
"""
Evaluate *node* as a standalone expression and replace it with the result. Returns whether the
replacement happened. The interpreter is the one `_interpreter_at` builds for every fold, so
a function the expression calls back into is resolved only where it is in place when *node*
runs, just as a named call is.
`_ThrowSignal` is caught alongside the interpreter's own refusals: a JavaScript exception raised
inside the evaluated expression is the program's business, not a value this fold may produce, and it
reaches here as its own exception type rather than an `InterpreterError`. An `IrreducibleExpression`
is a refusal too — the parameter substitution that makes it useful at a call site has no meaning for
an expression that takes no parameters.
"""
interpreter = self._interpreter_at(node)
try:
result = interpreter.eval_expression(node)
except (InterpreterError, IrreducibleExpression, _ThrowSignal):
return False
if not replace_with_value(node, result):
return False
self.mark_changed()
return True
def _interpreter_at(self, call: JsCallExpression) -> JsInterpreter:
"""
The interpreter every fold of *call* runs in. It is anchored at *call* and handed the
tampering oracle, so the trust questions its arms ask are answered for the moment this call
runs rather than the whole program — the decoder a file carries before the call it blocks
still refuses it, the one guaranteed to run after does not. It asks this evaluator whether a
function it resolves is in place (`_established_before`) at the point it names, and knows a
name declared outside the function it runs only as the constant `_constant_at` finds that
name holding when *call* runs.
"""
cache = self._cache_for(call)
return JsInterpreter(
effects=self._effects,
anchor=call,
tampering=cache.tampering if cache is not None else None,
established=self._established_before,
constant=lambda binding: self._constant_at(binding, call),
)
def _cache_for(self, node: Node):
"""
The model cache of the script holding *node*, for the anchor the interpreter's trust
questions are asked through — or `None` where no script holds it, leaving the interpreter
unanchored and its trust questions on their no-anchor arms.
"""
script = self._script
if script is None or not node.is_descendant_of(script):
return None
return model_cache(self, script)
def _established_before(self, func: Node, reference: Node) -> bool:
"""
Whether *func*'s value is installed before *reference* runs, where *reference* is a call or
a read of the function's name. A function declaration is hoisted, so it is always
established; a declarator initializer (`const`/`let`/`var f = function(){}`) is in place
only once its declarator has run, and a lone assignment (`f = function(){}`, the form
namespace flattening leaves) only once that assignment has run. A premature call to either
reads a value that is absent — a temporal dead zone `ReferenceError`, or the hoisted
`undefined` a `var` call throws a `TypeError` on — which the interpreted body must not
silently replace with a result. The model names the establishing nodes; dominance decides
whether they all precede *reference*.
"""
script = self._script
if script is None:
return False
return model_cache(self, script).dominance.established_before(func, reference)
def _try_named_call(self, node: JsCallExpression) -> None:
if self._effects is None:
return
func = self._effects.static_callee(node)
if func is None or id(func) not in self._pure_nodes:
return
if node.is_descendant_of(func):
return
if not self._established_before(func, node):
return
func_id = id(func)
self._call_counts[func_id] = self._call_counts.get(func_id, 0) + 1
interpreter = self._interpreter_at(node)
args = self._extract_constant_args(node.arguments, interpreter)
if args is None:
return
success = self._evaluate_and_replace(node, func, args, interpreter, gate_unresolved=True)
if success:
self._resolved_counts[func_id] = self._resolved_counts.get(func_id, 0) + 1
else:
self._failed_counts[func_id] = self._failed_counts.get(func_id, 0) + 1
def _try_iife(
self,
node: JsCallExpression,
func: JsFunctionExpression | JsArrowFunctionExpression,
) -> None:
if self._effects is None or not self._effects.summary_of(func).is_literal_replaceable:
return
known = self._known_names(self._effects.model.scope_of(node), func)
if not _is_evaluable(func, known, self._effects.model):
return
interpreter = self._interpreter_at(node)
args = self._extract_constant_args(node.arguments, interpreter)
if args is None:
return
self._evaluate_and_replace(node, func, args, interpreter, gate_unresolved=False)
def _evaluate_and_replace(
self,
node: JsCallExpression,
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
args: list,
interpreter: JsInterpreter,
gate_unresolved: bool,
) -> bool:
"""
Run *interpreter*, the one `_interpreter_at` built for *node*, on *func* with *args* and, on
success, replace *node* with the result. Returns True if the call site was resolved (either
to a value or a substituted expression).
"""
try:
result = interpreter.execute(func, args)
except IrreducibleExpression as irr:
if gate_unresolved and self._is_unresolved_call(irr.node):
return False
if not self._effects.summary_of(func).is_expression_replaceable:
# Splicing one body expression into the call site discards the rest of the body, so this
# exit needs a stricter admission than the literal one that follows: the dropped statements
# must carry no effect, and the spliced expression is not a fresh object the way a literal
# is, so a mutation the body baked into its returned container cannot come along.
return False
if self._substitution_would_break(irr.node, func, node):
return False
replacement = self._substitute_params_in_clone(irr.node, func, args, self)
if replacement is None:
return False
_replace_in_parent(node, replacement)
self.mark_changed()
return True
except InterpreterError:
return False
if not replace_with_value(node, result):
return False
self.mark_changed()
return True
def _function_is_removable(self, model: SemanticModel, func: _FuncNode) -> bool:
"""
Whether *func*'s definition can be deleted: it has no surviving static reference and cannot be
reached by a runtime name lookup. A function named inside a `with` body or otherwise reflected
(`model.reflection_can_reach`) must be kept even once every direct call has folded away — the
`with`-body call still needs the binding. This mirrors the reflection gate the unused-code
remover applies, so both transforms keep the same functions.
A function the caller declared to be a host entrypoint is likewise never removable: a host calls
it by name from outside the file, so no reference here can prove it dead. Both transforms consult
the same declaration, or they would disagree about which functions survive.
An exported binding is never removable for the same reason the other removal sweeps keep it:
an importer reads its value across the module boundary once the module has run, so no
reference here can prove it dead, and deleting the declarator would leave an `export` naming
a binding the module no longer declares.
"""
binding = model.naming_binding(func)
if binding is None:
return False
if self._is_host_entrypoint(model, binding):
return False
if binding.exported:
return False
exclude = self._function_exclude_node(func)
return (
not binding_has_references(model, binding, exclude=exclude)
and not model.reflection_can_reach(binding)
)
def _is_host_entrypoint(self, model: SemanticModel, binding: Binding) -> bool:
"""
Whether *binding* names a function the caller declared a host invokes, and is one a host could
actually reach — a top-level declaration under the script execution model.
"""
return a_host_reaches_the_binding(model, binding, self.options)
def _remove_resolved_definitions(self, script: JsScript) -> None:
removed: set[int] = set()
while True:
model = model_cache(self, script).model
before = len(removed)
for func in self._functions:
func_id = id(func)
if func_id in removed:
continue
call_count = self._call_counts.get(func_id, 0)
if call_count == 0:
continue
resolved = self._resolved_counts.get(func_id, 0)
failed = self._failed_counts.get(func_id, 0)
if (resolved + failed) < call_count:
continue
name = self._function_name(func)
if name is None:
continue
if self._function_is_removable(model, func):
self._remove_function(func)
removed.add(func_id)
self.mark_changed()
for func in self._functions:
func_id = id(func)
if func_id in removed:
continue
if func_id not in self._pure_nodes:
continue
name = self._function_name(func)
if name is None:
continue
call_count = self._call_counts.get(func_id, 0)
if call_count == 0:
continue
if self._function_is_removable(model, func):
self._remove_function(func)
removed.add(func_id)
self.mark_changed()
if len(removed) == before:
break
@staticmethod
def _function_name(func: _FuncNode) -> str | None:
if isinstance(func, JsFunctionDeclaration):
return func.id.name if isinstance(func.id, JsIdentifier) else None
declarator = func.parent
if isinstance(declarator, JsVariableDeclarator) and isinstance(declarator.id, JsIdentifier):
return declarator.id.name
return None
@staticmethod
def _function_exclude_node(func: _FuncNode) -> Node:
if isinstance(func, JsFunctionDeclaration):
return func
declarator = func.parent
if isinstance(declarator, JsVariableDeclarator):
return declarator
return func
def _remove_function(self, func: _FuncNode) -> None:
if isinstance(func, JsFunctionDeclaration):
_remove_from_parent(func)
else:
declarator = func.parent
if isinstance(declarator, JsVariableDeclarator):
remove_declarator(declarator)
else:
_remove_from_parent(func)
def _extract_constant_args(
self,
arguments: list,
interpreter: JsInterpreter,
) -> list[Value] | None:
"""
The values of a call's *arguments*, or `None` when one of them is not known: a literal, or a
name that holds a constant when the call runs. The name is read by the *interpreter* that
runs the call, through
`refinery.lib.scripts.js.deobfuscation.interpreter.JsInterpreter.constant_of`, so an
argument naming a table is the same object a read of that name in the body answers, as it
is in the program.
"""
args: list[Value] = []
for arg in arguments:
known, value = extract_literal_value(arg)
if not known and isinstance(arg, JsIdentifier):
known, value = interpreter.constant_of(arg)
if not known:
return None
args.append(value)
return args
def _is_unresolved_call(self, node: Node) -> bool:
"""
Check whether the irreducible expression contains any function call. If so, the wrapper
inliner or string-array resolver should handle it — the evaluator should not substitute
parameters into a call that it couldn't fully evaluate.
"""
for child in node.walk():
if isinstance(child, JsCallExpression):
return True
return False
def _substitution_would_break(self, node: Node, func: _FuncNode, call_site: Node) -> bool:
"""
Whether splicing *node* — an irreducible sub-expression of *func*'s body — into *call_site* by
parameter substitution would change behavior. Substitution replaces each parameter with its
original argument value and discards the rest of the body, so it is unsafe when *node*:
- writes a parameter, which would place the argument value at a write target (`delete p`
becomes `delete <literal>`, `(p = 5)` becomes `(<literal> = 5)`);
- reads a parameter that *func* reassigns — statically, or through a `with` body or direct
`eval` — whose value at the irreducible point is no longer the original argument the
substitution would supply;
- references a name bound inside *func* but declared outside *node* — a body local (including a
destructured or `catch` binding), a nested declaration, `arguments`, or the function
expression's own name — which has no binding once the body is discarded, leaving a dangling
reference;
- references a name that resolves outside *func*, or to no binding at all, but which a
same-named local in scope at *call_site* would recapture, so the spliced reference would bind
to a different declaration there than it does in *func*.
A binding *node* itself introduces travels with it and stays intact. Every reference is resolved
through the model, so shadowing and destructuring are exact; a free name whose read crosses a
`with` in *func* is treated as unsafe, since its runtime target cannot be matched at *call_site*.
"""
script = self._script
if script is None:
return True
model = model_cache(self, script).model
call_scope = model.scope_of(call_site)
if call_scope is None:
return True
param_bindings = {
binding
for param in func.params
for ident in pattern_identifiers(param)
if (binding := model.binding_of(ident)) is not None
}
for child in node.walk():
if not isinstance(child, JsIdentifier) or not model.is_reference(child):
continue
binding = model.resolve(child)
if binding is None:
if model.read_has_dynamic_effect(child):
return True
if model.lookup(child.name, call_scope) is not None:
return True
continue
owner = binding.scope.node
if owner is not func and not owner.is_descendant_of(func):
if model.lookup(child.name, call_scope) is not binding:
return True
continue
if binding in param_bindings:
if not model.binding_never_reassigned(binding):
return True
continue
if owner is node or owner.is_descendant_of(node):
continue
return True
return False
@staticmethod
def _substitute_params_in_clone(
node: Node,
func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression,
args: list[Value],
transformer: Transformer,
) -> Node | None:
"""
A clone of *node* with every parameter of *func* replaced by the argument the call handed it,
or None where a parameter *node* reads has nothing to be replaced by.
A parameter is left standing when the value handed to it has no faithful literal form, and a
parameter that is not a plain name binds no single node to substitute at all. Either way it
keeps its own name, and a name spliced into the call site reads whatever is bound to it there
rather than the value the call made. Refusing leaves the call standing, which reduces less
and states nothing false.
"""
params: list[Node] = []
arguments: list[Node] = []
standing: set[str] = set()
for index, param in enumerate(func.params):
argument = None
if isinstance(param, JsIdentifier):
argument = value_to_node(args[index] if index < len(args) else None)
if argument is None:
standing.update(ident.name for ident in pattern_identifiers(param))
continue
params.append(param)
arguments.append(argument)
for child in node.walk():
if isinstance(child, JsIdentifier) and child.name in standing:
return None
return substitute_params(node, params, arguments, transformer=transformer)
Classes
class JsFunctionEvaluator-
Evaluate pure JavaScript functions called with constant arguments, replacing call sites with computed results. Handles named function calls and IIFEs.
Expand source code Browse git
class JsFunctionEvaluator(ScriptLevelTransformer): """ Evaluate pure JavaScript functions called with constant arguments, replacing call sites with computed results. Handles named function calls and IIFEs. """ def __init__(self): super().__init__() self._script: JsScript | None = None self._effects: EffectModel | None = None self._functions: list[_FuncNode] = [] self._pure_nodes: set[int] = set() self._binding_constants: dict[Binding, tuple[bool, Value]] = {} self._call_counts: dict[int, int] = {} self._resolved_counts: dict[int, int] = {} self._failed_counts: dict[int, int] = {} def _process_script(self, node: JsScript) -> None: """ Decide every fold of one invocation against the model snapshot it opened with, holding the cache pinned through analysis and evaluation. The pin's obligation holds in one direction only, and every read obeys it: a fold replaces a call with its value or a substituted clone of a body expression, which only ever deletes — a callee reference, the effects the call carried, a tampering site it contained — so each held answer stays the stricter one and a later fold declines where a fresh model might have proceeded. The dominance a fold reads to know the callee's value is established before the call is a statement-order fact, which no expression replacement moves, and a spliced clone lands exactly where the call stood and carries only names `_substitution_would_break` has already bound identically at that spot, so no held fact is revealed more permissive by the splice. The held model has never resolved the names of such a clone, so a later fold running the code that holds it reads none of them off the model and declines instead. The removal of resolved definitions runs after the pin is released, against the model the cache rebuilds over the post-evaluation tree: whether anything still names a function is a structural fact the folds themselves change — each one deletes the references that kept the callee alive, and a spliced clone can name a binding the entry snapshot never saw — so this decision cannot be made against the held model without deleting a live function or holding a dead one back forever. """ self._script = node self._effects = None self._functions = [] self._pure_nodes.clear() self._binding_constants.clear() self._call_counts.clear() self._resolved_counts.clear() self._failed_counts.clear() cache = model_cache(self, node) with cache.pinned(): self._analyze_purity(node) self._evaluate_calls(node) self._remove_resolved_definitions(node) def _collect_named_functions(self, script: JsScript) -> list[_FuncNode]: """ Every function the model resolves a name to unambiguously: a function declaration, a `var`/`let`/`const` declarator initializer, or a hoisted `var` assigned a function exactly once (`var f; f = function(){}`, the form namespace flattening leaves). This is `EffectModel.unambiguous_function`, the same filter the interpreter resolves nested callees through, so every name a call site can reach is a candidate for purity analysis here. Anonymous functions and names that held a value and were then reassigned are excluded, because the model resolves no single function for them. """ effects = self._effects if effects is None: return [] model = effects.model functions: list[_FuncNode] = [] for node in script.walk(): if not isinstance(node, _FuncNode): continue if effects.unambiguous_function(model.invocation_binding(node)) is node: functions.append(node) return functions def _known_names(self, scope: Scope | None, func: _FuncNode) -> set[str]: """ The names *func*'s body reads that resolve, from *scope* outward, to something a call to *func* can be evaluated with: a function already proven pure, or a binding that holds a constant (`_binding_constant`) no write inside *func* establishes. A nearer binding wins: a name rebound below the scope that holds the pure function or the constant — a parameter or a local — shadows it and is not known, matching how the name resolves inside the body being analyzed. Only the names the body reads are asked about: the question is asked for every function on every round of `_analyze_purity`, and a scope may hold thousands of names that no body reads. Whether the constant is in place when a call runs is asked for each call, through the callback of the interpreter `_interpreter_at` builds. """ effects = self._effects body = func.body if effects is None or body is None: return set() model = effects.model names: set[str] = set() read = { node.name for node in walk_scope(body, include_root_body=True) if isinstance(node, JsIdentifier) and is_reference(node) } for name in read: current = scope while current is not None and name not in current.bindings: current = current.parent if current is None: continue binding = current.bindings[name] function = effects.unambiguous_function(binding) if function is not None and id(function) in self._pure_nodes: names.add(name) continue if not self._binding_constant(binding)[0]: continue sites = model.binding_establishment_sites(binding) if sites is None or any(site is func or site.is_descendant_of(func) for site in sites): continue names.add(name) return names def _binding_constant(self, binding: Binding) -> tuple[bool, Value]: """ The constant *binding* holds once established, as `refinery.lib.scripts.js.deobfuscation.helpers.binding_constant` answers it, asked once per binding for each invocation. """ effects = self._effects if effects is None: return False, None cached = self._binding_constants.get(binding) if cached is None: cached = binding_constant(effects, binding, self.options) self._binding_constants[binding] = cached return cached def _constant_at(self, binding: Binding | None, call: JsCallExpression) -> tuple[bool, Value]: """ The constant *binding* holds while *call* runs, as `(True, value)`, or `(False, None)`: the binding must hold a constant (`_binding_constant`) that is in place before *call* runs. This is the question the interpreter asks through its *constant* callback for a free name the folded body reads, and the one a constant argument of *call* is resolved by. """ if binding is None: return False, None known, value = self._binding_constant(binding) if not known: return False, None cache = self._cache_for(call) if cache is None or not cache.dominance.binding_established_before(binding, call): return False, None return True, value def _analyze_purity(self, script: JsScript) -> None: self._effects = model_cache(self, script).effects self._functions = self._collect_named_functions(script) changed = True while changed: changed = False for func in self._functions: if id(func) in self._pure_nodes: continue if not self._effects.summary_of(func).is_literal_replaceable: continue known = self._known_names(self._effects.model.function_scope(func), func) if _is_evaluable(func, known, self._effects.model): self._pure_nodes.add(id(func)) changed = True def _evaluate_calls(self, script: JsScript) -> None: for node in list(script.walk_in_order()): if not isinstance(node, JsCallExpression): continue if node.callee is None: continue callee = strip_parens(node.callee) if isinstance(callee, JsIdentifier): self._try_named_call(node) elif isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)): self._try_iife(node, callee) elif isinstance(callee, JsMemberExpression): self._try_method_chain(node) def _try_method_chain(self, node: JsCallExpression) -> None: """ Evaluate a method call whose value the interpreter can compute — the `[66, 79].map(f).join('')` decoder shape and everything it composes from. Unlike a call to a named function, there is no function node to run with arguments: the call *is* the expression, so it goes to `eval_expression` and the result replaces it. Walking outermost-first means the longest chain is attempted before any of its links, so a whole decoder collapses in one step and the inner links are gone before they are reached. Admission is the shared gate's decision alone; this adds no question of its own, because every one it would ask — is the prototype intact, does a callback write outside itself, is an argument effectful — the gate already asks. """ effects = self._effects if effects is None or not effects.call_is_foldable(node): return self._evaluate_expression_and_replace(node) def _evaluate_expression_and_replace(self, node: JsCallExpression) -> bool: """ Evaluate *node* as a standalone expression and replace it with the result. Returns whether the replacement happened. The interpreter is the one `_interpreter_at` builds for every fold, so a function the expression calls back into is resolved only where it is in place when *node* runs, just as a named call is. `_ThrowSignal` is caught alongside the interpreter's own refusals: a JavaScript exception raised inside the evaluated expression is the program's business, not a value this fold may produce, and it reaches here as its own exception type rather than an `InterpreterError`. An `IrreducibleExpression` is a refusal too — the parameter substitution that makes it useful at a call site has no meaning for an expression that takes no parameters. """ interpreter = self._interpreter_at(node) try: result = interpreter.eval_expression(node) except (InterpreterError, IrreducibleExpression, _ThrowSignal): return False if not replace_with_value(node, result): return False self.mark_changed() return True def _interpreter_at(self, call: JsCallExpression) -> JsInterpreter: """ The interpreter every fold of *call* runs in. It is anchored at *call* and handed the tampering oracle, so the trust questions its arms ask are answered for the moment this call runs rather than the whole program — the decoder a file carries before the call it blocks still refuses it, the one guaranteed to run after does not. It asks this evaluator whether a function it resolves is in place (`_established_before`) at the point it names, and knows a name declared outside the function it runs only as the constant `_constant_at` finds that name holding when *call* runs. """ cache = self._cache_for(call) return JsInterpreter( effects=self._effects, anchor=call, tampering=cache.tampering if cache is not None else None, established=self._established_before, constant=lambda binding: self._constant_at(binding, call), ) def _cache_for(self, node: Node): """ The model cache of the script holding *node*, for the anchor the interpreter's trust questions are asked through — or `None` where no script holds it, leaving the interpreter unanchored and its trust questions on their no-anchor arms. """ script = self._script if script is None or not node.is_descendant_of(script): return None return model_cache(self, script) def _established_before(self, func: Node, reference: Node) -> bool: """ Whether *func*'s value is installed before *reference* runs, where *reference* is a call or a read of the function's name. A function declaration is hoisted, so it is always established; a declarator initializer (`const`/`let`/`var f = function(){}`) is in place only once its declarator has run, and a lone assignment (`f = function(){}`, the form namespace flattening leaves) only once that assignment has run. A premature call to either reads a value that is absent — a temporal dead zone `ReferenceError`, or the hoisted `undefined` a `var` call throws a `TypeError` on — which the interpreted body must not silently replace with a result. The model names the establishing nodes; dominance decides whether they all precede *reference*. """ script = self._script if script is None: return False return model_cache(self, script).dominance.established_before(func, reference) def _try_named_call(self, node: JsCallExpression) -> None: if self._effects is None: return func = self._effects.static_callee(node) if func is None or id(func) not in self._pure_nodes: return if node.is_descendant_of(func): return if not self._established_before(func, node): return func_id = id(func) self._call_counts[func_id] = self._call_counts.get(func_id, 0) + 1 interpreter = self._interpreter_at(node) args = self._extract_constant_args(node.arguments, interpreter) if args is None: return success = self._evaluate_and_replace(node, func, args, interpreter, gate_unresolved=True) if success: self._resolved_counts[func_id] = self._resolved_counts.get(func_id, 0) + 1 else: self._failed_counts[func_id] = self._failed_counts.get(func_id, 0) + 1 def _try_iife( self, node: JsCallExpression, func: JsFunctionExpression | JsArrowFunctionExpression, ) -> None: if self._effects is None or not self._effects.summary_of(func).is_literal_replaceable: return known = self._known_names(self._effects.model.scope_of(node), func) if not _is_evaluable(func, known, self._effects.model): return interpreter = self._interpreter_at(node) args = self._extract_constant_args(node.arguments, interpreter) if args is None: return self._evaluate_and_replace(node, func, args, interpreter, gate_unresolved=False) def _evaluate_and_replace( self, node: JsCallExpression, func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression, args: list, interpreter: JsInterpreter, gate_unresolved: bool, ) -> bool: """ Run *interpreter*, the one `_interpreter_at` built for *node*, on *func* with *args* and, on success, replace *node* with the result. Returns True if the call site was resolved (either to a value or a substituted expression). """ try: result = interpreter.execute(func, args) except IrreducibleExpression as irr: if gate_unresolved and self._is_unresolved_call(irr.node): return False if not self._effects.summary_of(func).is_expression_replaceable: # Splicing one body expression into the call site discards the rest of the body, so this # exit needs a stricter admission than the literal one that follows: the dropped statements # must carry no effect, and the spliced expression is not a fresh object the way a literal # is, so a mutation the body baked into its returned container cannot come along. return False if self._substitution_would_break(irr.node, func, node): return False replacement = self._substitute_params_in_clone(irr.node, func, args, self) if replacement is None: return False _replace_in_parent(node, replacement) self.mark_changed() return True except InterpreterError: return False if not replace_with_value(node, result): return False self.mark_changed() return True def _function_is_removable(self, model: SemanticModel, func: _FuncNode) -> bool: """ Whether *func*'s definition can be deleted: it has no surviving static reference and cannot be reached by a runtime name lookup. A function named inside a `with` body or otherwise reflected (`model.reflection_can_reach`) must be kept even once every direct call has folded away — the `with`-body call still needs the binding. This mirrors the reflection gate the unused-code remover applies, so both transforms keep the same functions. A function the caller declared to be a host entrypoint is likewise never removable: a host calls it by name from outside the file, so no reference here can prove it dead. Both transforms consult the same declaration, or they would disagree about which functions survive. An exported binding is never removable for the same reason the other removal sweeps keep it: an importer reads its value across the module boundary once the module has run, so no reference here can prove it dead, and deleting the declarator would leave an `export` naming a binding the module no longer declares. """ binding = model.naming_binding(func) if binding is None: return False if self._is_host_entrypoint(model, binding): return False if binding.exported: return False exclude = self._function_exclude_node(func) return ( not binding_has_references(model, binding, exclude=exclude) and not model.reflection_can_reach(binding) ) def _is_host_entrypoint(self, model: SemanticModel, binding: Binding) -> bool: """ Whether *binding* names a function the caller declared a host invokes, and is one a host could actually reach — a top-level declaration under the script execution model. """ return a_host_reaches_the_binding(model, binding, self.options) def _remove_resolved_definitions(self, script: JsScript) -> None: removed: set[int] = set() while True: model = model_cache(self, script).model before = len(removed) for func in self._functions: func_id = id(func) if func_id in removed: continue call_count = self._call_counts.get(func_id, 0) if call_count == 0: continue resolved = self._resolved_counts.get(func_id, 0) failed = self._failed_counts.get(func_id, 0) if (resolved + failed) < call_count: continue name = self._function_name(func) if name is None: continue if self._function_is_removable(model, func): self._remove_function(func) removed.add(func_id) self.mark_changed() for func in self._functions: func_id = id(func) if func_id in removed: continue if func_id not in self._pure_nodes: continue name = self._function_name(func) if name is None: continue call_count = self._call_counts.get(func_id, 0) if call_count == 0: continue if self._function_is_removable(model, func): self._remove_function(func) removed.add(func_id) self.mark_changed() if len(removed) == before: break @staticmethod def _function_name(func: _FuncNode) -> str | None: if isinstance(func, JsFunctionDeclaration): return func.id.name if isinstance(func.id, JsIdentifier) else None declarator = func.parent if isinstance(declarator, JsVariableDeclarator) and isinstance(declarator.id, JsIdentifier): return declarator.id.name return None @staticmethod def _function_exclude_node(func: _FuncNode) -> Node: if isinstance(func, JsFunctionDeclaration): return func declarator = func.parent if isinstance(declarator, JsVariableDeclarator): return declarator return func def _remove_function(self, func: _FuncNode) -> None: if isinstance(func, JsFunctionDeclaration): _remove_from_parent(func) else: declarator = func.parent if isinstance(declarator, JsVariableDeclarator): remove_declarator(declarator) else: _remove_from_parent(func) def _extract_constant_args( self, arguments: list, interpreter: JsInterpreter, ) -> list[Value] | None: """ The values of a call's *arguments*, or `None` when one of them is not known: a literal, or a name that holds a constant when the call runs. The name is read by the *interpreter* that runs the call, through `refinery.lib.scripts.js.deobfuscation.interpreter.JsInterpreter.constant_of`, so an argument naming a table is the same object a read of that name in the body answers, as it is in the program. """ args: list[Value] = [] for arg in arguments: known, value = extract_literal_value(arg) if not known and isinstance(arg, JsIdentifier): known, value = interpreter.constant_of(arg) if not known: return None args.append(value) return args def _is_unresolved_call(self, node: Node) -> bool: """ Check whether the irreducible expression contains any function call. If so, the wrapper inliner or string-array resolver should handle it — the evaluator should not substitute parameters into a call that it couldn't fully evaluate. """ for child in node.walk(): if isinstance(child, JsCallExpression): return True return False def _substitution_would_break(self, node: Node, func: _FuncNode, call_site: Node) -> bool: """ Whether splicing *node* — an irreducible sub-expression of *func*'s body — into *call_site* by parameter substitution would change behavior. Substitution replaces each parameter with its original argument value and discards the rest of the body, so it is unsafe when *node*: - writes a parameter, which would place the argument value at a write target (`delete p` becomes `delete <literal>`, `(p = 5)` becomes `(<literal> = 5)`); - reads a parameter that *func* reassigns — statically, or through a `with` body or direct `eval` — whose value at the irreducible point is no longer the original argument the substitution would supply; - references a name bound inside *func* but declared outside *node* — a body local (including a destructured or `catch` binding), a nested declaration, `arguments`, or the function expression's own name — which has no binding once the body is discarded, leaving a dangling reference; - references a name that resolves outside *func*, or to no binding at all, but which a same-named local in scope at *call_site* would recapture, so the spliced reference would bind to a different declaration there than it does in *func*. A binding *node* itself introduces travels with it and stays intact. Every reference is resolved through the model, so shadowing and destructuring are exact; a free name whose read crosses a `with` in *func* is treated as unsafe, since its runtime target cannot be matched at *call_site*. """ script = self._script if script is None: return True model = model_cache(self, script).model call_scope = model.scope_of(call_site) if call_scope is None: return True param_bindings = { binding for param in func.params for ident in pattern_identifiers(param) if (binding := model.binding_of(ident)) is not None } for child in node.walk(): if not isinstance(child, JsIdentifier) or not model.is_reference(child): continue binding = model.resolve(child) if binding is None: if model.read_has_dynamic_effect(child): return True if model.lookup(child.name, call_scope) is not None: return True continue owner = binding.scope.node if owner is not func and not owner.is_descendant_of(func): if model.lookup(child.name, call_scope) is not binding: return True continue if binding in param_bindings: if not model.binding_never_reassigned(binding): return True continue if owner is node or owner.is_descendant_of(node): continue return True return False @staticmethod def _substitute_params_in_clone( node: Node, func: JsFunctionDeclaration | JsFunctionExpression | JsArrowFunctionExpression, args: list[Value], transformer: Transformer, ) -> Node | None: """ A clone of *node* with every parameter of *func* replaced by the argument the call handed it, or None where a parameter *node* reads has nothing to be replaced by. A parameter is left standing when the value handed to it has no faithful literal form, and a parameter that is not a plain name binds no single node to substitute at all. Either way it keeps its own name, and a name spliced into the call site reads whatever is bound to it there rather than the value the call made. Refusing leaves the call standing, which reduces less and states nothing false. """ params: list[Node] = [] arguments: list[Node] = [] standing: set[str] = set() for index, param in enumerate(func.params): argument = None if isinstance(param, JsIdentifier): argument = value_to_node(args[index] if index < len(args) else None) if argument is None: standing.update(ident.name for ident in pattern_identifiers(param)) continue params.append(param) arguments.append(argument) for child in node.walk(): if isinstance(child, JsIdentifier) and child.name in standing: return None return substitute_params(node, params, arguments, transformer=transformer)Ancestors
Inherited members