Module refinery.lib.scripts.js.deobfuscation.reflection
Inline reflectively executed JavaScript code: eval, Function constructor, constructor chains, and setTimeout/setInterval with string arguments. An obfuscator which wraps the entire program in
Function(param, code)(proxyObject)
is handled as a special case with automatic proxy object resolution.
Expand source code Browse git
"""
Inline reflectively executed JavaScript code: eval, Function constructor, constructor chains, and
setTimeout/setInterval with string arguments. An obfuscator which wraps the entire program in
Function(param, code)(proxyObject)
is handled as a special case with automatic proxy object resolution.
"""
from __future__ import annotations
import enum
from typing import Callable, Collection, NamedTuple
from refinery.lib.scripts import (
Expression,
Node,
_clone_node,
_replace_in_parent,
set_body,
spells_its_source,
)
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.effects import EffectModel, side_effect_free
from refinery.lib.scripts.js.analysis.model import (
REFLECTIVE_INTRINSICS,
SYNC_EVAL_NAMES,
TIMER_NAMES,
Binding,
BindingKind,
Role,
Scope,
SemanticModel,
build_semantic_model,
crosses_dynamic_scope,
is_member_write_target,
is_simple_assignment_target,
name_uses_in_scope,
reference_role,
)
from refinery.lib.scripts.js.analysis.tampering import (
denotes_function_intrinsic,
function_intrinsic_aliases,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
ScriptLevelTransformer,
a_host_reaches_the_binding,
access_key,
body_returns_undefined,
extract_literal_value,
get_body,
inlined_declarations_safe,
names_this_realms_global_object,
nothing_still_names,
preserve_script_end_value,
property_key,
reaches_script_completion,
references_new_target,
references_receiver_this,
remove_declarator,
replace_with_value,
rewrite_receiver_this_to_global,
sanitize_inlined_body,
seed_script_end_value,
string_value,
walk_scope,
)
from refinery.lib.scripts.js.deobfuscation.strict_divergence import diverges_under_strict
from refinery.lib.scripts.js.model import (
SCRIPT_CONTEXT,
CodeContext,
JsAssignmentExpression,
JsAwaitExpression,
JsBlockStatement,
JsCallExpression,
JsExpressionStatement,
JsForOfStatement,
JsFunctionExpression,
JsIdentifier,
JsMemberExpression,
JsNewExpression,
JsObjectExpression,
JsProperty,
JsPropertyKind,
JsReturnStatement,
JsScript,
JsSequenceExpression,
JsStringLiteral,
JsUnaryExpression,
JsVariableDeclaration,
JsVariableDeclarator,
Statement,
code_context_at,
function_context,
strip_parens,
)
from refinery.lib.scripts.js.numbers import TRIMMABLE_WHITESPACE
from refinery.lib.scripts.js.options import preserves_script_return, runs_as_module
from refinery.lib.scripts.js.strict import (
collect_strict_violations,
declares_use_strict,
strict_mode_at,
)
_REFLECTIVE_CALLEE_NAMES = REFLECTIVE_INTRINSICS | TIMER_NAMES | SYNC_EVAL_NAMES
class ReflectedScope(enum.Enum):
"""
The execution scope of reflectively evaluated code, which decides how its free names, `this`, and
top-level declarations must be treated when the code is inlined at its call site. A
`Function`-constructed function and indirect `eval`/string-timer code run in the global sloppy
scope; a direct `eval` runs in the caller's scope, which is the inline site itself, so its
references and `this` are already correct there and only its declarations need care.
"""
FUNCTION_CONSTRUCTOR = enum.auto()
GLOBAL_EVAL = enum.auto()
DIRECT_EVAL = enum.auto()
class _EndValueMode(enum.Enum):
"""
How an inlined reflective body must be adapted to hold the script's completion under
`preserve_script_return`, decided by the reflective form's own completion semantics. A constructed
function or an `execScript` that ran off its end returned `undefined` regardless of the value its
body's tail answers with, so an appended `void 0` forces the completion to `undefined`
(`FORCE_UNDEFINED`). A direct or indirect `eval` returned its code's own completion, so a prepended
`void 0` seeds the completion at `undefined` only where the code may complete empty, leaving a value
the body answers with in place (`SEED_UNDEFINED`, applied by `seed_script_end_value`). A form that
already carries its value — a trailing `return x` the splice exposed as the tail expression — needs
no adaptation (`NONE`).
"""
NONE = enum.auto()
FORCE_UNDEFINED = enum.auto()
SEED_UNDEFINED = enum.auto()
def _try_parse(
code: str,
*,
strict: bool,
module: bool = False,
context: CodeContext = SCRIPT_CONTEXT,
) -> JsScript | None:
"""
The tree the reflected code spells, or `None` where it spells no program. Inlining is the one
place a parse has to be believed rather than merely used: what comes back is printed into the
file around it, so text the parser did not read would be printed as source it never agreed to,
and a literal the code left open would run on into whatever follows it at the call site.
Recovery makes the parser total, so raising is not the test. The test is whether the tree
spells its source, which `refinery.lib.scripts.spells_its_source` decides and which is
precisely the domain over which printing it back means what it said. A payload cut off in the
middle of a construct is the case that makes the difference: the parser finishes it by writing
the token it was waiting for, so `x = f(1, 2` reads as a call that runs, and only the repair the
parser records keeps that from being spliced into the file as though it had been written whole.
Spelling its source is not the whole of it. A text can spell a tree the printer reproduces
exactly and still be one the language refuses to read — a repeated parameter where the grammar
wants a unique list, an accessor of the wrong arity, a Use Strict Directive under a parameter
list that may hold none. Evaluated, such a text is a `SyntaxError` the call site catches and
the program carries on from; spliced into the file, it takes the whole file down with it, and
nothing runs at all. So it is refused here, which leaves the `eval` or `Function` call standing
to throw exactly what it threw before.
Every surface evaluates its text with the Script goal — a direct `eval` inside an `async`
function included (§19.2.1.1) — so `await` is a name throughout the text and a `for await`
head stands only inside an `async` function written in it. A text awaiting at its own top level
is therefore a `SyntaxError` the call site catches, whatever the destination reads `await` as,
and `_has_top_level_await` refuses it so that the call stands to throw as it did. The text is
read the way a file is, and a file's top level reads a `for await` head, which is why that gate
is asked after the parse rather than of it.
*context* is what the splice site reads `await`, `yield` and `arguments` as, seeded into the
collector so that a payload legal as a script is refused where the site would refuse it: a
reference to `arguments` from a class field initializer, `await` bound in a static block,
`typeof yield` in a generator body, `typeof await` in an `async` one.
*strict* is the mode at the destination, and it is the mode the text has to be legal in, whichever
mode it would have run in where it stood. A body a `Function` constructor builds runs sloppy in the
global scope, but inlining it puts its text where the destination's mode governs; a direct `eval`
already runs in the destination's mode, and a text that mode refuses is a `SyntaxError` the call
site catches and carries on from. The two arrive by different routes at the same requirement, which
is why one seed answers for every surface.
*module* is the destination's goal symbol, told apart from its mode because a module carries two
rules beyond a strict script: `await` names nothing in it, and the HTML-like comment delimiters
open no comment. The reflected text runs its code as a script wherever it stood, so a payload
naming `await` is legal there and a `SyntaxError` only once inlined into a module — the very
throw that would take the file down where the call it replaced merely raised one the site
caught. So it is refused here, on the same seed and for the same reason as a strict violation,
whichever surface the text reached the file through.
Module-only syntax is refused for the same reason and needs no mode to decide it. Every surface
that reaches here evaluates its text as a Script, where an `import` or `export` declaration is a
`SyntaxError` the call site catches; spliced into the file it is a `SyntaxError` the file cannot
survive, and where the host does load the file as a module it is a declaration the program never
made. It is read off the mark the parser left rather than walked for again, so this gate and the
mode `refinery.lib.scripts.js.strict.strict_mode_at` reads for the same tree cannot part
company.
Refusing is free: the `eval` or `Function` call is left standing to throw exactly what it threw
before. Whether such a body would additionally *behave* differently at a strict destination is a
separate question, and one only the surfaces that run sloppy have to ask; `diverges_under_strict`
owns it.
"""
try:
from refinery.lib.scripts.js.parser import JsParser
parsed = JsParser(code).parse()
except Exception:
return None
if not parsed.body or parsed.module or not spells_its_source(parsed):
return None
if _has_top_level_await(parsed.body):
return None
if collect_strict_violations(parsed, strict=strict, module=module, context=context):
return None
return parsed
def _try_eval_string_arg(node: Expression, model: SemanticModel, effects: EffectModel) -> str | None:
"""
Fold an expression to the string it denotes by interpreting it, or `None` where that refuses.
The interpreter runs with the real effect model — every caller here is an extraction arm on a
file that carries a reflection surface by definition, so a builtin the program may have replaced
is not one this fold may trust on the strength of having no question to ask. The model answers
name resolution alone: a caller that holds only the semantic model passes it and pays no effect
model it never consults, which is why the two arrive separately.
"""
from refinery.lib.scripts.js.deobfuscation.interpreter import (
InterpreterError,
IrreducibleExpression,
JsInterpreter,
_ThrowSignal,
)
try:
result = JsInterpreter(model=model, effects=effects).eval_expression(node)
except (InterpreterError, IrreducibleExpression, _ThrowSignal, RecursionError, ValueError, OverflowError):
return None
if isinstance(result, str):
return result
return None
def _extract_eval_code(
node: JsCallExpression,
*,
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
) -> str | None:
"""
Extract the code string from a direct `eval("code")` / `(eval)("code")`. The callee must be the
free global `eval`; a locally-shadowed `eval` names an ordinary value whose call is left intact.
"""
if free_global_name(node.callee) != 'eval':
return None
if len(node.arguments) != 1:
return None
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
def _extract_indirect_eval_code(
node: JsCallExpression,
read_effect: Callable[[Node], bool] | None = None,
*,
alias_name: Callable[[Expression | None], str | None],
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
base_droppable: Callable[[Expression | None], bool] | None = None,
) -> str | None:
"""
Extract the code string from indirect eval patterns:
- `(0, eval)("code")`
- `window.eval("code")` / `globalThis.eval("code")` / `window['eval']("code")`
Inlining discards the comma-sequence prefix, so it is admitted only when dropping it is
side-effect free; *read_effect* rejects a prefix read that resolves through a `with` body's dynamic
scope (firing a getter or throwing), which the model-free check cannot see. *free_global_name*
confirms the sequence tail is the free global `eval` and *alias_name* resolves a global-object-alias
member to the intrinsic it names, both declining a shadowed name or a dynamic scope.
Inlining `<alias>.eval(code)` to `code` discards the base read and runs the code where the fold
stands rather than off the alias, so *base_droppable* gates it: the base must resolve, or its
`ReferenceError` where the host lacks the alias is dropped, and it must name *this* realm's global
object, or code that ran in another realm through `top`/`frames` is moved into this one.
"""
if len(node.arguments) != 1:
return None
callee = strip_parens(node.callee) if node.callee is not None else None
if isinstance(callee, JsSequenceExpression):
exprs = callee.expressions
if len(exprs) >= 2 and free_global_name(exprs[-1]) == 'eval':
if all(side_effect_free(e, read_effect=read_effect) for e in exprs[:-1]):
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
if alias_name(node.callee) == 'eval':
if base_droppable is None or not base_droppable(node.callee):
return None
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
return None
def _extract_string_call_code(
node: JsCallExpression,
names: frozenset[str],
*,
alias_name: Callable[[Expression | None], str | None],
free_global_name: Callable[[Expression | None], str | None],
eval_string: Callable[[Expression | None], str | None],
) -> str | None:
"""
Extract the code string a named global string-call evaluates — a deferred timer
(`setTimeout("code", ...)`, `setInterval`, `setImmediate`) or a synchronous global eval
(`execScript("code")`) — whether the global is named directly or through a global-object alias
(`window.setTimeout("code", ...)`), both of which reach the same evaluating global. *names* selects
which globals qualify. The callee must denote the free global: *free_global_name* resolves a bare
name and *alias_name* a global-object-alias member, each declining a locally shadowed name or a
dynamic scope.
"""
if node.callee is None:
return None
name = free_global_name(node.callee) or alias_name(node.callee)
if name not in names:
return None
if not node.arguments:
return None
return string_value(node.arguments[0]) or eval_string(node.arguments[0])
def _extract_function_body_code(
constructor_call: JsCallExpression | JsNewExpression,
*,
intrinsic_callee: Callable[[Expression | None], bool],
eval_string: Callable[[Expression | None], str | None],
) -> tuple[str, bool] | None:
"""
The body code string of a `Function` construction together with whether the construction binds
parameters, or `None` when it is not one. The shapes are the ones the callee can take:
Function("code") the free global, by name
new Function("code") the same, constructed
(function(){}).constructor("code") a `.constructor` navigation
var g = f.constructor; g("code") a name holding one of those
The last string argument is the function body; every preceding argument must be a string literal,
because those name parameters. A single leading argument that is empty or whitespace names a
zero-parameter function (`Function(" ", code)` — the parameter text is trimmed before the list is
parsed) and binds nothing; anything else the leading arguments spell is taken as binding, which
declines the inline rather than guessing the parameter list.
"""
if not intrinsic_callee(constructor_call.callee):
return None
args = constructor_call.arguments
if not args:
return None
last = args[-1]
body = string_value(last) or eval_string(last)
if body is None:
return None
if not all(isinstance(a, JsStringLiteral) for a in args[:-1]):
return None
return body, _leading_arguments_bind_parameters(args[:-1])
def _leading_arguments_bind_parameters(leading: list[Node]) -> bool:
"""
Whether the leading string arguments of a `Function` construction bind parameters. None do when
there are none — `Function(code)` is zero-parameter — and none when there is exactly one that is
empty or whitespace. Two empty arguments are parameter
text `','`, a `SyntaxError` the construction itself raises, and a construction that may not parse
is left standing rather than replaced by its body.
"""
if len(leading) != 1:
return bool(leading)
text = string_value(leading[0])
return bool(text and text.strip(TRIMMABLE_WHITESPACE))
def _function_constructor_body(
ctor_call: Node,
*,
intrinsic_callee: Callable[[Expression | None], bool],
eval_string: Callable[[Expression | None], str | None],
) -> tuple[str, bool] | None:
"""
Given the construction *ctor_call* itself — `Function("code")`, `new Function("code")`, or any
other spelling of the intrinsic its callee denotes — return its body code together with whether
the construction binds parameters. Returns `None` when *ctor_call* is not such a construction.
The caller decides how the constructed function is invoked and ORs in whether that invocation
passes arguments, since a body that binds either a parameter or a call argument cannot be inlined.
"""
if not isinstance(ctor_call, (JsCallExpression, JsNewExpression)):
return None
return _extract_function_body_code(
ctor_call, intrinsic_callee=intrinsic_callee, eval_string=eval_string)
def _parse_construction_function(ctor_call: Node, code: str) -> tuple[JsScript, JsFunctionExpression] | None:
"""
The script wrapping a `Function` construction's body as the function the construction builds,
together with that script, or `None` where the body is not one this package can execute: it does
not parse as a plain function's body, its parameters — the construction's leading string
arguments, spliced into the wrapper's parameter list — are not a plain comma-separated
identifier list, or it declares strict mode, which changes what `arguments`, `this` and a
global assignment mean in ways the execution below does not model.
The wrapper is a function expression rather than a bare statement list so the fragment model
built over it sees the function's own scope: the `arguments` binding the body reads lives there,
and the execution's question — may this call hand the body an arguments object — is one the
model can answer only for a function it placed.
"""
leading = [string_value(argument) for argument in ctor_call.arguments[:-1]]
if any(text is None for text in leading):
return None
parameters = ','.join(text for text in leading if text is not None)
parsed = _try_parse(
F'(function ({parameters}) {{ {code} }})',
strict=False,
module=False,
context=function_context(False, False),
)
if parsed is None or not parsed.body:
return None
statement = parsed.body[0]
if not isinstance(statement, JsExpressionStatement) or statement.expression is None:
return None
function = strip_parens(statement.expression)
if not isinstance(function, JsFunctionExpression):
return None
if not all(isinstance(param, JsIdentifier) for param in function.params):
return None
if declares_use_strict(function.body):
return None
return parsed, function
def _extract_getter_target(func: Expression | None) -> str | JsUnaryExpression | None:
"""
Extract the value returned by a getter. Expected patterns:
- `{ return <identifier>; }` -> returns the identifier name as `str`
- a `typeof` expression -> returns a `refinery.lib.scripts.js.model.JsUnaryExpression` clone
"""
if not isinstance(func, JsFunctionExpression):
return None
if func.body is None or not isinstance(func.body, JsBlockStatement):
return None
body = func.body.body
if len(body) != 1:
return None
stmt = body[0]
if not isinstance(stmt, JsReturnStatement) or stmt.argument is None:
return None
arg = stmt.argument
if isinstance(arg, JsIdentifier):
return arg.name
if (
isinstance(arg, JsUnaryExpression)
and arg.operator == 'typeof'
and isinstance(arg.operand, JsIdentifier)
):
return arg
return None
def _extract_setter_target(func: Expression | None) -> str | None:
"""
Extract the global assigned in a setter. Expected pattern:
{ return <global> = <param>; }
where the function has exactly one parameter. A setter assigning its own parameter names
nothing outside the setter, so it yields no target.
"""
if not isinstance(func, JsFunctionExpression):
return None
if len(func.params) != 1 or not isinstance(func.params[0], JsIdentifier):
return None
param_name = func.params[0].name
if func.body is None or not isinstance(func.body, JsBlockStatement):
return None
body = func.body.body
if len(body) != 1:
return None
stmt = body[0]
if isinstance(stmt, JsReturnStatement):
expr = stmt.argument
elif isinstance(stmt, JsExpressionStatement):
expr = stmt.expression
else:
return None
if not isinstance(expr, JsAssignmentExpression) or expr.operator != '=':
return None
if not isinstance(expr.left, JsIdentifier) or expr.left.name == param_name:
return None
if not isinstance(expr.right, JsIdentifier) or expr.right.name != param_name:
return None
return expr.left.name
class _ProxyMapping(NamedTuple):
getters: dict[str, str | JsUnaryExpression]
setters: dict[str, str]
def _build_proxy_mapping(
obj: JsObjectExpression,
) -> _ProxyMapping | None:
"""
Build getter and setter mappings from a pack proxy object. Returns `(getters, setters)` or
`None` if any property is malformed.
"""
getters: dict[str, str | JsUnaryExpression] = {}
setters: dict[str, str] = {}
for prop in obj.properties:
if not isinstance(prop, JsProperty):
return None
key = property_key(prop)
if key is None:
return None
if prop.kind == JsPropertyKind.GET:
target = _extract_getter_target(prop.value)
if target is None:
return None
getters[key] = target
elif prop.kind == JsPropertyKind.SET:
target = _extract_setter_target(prop.value)
if target is None:
return None
setters[key] = target
else:
return None
return _ProxyMapping(getters, setters)
def _substitute_proxy_accesses(
parsed: JsScript,
body_model: SemanticModel,
param_name: str,
getters: dict[str, str | JsUnaryExpression],
setters: dict[str, str],
) -> list[JsIdentifier] | None:
"""
Replace every free `param[key]` access in the parsed code with the name the proxy mapping
resolves it to, returning the replacement identifiers (a `typeof` target's operand for that
getter form) or `None` where resolution fails. Only a reference the body leaves free is the
constructed function's parameter: one a nested function binds is that function's own and is
left alone, while a top-level binding of the name aliases the parameter itself, which no script
splice reproduces, so it fails resolution — as does a free use that is not a member access,
since it uses the proxy object as a value. A plain read resolves to the getter target and a
simple `key = v` write to the setter target; a compound, update, or delete access reads via the
getter AND writes via the setter, which no single substitution preserves, so it fails too.
"""
bound = body_model.root_scope.bindings.get(param_name)
if bound is not None and bound.kind is not BindingKind.IMPLICIT_GLOBAL:
return None
replaced: list[JsIdentifier] = []
for node in list(parsed.walk()):
if not isinstance(node, JsIdentifier) or node.name != param_name:
continue
if not body_model.is_reference(node):
continue
binding = body_model.resolve(node)
if binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL:
continue
member = node.parent
if not isinstance(member, JsMemberExpression) or member.object is not node:
return None
key = access_key(member)
if key is None:
return None
if is_simple_assignment_target(member):
if key not in setters:
return None
replacement: JsIdentifier | JsUnaryExpression = JsIdentifier(name=setters[key])
elif is_member_write_target(member):
return None
else:
target = getters.get(key)
if target is None:
return None
if isinstance(target, str):
replacement = JsIdentifier(name=target)
else:
replacement = _clone_node(target)
_replace_in_parent(member, replacement)
if isinstance(replacement, JsIdentifier):
replaced.append(replacement)
elif isinstance(replacement.operand, JsIdentifier):
replaced.append(replacement.operand)
else:
return None
return replaced
def _try_unpack_function_constructor(
node: JsCallExpression,
*,
free_global_name: Callable[[Expression | None], str | None],
module: bool = False,
) -> tuple[JsScript, frozenset[str], list[JsIdentifier]] | None:
"""
Unpack an immediately-invoked `Function` constructor whose single argument is a proxy object
with getter/setter properties that redirect to global variables:
Function("p", "p.abc = p.def(p.ghi)")(
{get abc() { return x }, set abc(v) { x = v }, get def() { return y }, ...}
)
Parses the code string and resolves all free `p.key` accesses through the proxy mapping back to
their original identifiers. Returns the substituted body, the names whose site resolution the
substitution has already settled, and the identifiers the substitution introduced, or `None` if
the node does not match — including when the inner callee is not the free global `Function` —
or if any proxy access cannot be resolved. The caller must still admit the body the way every
reflected body is admitted; this function earns only the one exemption it returns. A getter or
setter target is spelled inside an accessor defined at the call site itself, so it resolves at
the site exactly as the accessor does, provided the substituted occurrence is still free where
it lands in the body — a body binding capturing one is declined in the admission — and
provided the packed code did not also read the name freely, in which case it stays held to
the global-resolution rule and is not returned.
"""
inner = node.callee
if not isinstance(inner, JsCallExpression):
return None
if free_global_name(inner.callee) != 'Function':
return None
if len(node.arguments) != 1 or not isinstance(node.arguments[0], JsObjectExpression):
return None
proxy_obj = node.arguments[0]
inner_args = inner.arguments
if len(inner_args) == 1:
param_name = ''
code = string_value(inner_args[0])
elif len(inner_args) == 2:
param_name = string_value(inner_args[0])
code = string_value(inner_args[1])
if param_name is None:
return None
else:
return None
if code is None:
return None
mapping = _build_proxy_mapping(proxy_obj)
if mapping is None:
return None
getters, setters = mapping
parsed = _try_parse(
code,
strict=strict_mode_at(node) or module,
module=module,
context=code_context_at(node),
)
if parsed is None:
return None
if not param_name:
return parsed, frozenset(), []
body_model = build_semantic_model(parsed)
originally_free = _body_free_names(body_model, parsed)
replaced = _substitute_proxy_accesses(parsed, body_model, param_name, getters, setters)
if replaced is None:
return None
introduced = {ident.name for ident in replaced}
return parsed, frozenset(introduced - (originally_free - {param_name})), replaced
def _is_pack_shaped(
node: JsCallExpression,
*,
free_global_name: Callable[[Expression | None], str | None],
) -> bool:
"""
Return `True` when the call has the shape of a pack pattern: the callee is a free-global `Function()`
call and the outer argument is an object expression. When this shape is detected, the generic
function-body extraction should be skipped to avoid inlining code with unresolved proxy references.
The callee is identified through the model, so a locally shadowed `Function` is not mistaken for the
intrinsic.
"""
inner = node.callee
if not isinstance(inner, JsCallExpression) or inner.callee is None:
return False
if free_global_name(inner.callee) != 'Function':
return False
return len(node.arguments) == 1 and isinstance(node.arguments[0], JsObjectExpression)
def _has_top_level_await(stmts: list[Statement]) -> bool:
"""
Whether *stmts* await outside every function written in them: an `await` expression or a
`for await` head that no `async` function within the text encloses. Reflected text is read
with the Script goal, which has no top-level `await`, so such a text is a `SyntaxError` at the
call that evaluates it, whatever the destination would read.
"""
return any(
isinstance(n, JsAwaitExpression) or (isinstance(n, JsForOfStatement) and n.is_await)
for s in stmts
for n in walk_scope(s)
)
def _has_top_level_return(stmts: list[Statement]) -> bool:
"""
Whether *stmts* — an evaluated code string's body — has a `return` at its own top level, outside any
nested function. A `return` outside a function is a SyntaxError in `eval` and string-timer code, so
such a body throws when evaluated and must not be inlined as if it produced a value or ran to
completion. The `Function` constructor is exempt: its body is a real function body, where a
top-level `return` is the function's own return.
"""
return any(isinstance(n, JsReturnStatement) for s in stmts for n in walk_scope(s))
def _body_free_names(body_model: SemanticModel, parsed: JsScript) -> set[str]:
"""
The names *parsed* reads or writes without binding them locally — the names a
`Function`-constructed body resolves against the global scope. A name bound inside the body is
excluded (inlining carries its binding along), as is a property name or key; an implicit-global
write the body performs is included, since it targets a global rather than a local binding.
"""
free: set[str] = set()
for ident in parsed.walk():
if not isinstance(ident, JsIdentifier) or not body_model.is_reference(ident):
continue
binding = body_model.resolve(ident)
if binding is None or binding.kind is BindingKind.IMPLICIT_GLOBAL:
free.add(ident.name)
return free
def _body_declared_names(body_model: SemanticModel) -> set[str]:
"""
The names a `Function`-constructed body declares at its top level — the `var`, function, `let`,
`const`, and `class` bindings that inlining would hoist into the caller's scope. Implicit globals
are excluded: those are writes to globals, covered by the free-name check rather than introduced as
new bindings.
"""
return {
name for name, binding in body_model.root_scope.bindings.items()
if binding.kind is not BindingKind.IMPLICIT_GLOBAL
}
def _body_written_free_names(body_model: SemanticModel, parsed: JsScript) -> set[str]:
"""
The names *parsed* writes without binding them locally — the subset of `_body_free_names` whose
reference is an assignment target rather than a read. A body that only reads a free name changes
nothing about what the name denotes elsewhere, while one that writes it gives every later
consultation of that name a value the pinned model has never seen.
"""
written: set[str] = set()
for ident in parsed.walk():
if not isinstance(ident, JsIdentifier) or not body_model.is_reference(ident):
continue
if reference_role(ident) is Role.READ:
continue
binding = body_model.resolve(ident)
if binding is None or binding.kind is BindingKind.IMPLICIT_GLOBAL:
written.add(ident.name)
return written
class JsReflectionInlining(ScriptLevelTransformer):
"""
Inline reflective code execution: `eval`, `Function` constructor, constructor chains, and
indirect invocation via `setTimeout` and `setInterval`.
"""
_read_effect: Callable[[Node], bool]
_alias_name: Callable[[Expression | None], str | None]
_free_global: Callable[[Expression | None], str | None]
_eval_string: Callable[[Expression | None], str | None]
_intrinsic_callee: Callable[[Expression | None], bool]
_pending_retire: dict[int, Binding]
_retire_candidates: dict[int, JsIdentifier]
_pending_atomic: dict[int, list[JsVariableDeclarator]]
_spliced_names: set[str]
def _process_script(self, node: JsScript) -> None:
"""
Inline every reflective site in the script, holding the semantic model for the whole pass.
Each inline splices in code that was a string, so this pass *can* reveal facts its held model
predates — `eval('Math.floor = f')` makes a write visible that no pre-inline model could see. What
makes holding the model sound is the precondition rather than the absence of such reveals: this
transform only ever does work on a script that has a reflective surface — the eval, `Function`,
timer, and constructor-chain sites it inlines are themselves read surfaces — and
`has_reflection_surface`
being true withdraws trust from every intrinsic (see
`refinery.lib.scripts.js.analysis.effects.EffectModel.trusted_intrinsic`). No fold against a
built-in can be admitted anywhere inside this window, so a write revealed here cannot be acted on
before the pin is released and the model rebuilt. Inlining can only turn that flag off, never on,
which leaves the held answer the stricter one. The write-side refusal
(`SemanticModel.has_opaque_global_write`) does not share that never-on property — a splice can
write one (`eval('globalThis[k] = f')`) — but it does not need it: while the read surface
stands, the same folds are refused under either fact, and the work sites keep the read
surface standing for as long as there is work to do.
The retirement of consumed temporaries is the one decision that argument cannot carry — whether a
temporary is still named is a structural fact the splices themselves change — so it runs after the
pin is released, against the model rebuilt over the post-splice tree. That rebuild is the second
root-model build a pass with a retirement candidate pays, and the only one.
Should this transform ever run on a script with no reflective surface, or should that flag stop
gating intrinsic trust, this argument does not hold and the pin must be reconsidered. The
tampering oracle (`ModelCache.builtins_intact_at`) is that reconsideration for the consumers
that hold an anchor: its site enumeration is model-backed and fails closed on nodes the pinned
models cannot place, which is the two-leg argument
`refinery.lib.scripts.js.analysis.tampering` states in full — the pin holds for the questions
asked through it, and the consumer that does not ask keeps the program-wide refusal above.
"""
cache = model_cache(self, node)
with cache.pinned():
# The resolvers below fetch the models lazily, per resolved site, which can fall after
# the first splice's edit. Warming the root-reading models at entry keeps each late
# build a pure function of held bases, so none reads a tree the pin's edits moved.
cache.warm()
self._spliced_names = set()
self._read_effect = self._dynamic_read_effect(node)
self._alias_name = self._alias_member_name(node)
self._free_global = self._free_global_name(node)
self._base_droppable = self._reflective_base_droppable(node)
self._eval_string = self._string_argument_value(node)
self._intrinsic_callee = self._function_intrinsic_callee(node)
self._pending_retire = {}
self._retire_candidates = {}
self._pending_atomic = {}
self._inline_statements(node)
self._inline_expressions(node)
self._lower_timers(node)
self._retire_consumed_temporaries(node)
def _note_retirement(self, site: Node, binding: Binding | None) -> None:
"""
Record that inlining the reflective call at *site* would retire the single-use temporary
*binding* — the local whose sole value is the `Function` construction the call invokes. The note
is provisional: it is keyed by the site and only acted on once `_confirm_retirement` sees the
inlining committed, so a resolution the caller declines (a body that could not be reduced to an
expression, a statement `_sanitize_inlined_body` rejects) retires nothing.
"""
if binding is not None:
self._pending_retire[id(site)] = binding
def _confirm_retirement(self, site: Node) -> None:
"""
Acknowledge that the inlining at *site* was committed, marking its temporary a retirement
candidate. Whether the candidate may actually go is decided by `_retire_consumed_temporaries`
against the model rebuilt after the pin, where every read the splices added is visible.
"""
binding = self._pending_retire.pop(id(site), None)
if binding is None or len(binding.declarations) != 1:
return
declaration = binding.declarations[0]
self._retire_candidates[id(declaration)] = declaration
def _retire_consumed_temporaries(self, root: JsScript) -> None:
"""
Drop the declarator of each single-assignment temporary whose construction invocations this
pass inlined and which nothing in the post-splice tree still names. The construction itself is
side-effect-free precisely because the inlining succeeded — `_resolve_reflected_body` parses the
code and declines a body it cannot, so a construction whose body was inlined provably parses and
cannot throw — which is the judgment `refinery.lib.scripts.js.analysis.effects.EffectModel`
withholds from an intrinsic under a live reflection surface and only this pass, having parsed
the code, can make. That judgment covers the construction alone: its arguments are ordinary
expressions whose effects the retirement would delete, so each must be droppable on its own.
Every question is asked of the model rebuilt after the pin, because the splices this pass
committed are exactly what the pinned model cannot see: a spliced `eval` body or lowered timer
that names the temporary, the handed-over global object a folded finder minted through which a
callee reads it as a property (both counted by `nothing_still_names`), and the name the analyst
declared a host reaches (`a_host_reaches_the_binding`). A reflective surface that survives the
pass — an `eval` this pass declined, a string timer it could not lower — can name the temporary
at runtime with no reference any model records, so any such site outside the candidates' own
constructions refuses the retirement; the constructions themselves are the one surface whose
code this pass parsed, which is what makes them transparent rather than opaque.
"""
if not self._retire_candidates:
return
cache = model_cache(self, root)
model = cache.model
transparent: set[int] = set()
candidates: list[tuple[Binding, JsVariableDeclarator, JsCallExpression | JsNewExpression]] = []
for declaration in self._retire_candidates.values():
binding = model.binding_of(declaration)
if binding is None or binding.exported or len(binding.declarations) != 1:
continue
declarator = binding.declarations[0].parent
if not isinstance(declarator, JsVariableDeclarator) or declarator.init is None:
continue
construction = strip_parens(declarator.init)
if not isinstance(construction, (JsCallExpression, JsNewExpression)):
continue
transparent.add(id(construction.callee))
candidates.append((binding, declarator, construction))
retired: list[JsVariableDeclarator] = []
for binding, declarator, construction in candidates:
if not nothing_still_names(model, [declarator]):
continue
if a_host_reaches_the_binding(model, binding, self.options):
continue
if any(
id(site) not in transparent
for site in model.reflection_surface_sites(binding)
):
continue
if not all(
cache.effects.is_side_effect_free(
argument, None,
call_established=cache.call_established, discarded=True,
reads_may_throw=True, read_established=cache.read_established)
for argument in construction.arguments
):
continue
retired.append(declarator)
for declarator in retired:
remove_declarator(declarator)
self.mark_changed()
def _dynamic_read_effect(self, root: JsScript) -> Callable[[Node], bool]:
"""
A predicate reporting whether reading a node fires a `with` object's getter or may throw a
`ReferenceError` a creating write has not certainly completed for, resolved against *root*'s
current model. Threaded into the reflective-inlining safety checks so a read that may fire a
getter or throw is never dropped as if it were pure — the same throwing read leaf every
other discarding context asks
(`refinery.lib.scripts.js.analysis.effects.EffectModel.throwing_read_effect` over the shared
establishment proof). Resolved lazily through the shared cache, so a script with no
reflective site builds no model.
"""
def read_effect(node: Node) -> bool:
cache = model_cache(self, root)
return cache.effects.read_throws(node, cache.read_established)
return read_effect
def _alias_member_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
"""
A resolver reporting the intrinsic a global-object-alias member names — `window.eval` yields
`'eval'`, `globalThis['setTimeout']` yields `'setTimeout'` — or `None` when the base is not the
real, unshadowed global object. A local `window` (a parameter, a `var`, a `with`-object
property) names an ordinary object whose member is not the reflective intrinsic and must not be
inlined; the model's shadow- and dynamic-scope-aware check is the single source of that judgment.
Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
"""
def resolve(callee: Expression | None) -> str | None:
if callee is None:
return None
member = strip_parens(callee)
if not isinstance(member, JsMemberExpression):
return None
base = strip_parens(member.object)
if isinstance(base, JsIdentifier) and base.name in self._spliced_names:
return None
model = model_cache(self, root).model
if model.scope_of(member) is None:
return None
name = model.global_alias_member_name(
member, module_scope=runs_as_module(self.options, root))
if name is not None and name in self._spliced_names:
return None
return name
return resolve
def _reflective_base_droppable(self, root: JsScript) -> Callable[[Expression | None], bool]:
"""
Whether the base of a global-object-alias `eval` member may be discarded when its call is
inlined. The base must name *this* realm's global object (`names_this_realms_global_object`, the
shared same-realm-alias predicate the finder fold and the alias-member collapse also key on),
never the cross-realm `top`/`frames`, whose `eval` runs code in another realm the inline would
move it out of; and it must resolve without throwing under the pinned host
(`SemanticModel.read_may_throw`), so the `ReferenceError` a lacking host raises reading it is not
dropped. Resolved lazily against *root*'s current model like `_alias_member_name`.
"""
def resolve(callee: Expression | None) -> bool:
member = strip_parens(callee) if callee is not None else None
if not isinstance(member, JsMemberExpression):
return False
model = model_cache(self, root).model
base = strip_parens(member.object)
if not isinstance(base, JsIdentifier) or not names_this_realms_global_object(model, base):
return False
return not model.read_may_throw(base)
return resolve
def _free_global_name(self, root: JsScript) -> Callable[[Expression | None], str | None]:
"""
A resolver reporting the reflective intrinsic a bare callee identifier denotes — `eval` yields
`'eval'`, `Function` yields `'Function'`, a timer or `execScript` its own name — or `None`. Only
a name that could name such a callee is resolved; any other identifier is declined before any
model lookup, since no caller acts on a non-reflective name. A local binding (a parameter, a
`var`, a `with`-object property) of the name is an ordinary value, not the intrinsic, and must
not drive an inline; the model resolves a reference to its binding for a shadow and to `None` for
a free global, and `read_has_dynamic_effect` rejects a name read through a dynamic scope.
Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`.
"""
def resolve(callee: Expression | None) -> str | None:
if callee is None:
return None
ident = strip_parens(callee)
if not isinstance(ident, JsIdentifier) or ident.name not in _REFLECTIVE_CALLEE_NAMES:
return None
if ident.name in self._spliced_names:
return None
model = model_cache(self, root).model
if model.scope_of(ident) is None:
return None
if model.resolve(ident) is None and not model.read_has_dynamic_effect(ident):
return ident.name
return None
return resolve
def _string_argument_value(self, root: JsScript) -> Callable[[Expression | None], str | None]:
"""
A resolver folding an argument expression to the string it denotes — `atob('...')` to the code
it decodes — or `None`. The interpreter is given *root*'s semantic model, because a call it
answers from the built-in registry is the built-in only where nothing has bound that name,
and the run's effect model, because whether it is the built-in at all is the trust question
no model-free fold may skip. Resolved lazily against *root*'s current models, mirroring
`_dynamic_read_effect`.
"""
def resolve(node: Expression | None) -> str | None:
if node is None:
return None
if self._spliced_names and any(
isinstance(ident, JsIdentifier) and ident.name in self._spliced_names
for ident in node.walk()
):
return None
cache = model_cache(self, root)
if cache.model.scope_of(node) is None:
return None
return _try_eval_string_arg(node, cache.model, cache.effects)
return resolve
def _function_intrinsic_callee(self, root: JsScript) -> Callable[[Expression | None], bool]:
"""
A resolver reporting whether a callee expression denotes the `Function` intrinsic — the
bare free global, a `.constructor` navigation (from a literal, or from a name the model pins
to one function), or a name holding one of those, transitively. This is the callee half of
every `Function`-construction extraction: a construction spelled with any of those
spellings builds the function the named intrinsic does, from the same arguments.
The recognition itself is the one shared vocabulary
(`refinery.lib.scripts.js.analysis.tampering.denotes_function_intrinsic`), consumed here
with the model's facts and this pass's own string resolution and effect checks: the same
recognizer answers the tampering oracle's construction sites. The alias hops resolve with
the position supplied — the cache's tampering model, its `singular_value_at` — so a
volatile alias the run can order answers where the stock question declines. A resolver it
cannot decide declines the inline, which is the safe answer for both consumers of it.
"""
def resolve(callee: Expression | None) -> bool:
cache = model_cache(self, root)
return denotes_function_intrinsic(
callee,
cache.model,
cache.effects,
cache.dominance,
eval_string=self._eval_string,
read_effect=self._read_effect,
positioned_value=cache.tampering.singular_value_at,
spliced_names=self._spliced_names,
) is True
return resolve
def _inline_statements(self, root: JsScript) -> None:
for container in list(root.walk()):
body = get_body(container)
if body is None:
continue
i = 0
while i < len(body):
original = body[i]
resolved = self._try_resolve_statement(original, root, container is root)
if resolved is None:
i += 1
continue
mode, parsed = resolved
parsed = sanitize_inlined_body(parsed)
if parsed is None:
self._pending_atomic.pop(id(original), None)
i += 1
continue
if preserves_script_return(self.options) and mode is not _EndValueMode.NONE:
reaches = reaches_script_completion(original, root)
if mode is _EndValueMode.SEED_UNDEFINED:
parsed = seed_script_end_value(parsed, reaches_completion=reaches)
else:
parsed = preserve_script_end_value(
parsed,
returns_undefined=True,
reaches_completion=reaches,
)
# The deletions an atomically admitted fold carries run before the splice, so the
# statements the loop still holds keep their positions.
i -= self._remove_consumed_temporaries_of(original, container, i)
set_body(container, [*body[:i], *parsed, *body[i + 1:]])
self._confirm_retirement(original)
self.mark_changed()
i += len(parsed)
def _inline_expressions(self, root: JsScript) -> None:
for node in list(root.walk()):
if not isinstance(node, JsCallExpression):
continue
if isinstance(node.parent, JsExpressionStatement):
continue
replacement = self._try_resolve_expression(node, root)
if replacement is None:
continue
_replace_in_parent(node, replacement)
self._remove_consumed_temporaries_of(node, None, 0)
self._confirm_retirement(node)
self.mark_changed()
def _lower_timers(self, root: JsScript) -> None:
"""
Rewrite a string-argument timer — `setTimeout("code", delay)`, `setInterval`, and their
`setImmediate`/global-alias variants — into a deferred function call
`setTimeout(function () { code }, delay)`, so the evaluated code is deobfuscated without changing
when or how often it runs. Unlike the eval and constructor paths, a timer is not inlined at the
call site: its value is a handle and its execution is deferred, so only its code string is
lowered. `execScript` is not a timer — it evaluates synchronously — so it is inlined in place by
`_try_resolve_statement` instead of lowered here.
"""
for node in list(root.walk()):
if isinstance(node, JsCallExpression):
self._try_lower_timer(node, root)
def _try_lower_timer(self, node: JsCallExpression, root: JsScript) -> None:
"""
Replace a string timer's code argument with a function wrapping the parsed code, when that code
runs safely in the global scope the timer would give it. The wrapper is defined at the call site,
so it is held to the same global-scope safety as an indirect eval — its `this` is rewritten to
`globalThis`, its free names must still denote the same global, and a top-level declaration
(whose global or transient environment a local function cannot reproduce) or a `return`/`await`
that a plain function body cannot host declines the lowering, leaving the string timer intact.
The body lands inside that wrapper and not as text at the site, so the words it is weighed
against are those a plain function reads: `await` and `yield` are names there, and
`arguments` is the wrapper's own, whatever the site reads them as.
"""
code = _extract_string_call_code(
node,
TIMER_NAMES,
alias_name=self._alias_name,
free_global_name=self._free_global,
eval_string=self._eval_string,
)
if code is None:
return
resolved = self._resolve_reflected_body(
code,
node,
root,
ReflectedScope.GLOBAL_EVAL,
at_global_scope=False,
destination=function_context(False, False),
)
if resolved is None:
return
block = JsBlockStatement(body=resolved.body)
wrapper = JsFunctionExpression(params=[], body=block)
block.parent = wrapper
for stmt in resolved.body:
stmt.parent = block
_replace_in_parent(node.arguments[0], wrapper)
self.mark_changed()
def _try_resolve_statement(
self, stmt: Statement, root: JsScript, at_global_scope: bool,
) -> tuple[_EndValueMode, list[Statement]] | None:
"""
Resolve a statement-position reflective call to the statements it should become, paired with
how its completion must be held under `preserve_script_return`, or `None`. A
`Function`-constructor pack is unpacked and its substituted body admitted like any constructed
body; a direct or indirect `eval` and a `Function` body are handled by
`_resolve_reflected_call`; `execScript("code")` runs its code synchronously in the global scope
and discards the value, so at statement position it is replaced by that code inlined in place.
An `await`-ed call is not a plain call expression here, so it is left for the expression pass,
which rewrites the `eval` inside `await eval("expr")` to `await (expr)` without dropping the
`await`.
The first element is the completion adaptation the inlined body needs (`_EndValueMode`). A
constructed function and `execScript` returned `undefined` unless the body ends in a value
`return` the splice exposes, so a fall-off body is forced to `undefined`. An `eval` returned its
own code's completion — a value the code answers with, or `undefined` where it completes empty —
which the inlined body reproduces on its own, seeded at `undefined` for the empty run.
"""
if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None:
return None
node = stmt.expression
if not isinstance(node, JsCallExpression):
return None
sync = _extract_string_call_code(
node,
SYNC_EVAL_NAMES,
alias_name=self._alias_name,
free_global_name=self._free_global,
eval_string=self._eval_string,
)
if sync is not None:
parsed = self._resolve_reflected_body(
sync, stmt, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
)
if parsed is None:
return None
return _EndValueMode.FORCE_UNDEFINED, parsed.body
pack = _try_unpack_function_constructor(
node,
free_global_name=self._free_global,
module=runs_as_module(self.options, root),
)
if pack is not None:
packed, site_resolved, substituted = pack
admitted = self._admit_reflected_body(
packed, stmt, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope,
site_resolved=site_resolved,
substituted=substituted,
)
if admitted is None:
return None
constructed = (
_EndValueMode.FORCE_UNDEFINED
if body_returns_undefined(admitted.body)
else _EndValueMode.NONE
)
return constructed, list(admitted.body)
if _is_pack_shaped(node, free_global_name=self._free_global):
return None
resolved = self._resolve_reflected_call(node, stmt, root, at_global_scope)
if resolved is None:
return None
scope, script = resolved
if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
mode = (
_EndValueMode.FORCE_UNDEFINED
if body_returns_undefined(script.body)
else _EndValueMode.NONE
)
else:
mode = _EndValueMode.SEED_UNDEFINED
return mode, script.body
def _try_resolve_expression(self, node: JsCallExpression, root: JsScript) -> Expression | None:
resolved = self._resolve_reflected_call(node, node, root, at_global_scope=False)
if resolved is None:
return None
scope, parsed = resolved
body = parsed.body
if len(body) != 1:
return None
stmt = body[0]
if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
if isinstance(stmt, JsReturnStatement) and stmt.argument is not None:
return stmt.argument
return None
if isinstance(stmt, JsExpressionStatement) and stmt.expression is not None:
return stmt.expression
return None
def _resolve_reflected_call(
self,
node: JsCallExpression,
site: Node,
root: JsScript,
at_global_scope: bool,
) -> tuple[ReflectedScope, JsScript] | None:
"""
Dispatch a reflective call to the safety gate for its execution scope, pairing the resolved body
with that scope or returning `None` to decline. A `Function` constructor or constructor chain is
a fresh global-scope function; a direct `eval` runs in the caller's scope; an indirect `eval`
runs in the global scope. A string timer is not inlined here: its value is a handle, not the
code's completion value, and its deferred execution is preserved instead by `_lower_timers`.
A construction whose body the inline route declines — one that binds parameters or reads its
`arguments`, or a call that passes any — is not the end: `_try_evaluate_construction` executes
the constructed body over the call's argument values, so a body too entangled to splice as
text can still resolve the call to the value it answers.
"""
read_effect = self._read_effect
alias_name = self._alias_name
free_global_name = self._free_global
resolved = self._resolved_constructor_call(node, root)
if resolved is not None:
ctor_call, retire = resolved
body = _function_constructor_body(
ctor_call, intrinsic_callee=self._intrinsic_callee,
eval_string=self._eval_string)
if body is not None:
code, ctor_binds = body
parsed = self._resolve_reflected_body(
code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope,
binds=ctor_binds,
invocation_arguments=node.arguments,
)
if parsed is not None:
self._note_retirement(site, retire)
return ReflectedScope.FUNCTION_CONSTRUCTOR, parsed
parsed = self._try_atomic_construction_inline(
node, site, ctor_call, retire, code, root, at_global_scope,
binds=ctor_binds)
if parsed is not None:
return ReflectedScope.FUNCTION_CONSTRUCTOR, parsed
self._try_evaluate_construction(node, site, ctor_call, code, retire, root)
return None
direct = _extract_eval_code(
node, free_global_name=free_global_name, eval_string=self._eval_string)
if direct is not None:
parsed = self._resolve_reflected_body(
direct, site, root, ReflectedScope.DIRECT_EVAL, at_global_scope,
)
return (ReflectedScope.DIRECT_EVAL, parsed) if parsed is not None else None
code = _extract_indirect_eval_code(
node, read_effect, alias_name=alias_name, free_global_name=free_global_name,
eval_string=self._eval_string, base_droppable=self._base_droppable)
if code is not None:
parsed = self._resolve_reflected_body(
code, site, root, ReflectedScope.GLOBAL_EVAL, at_global_scope,
)
return (ReflectedScope.GLOBAL_EVAL, parsed) if parsed is not None else None
return None
def _resolved_constructor_call(
self, node: JsCallExpression, root: JsScript,
) -> tuple[Node, Binding | None] | None:
"""
The `Function` construction that *node* invokes, paired with the single-use temporary to retire
once its sole read is inlined (or `None` to retire nothing). For the immediate forms —
`Function("code")()`, `new Function(...)()`, `(function(){}).constructor("code")()` — the
construction is `node`'s own callee. When the callee is a bare identifier, the construction is
the value the name provably holds (`SemanticModel.singular_value`, which already declines a
reassigned or dynamically rebindable binding), taken only where that value is established before
*node* (`DominanceModel.binding_established_before`) so the invocation cannot read it out of its
temporal dead zone — and not at all for a script-scope name while the program stores a property
on the global object under a runtime key (`SemanticModel.has_opaque_global_write`) that the
tampering oracle does not clear at *node* (`ModelCache.builtins_intact_at`): under the script
execution model such a name is a property of that object, the one such a write may rebind, so
its spelled value is not what the call runs — unless every write is guaranteed to follow the
invocation. A value that is not itself a construction declines, whatever it denotes: a name
holding the intrinsic (`var g = f.constructor`) makes a call through it a construction —
one that *builds* a function and never runs it — so there is no invocation for this to
resolve, and the call stays standing. The body is inlined at *node*, never the
construction relocated, so a `Function` reference in the initializer keeps its original scope;
retiring the dead temporary is
left to `_retire_consumed_temporaries` on the model rebuilt after the pass.
"""
callee = strip_parens(node.callee)
if isinstance(callee, (JsCallExpression, JsNewExpression)):
return callee, None
if not isinstance(callee, JsIdentifier):
return None
if callee.name in self._spliced_names:
return None
cache = model_cache(self, root)
binding = cache.model.resolve(callee)
if (
binding is not None
and binding.scope is cache.model.root_scope
and cache.model.has_opaque_global_write()
and not cache.builtins_intact_at(node)
):
return None
value = strip_parens(cache.model.singular_value(binding))
if not isinstance(value, (JsCallExpression, JsNewExpression)):
return None
if not cache.dominance.binding_established_before(binding, node):
return None
return value, binding
def _try_atomic_construction_inline(
self,
node: JsCallExpression,
site: Node,
ctor_call: Node,
retire: Binding | None,
code: str,
root: JsScript,
at_global_scope: bool,
*,
binds: bool = False,
) -> JsScript | None:
"""
The atomic route: splice the constructed body at the invocation and delete the temporaries
that spell the construction in the same edit, admitting the body against the tree that edit
leaves. The plain route holds every site-scope binding of a spliced body's names to the
global-resolution rule and every declaration to the capture rule, which is sound — but a
body whose injected dead code names exactly the holder (`var h = … .constructor`) or the
construction temporary (`var f = h(code)`) collides only with bindings the fold itself
consumes, and the collision is an artifact of asking before the edit. This route asks
after it: the bindings are excluded from the model's answers, so a name they occupy resolves
past it and a use resolving to one is carried off.
Only the temporaries may be deleted this way. Everything else the plain route refuses for
stays refused — a free name resolving to a binding the fold keeps, a declaration capturing
a live reference — because the exclusion covers no binding but the consumed ones.
The route runs only where the plain admission declined, so a construction whose argument
is not droppable (`atob(…)`) keeps every fold the plain route already gives it: the atomic
gates — a droppable initializer for each holder, arguments no other fold must keep — are
asked of no construction that inlines without them. A body that binds parameters keeps
declining with the plain route, which is where the evaluation route below picks it up.
"""
if binds:
return None
cache = model_cache(self, root)
aliases = function_intrinsic_aliases(
ctor_call.callee, cache.model, cache.effects, cache.dominance,
eval_string=self._eval_string, read_effect=self._read_effect,
positioned_value=cache.tampering.singular_value_at,
spliced_names=self._spliced_names,
)
if aliases is None:
return None
consumed = list(aliases)
if retire is not None:
consumed.append(retire)
if not consumed:
return None
declarators = self._consumed_temporaries_may_go(cache, consumed, ctor_call, site)
if declarators is None:
return None
parsed = self._resolve_reflected_body(
code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope,
invocation_arguments=node.arguments,
exclude=frozenset(consumed),
)
if parsed is None:
return None
self._spliced_names |= {binding.name for binding in consumed}
self._pending_atomic[id(site)] = declarators
return parsed
def _consumed_temporaries_may_go(
self,
cache: ModelCache,
consumed: list[Binding],
ctor_call: Node,
site: Node,
) -> list[JsVariableDeclarator] | None:
"""
Whether every temporary the fold consumes — the holder names the construction's callee
resolves through and the construction binding itself — may be deleted together with the
splice, returning their declarators or `None`. The questions are the ones
`_retire_consumed_temporaries` asks after the pin, asked here against the pinned model
because this deletion rides the same edit as the splice rather than following it: nothing
outside the region the edit replaces may still name a temporary (a second read, a
`with`-body use, an alias through a form no pass matches), no host the analyst named
reaches one, and no opaque reflective surface the edit does not carry off could name one
at runtime with no reference any model records. The construction itself is the one surface
whose code this fold parsed, which is what makes deleting it side-effect-free; its
arguments and each holder's initializer must be droppable on their own — a `.constructor`
read is a property read, droppable only while the chain it consults is intact. The hop
ordering the recognizer proves is what makes each declarator hold the value the
construction read, so no establishment question is asked here again.
"""
model = cache.model
declarators: list[JsVariableDeclarator] = []
for binding in consumed:
if binding.exported or len(binding.declarations) != 1:
return None
declaration = binding.declarations[0]
declarator = declaration.parent
if not isinstance(declarator, JsVariableDeclarator) or declarator.init is None:
return None
if a_host_reaches_the_binding(model, binding, self.options):
return None
declarators.append(declarator)
region = [site, *declarators]
if not nothing_still_names(model, region):
return None
for binding in consumed:
if any(
not any(
surface is removed or surface.is_descendant_of(removed)
for removed in region
)
for surface in model.reflection_surface_sites(binding)
):
return None
for declarator in declarators:
init = strip_parens(declarator.init)
if init is ctor_call:
continue
if not self._droppable_within_region(cache, init, region):
return None
if not all(
self._droppable_within_region(cache, argument, region)
for argument in ctor_call.arguments
):
return None
return declarators
def _droppable_within_region(
self, cache: ModelCache, node: Node, region: list[Node],
) -> bool:
"""
Whether dropping the evaluation of *node* — an initializer or construction argument
inside *region* — removes nothing observable, with every member read judged on the tree
the edit deleting *region* leaves standing rather than the one the pass is pinned to.
The reads this asks about all live inside the region, so each one's chain question
reaches `getter_free_read_dropped_with`, which forgives exactly the opaque surfaces the
same edit carries off and none of those it leaves.
"""
return cache.effects.is_side_effect_free(
node, None,
member_safe=lambda member: cache.effects.getter_free_read_dropped_with(member, region),
call_established=cache.call_established, discarded=True,
reads_may_throw=True, read_established=cache.read_established,
)
def _remove_consumed_temporaries_of(
self, site: Node, container: Node | None, index: int,
) -> int:
"""
Delete the declarators the committed splice at *site* consumes — the second half of the one
edit `_try_atomic_construction_inline` admitted against — returning how many statements
vanished from the body of *container* before *index*, so a caller splicing at that index
splices at the shifted one. Where the splice does not replace a statement of *container*'s
body — *container* is `None` — the declarators' parents are untouched by the replacement
and no shift is returned. A declaration left with other declarators keeps its statement,
which is why only a sole declarator counts as taking one away.
"""
removed = self._pending_atomic.pop(id(site), [])
vanished = 0
body = get_body(container) if container is not None else None
for declarator in removed:
declaration = declarator.parent
if (
body is not None
and isinstance(declaration, JsVariableDeclaration)
and declaration.parent is container
and len(declaration.declarations) == 1
and next((k for k, s in enumerate(body) if s is declaration), len(body)) < index
):
vanished += 1
remove_declarator(declarator)
return vanished
def _try_evaluate_construction(
self,
node: JsCallExpression,
site: Node,
ctor_call: Node,
code: str,
retire: Binding | None,
root: JsScript,
) -> None:
"""
The evaluation route: execute the body a `Function` construction builds over the argument
values the call passes, and replace the call with the value it answers. Where the inline
route splices the body's text — sound only where nothing in it binds parameters, reads
`arguments`, or observes the call's arguments — this route runs the body as an interpreter
would, so exactly the bodies the inline route exists for are the ones this one resolves:
the decoders that fold their arguments through `arguments[p]`.
The interpreter runs the parsed fragment against two models, one question per model. The
real program's effect model answers name integrity — which built-in is still the built-in
— and the tampering oracle vouches for it at *node*, the anchor: a construction a later
tampering site would not reach may trust what the program-wide questions refuse. The
fragment model over the parsed body answers name resolution, so a free name in the fragment
denotes the host global or nothing, never a binding of the real tree.
Two static gates refuse before anything runs. The fragment may not write a free name —
every write form, through the role machinery — for an execution that replaced the call
would drop the write the real program performs. And the fragment's free names must be
disjoint from the real tree's root-scope bindings: script-level `let`, `const`, `class`
and `var` names live in the global lexical environment, visible to a `Function`-constructed
body, so a fragment reading one answers a binding this route cannot see. Root scope only —
a binding nested inside a function is invisible to code the global scope runs, and reading
it as though it were reachable is what the tree-binding gate exists to stop.
A third gate refuses a fragment reading a name this pass has spliced in. The route runs
inside the pass's pinned-model window, so a statement an earlier site of the same pass
inlined — the write an `eval` carried, spliced after the models were built — is one no
pinned fact records: the name's write is invisible to the effect model that would refuse
it, and the fragment would answer a value the program replaced. The names the pass has
spliced are the gate's own record, the same one the inline route refuses through.
The route's remaining safety is the two-leg argument
`refinery.lib.scripts.js.analysis.tampering` states — the model-backed site enumeration
that fails closed on the nodes this pass splices in.
"""
fragment = _parse_construction_function(ctor_call, code)
if fragment is None:
return
fragment_script, function = fragment
cache = model_cache(self, root)
fragment_model = build_semantic_model(fragment_script)
if _body_written_free_names(fragment_model, fragment_script):
return
free = _body_free_names(fragment_model, fragment_script)
if not free.isdisjoint(cache.model.root_scope.bindings):
return
if not free.isdisjoint(self._spliced_names):
return
argument_values = self._construction_argument_values(node, root)
if argument_values is None:
return
from refinery.lib.scripts.js.deobfuscation.interpreter import (
InterpreterError,
IrreducibleExpression,
JsInterpreter,
_ThrowSignal,
)
interpreter = JsInterpreter(
effects=cache.effects,
model=fragment_model,
anchor=node,
tampering=cache.tampering,
)
try:
result = interpreter.execute(function, argument_values)
except (InterpreterError, IrreducibleExpression, _ThrowSignal, RecursionError, ValueError, OverflowError):
return
if not replace_with_value(node, result):
return
self._note_retirement(site, retire)
self._confirm_retirement(site)
self.mark_changed()
def _construction_argument_values(
self, node: JsCallExpression, root: JsScript,
) -> list | None:
"""
The values of *node*'s arguments, extracted under the evaluator's gates: a literal value,
or an interpreter evaluation — anchored at *node*, like the execution it feeds — of an
argument whose evaluation is side-effect free and cannot throw before the body runs.
`None` refuses the call: an argument with an effect or a throw is the program's business,
not a value this route may consume silently. Passing the values, rather than substituting
their text, is what keeps call-time binding — the value is read at the call, whatever the
name held when the fragment was written.
"""
cache = model_cache(self, root)
values: list = []
for argument in node.arguments:
if argument is None:
return None
ok, value = extract_literal_value(argument)
if ok:
values.append(value)
continue
if not cache.effects.is_side_effect_free(
argument, None,
call_established=cache.call_established, discarded=True,
reads_may_throw=True, read_established=cache.read_established,
):
return None
from refinery.lib.scripts.js.deobfuscation.interpreter import (
InterpreterError,
IrreducibleExpression,
JsInterpreter,
_ThrowSignal,
)
interpreter = JsInterpreter(
effects=cache.effects,
anchor=node,
tampering=cache.tampering,
established=lambda callee: cache.dominance.established_before(callee, node),
)
try:
values.append(interpreter.eval_expression(argument))
except (InterpreterError, IrreducibleExpression, _ThrowSignal, RecursionError, ValueError, OverflowError):
return None
return values
def _argument_may_be_dropped(self, argument: Node, root: JsScript) -> bool:
"""
Whether replacing a call that passes *argument* with the body it invokes may drop the
argument's evaluation. A literal is required — the value the call reads is one no splice of
the body reproduces, so anything that reads a name or runs a call keeps the call standing —
and the literal must additionally be one whose evaluation is side-effect free and cannot
throw, the same question `_retire_consumed_temporaries` asks of the arguments of a
construction it drops: a fold never mutes the program's own effects. The second gate holds
of every literal the first admits today, and is what keeps the first one sound to widen.
"""
ok, _ = extract_literal_value(argument)
if not ok:
return False
cache = model_cache(self, root)
return cache.effects.is_side_effect_free(
argument, None,
call_established=cache.call_established,
discarded=True,
reads_may_throw=True,
read_established=cache.read_established,
)
def _destination_may_be_strict(self, site: Node, root: JsScript) -> bool:
"""
Whether the destination of an inline could run the spliced text in strict mode. A
syntactically strict site is strict; and the module execution model is strictness-ambiguous —
`DeobfuscationOptions.module` covers a strict ES module and a sloppy CommonJS file alike, and
nothing here tells the two apart — so a module destination is treated as possibly strict.
This is the one mode the reflected text must be legal and mode-invariant in, because inlining
must be sound for the strict reading too: a sloppy-only body spliced into what turns out to be
an ES module is a `SyntaxError` that takes the whole file down, or behaves differently, where
the call it replaced merely raised one the site caught. The sloppy reading only ever inlines a
strict subset of what this admits, so declining here costs a CommonJS-only recall and never a
soundness. This is why it is asked at every reflection strictness gate rather than left to
`strict_mode_at`, whose pure-tree answer is blind to the module option, and why the
complementary sloppy-conservative gates — a mapped `arguments` object, a rest unpacking — read
`strict_mode_at` alone: their soundness runs the other way, so they must assume the sloppy
CommonJS reading, not the strict one.
"""
return strict_mode_at(site) or runs_as_module(self.options, root)
def _resolve_reflected_body(
self,
code: str,
site: Node,
root: JsScript,
scope: ReflectedScope,
at_global_scope: bool,
*,
binds: bool = False,
destination: CodeContext | None = None,
invocation_arguments: list[Node] | None = None,
exclude: Collection[Binding] = (),
) -> JsScript | None:
"""
Parse reflectively evaluated *code* and admit it through `_admit_reflected_body`, or decline
(`None`). A body that binds parameters (*binds*) cannot be inlined as text, so it declines
before the parse; a body that observes its arguments declines in the admission, which has
the parsed body to ask. *invocation_arguments* is the argument list of the call the splice
would replace, where the code is a construction's body and the call is the invocation of
what it built — `None` where the site is no call that passes any. *destination* is the
context the text is read in once spliced, where that is not *site*'s own: a string timer's
body lands inside a plain function of its own. *exclude* is the set of bindings the same
edit deletes, handed to the admission as its post-rewrite view.
"""
if binds:
return None
parsed = _try_parse(
code,
strict=self._destination_may_be_strict(site, root),
module=runs_as_module(self.options, root),
context=code_context_at(site) if destination is None else destination,
)
if parsed is None:
return None
return self._admit_reflected_body(
parsed, site, root, scope, at_global_scope,
invocation_arguments=invocation_arguments,
exclude=exclude,
)
def _admit_reflected_body(
self,
parsed: JsScript,
site: Node,
root: JsScript,
scope: ReflectedScope,
at_global_scope: bool,
*,
site_resolved: frozenset[str] = frozenset(),
substituted: Collection[JsIdentifier] = (),
invocation_arguments: list[Node] | None = None,
exclude: Collection[Binding] = (),
) -> JsScript | None:
"""
Decide whether inlining the reflected body *parsed* at *site* preserves meaning, given the
`ReflectedScope` it runs in. Global-scope code — a `Function`-constructed body or indirect
`eval`/string-timer code — must run in the global sloppy mode it would have: a strict
context at *site* declines a body that would diverge under strict mode
(`diverges_under_strict`), as does a `"use strict"` prologue; every receiver `this` becomes
`globalThis`; and a body reading `arguments`, `super`, or `new.target`, or a free
name that no longer denotes the same global at *site* — including one a `with` on the path could
capture — declines. Direct `eval` runs in the caller's scope, which is *site* itself, so its
references and `this` are already correct there and only the checks below apply. A top-level
`return` is a SyntaxError in evaluated code, so an eval body with one declines. Declaration
handling is delegated to `_reflected_declarations_safe`. Anything not provably safe is left
intact (returns `None`) — declining is always sound.
*invocation_arguments* is the argument list of the call the splice replaces, where the site
is the invocation of a construction: the call evaluates those arguments before the body
runs, so a splice that drops them may mute an effect or a throw. Every one of them must be
droppable — `_argument_may_be_dropped` — and a body reading its `arguments` declines below,
for the call's arguments are exactly what such a body observes.
*site_resolved* is the one exemption the pack route earns: a name its proxy substitution
introduced resolves at the site by construction, the accessor spelling it being defined
there, so it is not held to the global-resolution rule the reflected code's own free names
must meet. The exemption is conditional, and the admission holds the condition itself:
every identifier entering the body from outside its own text must still resolve to the
implicit global where it lands, asked of the model this admission builds. Those the pack
substitution spliced in (*substituted*) are declined where a body binding captures one; a
landing inside a `with` body is accepted, since the `with` objects of packed code rebind
the machinery names the pack itself spells and never the accessor targets. Those the
receiver rewrite above synthesized are declined by a body binding capture as well, and
additionally by a landing the model cannot resolve determinately — inside a `with` or
another dynamic region — since the original read was of the receiver, which no `with`
object supplies. Every other check still applies to an exempted name.
Every name-based answer above is read from the model pinned before any splice, so a body
naming what an earlier splice this pass declared or wrote is declined outright: for such a
name the pinned lookup, capture, and dominance answers describe a tree that no longer
exists. The declined site is untouched and inlines on the next pass, whose model has seen
the splice. A body that only reads names no splice bound contributes nothing to that veto,
so a chain of sites sharing free reads still inlines in one pass.
"""
resolves_globally = scope is not ReflectedScope.DIRECT_EVAL
site_is_strict = strict_mode_at(site)
if declares_use_strict(parsed) and (resolves_globally or not site_is_strict):
return None
synthesized: list[JsIdentifier] = []
if resolves_globally:
synthesized = rewrite_receiver_this_to_global(parsed)
if references_receiver_this(parsed) or references_new_target(parsed):
return None
if scope is not ReflectedScope.FUNCTION_CONSTRUCTOR and _has_top_level_return(parsed.body):
return None
if invocation_arguments and any(
argument is not None and not self._argument_may_be_dropped(argument, root)
for argument in invocation_arguments
):
return None
body_model = build_semantic_model(parsed)
for ident in substituted:
binding = body_model.resolve(ident)
if binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL:
return None
for ident in synthesized:
binding = body_model.resolve(ident)
if binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL:
return None
if binding is None and crosses_dynamic_scope(body_model.scope_of(ident)):
return None
if resolves_globally and self._destination_may_be_strict(site, root) and diverges_under_strict(
parsed, body_model, site_resolved,
):
return None
free = _body_free_names(body_model, parsed)
if resolves_globally and 'arguments' in free:
return None
declared = _body_declared_names(body_model)
if not self._spliced_names.isdisjoint(free | declared):
return None
if not free and not declared:
return parsed
root_model = model_cache(self, root).model
site_scope = root_model.scope_of(site)
if site_scope is None:
return None
if resolves_globally and free:
if crosses_dynamic_scope(site_scope):
return None
for name in free:
if name in site_resolved:
continue
binding = root_model.lookup(name, site_scope, exclude=exclude)
if binding is not None and not root_model.reaches_global_object(
binding, module_scope=runs_as_module(self.options, root),
):
return None
if declared and not self._reflected_declarations_safe(
body_model, root_model, site_scope, site, scope, at_global_scope, exclude,
):
return None
self._spliced_names |= declared | _body_written_free_names(body_model, parsed)
return parsed
def _reflected_declarations_safe(
self,
body_model: SemanticModel,
root_model: SemanticModel,
site_scope: Scope,
site: Node,
scope: ReflectedScope,
at_global_scope: bool,
exclude: Collection[Binding] = (),
) -> bool:
"""
Whether the top-level declarations of a reflected body can be reproduced by inlining it at the
call site. A `Function`-constructed body's declarations are local to the created function and
lift into the caller's scopes (`inlined_declarations_safe`); evaluated code declares in its
execution scope and is handled by `_eval_declarations_safe`. *exclude* carries the atomic
fold's post-rewrite view to the lift check.
"""
if scope is ReflectedScope.FUNCTION_CONSTRUCTOR:
return inlined_declarations_safe(
body_model.root_scope, root_model, site_scope, exclude=exclude)
return self._eval_declarations_safe(
body_model, root_model, site_scope, site, scope, at_global_scope,
)
def _eval_declarations_safe(
self,
body_model: SemanticModel,
root_model: SemanticModel,
site_scope: Scope,
site: Node,
scope: ReflectedScope,
at_global_scope: bool,
) -> bool:
"""
Whether an `eval` body's top-level declarations can be inlined at the call site. A
`let`/`const`/`class` lives in a declarative environment discarded when the evaluation
returns, so a persistent inlined binding differs only if a name it declares is referenced
outside the body; it is declined exactly when introducing it at the site would capture such a
reference. A `var` or function persists: under indirect eval it becomes a global-object
property, reproducible only at top-level script scope and never under the module model; under
direct eval it lands in the caller's variable scope, but never under a strict direct eval,
whose `var` stays local to the eval. The module execution model could be a strict ES module,
where the `var` is ephemeral, so `_destination_may_be_strict` treats a module direct eval as
possibly strict and declines the inline; the sloppy CommonJS reading would leak the `var` and
keep the inline, but declining is sound for both. Such a declaration hoists to the head of its
variable scope, so it is inlined only when the eval site strictly dominates every reference to
the name already there — one that runs before it or shares its statement, or reads the name
through a closure, would be rebound.
"""
root = root_model.root
bindings = body_model.root_scope.bindings
lexical = {name for name, binding in bindings.items() if binding.is_lexical}
if lexical and root_model.would_capture(lexical, site_scope):
return False
hoisted = {name for name, binding in bindings.items() if binding.is_hoisted}
if not hoisted:
return True
if scope is ReflectedScope.GLOBAL_EVAL:
if runs_as_module(self.options, root) or not at_global_scope:
return False
elif self._destination_may_be_strict(site, root) or declares_use_strict(body_model.root):
return False
var_scope = site_scope.var_scope
if var_scope is None:
return False
dominance = model_cache(self, root).dominance
return all(
dominance.strictly_dominates(site, node)
for node in name_uses_in_scope(hoisted, var_scope)
)
Classes
class ReflectedScope (*args, **kwds)-
The execution scope of reflectively evaluated code, which decides how its free names,
this, and top-level declarations must be treated when the code is inlined at its call site. AFunction-constructed function and indirecteval/string-timer code run in the global sloppy scope; a directevalruns in the caller's scope, which is the inline site itself, so its references andthisare already correct there and only its declarations need care.Expand source code Browse git
class ReflectedScope(enum.Enum): """ The execution scope of reflectively evaluated code, which decides how its free names, `this`, and top-level declarations must be treated when the code is inlined at its call site. A `Function`-constructed function and indirect `eval`/string-timer code run in the global sloppy scope; a direct `eval` runs in the caller's scope, which is the inline site itself, so its references and `this` are already correct there and only its declarations need care. """ FUNCTION_CONSTRUCTOR = enum.auto() GLOBAL_EVAL = enum.auto() DIRECT_EVAL = enum.auto()Ancestors
- enum.Enum
Class variables
var FUNCTION_CONSTRUCTOR-
The type of the None singleton.
var GLOBAL_EVAL-
The type of the None singleton.
var DIRECT_EVAL-
The type of the None singleton.
class JsReflectionInlining-
Inline reflective code execution:
eval,Functionconstructor, constructor chains, and indirect invocation viasetTimeoutandsetInterval.Expand source code Browse git
class JsReflectionInlining(ScriptLevelTransformer): """ Inline reflective code execution: `eval`, `Function` constructor, constructor chains, and indirect invocation via `setTimeout` and `setInterval`. """ _read_effect: Callable[[Node], bool] _alias_name: Callable[[Expression | None], str | None] _free_global: Callable[[Expression | None], str | None] _eval_string: Callable[[Expression | None], str | None] _intrinsic_callee: Callable[[Expression | None], bool] _pending_retire: dict[int, Binding] _retire_candidates: dict[int, JsIdentifier] _pending_atomic: dict[int, list[JsVariableDeclarator]] _spliced_names: set[str] def _process_script(self, node: JsScript) -> None: """ Inline every reflective site in the script, holding the semantic model for the whole pass. Each inline splices in code that was a string, so this pass *can* reveal facts its held model predates — `eval('Math.floor = f')` makes a write visible that no pre-inline model could see. What makes holding the model sound is the precondition rather than the absence of such reveals: this transform only ever does work on a script that has a reflective surface — the eval, `Function`, timer, and constructor-chain sites it inlines are themselves read surfaces — and `has_reflection_surface` being true withdraws trust from every intrinsic (see `refinery.lib.scripts.js.analysis.effects.EffectModel.trusted_intrinsic`). No fold against a built-in can be admitted anywhere inside this window, so a write revealed here cannot be acted on before the pin is released and the model rebuilt. Inlining can only turn that flag off, never on, which leaves the held answer the stricter one. The write-side refusal (`SemanticModel.has_opaque_global_write`) does not share that never-on property — a splice can write one (`eval('globalThis[k] = f')`) — but it does not need it: while the read surface stands, the same folds are refused under either fact, and the work sites keep the read surface standing for as long as there is work to do. The retirement of consumed temporaries is the one decision that argument cannot carry — whether a temporary is still named is a structural fact the splices themselves change — so it runs after the pin is released, against the model rebuilt over the post-splice tree. That rebuild is the second root-model build a pass with a retirement candidate pays, and the only one. Should this transform ever run on a script with no reflective surface, or should that flag stop gating intrinsic trust, this argument does not hold and the pin must be reconsidered. The tampering oracle (`ModelCache.builtins_intact_at`) is that reconsideration for the consumers that hold an anchor: its site enumeration is model-backed and fails closed on nodes the pinned models cannot place, which is the two-leg argument `refinery.lib.scripts.js.analysis.tampering` states in full — the pin holds for the questions asked through it, and the consumer that does not ask keeps the program-wide refusal above. """ cache = model_cache(self, node) with cache.pinned(): # The resolvers below fetch the models lazily, per resolved site, which can fall after # the first splice's edit. Warming the root-reading models at entry keeps each late # build a pure function of held bases, so none reads a tree the pin's edits moved. cache.warm() self._spliced_names = set() self._read_effect = self._dynamic_read_effect(node) self._alias_name = self._alias_member_name(node) self._free_global = self._free_global_name(node) self._base_droppable = self._reflective_base_droppable(node) self._eval_string = self._string_argument_value(node) self._intrinsic_callee = self._function_intrinsic_callee(node) self._pending_retire = {} self._retire_candidates = {} self._pending_atomic = {} self._inline_statements(node) self._inline_expressions(node) self._lower_timers(node) self._retire_consumed_temporaries(node) def _note_retirement(self, site: Node, binding: Binding | None) -> None: """ Record that inlining the reflective call at *site* would retire the single-use temporary *binding* — the local whose sole value is the `Function` construction the call invokes. The note is provisional: it is keyed by the site and only acted on once `_confirm_retirement` sees the inlining committed, so a resolution the caller declines (a body that could not be reduced to an expression, a statement `_sanitize_inlined_body` rejects) retires nothing. """ if binding is not None: self._pending_retire[id(site)] = binding def _confirm_retirement(self, site: Node) -> None: """ Acknowledge that the inlining at *site* was committed, marking its temporary a retirement candidate. Whether the candidate may actually go is decided by `_retire_consumed_temporaries` against the model rebuilt after the pin, where every read the splices added is visible. """ binding = self._pending_retire.pop(id(site), None) if binding is None or len(binding.declarations) != 1: return declaration = binding.declarations[0] self._retire_candidates[id(declaration)] = declaration def _retire_consumed_temporaries(self, root: JsScript) -> None: """ Drop the declarator of each single-assignment temporary whose construction invocations this pass inlined and which nothing in the post-splice tree still names. The construction itself is side-effect-free precisely because the inlining succeeded — `_resolve_reflected_body` parses the code and declines a body it cannot, so a construction whose body was inlined provably parses and cannot throw — which is the judgment `refinery.lib.scripts.js.analysis.effects.EffectModel` withholds from an intrinsic under a live reflection surface and only this pass, having parsed the code, can make. That judgment covers the construction alone: its arguments are ordinary expressions whose effects the retirement would delete, so each must be droppable on its own. Every question is asked of the model rebuilt after the pin, because the splices this pass committed are exactly what the pinned model cannot see: a spliced `eval` body or lowered timer that names the temporary, the handed-over global object a folded finder minted through which a callee reads it as a property (both counted by `nothing_still_names`), and the name the analyst declared a host reaches (`a_host_reaches_the_binding`). A reflective surface that survives the pass — an `eval` this pass declined, a string timer it could not lower — can name the temporary at runtime with no reference any model records, so any such site outside the candidates' own constructions refuses the retirement; the constructions themselves are the one surface whose code this pass parsed, which is what makes them transparent rather than opaque. """ if not self._retire_candidates: return cache = model_cache(self, root) model = cache.model transparent: set[int] = set() candidates: list[tuple[Binding, JsVariableDeclarator, JsCallExpression | JsNewExpression]] = [] for declaration in self._retire_candidates.values(): binding = model.binding_of(declaration) if binding is None or binding.exported or len(binding.declarations) != 1: continue declarator = binding.declarations[0].parent if not isinstance(declarator, JsVariableDeclarator) or declarator.init is None: continue construction = strip_parens(declarator.init) if not isinstance(construction, (JsCallExpression, JsNewExpression)): continue transparent.add(id(construction.callee)) candidates.append((binding, declarator, construction)) retired: list[JsVariableDeclarator] = [] for binding, declarator, construction in candidates: if not nothing_still_names(model, [declarator]): continue if a_host_reaches_the_binding(model, binding, self.options): continue if any( id(site) not in transparent for site in model.reflection_surface_sites(binding) ): continue if not all( cache.effects.is_side_effect_free( argument, None, call_established=cache.call_established, discarded=True, reads_may_throw=True, read_established=cache.read_established) for argument in construction.arguments ): continue retired.append(declarator) for declarator in retired: remove_declarator(declarator) self.mark_changed() def _dynamic_read_effect(self, root: JsScript) -> Callable[[Node], bool]: """ A predicate reporting whether reading a node fires a `with` object's getter or may throw a `ReferenceError` a creating write has not certainly completed for, resolved against *root*'s current model. Threaded into the reflective-inlining safety checks so a read that may fire a getter or throw is never dropped as if it were pure — the same throwing read leaf every other discarding context asks (`refinery.lib.scripts.js.analysis.effects.EffectModel.throwing_read_effect` over the shared establishment proof). Resolved lazily through the shared cache, so a script with no reflective site builds no model. """ def read_effect(node: Node) -> bool: cache = model_cache(self, root) return cache.effects.read_throws(node, cache.read_established) return read_effect def _alias_member_name(self, root: JsScript) -> Callable[[Expression | None], str | None]: """ A resolver reporting the intrinsic a global-object-alias member names — `window.eval` yields `'eval'`, `globalThis['setTimeout']` yields `'setTimeout'` — or `None` when the base is not the real, unshadowed global object. A local `window` (a parameter, a `var`, a `with`-object property) names an ordinary object whose member is not the reflective intrinsic and must not be inlined; the model's shadow- and dynamic-scope-aware check is the single source of that judgment. Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`. """ def resolve(callee: Expression | None) -> str | None: if callee is None: return None member = strip_parens(callee) if not isinstance(member, JsMemberExpression): return None base = strip_parens(member.object) if isinstance(base, JsIdentifier) and base.name in self._spliced_names: return None model = model_cache(self, root).model if model.scope_of(member) is None: return None name = model.global_alias_member_name( member, module_scope=runs_as_module(self.options, root)) if name is not None and name in self._spliced_names: return None return name return resolve def _reflective_base_droppable(self, root: JsScript) -> Callable[[Expression | None], bool]: """ Whether the base of a global-object-alias `eval` member may be discarded when its call is inlined. The base must name *this* realm's global object (`names_this_realms_global_object`, the shared same-realm-alias predicate the finder fold and the alias-member collapse also key on), never the cross-realm `top`/`frames`, whose `eval` runs code in another realm the inline would move it out of; and it must resolve without throwing under the pinned host (`SemanticModel.read_may_throw`), so the `ReferenceError` a lacking host raises reading it is not dropped. Resolved lazily against *root*'s current model like `_alias_member_name`. """ def resolve(callee: Expression | None) -> bool: member = strip_parens(callee) if callee is not None else None if not isinstance(member, JsMemberExpression): return False model = model_cache(self, root).model base = strip_parens(member.object) if not isinstance(base, JsIdentifier) or not names_this_realms_global_object(model, base): return False return not model.read_may_throw(base) return resolve def _free_global_name(self, root: JsScript) -> Callable[[Expression | None], str | None]: """ A resolver reporting the reflective intrinsic a bare callee identifier denotes — `eval` yields `'eval'`, `Function` yields `'Function'`, a timer or `execScript` its own name — or `None`. Only a name that could name such a callee is resolved; any other identifier is declined before any model lookup, since no caller acts on a non-reflective name. A local binding (a parameter, a `var`, a `with`-object property) of the name is an ordinary value, not the intrinsic, and must not drive an inline; the model resolves a reference to its binding for a shadow and to `None` for a free global, and `read_has_dynamic_effect` rejects a name read through a dynamic scope. Resolved lazily against *root*'s current model, mirroring `_dynamic_read_effect`. """ def resolve(callee: Expression | None) -> str | None: if callee is None: return None ident = strip_parens(callee) if not isinstance(ident, JsIdentifier) or ident.name not in _REFLECTIVE_CALLEE_NAMES: return None if ident.name in self._spliced_names: return None model = model_cache(self, root).model if model.scope_of(ident) is None: return None if model.resolve(ident) is None and not model.read_has_dynamic_effect(ident): return ident.name return None return resolve def _string_argument_value(self, root: JsScript) -> Callable[[Expression | None], str | None]: """ A resolver folding an argument expression to the string it denotes — `atob('...')` to the code it decodes — or `None`. The interpreter is given *root*'s semantic model, because a call it answers from the built-in registry is the built-in only where nothing has bound that name, and the run's effect model, because whether it is the built-in at all is the trust question no model-free fold may skip. Resolved lazily against *root*'s current models, mirroring `_dynamic_read_effect`. """ def resolve(node: Expression | None) -> str | None: if node is None: return None if self._spliced_names and any( isinstance(ident, JsIdentifier) and ident.name in self._spliced_names for ident in node.walk() ): return None cache = model_cache(self, root) if cache.model.scope_of(node) is None: return None return _try_eval_string_arg(node, cache.model, cache.effects) return resolve def _function_intrinsic_callee(self, root: JsScript) -> Callable[[Expression | None], bool]: """ A resolver reporting whether a callee expression denotes the `Function` intrinsic — the bare free global, a `.constructor` navigation (from a literal, or from a name the model pins to one function), or a name holding one of those, transitively. This is the callee half of every `Function`-construction extraction: a construction spelled with any of those spellings builds the function the named intrinsic does, from the same arguments. The recognition itself is the one shared vocabulary (`refinery.lib.scripts.js.analysis.tampering.denotes_function_intrinsic`), consumed here with the model's facts and this pass's own string resolution and effect checks: the same recognizer answers the tampering oracle's construction sites. The alias hops resolve with the position supplied — the cache's tampering model, its `singular_value_at` — so a volatile alias the run can order answers where the stock question declines. A resolver it cannot decide declines the inline, which is the safe answer for both consumers of it. """ def resolve(callee: Expression | None) -> bool: cache = model_cache(self, root) return denotes_function_intrinsic( callee, cache.model, cache.effects, cache.dominance, eval_string=self._eval_string, read_effect=self._read_effect, positioned_value=cache.tampering.singular_value_at, spliced_names=self._spliced_names, ) is True return resolve def _inline_statements(self, root: JsScript) -> None: for container in list(root.walk()): body = get_body(container) if body is None: continue i = 0 while i < len(body): original = body[i] resolved = self._try_resolve_statement(original, root, container is root) if resolved is None: i += 1 continue mode, parsed = resolved parsed = sanitize_inlined_body(parsed) if parsed is None: self._pending_atomic.pop(id(original), None) i += 1 continue if preserves_script_return(self.options) and mode is not _EndValueMode.NONE: reaches = reaches_script_completion(original, root) if mode is _EndValueMode.SEED_UNDEFINED: parsed = seed_script_end_value(parsed, reaches_completion=reaches) else: parsed = preserve_script_end_value( parsed, returns_undefined=True, reaches_completion=reaches, ) # The deletions an atomically admitted fold carries run before the splice, so the # statements the loop still holds keep their positions. i -= self._remove_consumed_temporaries_of(original, container, i) set_body(container, [*body[:i], *parsed, *body[i + 1:]]) self._confirm_retirement(original) self.mark_changed() i += len(parsed) def _inline_expressions(self, root: JsScript) -> None: for node in list(root.walk()): if not isinstance(node, JsCallExpression): continue if isinstance(node.parent, JsExpressionStatement): continue replacement = self._try_resolve_expression(node, root) if replacement is None: continue _replace_in_parent(node, replacement) self._remove_consumed_temporaries_of(node, None, 0) self._confirm_retirement(node) self.mark_changed() def _lower_timers(self, root: JsScript) -> None: """ Rewrite a string-argument timer — `setTimeout("code", delay)`, `setInterval`, and their `setImmediate`/global-alias variants — into a deferred function call `setTimeout(function () { code }, delay)`, so the evaluated code is deobfuscated without changing when or how often it runs. Unlike the eval and constructor paths, a timer is not inlined at the call site: its value is a handle and its execution is deferred, so only its code string is lowered. `execScript` is not a timer — it evaluates synchronously — so it is inlined in place by `_try_resolve_statement` instead of lowered here. """ for node in list(root.walk()): if isinstance(node, JsCallExpression): self._try_lower_timer(node, root) def _try_lower_timer(self, node: JsCallExpression, root: JsScript) -> None: """ Replace a string timer's code argument with a function wrapping the parsed code, when that code runs safely in the global scope the timer would give it. The wrapper is defined at the call site, so it is held to the same global-scope safety as an indirect eval — its `this` is rewritten to `globalThis`, its free names must still denote the same global, and a top-level declaration (whose global or transient environment a local function cannot reproduce) or a `return`/`await` that a plain function body cannot host declines the lowering, leaving the string timer intact. The body lands inside that wrapper and not as text at the site, so the words it is weighed against are those a plain function reads: `await` and `yield` are names there, and `arguments` is the wrapper's own, whatever the site reads them as. """ code = _extract_string_call_code( node, TIMER_NAMES, alias_name=self._alias_name, free_global_name=self._free_global, eval_string=self._eval_string, ) if code is None: return resolved = self._resolve_reflected_body( code, node, root, ReflectedScope.GLOBAL_EVAL, at_global_scope=False, destination=function_context(False, False), ) if resolved is None: return block = JsBlockStatement(body=resolved.body) wrapper = JsFunctionExpression(params=[], body=block) block.parent = wrapper for stmt in resolved.body: stmt.parent = block _replace_in_parent(node.arguments[0], wrapper) self.mark_changed() def _try_resolve_statement( self, stmt: Statement, root: JsScript, at_global_scope: bool, ) -> tuple[_EndValueMode, list[Statement]] | None: """ Resolve a statement-position reflective call to the statements it should become, paired with how its completion must be held under `preserve_script_return`, or `None`. A `Function`-constructor pack is unpacked and its substituted body admitted like any constructed body; a direct or indirect `eval` and a `Function` body are handled by `_resolve_reflected_call`; `execScript("code")` runs its code synchronously in the global scope and discards the value, so at statement position it is replaced by that code inlined in place. An `await`-ed call is not a plain call expression here, so it is left for the expression pass, which rewrites the `eval` inside `await eval("expr")` to `await (expr)` without dropping the `await`. The first element is the completion adaptation the inlined body needs (`_EndValueMode`). A constructed function and `execScript` returned `undefined` unless the body ends in a value `return` the splice exposes, so a fall-off body is forced to `undefined`. An `eval` returned its own code's completion — a value the code answers with, or `undefined` where it completes empty — which the inlined body reproduces on its own, seeded at `undefined` for the empty run. """ if not isinstance(stmt, JsExpressionStatement) or stmt.expression is None: return None node = stmt.expression if not isinstance(node, JsCallExpression): return None sync = _extract_string_call_code( node, SYNC_EVAL_NAMES, alias_name=self._alias_name, free_global_name=self._free_global, eval_string=self._eval_string, ) if sync is not None: parsed = self._resolve_reflected_body( sync, stmt, root, ReflectedScope.GLOBAL_EVAL, at_global_scope, ) if parsed is None: return None return _EndValueMode.FORCE_UNDEFINED, parsed.body pack = _try_unpack_function_constructor( node, free_global_name=self._free_global, module=runs_as_module(self.options, root), ) if pack is not None: packed, site_resolved, substituted = pack admitted = self._admit_reflected_body( packed, stmt, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope, site_resolved=site_resolved, substituted=substituted, ) if admitted is None: return None constructed = ( _EndValueMode.FORCE_UNDEFINED if body_returns_undefined(admitted.body) else _EndValueMode.NONE ) return constructed, list(admitted.body) if _is_pack_shaped(node, free_global_name=self._free_global): return None resolved = self._resolve_reflected_call(node, stmt, root, at_global_scope) if resolved is None: return None scope, script = resolved if scope is ReflectedScope.FUNCTION_CONSTRUCTOR: mode = ( _EndValueMode.FORCE_UNDEFINED if body_returns_undefined(script.body) else _EndValueMode.NONE ) else: mode = _EndValueMode.SEED_UNDEFINED return mode, script.body def _try_resolve_expression(self, node: JsCallExpression, root: JsScript) -> Expression | None: resolved = self._resolve_reflected_call(node, node, root, at_global_scope=False) if resolved is None: return None scope, parsed = resolved body = parsed.body if len(body) != 1: return None stmt = body[0] if scope is ReflectedScope.FUNCTION_CONSTRUCTOR: if isinstance(stmt, JsReturnStatement) and stmt.argument is not None: return stmt.argument return None if isinstance(stmt, JsExpressionStatement) and stmt.expression is not None: return stmt.expression return None def _resolve_reflected_call( self, node: JsCallExpression, site: Node, root: JsScript, at_global_scope: bool, ) -> tuple[ReflectedScope, JsScript] | None: """ Dispatch a reflective call to the safety gate for its execution scope, pairing the resolved body with that scope or returning `None` to decline. A `Function` constructor or constructor chain is a fresh global-scope function; a direct `eval` runs in the caller's scope; an indirect `eval` runs in the global scope. A string timer is not inlined here: its value is a handle, not the code's completion value, and its deferred execution is preserved instead by `_lower_timers`. A construction whose body the inline route declines — one that binds parameters or reads its `arguments`, or a call that passes any — is not the end: `_try_evaluate_construction` executes the constructed body over the call's argument values, so a body too entangled to splice as text can still resolve the call to the value it answers. """ read_effect = self._read_effect alias_name = self._alias_name free_global_name = self._free_global resolved = self._resolved_constructor_call(node, root) if resolved is not None: ctor_call, retire = resolved body = _function_constructor_body( ctor_call, intrinsic_callee=self._intrinsic_callee, eval_string=self._eval_string) if body is not None: code, ctor_binds = body parsed = self._resolve_reflected_body( code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope, binds=ctor_binds, invocation_arguments=node.arguments, ) if parsed is not None: self._note_retirement(site, retire) return ReflectedScope.FUNCTION_CONSTRUCTOR, parsed parsed = self._try_atomic_construction_inline( node, site, ctor_call, retire, code, root, at_global_scope, binds=ctor_binds) if parsed is not None: return ReflectedScope.FUNCTION_CONSTRUCTOR, parsed self._try_evaluate_construction(node, site, ctor_call, code, retire, root) return None direct = _extract_eval_code( node, free_global_name=free_global_name, eval_string=self._eval_string) if direct is not None: parsed = self._resolve_reflected_body( direct, site, root, ReflectedScope.DIRECT_EVAL, at_global_scope, ) return (ReflectedScope.DIRECT_EVAL, parsed) if parsed is not None else None code = _extract_indirect_eval_code( node, read_effect, alias_name=alias_name, free_global_name=free_global_name, eval_string=self._eval_string, base_droppable=self._base_droppable) if code is not None: parsed = self._resolve_reflected_body( code, site, root, ReflectedScope.GLOBAL_EVAL, at_global_scope, ) return (ReflectedScope.GLOBAL_EVAL, parsed) if parsed is not None else None return None def _resolved_constructor_call( self, node: JsCallExpression, root: JsScript, ) -> tuple[Node, Binding | None] | None: """ The `Function` construction that *node* invokes, paired with the single-use temporary to retire once its sole read is inlined (or `None` to retire nothing). For the immediate forms — `Function("code")()`, `new Function(...)()`, `(function(){}).constructor("code")()` — the construction is `node`'s own callee. When the callee is a bare identifier, the construction is the value the name provably holds (`SemanticModel.singular_value`, which already declines a reassigned or dynamically rebindable binding), taken only where that value is established before *node* (`DominanceModel.binding_established_before`) so the invocation cannot read it out of its temporal dead zone — and not at all for a script-scope name while the program stores a property on the global object under a runtime key (`SemanticModel.has_opaque_global_write`) that the tampering oracle does not clear at *node* (`ModelCache.builtins_intact_at`): under the script execution model such a name is a property of that object, the one such a write may rebind, so its spelled value is not what the call runs — unless every write is guaranteed to follow the invocation. A value that is not itself a construction declines, whatever it denotes: a name holding the intrinsic (`var g = f.constructor`) makes a call through it a construction — one that *builds* a function and never runs it — so there is no invocation for this to resolve, and the call stays standing. The body is inlined at *node*, never the construction relocated, so a `Function` reference in the initializer keeps its original scope; retiring the dead temporary is left to `_retire_consumed_temporaries` on the model rebuilt after the pass. """ callee = strip_parens(node.callee) if isinstance(callee, (JsCallExpression, JsNewExpression)): return callee, None if not isinstance(callee, JsIdentifier): return None if callee.name in self._spliced_names: return None cache = model_cache(self, root) binding = cache.model.resolve(callee) if ( binding is not None and binding.scope is cache.model.root_scope and cache.model.has_opaque_global_write() and not cache.builtins_intact_at(node) ): return None value = strip_parens(cache.model.singular_value(binding)) if not isinstance(value, (JsCallExpression, JsNewExpression)): return None if not cache.dominance.binding_established_before(binding, node): return None return value, binding def _try_atomic_construction_inline( self, node: JsCallExpression, site: Node, ctor_call: Node, retire: Binding | None, code: str, root: JsScript, at_global_scope: bool, *, binds: bool = False, ) -> JsScript | None: """ The atomic route: splice the constructed body at the invocation and delete the temporaries that spell the construction in the same edit, admitting the body against the tree that edit leaves. The plain route holds every site-scope binding of a spliced body's names to the global-resolution rule and every declaration to the capture rule, which is sound — but a body whose injected dead code names exactly the holder (`var h = … .constructor`) or the construction temporary (`var f = h(code)`) collides only with bindings the fold itself consumes, and the collision is an artifact of asking before the edit. This route asks after it: the bindings are excluded from the model's answers, so a name they occupy resolves past it and a use resolving to one is carried off. Only the temporaries may be deleted this way. Everything else the plain route refuses for stays refused — a free name resolving to a binding the fold keeps, a declaration capturing a live reference — because the exclusion covers no binding but the consumed ones. The route runs only where the plain admission declined, so a construction whose argument is not droppable (`atob(…)`) keeps every fold the plain route already gives it: the atomic gates — a droppable initializer for each holder, arguments no other fold must keep — are asked of no construction that inlines without them. A body that binds parameters keeps declining with the plain route, which is where the evaluation route below picks it up. """ if binds: return None cache = model_cache(self, root) aliases = function_intrinsic_aliases( ctor_call.callee, cache.model, cache.effects, cache.dominance, eval_string=self._eval_string, read_effect=self._read_effect, positioned_value=cache.tampering.singular_value_at, spliced_names=self._spliced_names, ) if aliases is None: return None consumed = list(aliases) if retire is not None: consumed.append(retire) if not consumed: return None declarators = self._consumed_temporaries_may_go(cache, consumed, ctor_call, site) if declarators is None: return None parsed = self._resolve_reflected_body( code, site, root, ReflectedScope.FUNCTION_CONSTRUCTOR, at_global_scope, invocation_arguments=node.arguments, exclude=frozenset(consumed), ) if parsed is None: return None self._spliced_names |= {binding.name for binding in consumed} self._pending_atomic[id(site)] = declarators return parsed def _consumed_temporaries_may_go( self, cache: ModelCache, consumed: list[Binding], ctor_call: Node, site: Node, ) -> list[JsVariableDeclarator] | None: """ Whether every temporary the fold consumes — the holder names the construction's callee resolves through and the construction binding itself — may be deleted together with the splice, returning their declarators or `None`. The questions are the ones `_retire_consumed_temporaries` asks after the pin, asked here against the pinned model because this deletion rides the same edit as the splice rather than following it: nothing outside the region the edit replaces may still name a temporary (a second read, a `with`-body use, an alias through a form no pass matches), no host the analyst named reaches one, and no opaque reflective surface the edit does not carry off could name one at runtime with no reference any model records. The construction itself is the one surface whose code this fold parsed, which is what makes deleting it side-effect-free; its arguments and each holder's initializer must be droppable on their own — a `.constructor` read is a property read, droppable only while the chain it consults is intact. The hop ordering the recognizer proves is what makes each declarator hold the value the construction read, so no establishment question is asked here again. """ model = cache.model declarators: list[JsVariableDeclarator] = [] for binding in consumed: if binding.exported or len(binding.declarations) != 1: return None declaration = binding.declarations[0] declarator = declaration.parent if not isinstance(declarator, JsVariableDeclarator) or declarator.init is None: return None if a_host_reaches_the_binding(model, binding, self.options): return None declarators.append(declarator) region = [site, *declarators] if not nothing_still_names(model, region): return None for binding in consumed: if any( not any( surface is removed or surface.is_descendant_of(removed) for removed in region ) for surface in model.reflection_surface_sites(binding) ): return None for declarator in declarators: init = strip_parens(declarator.init) if init is ctor_call: continue if not self._droppable_within_region(cache, init, region): return None if not all( self._droppable_within_region(cache, argument, region) for argument in ctor_call.arguments ): return None return declarators def _droppable_within_region( self, cache: ModelCache, node: Node, region: list[Node], ) -> bool: """ Whether dropping the evaluation of *node* — an initializer or construction argument inside *region* — removes nothing observable, with every member read judged on the tree the edit deleting *region* leaves standing rather than the one the pass is pinned to. The reads this asks about all live inside the region, so each one's chain question reaches `getter_free_read_dropped_with`, which forgives exactly the opaque surfaces the same edit carries off and none of those it leaves. """ return cache.effects.is_side_effect_free( node, None, member_safe=lambda member: cache.effects.getter_free_read_dropped_with(member, region), call_established=cache.call_established, discarded=True, reads_may_throw=True, read_established=cache.read_established, ) def _remove_consumed_temporaries_of( self, site: Node, container: Node | None, index: int, ) -> int: """ Delete the declarators the committed splice at *site* consumes — the second half of the one edit `_try_atomic_construction_inline` admitted against — returning how many statements vanished from the body of *container* before *index*, so a caller splicing at that index splices at the shifted one. Where the splice does not replace a statement of *container*'s body — *container* is `None` — the declarators' parents are untouched by the replacement and no shift is returned. A declaration left with other declarators keeps its statement, which is why only a sole declarator counts as taking one away. """ removed = self._pending_atomic.pop(id(site), []) vanished = 0 body = get_body(container) if container is not None else None for declarator in removed: declaration = declarator.parent if ( body is not None and isinstance(declaration, JsVariableDeclaration) and declaration.parent is container and len(declaration.declarations) == 1 and next((k for k, s in enumerate(body) if s is declaration), len(body)) < index ): vanished += 1 remove_declarator(declarator) return vanished def _try_evaluate_construction( self, node: JsCallExpression, site: Node, ctor_call: Node, code: str, retire: Binding | None, root: JsScript, ) -> None: """ The evaluation route: execute the body a `Function` construction builds over the argument values the call passes, and replace the call with the value it answers. Where the inline route splices the body's text — sound only where nothing in it binds parameters, reads `arguments`, or observes the call's arguments — this route runs the body as an interpreter would, so exactly the bodies the inline route exists for are the ones this one resolves: the decoders that fold their arguments through `arguments[p]`. The interpreter runs the parsed fragment against two models, one question per model. The real program's effect model answers name integrity — which built-in is still the built-in — and the tampering oracle vouches for it at *node*, the anchor: a construction a later tampering site would not reach may trust what the program-wide questions refuse. The fragment model over the parsed body answers name resolution, so a free name in the fragment denotes the host global or nothing, never a binding of the real tree. Two static gates refuse before anything runs. The fragment may not write a free name — every write form, through the role machinery — for an execution that replaced the call would drop the write the real program performs. And the fragment's free names must be disjoint from the real tree's root-scope bindings: script-level `let`, `const`, `class` and `var` names live in the global lexical environment, visible to a `Function`-constructed body, so a fragment reading one answers a binding this route cannot see. Root scope only — a binding nested inside a function is invisible to code the global scope runs, and reading it as though it were reachable is what the tree-binding gate exists to stop. A third gate refuses a fragment reading a name this pass has spliced in. The route runs inside the pass's pinned-model window, so a statement an earlier site of the same pass inlined — the write an `eval` carried, spliced after the models were built — is one no pinned fact records: the name's write is invisible to the effect model that would refuse it, and the fragment would answer a value the program replaced. The names the pass has spliced are the gate's own record, the same one the inline route refuses through. The route's remaining safety is the two-leg argument `refinery.lib.scripts.js.analysis.tampering` states — the model-backed site enumeration that fails closed on the nodes this pass splices in. """ fragment = _parse_construction_function(ctor_call, code) if fragment is None: return fragment_script, function = fragment cache = model_cache(self, root) fragment_model = build_semantic_model(fragment_script) if _body_written_free_names(fragment_model, fragment_script): return free = _body_free_names(fragment_model, fragment_script) if not free.isdisjoint(cache.model.root_scope.bindings): return if not free.isdisjoint(self._spliced_names): return argument_values = self._construction_argument_values(node, root) if argument_values is None: return from refinery.lib.scripts.js.deobfuscation.interpreter import ( InterpreterError, IrreducibleExpression, JsInterpreter, _ThrowSignal, ) interpreter = JsInterpreter( effects=cache.effects, model=fragment_model, anchor=node, tampering=cache.tampering, ) try: result = interpreter.execute(function, argument_values) except (InterpreterError, IrreducibleExpression, _ThrowSignal, RecursionError, ValueError, OverflowError): return if not replace_with_value(node, result): return self._note_retirement(site, retire) self._confirm_retirement(site) self.mark_changed() def _construction_argument_values( self, node: JsCallExpression, root: JsScript, ) -> list | None: """ The values of *node*'s arguments, extracted under the evaluator's gates: a literal value, or an interpreter evaluation — anchored at *node*, like the execution it feeds — of an argument whose evaluation is side-effect free and cannot throw before the body runs. `None` refuses the call: an argument with an effect or a throw is the program's business, not a value this route may consume silently. Passing the values, rather than substituting their text, is what keeps call-time binding — the value is read at the call, whatever the name held when the fragment was written. """ cache = model_cache(self, root) values: list = [] for argument in node.arguments: if argument is None: return None ok, value = extract_literal_value(argument) if ok: values.append(value) continue if not cache.effects.is_side_effect_free( argument, None, call_established=cache.call_established, discarded=True, reads_may_throw=True, read_established=cache.read_established, ): return None from refinery.lib.scripts.js.deobfuscation.interpreter import ( InterpreterError, IrreducibleExpression, JsInterpreter, _ThrowSignal, ) interpreter = JsInterpreter( effects=cache.effects, anchor=node, tampering=cache.tampering, established=lambda callee: cache.dominance.established_before(callee, node), ) try: values.append(interpreter.eval_expression(argument)) except (InterpreterError, IrreducibleExpression, _ThrowSignal, RecursionError, ValueError, OverflowError): return None return values def _argument_may_be_dropped(self, argument: Node, root: JsScript) -> bool: """ Whether replacing a call that passes *argument* with the body it invokes may drop the argument's evaluation. A literal is required — the value the call reads is one no splice of the body reproduces, so anything that reads a name or runs a call keeps the call standing — and the literal must additionally be one whose evaluation is side-effect free and cannot throw, the same question `_retire_consumed_temporaries` asks of the arguments of a construction it drops: a fold never mutes the program's own effects. The second gate holds of every literal the first admits today, and is what keeps the first one sound to widen. """ ok, _ = extract_literal_value(argument) if not ok: return False cache = model_cache(self, root) return cache.effects.is_side_effect_free( argument, None, call_established=cache.call_established, discarded=True, reads_may_throw=True, read_established=cache.read_established, ) def _destination_may_be_strict(self, site: Node, root: JsScript) -> bool: """ Whether the destination of an inline could run the spliced text in strict mode. A syntactically strict site is strict; and the module execution model is strictness-ambiguous — `DeobfuscationOptions.module` covers a strict ES module and a sloppy CommonJS file alike, and nothing here tells the two apart — so a module destination is treated as possibly strict. This is the one mode the reflected text must be legal and mode-invariant in, because inlining must be sound for the strict reading too: a sloppy-only body spliced into what turns out to be an ES module is a `SyntaxError` that takes the whole file down, or behaves differently, where the call it replaced merely raised one the site caught. The sloppy reading only ever inlines a strict subset of what this admits, so declining here costs a CommonJS-only recall and never a soundness. This is why it is asked at every reflection strictness gate rather than left to `strict_mode_at`, whose pure-tree answer is blind to the module option, and why the complementary sloppy-conservative gates — a mapped `arguments` object, a rest unpacking — read `strict_mode_at` alone: their soundness runs the other way, so they must assume the sloppy CommonJS reading, not the strict one. """ return strict_mode_at(site) or runs_as_module(self.options, root) def _resolve_reflected_body( self, code: str, site: Node, root: JsScript, scope: ReflectedScope, at_global_scope: bool, *, binds: bool = False, destination: CodeContext | None = None, invocation_arguments: list[Node] | None = None, exclude: Collection[Binding] = (), ) -> JsScript | None: """ Parse reflectively evaluated *code* and admit it through `_admit_reflected_body`, or decline (`None`). A body that binds parameters (*binds*) cannot be inlined as text, so it declines before the parse; a body that observes its arguments declines in the admission, which has the parsed body to ask. *invocation_arguments* is the argument list of the call the splice would replace, where the code is a construction's body and the call is the invocation of what it built — `None` where the site is no call that passes any. *destination* is the context the text is read in once spliced, where that is not *site*'s own: a string timer's body lands inside a plain function of its own. *exclude* is the set of bindings the same edit deletes, handed to the admission as its post-rewrite view. """ if binds: return None parsed = _try_parse( code, strict=self._destination_may_be_strict(site, root), module=runs_as_module(self.options, root), context=code_context_at(site) if destination is None else destination, ) if parsed is None: return None return self._admit_reflected_body( parsed, site, root, scope, at_global_scope, invocation_arguments=invocation_arguments, exclude=exclude, ) def _admit_reflected_body( self, parsed: JsScript, site: Node, root: JsScript, scope: ReflectedScope, at_global_scope: bool, *, site_resolved: frozenset[str] = frozenset(), substituted: Collection[JsIdentifier] = (), invocation_arguments: list[Node] | None = None, exclude: Collection[Binding] = (), ) -> JsScript | None: """ Decide whether inlining the reflected body *parsed* at *site* preserves meaning, given the `ReflectedScope` it runs in. Global-scope code — a `Function`-constructed body or indirect `eval`/string-timer code — must run in the global sloppy mode it would have: a strict context at *site* declines a body that would diverge under strict mode (`diverges_under_strict`), as does a `"use strict"` prologue; every receiver `this` becomes `globalThis`; and a body reading `arguments`, `super`, or `new.target`, or a free name that no longer denotes the same global at *site* — including one a `with` on the path could capture — declines. Direct `eval` runs in the caller's scope, which is *site* itself, so its references and `this` are already correct there and only the checks below apply. A top-level `return` is a SyntaxError in evaluated code, so an eval body with one declines. Declaration handling is delegated to `_reflected_declarations_safe`. Anything not provably safe is left intact (returns `None`) — declining is always sound. *invocation_arguments* is the argument list of the call the splice replaces, where the site is the invocation of a construction: the call evaluates those arguments before the body runs, so a splice that drops them may mute an effect or a throw. Every one of them must be droppable — `_argument_may_be_dropped` — and a body reading its `arguments` declines below, for the call's arguments are exactly what such a body observes. *site_resolved* is the one exemption the pack route earns: a name its proxy substitution introduced resolves at the site by construction, the accessor spelling it being defined there, so it is not held to the global-resolution rule the reflected code's own free names must meet. The exemption is conditional, and the admission holds the condition itself: every identifier entering the body from outside its own text must still resolve to the implicit global where it lands, asked of the model this admission builds. Those the pack substitution spliced in (*substituted*) are declined where a body binding captures one; a landing inside a `with` body is accepted, since the `with` objects of packed code rebind the machinery names the pack itself spells and never the accessor targets. Those the receiver rewrite above synthesized are declined by a body binding capture as well, and additionally by a landing the model cannot resolve determinately — inside a `with` or another dynamic region — since the original read was of the receiver, which no `with` object supplies. Every other check still applies to an exempted name. Every name-based answer above is read from the model pinned before any splice, so a body naming what an earlier splice this pass declared or wrote is declined outright: for such a name the pinned lookup, capture, and dominance answers describe a tree that no longer exists. The declined site is untouched and inlines on the next pass, whose model has seen the splice. A body that only reads names no splice bound contributes nothing to that veto, so a chain of sites sharing free reads still inlines in one pass. """ resolves_globally = scope is not ReflectedScope.DIRECT_EVAL site_is_strict = strict_mode_at(site) if declares_use_strict(parsed) and (resolves_globally or not site_is_strict): return None synthesized: list[JsIdentifier] = [] if resolves_globally: synthesized = rewrite_receiver_this_to_global(parsed) if references_receiver_this(parsed) or references_new_target(parsed): return None if scope is not ReflectedScope.FUNCTION_CONSTRUCTOR and _has_top_level_return(parsed.body): return None if invocation_arguments and any( argument is not None and not self._argument_may_be_dropped(argument, root) for argument in invocation_arguments ): return None body_model = build_semantic_model(parsed) for ident in substituted: binding = body_model.resolve(ident) if binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL: return None for ident in synthesized: binding = body_model.resolve(ident) if binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL: return None if binding is None and crosses_dynamic_scope(body_model.scope_of(ident)): return None if resolves_globally and self._destination_may_be_strict(site, root) and diverges_under_strict( parsed, body_model, site_resolved, ): return None free = _body_free_names(body_model, parsed) if resolves_globally and 'arguments' in free: return None declared = _body_declared_names(body_model) if not self._spliced_names.isdisjoint(free | declared): return None if not free and not declared: return parsed root_model = model_cache(self, root).model site_scope = root_model.scope_of(site) if site_scope is None: return None if resolves_globally and free: if crosses_dynamic_scope(site_scope): return None for name in free: if name in site_resolved: continue binding = root_model.lookup(name, site_scope, exclude=exclude) if binding is not None and not root_model.reaches_global_object( binding, module_scope=runs_as_module(self.options, root), ): return None if declared and not self._reflected_declarations_safe( body_model, root_model, site_scope, site, scope, at_global_scope, exclude, ): return None self._spliced_names |= declared | _body_written_free_names(body_model, parsed) return parsed def _reflected_declarations_safe( self, body_model: SemanticModel, root_model: SemanticModel, site_scope: Scope, site: Node, scope: ReflectedScope, at_global_scope: bool, exclude: Collection[Binding] = (), ) -> bool: """ Whether the top-level declarations of a reflected body can be reproduced by inlining it at the call site. A `Function`-constructed body's declarations are local to the created function and lift into the caller's scopes (`inlined_declarations_safe`); evaluated code declares in its execution scope and is handled by `_eval_declarations_safe`. *exclude* carries the atomic fold's post-rewrite view to the lift check. """ if scope is ReflectedScope.FUNCTION_CONSTRUCTOR: return inlined_declarations_safe( body_model.root_scope, root_model, site_scope, exclude=exclude) return self._eval_declarations_safe( body_model, root_model, site_scope, site, scope, at_global_scope, ) def _eval_declarations_safe( self, body_model: SemanticModel, root_model: SemanticModel, site_scope: Scope, site: Node, scope: ReflectedScope, at_global_scope: bool, ) -> bool: """ Whether an `eval` body's top-level declarations can be inlined at the call site. A `let`/`const`/`class` lives in a declarative environment discarded when the evaluation returns, so a persistent inlined binding differs only if a name it declares is referenced outside the body; it is declined exactly when introducing it at the site would capture such a reference. A `var` or function persists: under indirect eval it becomes a global-object property, reproducible only at top-level script scope and never under the module model; under direct eval it lands in the caller's variable scope, but never under a strict direct eval, whose `var` stays local to the eval. The module execution model could be a strict ES module, where the `var` is ephemeral, so `_destination_may_be_strict` treats a module direct eval as possibly strict and declines the inline; the sloppy CommonJS reading would leak the `var` and keep the inline, but declining is sound for both. Such a declaration hoists to the head of its variable scope, so it is inlined only when the eval site strictly dominates every reference to the name already there — one that runs before it or shares its statement, or reads the name through a closure, would be rebound. """ root = root_model.root bindings = body_model.root_scope.bindings lexical = {name for name, binding in bindings.items() if binding.is_lexical} if lexical and root_model.would_capture(lexical, site_scope): return False hoisted = {name for name, binding in bindings.items() if binding.is_hoisted} if not hoisted: return True if scope is ReflectedScope.GLOBAL_EVAL: if runs_as_module(self.options, root) or not at_global_scope: return False elif self._destination_may_be_strict(site, root) or declares_use_strict(body_model.root): return False var_scope = site_scope.var_scope if var_scope is None: return False dominance = model_cache(self, root).dominance return all( dominance.strictly_dominates(site, node) for node in name_uses_in_scope(hoisted, var_scope) )Ancestors
Inherited members