Module refinery.lib.scripts.js.deobfuscation.simplify
JavaScript syntax normalization transforms.
Expand source code Browse git
"""
JavaScript syntax normalization transforms.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from refinery.lib.scripts import (
Expression,
Node,
_replace_in_parent,
set_child,
set_child_list,
set_value,
)
from refinery.lib.scripts.js.analysis.assignment import DefiniteAssignmentModel
from refinery.lib.scripts.js.analysis.cache import ModelCache
from refinery.lib.scripts.js.analysis.dominance import DominanceModel
from refinery.lib.scripts.js.analysis.effects import GLOBAL_OBJECT, EffectModel
from refinery.lib.scripts.js.analysis.environment import GUARANTEED_GLOBALS
from refinery.lib.scripts.js.analysis.intrinsics import ESCAPE_WATCHED_ROOTS
from refinery.lib.scripts.js.analysis.model import (
FUNCTION_NODES,
SAME_REALM_GLOBAL_OBJECT_ALIASES,
Binding,
BindingKind,
ContainerRole,
Role,
SemanticModel,
container_reference_role,
enclosing_operator,
is_constructed_or_invoked,
is_invocation_target,
is_member_write_target,
reference_role,
)
from refinery.lib.scripts.js.analysis.reaching import ReachingModel
from refinery.lib.scripts.js.builtins import OBJECT_PROTOTYPE_MEMBERS
from refinery.lib.scripts.js.deobfuscation.helpers import (
FUNCTION_MEMBERS,
MAX_RESULT_ARRAY_LEN,
PinnedTransformer,
access_key,
allocated_object_type,
coerces_uninterceptably,
converted_operand,
denoted_value,
escape_js_string,
folded_literal,
is_literal,
is_nullish,
is_simple_expression,
is_truthy,
is_valid_property_key,
make_string_literal,
numeric_value,
spelled_for_the_callee_position,
string_value,
try_inline_trivial_function,
utf16_code_units,
value_to_node,
)
from refinery.lib.scripts.js.deobfuscation.interpreter import (
STATIC_OBJECTS,
RegisteredBuiltin,
registered_builtin,
)
from refinery.lib.scripts.js.keys import expression_key, spelled_upward
from refinery.lib.scripts.js.model import (
LOGICAL_ASSIGNMENT_OPERATORS,
JsArrayExpression,
JsArrowFunctionExpression,
JsAssignmentExpression,
JsBinaryExpression,
JsBooleanLiteral,
JsCallExpression,
JsClassDeclaration,
JsClassExpression,
JsConditionalExpression,
JsExpressionStatement,
JsFunctionExpression,
JsIdentifier,
JsLogicalExpression,
JsMemberExpression,
JsNumericLiteral,
JsObjectExpression,
JsParenthesizedExpression,
JsProperty,
JsScript,
JsSequenceExpression,
JsSpreadElement,
JsStringLiteral,
JsUnaryExpression,
callee_form_sensitive,
object_member_access_runs_accessor,
static_property_key,
static_string,
strip_parens,
)
from refinery.lib.scripts.js.numbers import canonical_array_index, exact_integer
from refinery.lib.scripts.js.options import clock_window
from refinery.lib.scripts.js.precedence import parens_required
from refinery.lib.scripts.js.strict import joins_directive_prologue, spelling_states
from refinery.lib.scripts.js.synth import JsSynthesizer
from refinery.lib.scripts.js.values import (
BINARY_OPS,
BuiltinFunction,
converts_uninterceptably,
to_string,
)
if TYPE_CHECKING:
from refinery.lib.scripts.js.values import Value
_OBJECT_PROTO_PROPERTIES = OBJECT_PROTOTYPE_MEMBERS
_EMPTY_OBJECT_PROPERTIES = _OBJECT_PROTO_PROPERTIES
_MIRRORED_COMPARISONS = {
'<' : '>',
'<=' : '>=',
'>' : '<',
'>=' : '<=',
}
def _respells(literal: Expression, node: Node) -> bool:
"""
Whether writing *literal* in place of *node* would write the same node again.
"""
if type(literal) is not type(node):
return False
synthesizer = JsSynthesizer()
return synthesizer.convert(literal) == synthesizer.convert(node)
def _is_the_object_of_an_in(node: Node) -> bool:
"""
Whether *node* is the right operand of an `in`, which reads the keys of the object it names and
stores none.
"""
holder = enclosing_operator(node)
return (
isinstance(holder, JsBinaryExpression)
and holder.operator == 'in'
and strip_parens(holder.right) is node
)
def _shows_the_models_a_name(held: ModelCache, replaced: Node, value: str, string: bool) -> bool:
"""
Whether a value whose key is *value*, a string where *string* says so and otherwise a number,
a boolean or `null`, written in place of *replaced*, shows the held models a fact they do not
record: an answer assumed that *replaced*, or a parenthesis or a concatenation the value
completes, is none of the names a reader looked for, and the key read there is one of them
(`refinery.lib.scripts.js.keys.KeyAssumptions.revealed_by`); or a string on that climb spells a
name whose readers look at every string and still answer no
(`refinery.lib.scripts.js.analysis.model.SemanticModel.strings_still_unseen`). The climb starts
at *replaced*, which still stands in the tree, and builds each string from the one below it
(`refinery.lib.scripts.js.keys.spelled_upward`).
"""
model = held.model
assumed = model.assumed_keys
unseen = model.strings_still_unseen()
return any(
text in unseen or assumed.revealed_by(node, text)
for node, text in spelled_upward(replaced, value, string)
)
class JsSimplifications(PinnedTransformer):
def __init__(self):
super().__init__()
self._renewal_due = False
self._undecided: set[Node] = set()
def _forget_edits(self) -> None:
self._renewal_due = False
self._undecided = set()
@property
def cache(self) -> ModelCache:
"""
The models a decision reads (`_facts`).
"""
return self._facts()
def _facts(self) -> ModelCache:
"""
The held models, renewed first where a fold since the last renewal wrote what shows them a
fact the held ones lack (`visit`, `_written_constructor`). The renewal waits for the
decision after the fold, by which point what it wrote stands in the tree (`visit`). A
decision that hands this pass to a helper reading the shared cache by itself, as
`refinery.lib.scripts.js.deobfuscation.helpers.try_inline_trivial_function` does, asks this
first.
"""
if self._renewal_due:
self.renew_models()
return self.held_models()
def visit(self, node: Node) -> Node | None:
"""
Visit *node*, and make a renewal due where the replacement a fold returns for it reads as a
key *node* did not read as, a string, a number, a boolean or `null`, and that key shows the
held models a fact (`_shows_the_models_a_name`). Every fold returns its replacement through here, so a key
that `String.fromCharCode`, a `join`, a sequence or a conditional collapses to is seen as
surely as one an operator computes. The dispatch of `refinery.lib.scripts.Visitor.visit` is
written out rather than called, so that a deep tree costs this pass no more stack than it
costs any other transform.
The check climbs from *node*, which still stands in the tree. A replacement returned while a
renewal is due is written in its place at once: the walk of
`refinery.lib.scripts.Transformer.generic_visit` writes the items of a list only once it has
visited all of them, so the renewal the next item's decision makes would otherwise build
models of the tree the fold has not changed yet, and count itself done. Every other
replacement is left to the walk, since writing one in place scans the fields of its parent,
and a list of folded items would pay its square.
"""
kind = type(node)
handler = self._dispatch.get(kind)
if handler is None:
handler = self._dispatch[kind] = getattr(self, F'visit_{kind.__name__}', self.generic_visit)
replacement = handler(node)
if replacement is None:
return None
if not self._renewal_due:
value = expression_key(replacement)
if value is not None and expression_key(node) != value:
string = static_string(replacement) is not None
self._renewal_due = _shows_the_models_a_name(self.held_models(), node, value, string)
if self._renewal_due:
_replace_in_parent(node, replacement)
return replacement
@property
def model(self) -> SemanticModel:
return self.cache.model
@property
def assignment(self) -> DefiniteAssignmentModel:
return self.cache.assignment
@property
def effects(self) -> EffectModel:
return self.cache.effects
@property
def dominance(self) -> DominanceModel:
return self.cache.dominance
@property
def reaching(self) -> ReachingModel:
return self.cache.reaching
def _effect_model(self) -> EffectModel:
"""
The effect model a question of `refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`
is asked of when it is asked, which is the one `cache` holds by then.
"""
return self.effects
def _global_object_alias_base(self, member: JsMemberExpression) -> bool:
"""
Whether *member*'s base is a local single-assigned to the global object whose value reaches this
read unchanged — the `var g = globalThis || {}; g.String` idiom. The value must be established
before the read (`ReachingModel.value_preserved`), so collapsing `g.String` to `String` cannot
turn a not-yet-assigned `undefined.String` into a different result. The bare syntactic-alias case
(`globalThis.String`) is handled by `global_alias_member_name`; this covers only the local alias.
"""
base = member.object
if not isinstance(base, JsIdentifier) or self._names_a_global(member) is not None:
return False
binding = self.model.resolve(base)
if binding is None:
return False
value = self.model.singular_value(binding)
if value is None or self.effects.intrinsic_of(value) is not GLOBAL_OBJECT:
return False
return self.reaching.value_preserved(binding, value, base)
def visit_JsScript(self, node: JsScript):
"""
Attach the shared model cache for the whole script, then rewrite with the models held for
the length of the pass (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`),
renewed only where a fold shows them a fact. Rebuilding them per rewrite is what made
deobfuscation cost the product of tree size and rewrite count; a fold gate is consulted on
nearly every rewrite, so each rewrite paid for a full rebuild of the semantic and effect
models.
Holding them is sound because this pass cannot loosen the facts those models report. It only ever
removes bindings, never adds one, so a name reported as locally bound stays bound and reading
shadowing from a held model can only over-preserve a global-alias access, never collapse one a
local now captures. In the same way it cannot reveal an intrinsic as patched: an install is
attributed to every name its target may denote, before any fold collapses the target to a plain
name, so `_globals_written` and `global_pristine` already account for the rewrites this pass
performs. Were that not so, a held model would report a patched built-in as pristine and admit a
fold against it.
"Every name its target may denote" spans two kinds of collapse, and both must already be accounted
for. One is syntactic — `0 || Math` names `Math` before the fold that reduces it — and the other is
an alias: `var m = Math; m.floor = f` names `Math` though the assignment mentions only `m`, so
inlining the alias reveals nothing the held set lacked.
A write need not be in the file at all for the same argument to be needed. An intrinsic handed to
code whose writes cannot be enumerated — `patch(Math)` for an unresolvable `patch` — is recorded as
written when it *escapes*, and escaping is decided through the same value-forwarding forms, so a
fold that collapses `p(0 || Math)` to `p(Math)` finds the name already recorded. What the pass can do
is remove an escape, by deleting a dead branch containing one; that shrinks the set, which is the
direction a held answer may safely be stale in.
A built-in object a read reaches without spelling its owner is one the scan cannot attribute
a write through, and a value that may be one is unattributed
(`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.followable`): a fold that
reads such a value away, or collapses a key it was read under to a name the scan does not
count, leaves fewer of them, never more.
Two edits do show the models a fact the held ones lack: a string written where an answer
assumed a key it could not read is none of the names its reader looked for, or a string
spelling a name whose readers look at every string, which any fold collapsing a key may
write (`visit`); and the name of a constructor written where the scan then records a write
or a hand-off through it. Such a fold renews the models before the next decision
(`cache`).
The same argument covers the freshness the effect model reports for a container base. A binding is
judged fresh only when this pass owns every value it takes, and the pass adds no value to any binding;
the array guarantee that an allocating method's result depends on is withdrawn by a write to
`constructor` or `__proto__`, where a key this pass cannot read counts as possibly naming one. Folding
a computed key to a literal therefore moves that answer from withdrawn to withdrawn — never from
granted to withdrawn — which is the direction a held model may be stale in.
The pin also has to keep every model the window reads consistent with its entry. A model that
reads the live tree would otherwise be built at the first gate that consults it, which can
fall after an earlier fold's edit, layering it over held base models from an earlier tree —
the one state no unpinned run builds, which the pin's exit refuses. Building the models at
entry, before the first fold, forecloses that; a model derived only from them stays a pure
function of the held bases wherever it is first built.
"""
with self.holding_models(node):
self.held_models()
self.generic_visit(node)
return None
def _resolves_to_local(self, member: JsMemberExpression, name: str) -> bool:
"""
Whether *name*, written as a bare identifier where *member* sits, would bind to a local
declaration rather than the global the `<global-alias>.name` access denotes. An implicit
global is not a local: the bare name and the property access name the same global, so
collapsing them stays sound. An unmapped or dynamically-scoped position is treated as bound,
leaving the access untouched where the model cannot prove the name is free.
"""
scope = self.model.scope_of(member)
if scope is None:
return True
binding = self.model.lookup(name, scope)
return binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL
def _alias_collapse_is_sound(
self, member: JsMemberExpression, base: JsIdentifier, name: str
) -> bool:
"""
Whether collapsing `<global-alias>.name` — base *base*, property *name* — to the bare `name`
preserves both throws the access can raise, so the rewrite drops neither. The binding of *name*
is resolved once and answers both.
The property must be defined, so the `undefined` a resolvable read yields where the property is
absent does not become a `ReferenceError`: a free name must be one the specification mandates on
the global object (`GUARANTEED_GLOBALS`); a name the program itself defines as a global (an
implicit global) exists only after its establishing write, so it is admitted only when a write
is proven to run before this read — interprocedurally, so a top-level write still covers a read
inside a function invoked after it, but strictly, so a same-statement or earlier read is
declined.
The base must resolve, so the `ReferenceError` an absent host raises reading it is not dropped:
a base the language mandates in every host (`globalThis`) or one the pinned host defines never
throws (`SemanticModel.read_may_throw`). A host-conditional alias (`window`, `self`, `top`,
`frames`, `global`) may not resolve on its own — but where *name* is a global the program itself
defines, an establishing write made through this same alias spelling reads the base before
assigning, so its having run (which the definite-assignment proof requires) proves the base
resolved on every path reaching this read. A write through a *second* alias, or a bare
assignment naming no alias, establishes *name* without resolving this base, so the read is kept
unless the host defines the base outright; this is the cross-alias hole the resolution fact
closes.
"""
binding = self.model.lookup(name, self.model.scope_of(member))
if binding is None:
if name not in GUARANTEED_GLOBALS:
return False
return not self.model.read_may_throw(base)
if not self.assignment.definitely_assigned_at(binding, member):
return False
return self._collapse_base_resolves(binding, base)
def _collapse_base_resolves(self, binding: Binding, base: JsIdentifier) -> bool:
"""
Whether reading *base* is certain not to throw where one of *binding*'s accesses is collapsed to
a bare name. The base resolves when the host defines it, or when every write establishing the
global goes through this same alias spelling: such a write reads the base before it assigns, so
the write the definite-assignment proof relies on having run proves the base resolved, and no
path reaches the collapse without such a write. A write through another alias or a bare
assignment resolves nothing about this base, so its presence leaves the base unproven.
"""
if not self.model.read_may_throw(base):
return True
writes = (*binding.writes, *binding.indefinite_writes)
return bool(writes) and all(
isinstance(write, JsMemberExpression)
and isinstance(write_base := strip_parens(write.object), JsIdentifier)
and write_base.name == base.name
for write in writes
)
def _names_a_global(self, member: JsMemberExpression) -> str | None:
"""
The global *member* names, read under the execution model this run was asked for and only
where the base names this realm's global object. Every fold this pass makes on the strength
of an access naming a global asks it this way, since a rewrite it drives is one an analyst
runs the file under that model.
`top` and `frames` name another document's global object, so a property on one is not the
property a bare name in this file reads and a fold that treats the two as one turns an
`undefined` into a `ReferenceError`. They are read as global-object bases all the same
wherever the answer only keeps code; a rewrite is the case where it does not.
"""
base = strip_parens(member.object)
if isinstance(base, JsIdentifier) and base.name not in SAME_REALM_GLOBAL_OBJECT_ALIASES:
return None
return self.model.global_alias_member_name(member)
def _resolve_in(self, node: JsBinaryExpression, key: str) -> bool | None:
"""
Statically resolve `key in name` by asking the model what value *name* holds. A sole function,
an empty class, or an empty object literal has a bounded property set — the built-in members of
its type plus the own properties the binding is assigned — so membership is decidable; any other
value, or one whose own-property set cannot be bounded, yields `None`. The binding is
resolved through the model, so the answer is shadowing-correct across scopes and recognizes
the bare-assignment form namespace flattening leaves, not only declarations. The value
reads `undefined` until whatever establishes it — a declarator initializer, a class
declaration, or a lone assignment — has run, so a `key in name` whose establishing node does
not run before the read is left unresolved rather than fold away the `TypeError` a premature
read would throw.
Bounded is a claim about the prototype chain and holds only while the file leaves that chain
alone, so **both** answers need the chain asked about, and both ask it the same way. A name
the tables do not list is there once `Object.prototype.z = 9` has run, and a name they do
list is gone once a `delete` has removed it, so neither side is free and neither may be
answered under a surface the other refuses under. The tables are asked for the whole
property set and not the inherited half `property_provably_absent` enumerates, an own
`length` and `prototype` included, so the membership question stays this one's and only the
chain question is shared. A class constructor is a function value, and its chain is a
function's. The chain asked about is the one the value starts with, so a name the tables
list is answered only where every use of the value is one `_own_property_stores` reads:
`Object.setPrototypeOf(o, null)` or `o.__proto__ = null` leaves no `toString` to find.
"""
right = node.right
if not isinstance(right, JsIdentifier):
return None
binding = self.model.resolve(right)
if binding is None:
return None
value = self.model.singular_value(binding)
if value is None:
return None
if not self.dominance.binding_established_before(binding, right):
return None
if isinstance(value, FUNCTION_NODES):
members, receiver_type = FUNCTION_MEMBERS, JsFunctionExpression
elif isinstance(value, (JsClassDeclaration, JsClassExpression)):
if value.super_class is not None:
return None
if value.body is not None and value.body.body:
return None
members, receiver_type = FUNCTION_MEMBERS, JsFunctionExpression
elif isinstance(value, JsObjectExpression) and not value.properties:
members, receiver_type = _EMPTY_OBJECT_PROPERTIES, dict
else:
return None
state = self._own_property_stores(binding, right)
if state is None:
return None
if key in members:
return True if self.effects.read_chain_intact(receiver_type) else None
present, any_store = state
if key in present:
return True
if any_store:
return None
if not self.effects.read_chain_intact(receiver_type):
return None
return False
def _own_property_stores(self, binding: Binding, read: Node) -> tuple[set[str], bool] | None:
"""
The own-property state of *binding*'s value at *read*: a pair `(present, any_store)`.
*present* is the property names a store (`name.k = ...`, `name['k'] = ...`) provably
installs before *read* runs and that no `delete name.k` can remove; a store whose value is
not computed before *read* runs cannot make the key present at it, and a key that may be
deleted, or that shares the binding with a `delete` of an unbounded computed key, is
withheld. *any_store* records whether the value receives any own-property store at all, so
the caller only concludes absence for a value that receives none. A logical assignment to a
key stores on some runs only and counts toward *any_store* alone. References are resolved
through the model so the set is shadowing-correct. A store through a member access the model
records in place of the name, `w.o.k = ...` for an `o` read through a `w` that may be the
global object, may install its key and counts toward *any_store*, but it proves no key
present, because `w` may hold another object.
Every reference is one of the few this can read: a write of the name, the object of another
`in`, a store or a `delete` of a key, or a plain read of one. `None` for any other, since
the object may then gain a key no spelling here shows: handed to a call, aliased, called
through, updated or destructured into. `None` as well when a store or delete targets a
computed key that is not a string literal, since the own-property set is then unbounded and
no `in` membership can be decided. The key `__proto__` names the prototype rather than an
own property: a store to it replaces the chain and a read of it hands the chain out to be
written, `o.__proto__.k = 1` giving every object a `k`, so a use of it is `None`, and so is
a read under a computed key that is not a string literal, which may spell it.
"""
stores: list[tuple[str, Node]] = []
deleted: set[str] = set()
unbounded_delete = False
any_store = False
for ref in self.model.references(binding):
member = ref.parent
if not isinstance(member, JsMemberExpression) or member.object is not ref:
if isinstance(ref, JsIdentifier) and reference_role(ref) is Role.WRITE:
continue
if _is_the_object_of_an_in(ref):
continue
return None
prop = member.property
if member.computed:
name = prop.value if isinstance(prop, JsStringLiteral) else None
elif isinstance(prop, JsIdentifier):
name = prop.name
else:
name = None
if name == '__proto__':
return None
parent = member.parent
if isinstance(parent, JsAssignmentExpression) and strip_parens(parent.left) is member:
if name is None:
return None
any_store = True
if (
isinstance(ref, JsIdentifier)
and parent.operator not in LOGICAL_ASSIGNMENT_OPERATORS
and parent.right is not None
):
stores.append((name, parent.right))
elif (
isinstance(parent, JsUnaryExpression)
and parent.operator == 'delete'
and strip_parens(parent.operand) is member
):
if name is None:
unbounded_delete = True
else:
deleted.add(name)
elif is_member_write_target(member) or is_invocation_target(member) or name is None:
return None
present: set[str] = set()
for name, value in stores:
if name in deleted or unbounded_delete:
continue
if self.dominance.runs_before(value, read):
present.add(name)
return present, any_store
def visit_JsBinaryExpression(self, node: JsBinaryExpression):
self.generic_visit(node)
if node.left is None or node.right is None:
return None
op = node.operator
if (folded := self._fold_known_operands(node)) is not None:
return folded
if op == '+' and (merged := self._merge_concat_tail(node)) is not None:
return merged
if (decided := self._clock_comparison(node)) is not None:
return JsBooleanLiteral(value=decided)
if op == 'in' and isinstance(node.left, JsStringLiteral) and node.left.value is not None:
result = self._resolve_in(node, node.left.value)
if result is not None:
return JsBooleanLiteral(value=result)
return None
def _fold_known_operands(self, node: JsBinaryExpression) -> Expression | None:
"""
The literal for what *node* computes where both operands are decided, answered by
`refinery.lib.scripts.js.values.BINARY_OPS`, so that a fold performed here and the same
operator applied by the interpreter cannot disagree. A primitive converts by an operation no
program can replace, and an object operand only where its conversion is vouched for
(`refinery.lib.scripts.js.deobfuscation.helpers.converted_operand`). A power whose value the
language leaves to the engine is not written.
"""
if node.operator not in BINARY_OPS:
return None
known, value = self._denoted(node)
if not known:
return None
return self._written(node, value)
def _denoted(self, node: Node | None) -> tuple[bool, Value]:
"""
The value *node* denotes (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`)
with the facts of this pass's effect model, remembering what nothing decided in
`_undecided`.
"""
return denoted_value(node, self.model, self._uninterceptable, self._effect_model, self._undecided)
def _written(self, node: Node, value: Value) -> Expression | None:
"""
The literal a fold writes in place of *node* for the *value* it computed
(`refinery.lib.scripts.js.deobfuscation.helpers.folded_literal`), or `None` where it may
write none: no literal denotes the value, or the literal spells *node* again. `0 / 0` and
`void 0` are how `NaN` and `undefined` are written, and writing either in place of itself
would rewrite the node for as long as the pass is allowed to run. The fold of a unary
operator, of a binary operator, and of a member read write here, being the folds whose value
may be any value. A function the language installs is written as the constructor it may be
(`_written_constructor`).
"""
if isinstance(value, BuiltinFunction):
return self._written_constructor(node, value)
literal = folded_literal(value)
if literal is None or _respells(literal, node):
return None
return literal
def _written_constructor(self, node: Node, function: BuiltinFunction) -> Expression | None:
"""
The global name of the constructor *function* where that name still denotes it at *node*
(`refinery.lib.scripts.js.analysis.effects.EffectModel.trusted_intrinsic_name`,
`refinery.lib.scripts.js.analysis.model.SemanticModel.bare_name_reaches_the_host`), or
`None`; no other built-in function has a name to write. `Function` keeps the spelling it
has, since what the reflection check reads of a construction is how its callee is spelled;
the reflection pass recognizes a spelling through `constructor`
(`refinery.lib.scripts.js.analysis.tampering.denotes_function_intrinsic`). For the same
reason a read whose `constructor` is read in turn keeps its spelling:
`[].constructor.constructor` names `Function` to every reader of the text, and
`Array.constructor` only to one that evaluates `Array`. The scan follows writes and
hand-offs through the name, so writing it makes a renewal due where the scan records one
there (`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.records_a_read`).
"""
if not function.is_constructor or function.name == 'Function':
return None
holder = enclosing_operator(node)
if (
isinstance(holder, JsMemberExpression)
and strip_parens(holder.object) is node
and static_property_key(holder) == 'constructor'
):
return None
if not self.effects.trusted_intrinsic_name(function.name):
return None
if not self.model.bare_name_reaches_the_host(function.name, node):
return None
name = JsIdentifier(name=function.name)
if _respells(name, node):
return None
watched = frozenset({function.name}) & ESCAPE_WATCHED_ROOTS
if watched and self.held_models().intrinsic_writes.records_a_read(node, watched):
self._renewal_due = True
return name
def _denoted_primitive(self, node: Node | None) -> tuple[bool, Value]:
"""
The value *node* denotes (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`)
where it is a primitive, as `(True, value)`, or `(False, None)`. Every primitive converts by
an internal specification operation that no program can intercept, so an operator applied
to it answers the same whatever the file did to the prototypes
(`refinery.lib.scripts.js.values.converts_uninterceptably`); an array literal is excluded
because its conversion runs `Array.prototype.join`.
"""
known, value = denoted_value(node, self.model, self._uninterceptable)
if not known or not converts_uninterceptably(value):
return False, None
return True, value
def _clock_comparison(self, node: JsBinaryExpression) -> bool | None:
"""
What *node* yields where it compares a read of the host's clock with a number outside the
window of times the run assumes (`refinery.lib.scripts.js.options.clock_window`), the clock
on either side. The read is asked at the comparison
(`refinery.lib.scripts.js.analysis.effects.AnchoredTrust.reads_the_clock`), so a `Date` the
program replaces or writes on is no clock.
"""
left, right, operator = node.left, node.right, node.operator
if left is None or right is None:
return None
if (time := numeric_value(right)) is not None:
clock = left
elif (time := numeric_value(left)) is not None:
clock = right
operator = _MIRRORED_COMPARISONS.get(operator, operator)
else:
return None
window = clock_window(self.options)
if window is None or (decided := window.comparison(operator, time)) is None:
return None
if not self.cache.trust_at(node).reads_the_clock(clock):
return None
return decided
def _merge_concat_tail(self, node: JsBinaryExpression) -> JsBinaryExpression | None:
"""
Reassociate `(x + 'a') + 'b'` into `x + 'ab'`. Splitting a string across a `+` chain is a common
concealment, and because `+` is left-associative the chain nests as `((x + 'a') + 'b') + 'c'`, so
no single node ever has two literal operands and pairwise folding alone never reduces it.
Merging is legal exactly when the inner node's right operand is a *string* literal. That makes
the inner `+` a concatenation whatever `x` is, so the outer operand appends to the same string the
unmerged chain would build, and both forms convert `x` once. When the inner right operand is a
number the pair cannot be reduced at all: `x + 1 + 2` is `10` for `x = 7` but `'712'` for
`x = '7'`, so neither `x + 3` nor `x + '12'` is the chain. The number is left where it binds.
`x` is not converted here and need not be statically known, so a call or an object with a
side-effecting `valueOf` is fine — it stays in place and still runs exactly once.
"""
inner = node.left
if not isinstance(inner, JsBinaryExpression) or inner.operator != '+':
return None
if inner.left is None or (head := string_value(inner.right)) is None:
return None
known, tail = self._denoted_primitive(node.right)
if not known:
return None
return JsBinaryExpression(
operator='+',
left=inner.left,
right=make_string_literal(head + to_string(tail)),
)
def visit_JsCallExpression(self, node: JsCallExpression):
self.generic_visit(node)
fn = strip_parens(node.callee)
if isinstance(fn, JsFunctionExpression):
self._facts()
replacement = try_inline_trivial_function(fn, node.arguments, transformer=self)
if replacement is None:
return None
return spelled_for_the_callee_position(node, replacement)
return (
self._try_fold_static_method(node)
or self._try_fold_free_function(node)
or self._try_fold_instance_method(node)
or self._try_fold_split(node)
or self._try_fold_join(node)
)
def _try_fold_static_method(self, node: JsCallExpression) -> Node | None:
callee = node.callee
if not isinstance(callee, JsMemberExpression):
return None
if not isinstance(callee.object, JsIdentifier):
return None
static_name = callee.object.name
if static_name not in STATIC_OBJECTS:
return None
method_name = access_key(callee)
if method_name is None:
return None
builtin = registered_builtin((static_name, method_name), self.model.environment)
if builtin is None:
return None
if not self.effects.call_is_foldable(node):
return None
args = self._builtin_arguments(node, builtin)
if args is None:
return None
try:
result = builtin.function(args)
except Exception:
return None
return folded_literal(result)
def _try_fold_free_function(self, node: JsCallExpression) -> Node | None:
callee = node.callee
if not isinstance(callee, JsIdentifier):
return None
builtin = registered_builtin((None, callee.name), self.model.environment)
if builtin is None:
return None
if not self.effects.call_is_foldable(node):
return None
args = self._builtin_arguments(node, builtin)
if args is None:
return None
try:
result = builtin.function(args)
except Exception:
return None
return folded_literal(result)
def _try_fold_instance_method(self, node: JsCallExpression) -> Node | None:
callee = node.callee
if not isinstance(callee, JsMemberExpression):
return None
method_name = access_key(callee)
if method_name is None or method_name in ('split', 'join'):
return None
if callee.object is None:
return None
known, receiver = denoted_value(callee.object, self.model, self._uninterceptable)
if not known:
return None
if isinstance(receiver, str):
builtin = registered_builtin((str, method_name), self.model.environment)
elif isinstance(receiver, list):
builtin = registered_builtin((list, method_name), self.model.environment)
else:
return None
if builtin is None:
return None
if not self.effects.call_is_foldable(node, receiver_type=type(receiver)):
return None
args = self._builtin_arguments(node, builtin)
if args is None:
return None
try:
result = builtin.function(receiver, args)
except Exception:
return None
return folded_literal(result)
def _try_fold_split(self, node: JsCallExpression) -> JsArrayExpression | None:
if len(node.arguments) != 1:
return None
callee = node.callee
if not isinstance(callee, JsMemberExpression):
return None
obj_str = string_value(callee.object)
if obj_str is None:
return None
method_name = access_key(callee)
if method_name != 'split':
return None
sep = string_value(node.arguments[0])
if sep is None:
return None
if not self.effects.call_is_foldable(node, receiver_type=str):
return None
parts = obj_str.split(sep) if sep else utf16_code_units(obj_str)
if len(parts) > MAX_RESULT_ARRAY_LEN:
return None
return JsArrayExpression(
elements=[make_string_literal(p) for p in parts],
)
def _try_fold_join(self, node: JsCallExpression) -> JsStringLiteral | None:
if len(node.arguments) > 1:
return None
callee = node.callee
if not isinstance(callee, JsMemberExpression):
return None
method_name = access_key(callee)
if method_name != 'join':
return None
obj = callee.object
if not isinstance(obj, JsArrayExpression):
return None
parts: list[str] = []
for e in obj.elements:
if not isinstance(e, JsStringLiteral) or e.value is None:
return None
parts.append(e.value)
if node.arguments:
sep = string_value(node.arguments[0])
if sep is None:
return None
else:
sep = ','
if not self.effects.call_is_foldable(node, receiver_type=list):
return None
return make_string_literal(sep.join(parts))
def _discarding(
self,
node: Expression,
test: Expression,
kept: Expression | None,
) -> Expression | None:
"""
*kept* in place of *node*, preceded by *test* where dropping the latter would change what the
program does. Two separate reasons it may have to stay.
Evaluating it is an effect: a fold that picks a branch answers from the test's *value* and has
no further use for it, but the test may call, or read a property whose getter runs.
`JsDeadCodeElimination` keeps a statement test for the same reason and in the same way.
And *node* may be what a call invokes, in which case removing the operator around *kept*
changes the receiver: `(1 ? o.m : g)()` invokes with no receiver, where a bare `o.m()` binds
`o`, and `(1 ? eval : g)(s)` is an indirect eval where a bare `eval(s)` is a direct one. The
sequence is what preserves that — `(0, o.m)()` is the idiom for calling without a receiver —
so a form-sensitive branch keeps it even when the test does nothing at all.
"""
if kept is None:
return None
receiver_sensitive = is_invocation_target(node) and callee_form_sensitive(kept, node)
if not receiver_sensitive and self.effects.is_side_effect_free(
test, discarded=True, reads_may_throw=True, read_established=self._read_established
):
return kept
return JsSequenceExpression(expressions=[test, kept])
def _read_established(self, node: JsIdentifier) -> bool:
return self.cache.read_established(node)
def visit_JsConditionalExpression(self, node: JsConditionalExpression):
self.generic_visit(node)
if node.test is None:
return None
truthy = is_truthy(node.test, self.cache)
if truthy is None:
return None
return self._discarding(node, node.test, node.consequent if truthy else node.alternate)
def visit_JsSequenceExpression(self, node: JsSequenceExpression):
self.generic_visit(node)
if not node.expressions:
return None
filtered = [
e for i, e in enumerate(node.expressions)
if i == len(node.expressions) - 1
or not is_simple_expression(e)
or self.effects.read_throws(e, self._read_established)
]
if len(filtered) == len(node.expressions):
return None
if len(filtered) == 1:
if is_invocation_target(node) and callee_form_sensitive(filtered[0], node):
return None
return filtered[0]
set_child_list(node, 'expressions', filtered)
self.mark_changed()
return None
def visit_JsParenthesizedExpression(self, node: JsParenthesizedExpression):
self.generic_visit(node)
inner = node.expression
if inner is None:
return None
if isinstance(inner, (
JsSequenceExpression,
JsFunctionExpression,
JsArrowFunctionExpression,
JsObjectExpression,
JsClassExpression,
)):
return None
if parens_required(inner, node.parent, node):
return None
return inner
def visit_JsMemberExpression(self, node: JsMemberExpression):
"""
Replace an access by what it reads, where that is decided and standing in its place means the
same thing.
A target that is assigned, updated, deleted, or destructured is not such a place at all: it
is somewhere to store into, a constant is not, and every arm below is refused there.
Where the value is applied, the arms part company, because they write down different things.
The alias arm writes another way of naming the same function, so it has only to keep the
receiver a call reads off a member and the scope a direct `eval` needs. The constructor arm
writes the name of the constructor a read reaches (`_invoked_constructor`), which no call
reads a receiver of. The arms that write a constant have nothing to keep: a constant is
neither callable nor a constructor, so the application throws whatever they do, and folding
only changes which text the `TypeError` names — which is why they refuse a `new` the alias
arm is free to fold under.
"""
self.generic_visit(node)
in_read_position = not is_member_write_target(node)
reads_a_value = in_read_position and not is_constructed_or_invoked(node)
if (
not node.computed
and isinstance(node.object, JsIdentifier)
and isinstance(node.property, JsIdentifier)
and (self._names_a_global(node) is not None or self._global_object_alias_base(node))
and not self._resolves_to_local(node, node.property.name)
and self._alias_collapse_is_sound(node, node.object, node.property.name)
):
if in_read_position and not is_invocation_target(node):
return node.property
return None
if in_read_position and is_constructed_or_invoked(node):
if (constructor := self._invoked_constructor(node)) is not None:
return constructor
if reads_a_value:
if (folded := self._folded_array_length(node)) is not None:
return folded
if (element := self._literal_array_element(node)) is not None:
return element
if (folded := self._folded_member(node)) is not None:
return folded
if (character := self._folded_date_character(node)) is not None:
return character
if node.computed and node.object is not None and node.property is not None:
prop_str = string_value(node.property)
if prop_str is not None and is_valid_property_key(prop_str):
set_value(node, 'computed', False)
set_child(node, 'property', JsIdentifier(name=prop_str))
self.mark_changed()
return None
return None
def visit_JsProperty(self, node: JsProperty):
self.generic_visit(node)
if node.computed and node.key is not None:
key_str = string_value(node.key)
if (
key_str is not None
and is_valid_property_key(key_str)
and (node.method or key_str != '__proto__')
):
set_value(node, 'computed', False)
set_child(node, 'key', JsIdentifier(name=key_str))
self.mark_changed()
return None
def _fresh_object_type(self, operand: Node | None) -> str | None:
"""
The `typeof` of the object *operand* allocates, when creating it has no other effect — `None`
otherwise. The type is `allocated_object_type`'s syntactic answer; the freedom from effects is
asked with the value discarded, because that is what folding does with it. Only the type or the
truthiness survives, and the object itself does not.
"""
kind = allocated_object_type(operand)
if kind is None:
return None
node = strip_parens(operand)
if node is None:
return None
free = self.effects.is_side_effect_free(
node, discarded=True, reads_may_throw=True, read_established=self._read_established)
return kind if free else None
def _member_key(self, node: JsMemberExpression) -> str | None:
"""
The property name the access at *node* reads, or `None` when nothing decides it. A dot access
is named by its identifier and a computed one by the string its key converts to, which is the
rule the interpreter's `_member_key` applies to an evaluated key and the reason a numeric one
is asked with `denoted_value` rather than `string_value`: `'abc'[1]` reads the property named
`'1'`, and a reader that only recognized a string key would decide every index access to be
undecidable.
Naming the property converts the key, and an array or an object converts through a
prototype method the file can replace: `Array.prototype.join = () => '2'` makes `'abc'[[1]]`
read `'c'`. A key that is no primitive is therefore converted only where that conversion is
vouched for (`refinery.lib.scripts.js.deobfuscation.helpers.converted_operand`).
"""
if not node.computed:
return node.property.name if isinstance(node.property, JsIdentifier) else None
known, key = self._denoted(node.property)
if not known:
return None
known, key = converted_operand(key, self._uninterceptable, self._effect_model, gated=True)
if not known:
return None
return to_string(key)
def _invoked_constructor(self, node: JsMemberExpression) -> Expression | None:
"""
The global name of the constructor the read *node* reaches where it is called or
constructed with, as `''.constructor(5)` calls `String` (`_written_constructor`). The call
then passes no receiver where the read passed its object, and no constructor reads one.
"""
known, value = self._denoted(node)
if not known or not isinstance(value, BuiltinFunction):
return None
return self._written_constructor(node, value)
def _literal_array_element(self, node: JsMemberExpression) -> Expression | None:
"""
The element an array literal of literals holds at the index a numeric literal names, handed
back as the node the file spelled it with.
"""
array, index = node.object, node.property
if not node.computed or not isinstance(array, JsArrayExpression):
return None
if not isinstance(index, JsNumericLiteral):
return None
idx = exact_integer(index.value)
elements = array.elements
if idx is None or not 0 <= idx < len(elements):
return None
if not all(e is not None and is_literal(e) for e in elements):
return None
return elements[idx]
def _folded_member(self, node: JsMemberExpression) -> Expression | None:
"""
The value a property read off a decided value denotes, as a node, or `None` when the read is
not one this decides (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_member`).
`'abc'.length` is `3` and `'abc'[1]` is `'b'`, own properties of the string that no
prototype can shadow, and `'abc'.nope` is `undefined` where the chain is the one the
language describes and the file writes no built-in object behind the scan's back.
The object is asked with `denoted_value`, which decides a literal, a global value name, and
an operator or a read over those — every one of them free of effects — so nothing is
discarded by replacing the access with what it reads, and no separate effect gate is needed.
An index answers a string, and a string is the one value whose spelling a statement position
reads: standing alone at the top of a body it is a directive. `'abc'[0];` is an ordinary
statement that ends a Directive Prologue, and `'a';` continues one, which would make a
`'use strict'` below it govern the file it merely stood in. Such a read is left alone.
"""
known, value = self._denoted(node)
if not known:
return None
if isinstance(value, str) and self._stands_in_a_directive_prologue(node):
return None
return self._written(node, value)
def _folded_date_character(self, node: JsMemberExpression) -> Expression | None:
"""
The character a read at a decided index off `p + Date()` holds whatever the time, where `p`
is a decided primitive and `Date` the function the language installs, called without
arguments. The string the engine of the host writes for the time
(`refinery.lib.scripts.js.analysis.environment.JsEngine.date_character`) fixes some of its
positions while the year has four digits, which the window of times the run assumes
(`refinery.lib.scripts.js.options.clock_window`) guarantees, and a position inside `p` holds
the character of `p`. Both operands are free of effects, so nothing is discarded by writing
the character in place of the read.
"""
engine = self.model.environment.engine
if engine is None or clock_window(self.options) is None or not node.computed:
return None
concatenation = strip_parens(node.object)
if not isinstance(concatenation, JsBinaryExpression) or concatenation.operator != '+':
return None
call = strip_parens(concatenation.right)
if (
not isinstance(call, JsCallExpression)
or call.optional
or call.arguments
or self.effects.trusted_intrinsic(call.callee) != 'Date'
):
return None
known, prefix = self._denoted(concatenation.left)
if not known or not converts_uninterceptably(prefix):
return None
known, key = self._denoted(node.property)
if not known or not converts_uninterceptably(key):
return None
index = canonical_array_index(to_string(key))
if index is None:
return None
text = to_string(prefix)
character = text[index] if index < len(text) else engine.date_character(index - len(text))
if character is None or self._stands_in_a_directive_prologue(node):
return None
return make_string_literal(character)
def _stands_in_a_directive_prologue(self, node: Node) -> bool:
"""
Whether *node* is the whole of a statement that a Directive Prologue would take in were it
spelled as a string literal. `joins_directive_prologue` decides that of the statement; what is
decided here is that *node* is the whole of one.
The brackets a read stands in are climbed through rather than trusted to keep it out of the
prologue: `('abc'[0]);` is not a directive, but nothing keeps the printer from spelling the
folded literal without them, and then it is.
"""
cursor: Node = node
statement = cursor.parent
while isinstance(statement, JsParenthesizedExpression):
cursor, statement = statement, statement.parent
if not isinstance(statement, JsExpressionStatement):
return False
if strip_parens(statement.expression) is not node:
return False
return joins_directive_prologue(statement)
def _folded_array_length(self, node: JsMemberExpression) -> Expression | None:
"""
The `length` of an array literal, as a node, or `None` when the literal does not decide it.
This is the one property a value domain cannot answer: `[a, b, c]` and `[1, , 3]` denote no
value the folder can hold, because an element is not a literal or is not there at all, yet
each is three elements long whatever those elements turn out to be. The count is read off the
syntax, where the parser records an elision as an element that is absent and a trailing comma
as no element at all, exactly as the language counts them.
A spread is the one element whose count is not the literal's to know — `[...'abc']` is three
elements from one — so a literal holding any is declined rather than counted wrong. That
stays stated here even though the effect gate below happens to refuse a spread too: what
makes a spread wrong is the count, not the iterating, and a later effect model that learned
to trust iterating an array would otherwise start answering `[...[1, 2]].length` with `1`.
Only the length survives, so the array and every element in it is discarded, and evaluating
an element may therefore do nothing at all. Every element must be a literal or an elision,
which is the strongest thing the folder can say and the only one that is true: asking
`is_side_effect_free` instead — the question `_fresh_object_type` asks of an allocation —
answers yes for a name no binding resolves and for an operator that runs `valueOf`, so
`[zzz].length` would print `1` where the file threw a `ReferenceError` and `[+o].length`
would swallow what reading `o` writes.
"""
if self._member_key(node) != 'length':
return None
array = strip_parens(node.object)
if not isinstance(array, JsArrayExpression):
return None
if any(isinstance(element, JsSpreadElement) for element in array.elements):
return None
if not all(element is None or is_literal(element) for element in array.elements):
return None
return value_to_node(len(array.elements))
def _uninterceptable(self, value: Value) -> bool:
"""
Whether converting *value* runs no code the program installed
(`refinery.lib.scripts.js.deobfuscation.helpers.coerces_uninterceptably`), asked of this
pass's effect model.
"""
return coerces_uninterceptably(self.effects, value)
def _builtin_arguments(self, node: JsCallExpression, builtin: RegisteredBuiltin) -> list[Value] | None:
"""
The values of the arguments of *node* for a call of *builtin*, or `None` where one of them
denotes nothing or may convert through a method the program replaced. A built-in may
convert any argument it is handed, as `Number([5])` runs `Array.prototype.join`, so each is
asked `refinery.lib.scripts.js.deobfuscation.helpers.coerces_uninterceptably`. A built-in
that reads every argument as a string gets each converted as an operator converts an
operand (`refinery.lib.scripts.js.deobfuscation.helpers.converted_operand`), which gives a
function the language installs the text the host's engine writes for it.
"""
args: list[Value] = []
for argument in node.arguments:
if builtin.reads_strings:
known, value = self._denoted(argument)
if known:
known, value = converted_operand(value, self._uninterceptable, self._effect_model, gated=True)
else:
known, value = denoted_value(argument, self.model, self._uninterceptable)
known = known and self._uninterceptable(value)
if not known:
return None
args.append(value)
return args
def _deletion_answer(self, node: JsUnaryExpression) -> bool | None:
"""
The value the `delete` at *node* gives where it changes nothing anything reads, so that only
that value remains, or `None` where the `delete` stays. It is `true` where the `delete`
removes a property that nothing in the program can read, the answer for every configurable
and every absent property: the deletion is a dead store. The object literal keeps the
property, which is sound precisely because no reader of it survives; editing the literal
instead would have to be ordered against every read, while leaving it needs no ordering at
all. It is `false` for the `length` of an array, which the array owns and cannot lose, where
the code cannot run as strict code; strict code throws there, and the `delete` stays.
The object must be a container held by a local that nothing else can reach: a single
unwritten declaration bound to a freshly allocated literal, immutable across the rest of the
program, and referenced only through plain reads of statically named properties other than
the deleted one (`refinery.lib.scripts.js.analysis.model.ContainerRole.MEMBER_READ`). That
last condition is what rules out every reader — a method call, a spread, a `for-in`, or an
escape into a call would each be able to observe the property, and none of them is a plain
named read. A method call names a key of its own, as `a.join('-')` names `join`, and still
reads every element, and a named read runs an accessor where the literal declares one or
installs a prototype through `__proto__:`, which may read the deleted key through `this`.
"""
member = strip_parens(node.operand)
if not isinstance(member, JsMemberExpression):
return None
base = member.object
key = access_key(member)
if key is None or not isinstance(base, JsIdentifier):
return None
binding = self.model.resolve(base)
if binding is None or binding.writes or len(binding.declarations) != 1:
return None
value = self.model.singular_value(binding)
if not isinstance(value, (JsObjectExpression, JsArrayExpression)):
return None
if isinstance(value, JsObjectExpression) and object_member_access_runs_accessor(value):
return None
if not self.effects.binding_is_immutable_container(binding, exclude=node):
return None
for reference in self.model.references(binding):
if reference is base:
continue
access = reference.parent
if not isinstance(access, JsMemberExpression) or access.object is not reference:
return None
if access_key(access) in (None, key):
return None
if container_reference_role(reference) is not ContainerRole.MEMBER_READ:
return None
if isinstance(value, JsArrayExpression) and key == 'length':
return None if self.model.may_run_as_strict(node) else False
return True
def _typeof_of_global_read(self, operand: Node | None) -> str | None:
"""
The string `typeof <operand>` yields when *operand* is a bare read of a global name the run's
host settles — `None` otherwise. `refinery.lib.scripts.js.analysis.environment.typeof_of_global`
answers it against the pinned host: a name whose `typeof` is host-independent (`String`,
`globalThis`, `escape`) folds in every host, and a host-conditional alias (`window`, `Buffer`)
folds only where the host proves it present — to its type — or absent — to `'undefined'`, and
abstains under the default `universal` host. This is what lets a `typeof globalThis !== 'undefined'`
guard fold to `true`, so `refinery.lib.scripts.js.deobfuscation.deadcode.JsDeadCodeElimination`
can prune the arm it protects and a global-object finder reduce to `return globalThis`, and what
lets `-e node` fold a `typeof Buffer` guard the same way. The question is asked at the
`typeof` (`refinery.lib.scripts.js.analysis.effects.AnchoredTrust.global_typeof`), which
refuses a name the program binds or writes anywhere, one a reflection surface could reach
before the `typeof` runs, and one a module the program loads before it may set up. The
interpreter's `typeof` fold asks the same question, so a fold made here and one made there
cannot disagree.
"""
node = strip_parens(operand)
if not isinstance(node, JsIdentifier):
return None
return self.cache.trust_at(node).global_typeof(node.name)
def visit_JsUnaryExpression(self, node: JsUnaryExpression):
"""
Fold a unary operator against its operand. Everything that is a function of the operand's
value is the value the whole expression denotes
(`refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`), which applies the shared
`UNARY_OPS` kernel, so that a fold performed here and the same operator applied by the
interpreter cannot disagree, and which refuses to convert an array whose conversion the
program may have replaced. What is left is the two questions a value cannot answer — the
type of an object whose identity no literal spells, and whether a `delete` may be dropped. A
third, `typeof` of a pristine guaranteed global, is a value the operator hides rather than
one the operand denotes, so it is decided by name (`_typeof_of_global_read`).
A node that already spells its value the way a fold writes it is left alone (`_written`):
`void 0` is how `undefined` is written, so folding it produces the expression it replaces,
and the pass would rewrite the same node for as long as it is allowed to run.
"""
self.generic_visit(node)
operand = node.operand
if operand is None:
return None
op = node.operator
if op == 'delete':
answer = self._deletion_answer(node)
return None if answer is None else JsBooleanLiteral(value=answer)
kind = self._fresh_object_type(operand)
if kind is not None:
if op == 'typeof':
return make_string_literal(kind)
if op == '!':
return JsBooleanLiteral(value=False)
if op == 'typeof':
typed = self._typeof_of_global_read(operand)
if typed is not None:
return make_string_literal(typed)
known, value = self._denoted(node)
if not known:
return None
return self._written(node, value)
def visit_JsStringLiteral(self, node: JsStringLiteral):
"""
Spell a literal the shortest way that denotes the same text, where the spelling it was
given states nothing of its own.
Two spellings state something. One the source never closed has no spelling at all, and
writing one hands it the quote nobody wrote — the file stops being the file that was read.
And a spelling a rule reads may not be traded for one the same rule reads differently: an
escape hiding a space in `'use\\x20strict'` is what keeps that line from being a directive,
and re-spelling it makes every line behind it strict code.
"""
if not node.terminated or node.value is None:
return None
quote = node.raw[0] if node.raw else '\''
rebuilt = quote + escape_js_string(node.value, quote) + quote
if rebuilt == node.raw:
return None
if spelling_states(rebuilt[1:-1]) != spelling_states(node.body):
return None
set_value(node, 'raw', rebuilt)
self.mark_changed()
return None
def visit_JsLogicalExpression(self, node: JsLogicalExpression):
self.generic_visit(node)
if node.left is None or node.right is None:
return None
if node.operator == '||' and self.effects.intrinsic_of(node.left) is not None:
return node.left
op = node.operator
if op == '??':
nullish = is_nullish(node.left, self.model, self._uninterceptable)
if nullish is None:
return None
return self._discarding(node, node.left, node.right) if nullish else node.left
truthy = is_truthy(node.left, self.cache)
if truthy is None:
return None
if op == '&&':
return self._discarding(node, node.left, node.right) if truthy else node.left
if op == '||':
return node.left if truthy else self._discarding(node, node.left, node.right)
return None
Classes
class JsSimplifications-
Intermediate base for transforms that hold the models of the tree an invocation entered on across their own edits, through
ModelCacheBase.pinned(), and renew them in place where an edit may have made a held answer more permissive than a rebuilt one. It is the pin's regime for a pass whose walk it cannot leave and enter again: a renewal (ModelCacheBase.renew()) holds the models of the current tree, as leaving the pin and entering it again would, and the walk goes on where it was.A subclass owes the regime four obligations:
- It names the models it reads in
reads, empty for the cache's warm set, and reads no other. It holds them throughholding_modelsand takes them fromheld_models, whose first call it makes at its first decision, before its first edit. - It renews (
renew_models) before a decision that reads code the invocation made or edited since the last renewal: a held model knows that code as it was, or not at all. - After an edit that may show the models a fact the held ones do not record, it renews before it
next reads a fact from them: an edit that puts a value the models follow only through bindings
(
IntrinsicWrites.follows_only_through_bindings()) where they read one (value_landing), a string where it names a property, a name where it makes a call run a string of code, the last piece of a concatenation the models read as one string, that removes thewithstatement between a name and a binding holding such a value, or that declares or assigns a name the intrinsic-write scan watches (declares_a_watched_name). - A per-pass docstring of its own shows that every other edit leaves the answers it reads no more permissive than rebuilt ones.
What the pass records of its edits to decide the second obligation is forgotten at every renewal, through
_forget_edits. The--no-pindifferential (test/conftest.py) runs every such pass with the pin neutralized, where every read is answered by models of the current tree.Expand source code Browse git
class JsSimplifications(PinnedTransformer): def __init__(self): super().__init__() self._renewal_due = False self._undecided: set[Node] = set() def _forget_edits(self) -> None: self._renewal_due = False self._undecided = set() @property def cache(self) -> ModelCache: """ The models a decision reads (`_facts`). """ return self._facts() def _facts(self) -> ModelCache: """ The held models, renewed first where a fold since the last renewal wrote what shows them a fact the held ones lack (`visit`, `_written_constructor`). The renewal waits for the decision after the fold, by which point what it wrote stands in the tree (`visit`). A decision that hands this pass to a helper reading the shared cache by itself, as `refinery.lib.scripts.js.deobfuscation.helpers.try_inline_trivial_function` does, asks this first. """ if self._renewal_due: self.renew_models() return self.held_models() def visit(self, node: Node) -> Node | None: """ Visit *node*, and make a renewal due where the replacement a fold returns for it reads as a key *node* did not read as, a string, a number, a boolean or `null`, and that key shows the held models a fact (`_shows_the_models_a_name`). Every fold returns its replacement through here, so a key that `String.fromCharCode`, a `join`, a sequence or a conditional collapses to is seen as surely as one an operator computes. The dispatch of `refinery.lib.scripts.Visitor.visit` is written out rather than called, so that a deep tree costs this pass no more stack than it costs any other transform. The check climbs from *node*, which still stands in the tree. A replacement returned while a renewal is due is written in its place at once: the walk of `refinery.lib.scripts.Transformer.generic_visit` writes the items of a list only once it has visited all of them, so the renewal the next item's decision makes would otherwise build models of the tree the fold has not changed yet, and count itself done. Every other replacement is left to the walk, since writing one in place scans the fields of its parent, and a list of folded items would pay its square. """ kind = type(node) handler = self._dispatch.get(kind) if handler is None: handler = self._dispatch[kind] = getattr(self, F'visit_{kind.__name__}', self.generic_visit) replacement = handler(node) if replacement is None: return None if not self._renewal_due: value = expression_key(replacement) if value is not None and expression_key(node) != value: string = static_string(replacement) is not None self._renewal_due = _shows_the_models_a_name(self.held_models(), node, value, string) if self._renewal_due: _replace_in_parent(node, replacement) return replacement @property def model(self) -> SemanticModel: return self.cache.model @property def assignment(self) -> DefiniteAssignmentModel: return self.cache.assignment @property def effects(self) -> EffectModel: return self.cache.effects @property def dominance(self) -> DominanceModel: return self.cache.dominance @property def reaching(self) -> ReachingModel: return self.cache.reaching def _effect_model(self) -> EffectModel: """ The effect model a question of `refinery.lib.scripts.js.deobfuscation.helpers.denoted_value` is asked of when it is asked, which is the one `cache` holds by then. """ return self.effects def _global_object_alias_base(self, member: JsMemberExpression) -> bool: """ Whether *member*'s base is a local single-assigned to the global object whose value reaches this read unchanged — the `var g = globalThis || {}; g.String` idiom. The value must be established before the read (`ReachingModel.value_preserved`), so collapsing `g.String` to `String` cannot turn a not-yet-assigned `undefined.String` into a different result. The bare syntactic-alias case (`globalThis.String`) is handled by `global_alias_member_name`; this covers only the local alias. """ base = member.object if not isinstance(base, JsIdentifier) or self._names_a_global(member) is not None: return False binding = self.model.resolve(base) if binding is None: return False value = self.model.singular_value(binding) if value is None or self.effects.intrinsic_of(value) is not GLOBAL_OBJECT: return False return self.reaching.value_preserved(binding, value, base) def visit_JsScript(self, node: JsScript): """ Attach the shared model cache for the whole script, then rewrite with the models held for the length of the pass (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`), renewed only where a fold shows them a fact. Rebuilding them per rewrite is what made deobfuscation cost the product of tree size and rewrite count; a fold gate is consulted on nearly every rewrite, so each rewrite paid for a full rebuild of the semantic and effect models. Holding them is sound because this pass cannot loosen the facts those models report. It only ever removes bindings, never adds one, so a name reported as locally bound stays bound and reading shadowing from a held model can only over-preserve a global-alias access, never collapse one a local now captures. In the same way it cannot reveal an intrinsic as patched: an install is attributed to every name its target may denote, before any fold collapses the target to a plain name, so `_globals_written` and `global_pristine` already account for the rewrites this pass performs. Were that not so, a held model would report a patched built-in as pristine and admit a fold against it. "Every name its target may denote" spans two kinds of collapse, and both must already be accounted for. One is syntactic — `0 || Math` names `Math` before the fold that reduces it — and the other is an alias: `var m = Math; m.floor = f` names `Math` though the assignment mentions only `m`, so inlining the alias reveals nothing the held set lacked. A write need not be in the file at all for the same argument to be needed. An intrinsic handed to code whose writes cannot be enumerated — `patch(Math)` for an unresolvable `patch` — is recorded as written when it *escapes*, and escaping is decided through the same value-forwarding forms, so a fold that collapses `p(0 || Math)` to `p(Math)` finds the name already recorded. What the pass can do is remove an escape, by deleting a dead branch containing one; that shrinks the set, which is the direction a held answer may safely be stale in. A built-in object a read reaches without spelling its owner is one the scan cannot attribute a write through, and a value that may be one is unattributed (`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.followable`): a fold that reads such a value away, or collapses a key it was read under to a name the scan does not count, leaves fewer of them, never more. Two edits do show the models a fact the held ones lack: a string written where an answer assumed a key it could not read is none of the names its reader looked for, or a string spelling a name whose readers look at every string, which any fold collapsing a key may write (`visit`); and the name of a constructor written where the scan then records a write or a hand-off through it. Such a fold renews the models before the next decision (`cache`). The same argument covers the freshness the effect model reports for a container base. A binding is judged fresh only when this pass owns every value it takes, and the pass adds no value to any binding; the array guarantee that an allocating method's result depends on is withdrawn by a write to `constructor` or `__proto__`, where a key this pass cannot read counts as possibly naming one. Folding a computed key to a literal therefore moves that answer from withdrawn to withdrawn — never from granted to withdrawn — which is the direction a held model may be stale in. The pin also has to keep every model the window reads consistent with its entry. A model that reads the live tree would otherwise be built at the first gate that consults it, which can fall after an earlier fold's edit, layering it over held base models from an earlier tree — the one state no unpinned run builds, which the pin's exit refuses. Building the models at entry, before the first fold, forecloses that; a model derived only from them stays a pure function of the held bases wherever it is first built. """ with self.holding_models(node): self.held_models() self.generic_visit(node) return None def _resolves_to_local(self, member: JsMemberExpression, name: str) -> bool: """ Whether *name*, written as a bare identifier where *member* sits, would bind to a local declaration rather than the global the `<global-alias>.name` access denotes. An implicit global is not a local: the bare name and the property access name the same global, so collapsing them stays sound. An unmapped or dynamically-scoped position is treated as bound, leaving the access untouched where the model cannot prove the name is free. """ scope = self.model.scope_of(member) if scope is None: return True binding = self.model.lookup(name, scope) return binding is not None and binding.kind is not BindingKind.IMPLICIT_GLOBAL def _alias_collapse_is_sound( self, member: JsMemberExpression, base: JsIdentifier, name: str ) -> bool: """ Whether collapsing `<global-alias>.name` — base *base*, property *name* — to the bare `name` preserves both throws the access can raise, so the rewrite drops neither. The binding of *name* is resolved once and answers both. The property must be defined, so the `undefined` a resolvable read yields where the property is absent does not become a `ReferenceError`: a free name must be one the specification mandates on the global object (`GUARANTEED_GLOBALS`); a name the program itself defines as a global (an implicit global) exists only after its establishing write, so it is admitted only when a write is proven to run before this read — interprocedurally, so a top-level write still covers a read inside a function invoked after it, but strictly, so a same-statement or earlier read is declined. The base must resolve, so the `ReferenceError` an absent host raises reading it is not dropped: a base the language mandates in every host (`globalThis`) or one the pinned host defines never throws (`SemanticModel.read_may_throw`). A host-conditional alias (`window`, `self`, `top`, `frames`, `global`) may not resolve on its own — but where *name* is a global the program itself defines, an establishing write made through this same alias spelling reads the base before assigning, so its having run (which the definite-assignment proof requires) proves the base resolved on every path reaching this read. A write through a *second* alias, or a bare assignment naming no alias, establishes *name* without resolving this base, so the read is kept unless the host defines the base outright; this is the cross-alias hole the resolution fact closes. """ binding = self.model.lookup(name, self.model.scope_of(member)) if binding is None: if name not in GUARANTEED_GLOBALS: return False return not self.model.read_may_throw(base) if not self.assignment.definitely_assigned_at(binding, member): return False return self._collapse_base_resolves(binding, base) def _collapse_base_resolves(self, binding: Binding, base: JsIdentifier) -> bool: """ Whether reading *base* is certain not to throw where one of *binding*'s accesses is collapsed to a bare name. The base resolves when the host defines it, or when every write establishing the global goes through this same alias spelling: such a write reads the base before it assigns, so the write the definite-assignment proof relies on having run proves the base resolved, and no path reaches the collapse without such a write. A write through another alias or a bare assignment resolves nothing about this base, so its presence leaves the base unproven. """ if not self.model.read_may_throw(base): return True writes = (*binding.writes, *binding.indefinite_writes) return bool(writes) and all( isinstance(write, JsMemberExpression) and isinstance(write_base := strip_parens(write.object), JsIdentifier) and write_base.name == base.name for write in writes ) def _names_a_global(self, member: JsMemberExpression) -> str | None: """ The global *member* names, read under the execution model this run was asked for and only where the base names this realm's global object. Every fold this pass makes on the strength of an access naming a global asks it this way, since a rewrite it drives is one an analyst runs the file under that model. `top` and `frames` name another document's global object, so a property on one is not the property a bare name in this file reads and a fold that treats the two as one turns an `undefined` into a `ReferenceError`. They are read as global-object bases all the same wherever the answer only keeps code; a rewrite is the case where it does not. """ base = strip_parens(member.object) if isinstance(base, JsIdentifier) and base.name not in SAME_REALM_GLOBAL_OBJECT_ALIASES: return None return self.model.global_alias_member_name(member) def _resolve_in(self, node: JsBinaryExpression, key: str) -> bool | None: """ Statically resolve `key in name` by asking the model what value *name* holds. A sole function, an empty class, or an empty object literal has a bounded property set — the built-in members of its type plus the own properties the binding is assigned — so membership is decidable; any other value, or one whose own-property set cannot be bounded, yields `None`. The binding is resolved through the model, so the answer is shadowing-correct across scopes and recognizes the bare-assignment form namespace flattening leaves, not only declarations. The value reads `undefined` until whatever establishes it — a declarator initializer, a class declaration, or a lone assignment — has run, so a `key in name` whose establishing node does not run before the read is left unresolved rather than fold away the `TypeError` a premature read would throw. Bounded is a claim about the prototype chain and holds only while the file leaves that chain alone, so **both** answers need the chain asked about, and both ask it the same way. A name the tables do not list is there once `Object.prototype.z = 9` has run, and a name they do list is gone once a `delete` has removed it, so neither side is free and neither may be answered under a surface the other refuses under. The tables are asked for the whole property set and not the inherited half `property_provably_absent` enumerates, an own `length` and `prototype` included, so the membership question stays this one's and only the chain question is shared. A class constructor is a function value, and its chain is a function's. The chain asked about is the one the value starts with, so a name the tables list is answered only where every use of the value is one `_own_property_stores` reads: `Object.setPrototypeOf(o, null)` or `o.__proto__ = null` leaves no `toString` to find. """ right = node.right if not isinstance(right, JsIdentifier): return None binding = self.model.resolve(right) if binding is None: return None value = self.model.singular_value(binding) if value is None: return None if not self.dominance.binding_established_before(binding, right): return None if isinstance(value, FUNCTION_NODES): members, receiver_type = FUNCTION_MEMBERS, JsFunctionExpression elif isinstance(value, (JsClassDeclaration, JsClassExpression)): if value.super_class is not None: return None if value.body is not None and value.body.body: return None members, receiver_type = FUNCTION_MEMBERS, JsFunctionExpression elif isinstance(value, JsObjectExpression) and not value.properties: members, receiver_type = _EMPTY_OBJECT_PROPERTIES, dict else: return None state = self._own_property_stores(binding, right) if state is None: return None if key in members: return True if self.effects.read_chain_intact(receiver_type) else None present, any_store = state if key in present: return True if any_store: return None if not self.effects.read_chain_intact(receiver_type): return None return False def _own_property_stores(self, binding: Binding, read: Node) -> tuple[set[str], bool] | None: """ The own-property state of *binding*'s value at *read*: a pair `(present, any_store)`. *present* is the property names a store (`name.k = ...`, `name['k'] = ...`) provably installs before *read* runs and that no `delete name.k` can remove; a store whose value is not computed before *read* runs cannot make the key present at it, and a key that may be deleted, or that shares the binding with a `delete` of an unbounded computed key, is withheld. *any_store* records whether the value receives any own-property store at all, so the caller only concludes absence for a value that receives none. A logical assignment to a key stores on some runs only and counts toward *any_store* alone. References are resolved through the model so the set is shadowing-correct. A store through a member access the model records in place of the name, `w.o.k = ...` for an `o` read through a `w` that may be the global object, may install its key and counts toward *any_store*, but it proves no key present, because `w` may hold another object. Every reference is one of the few this can read: a write of the name, the object of another `in`, a store or a `delete` of a key, or a plain read of one. `None` for any other, since the object may then gain a key no spelling here shows: handed to a call, aliased, called through, updated or destructured into. `None` as well when a store or delete targets a computed key that is not a string literal, since the own-property set is then unbounded and no `in` membership can be decided. The key `__proto__` names the prototype rather than an own property: a store to it replaces the chain and a read of it hands the chain out to be written, `o.__proto__.k = 1` giving every object a `k`, so a use of it is `None`, and so is a read under a computed key that is not a string literal, which may spell it. """ stores: list[tuple[str, Node]] = [] deleted: set[str] = set() unbounded_delete = False any_store = False for ref in self.model.references(binding): member = ref.parent if not isinstance(member, JsMemberExpression) or member.object is not ref: if isinstance(ref, JsIdentifier) and reference_role(ref) is Role.WRITE: continue if _is_the_object_of_an_in(ref): continue return None prop = member.property if member.computed: name = prop.value if isinstance(prop, JsStringLiteral) else None elif isinstance(prop, JsIdentifier): name = prop.name else: name = None if name == '__proto__': return None parent = member.parent if isinstance(parent, JsAssignmentExpression) and strip_parens(parent.left) is member: if name is None: return None any_store = True if ( isinstance(ref, JsIdentifier) and parent.operator not in LOGICAL_ASSIGNMENT_OPERATORS and parent.right is not None ): stores.append((name, parent.right)) elif ( isinstance(parent, JsUnaryExpression) and parent.operator == 'delete' and strip_parens(parent.operand) is member ): if name is None: unbounded_delete = True else: deleted.add(name) elif is_member_write_target(member) or is_invocation_target(member) or name is None: return None present: set[str] = set() for name, value in stores: if name in deleted or unbounded_delete: continue if self.dominance.runs_before(value, read): present.add(name) return present, any_store def visit_JsBinaryExpression(self, node: JsBinaryExpression): self.generic_visit(node) if node.left is None or node.right is None: return None op = node.operator if (folded := self._fold_known_operands(node)) is not None: return folded if op == '+' and (merged := self._merge_concat_tail(node)) is not None: return merged if (decided := self._clock_comparison(node)) is not None: return JsBooleanLiteral(value=decided) if op == 'in' and isinstance(node.left, JsStringLiteral) and node.left.value is not None: result = self._resolve_in(node, node.left.value) if result is not None: return JsBooleanLiteral(value=result) return None def _fold_known_operands(self, node: JsBinaryExpression) -> Expression | None: """ The literal for what *node* computes where both operands are decided, answered by `refinery.lib.scripts.js.values.BINARY_OPS`, so that a fold performed here and the same operator applied by the interpreter cannot disagree. A primitive converts by an operation no program can replace, and an object operand only where its conversion is vouched for (`refinery.lib.scripts.js.deobfuscation.helpers.converted_operand`). A power whose value the language leaves to the engine is not written. """ if node.operator not in BINARY_OPS: return None known, value = self._denoted(node) if not known: return None return self._written(node, value) def _denoted(self, node: Node | None) -> tuple[bool, Value]: """ The value *node* denotes (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`) with the facts of this pass's effect model, remembering what nothing decided in `_undecided`. """ return denoted_value(node, self.model, self._uninterceptable, self._effect_model, self._undecided) def _written(self, node: Node, value: Value) -> Expression | None: """ The literal a fold writes in place of *node* for the *value* it computed (`refinery.lib.scripts.js.deobfuscation.helpers.folded_literal`), or `None` where it may write none: no literal denotes the value, or the literal spells *node* again. `0 / 0` and `void 0` are how `NaN` and `undefined` are written, and writing either in place of itself would rewrite the node for as long as the pass is allowed to run. The fold of a unary operator, of a binary operator, and of a member read write here, being the folds whose value may be any value. A function the language installs is written as the constructor it may be (`_written_constructor`). """ if isinstance(value, BuiltinFunction): return self._written_constructor(node, value) literal = folded_literal(value) if literal is None or _respells(literal, node): return None return literal def _written_constructor(self, node: Node, function: BuiltinFunction) -> Expression | None: """ The global name of the constructor *function* where that name still denotes it at *node* (`refinery.lib.scripts.js.analysis.effects.EffectModel.trusted_intrinsic_name`, `refinery.lib.scripts.js.analysis.model.SemanticModel.bare_name_reaches_the_host`), or `None`; no other built-in function has a name to write. `Function` keeps the spelling it has, since what the reflection check reads of a construction is how its callee is spelled; the reflection pass recognizes a spelling through `constructor` (`refinery.lib.scripts.js.analysis.tampering.denotes_function_intrinsic`). For the same reason a read whose `constructor` is read in turn keeps its spelling: `[].constructor.constructor` names `Function` to every reader of the text, and `Array.constructor` only to one that evaluates `Array`. The scan follows writes and hand-offs through the name, so writing it makes a renewal due where the scan records one there (`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.records_a_read`). """ if not function.is_constructor or function.name == 'Function': return None holder = enclosing_operator(node) if ( isinstance(holder, JsMemberExpression) and strip_parens(holder.object) is node and static_property_key(holder) == 'constructor' ): return None if not self.effects.trusted_intrinsic_name(function.name): return None if not self.model.bare_name_reaches_the_host(function.name, node): return None name = JsIdentifier(name=function.name) if _respells(name, node): return None watched = frozenset({function.name}) & ESCAPE_WATCHED_ROOTS if watched and self.held_models().intrinsic_writes.records_a_read(node, watched): self._renewal_due = True return name def _denoted_primitive(self, node: Node | None) -> tuple[bool, Value]: """ The value *node* denotes (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`) where it is a primitive, as `(True, value)`, or `(False, None)`. Every primitive converts by an internal specification operation that no program can intercept, so an operator applied to it answers the same whatever the file did to the prototypes (`refinery.lib.scripts.js.values.converts_uninterceptably`); an array literal is excluded because its conversion runs `Array.prototype.join`. """ known, value = denoted_value(node, self.model, self._uninterceptable) if not known or not converts_uninterceptably(value): return False, None return True, value def _clock_comparison(self, node: JsBinaryExpression) -> bool | None: """ What *node* yields where it compares a read of the host's clock with a number outside the window of times the run assumes (`refinery.lib.scripts.js.options.clock_window`), the clock on either side. The read is asked at the comparison (`refinery.lib.scripts.js.analysis.effects.AnchoredTrust.reads_the_clock`), so a `Date` the program replaces or writes on is no clock. """ left, right, operator = node.left, node.right, node.operator if left is None or right is None: return None if (time := numeric_value(right)) is not None: clock = left elif (time := numeric_value(left)) is not None: clock = right operator = _MIRRORED_COMPARISONS.get(operator, operator) else: return None window = clock_window(self.options) if window is None or (decided := window.comparison(operator, time)) is None: return None if not self.cache.trust_at(node).reads_the_clock(clock): return None return decided def _merge_concat_tail(self, node: JsBinaryExpression) -> JsBinaryExpression | None: """ Reassociate `(x + 'a') + 'b'` into `x + 'ab'`. Splitting a string across a `+` chain is a common concealment, and because `+` is left-associative the chain nests as `((x + 'a') + 'b') + 'c'`, so no single node ever has two literal operands and pairwise folding alone never reduces it. Merging is legal exactly when the inner node's right operand is a *string* literal. That makes the inner `+` a concatenation whatever `x` is, so the outer operand appends to the same string the unmerged chain would build, and both forms convert `x` once. When the inner right operand is a number the pair cannot be reduced at all: `x + 1 + 2` is `10` for `x = 7` but `'712'` for `x = '7'`, so neither `x + 3` nor `x + '12'` is the chain. The number is left where it binds. `x` is not converted here and need not be statically known, so a call or an object with a side-effecting `valueOf` is fine — it stays in place and still runs exactly once. """ inner = node.left if not isinstance(inner, JsBinaryExpression) or inner.operator != '+': return None if inner.left is None or (head := string_value(inner.right)) is None: return None known, tail = self._denoted_primitive(node.right) if not known: return None return JsBinaryExpression( operator='+', left=inner.left, right=make_string_literal(head + to_string(tail)), ) def visit_JsCallExpression(self, node: JsCallExpression): self.generic_visit(node) fn = strip_parens(node.callee) if isinstance(fn, JsFunctionExpression): self._facts() replacement = try_inline_trivial_function(fn, node.arguments, transformer=self) if replacement is None: return None return spelled_for_the_callee_position(node, replacement) return ( self._try_fold_static_method(node) or self._try_fold_free_function(node) or self._try_fold_instance_method(node) or self._try_fold_split(node) or self._try_fold_join(node) ) def _try_fold_static_method(self, node: JsCallExpression) -> Node | None: callee = node.callee if not isinstance(callee, JsMemberExpression): return None if not isinstance(callee.object, JsIdentifier): return None static_name = callee.object.name if static_name not in STATIC_OBJECTS: return None method_name = access_key(callee) if method_name is None: return None builtin = registered_builtin((static_name, method_name), self.model.environment) if builtin is None: return None if not self.effects.call_is_foldable(node): return None args = self._builtin_arguments(node, builtin) if args is None: return None try: result = builtin.function(args) except Exception: return None return folded_literal(result) def _try_fold_free_function(self, node: JsCallExpression) -> Node | None: callee = node.callee if not isinstance(callee, JsIdentifier): return None builtin = registered_builtin((None, callee.name), self.model.environment) if builtin is None: return None if not self.effects.call_is_foldable(node): return None args = self._builtin_arguments(node, builtin) if args is None: return None try: result = builtin.function(args) except Exception: return None return folded_literal(result) def _try_fold_instance_method(self, node: JsCallExpression) -> Node | None: callee = node.callee if not isinstance(callee, JsMemberExpression): return None method_name = access_key(callee) if method_name is None or method_name in ('split', 'join'): return None if callee.object is None: return None known, receiver = denoted_value(callee.object, self.model, self._uninterceptable) if not known: return None if isinstance(receiver, str): builtin = registered_builtin((str, method_name), self.model.environment) elif isinstance(receiver, list): builtin = registered_builtin((list, method_name), self.model.environment) else: return None if builtin is None: return None if not self.effects.call_is_foldable(node, receiver_type=type(receiver)): return None args = self._builtin_arguments(node, builtin) if args is None: return None try: result = builtin.function(receiver, args) except Exception: return None return folded_literal(result) def _try_fold_split(self, node: JsCallExpression) -> JsArrayExpression | None: if len(node.arguments) != 1: return None callee = node.callee if not isinstance(callee, JsMemberExpression): return None obj_str = string_value(callee.object) if obj_str is None: return None method_name = access_key(callee) if method_name != 'split': return None sep = string_value(node.arguments[0]) if sep is None: return None if not self.effects.call_is_foldable(node, receiver_type=str): return None parts = obj_str.split(sep) if sep else utf16_code_units(obj_str) if len(parts) > MAX_RESULT_ARRAY_LEN: return None return JsArrayExpression( elements=[make_string_literal(p) for p in parts], ) def _try_fold_join(self, node: JsCallExpression) -> JsStringLiteral | None: if len(node.arguments) > 1: return None callee = node.callee if not isinstance(callee, JsMemberExpression): return None method_name = access_key(callee) if method_name != 'join': return None obj = callee.object if not isinstance(obj, JsArrayExpression): return None parts: list[str] = [] for e in obj.elements: if not isinstance(e, JsStringLiteral) or e.value is None: return None parts.append(e.value) if node.arguments: sep = string_value(node.arguments[0]) if sep is None: return None else: sep = ',' if not self.effects.call_is_foldable(node, receiver_type=list): return None return make_string_literal(sep.join(parts)) def _discarding( self, node: Expression, test: Expression, kept: Expression | None, ) -> Expression | None: """ *kept* in place of *node*, preceded by *test* where dropping the latter would change what the program does. Two separate reasons it may have to stay. Evaluating it is an effect: a fold that picks a branch answers from the test's *value* and has no further use for it, but the test may call, or read a property whose getter runs. `JsDeadCodeElimination` keeps a statement test for the same reason and in the same way. And *node* may be what a call invokes, in which case removing the operator around *kept* changes the receiver: `(1 ? o.m : g)()` invokes with no receiver, where a bare `o.m()` binds `o`, and `(1 ? eval : g)(s)` is an indirect eval where a bare `eval(s)` is a direct one. The sequence is what preserves that — `(0, o.m)()` is the idiom for calling without a receiver — so a form-sensitive branch keeps it even when the test does nothing at all. """ if kept is None: return None receiver_sensitive = is_invocation_target(node) and callee_form_sensitive(kept, node) if not receiver_sensitive and self.effects.is_side_effect_free( test, discarded=True, reads_may_throw=True, read_established=self._read_established ): return kept return JsSequenceExpression(expressions=[test, kept]) def _read_established(self, node: JsIdentifier) -> bool: return self.cache.read_established(node) def visit_JsConditionalExpression(self, node: JsConditionalExpression): self.generic_visit(node) if node.test is None: return None truthy = is_truthy(node.test, self.cache) if truthy is None: return None return self._discarding(node, node.test, node.consequent if truthy else node.alternate) def visit_JsSequenceExpression(self, node: JsSequenceExpression): self.generic_visit(node) if not node.expressions: return None filtered = [ e for i, e in enumerate(node.expressions) if i == len(node.expressions) - 1 or not is_simple_expression(e) or self.effects.read_throws(e, self._read_established) ] if len(filtered) == len(node.expressions): return None if len(filtered) == 1: if is_invocation_target(node) and callee_form_sensitive(filtered[0], node): return None return filtered[0] set_child_list(node, 'expressions', filtered) self.mark_changed() return None def visit_JsParenthesizedExpression(self, node: JsParenthesizedExpression): self.generic_visit(node) inner = node.expression if inner is None: return None if isinstance(inner, ( JsSequenceExpression, JsFunctionExpression, JsArrowFunctionExpression, JsObjectExpression, JsClassExpression, )): return None if parens_required(inner, node.parent, node): return None return inner def visit_JsMemberExpression(self, node: JsMemberExpression): """ Replace an access by what it reads, where that is decided and standing in its place means the same thing. A target that is assigned, updated, deleted, or destructured is not such a place at all: it is somewhere to store into, a constant is not, and every arm below is refused there. Where the value is applied, the arms part company, because they write down different things. The alias arm writes another way of naming the same function, so it has only to keep the receiver a call reads off a member and the scope a direct `eval` needs. The constructor arm writes the name of the constructor a read reaches (`_invoked_constructor`), which no call reads a receiver of. The arms that write a constant have nothing to keep: a constant is neither callable nor a constructor, so the application throws whatever they do, and folding only changes which text the `TypeError` names — which is why they refuse a `new` the alias arm is free to fold under. """ self.generic_visit(node) in_read_position = not is_member_write_target(node) reads_a_value = in_read_position and not is_constructed_or_invoked(node) if ( not node.computed and isinstance(node.object, JsIdentifier) and isinstance(node.property, JsIdentifier) and (self._names_a_global(node) is not None or self._global_object_alias_base(node)) and not self._resolves_to_local(node, node.property.name) and self._alias_collapse_is_sound(node, node.object, node.property.name) ): if in_read_position and not is_invocation_target(node): return node.property return None if in_read_position and is_constructed_or_invoked(node): if (constructor := self._invoked_constructor(node)) is not None: return constructor if reads_a_value: if (folded := self._folded_array_length(node)) is not None: return folded if (element := self._literal_array_element(node)) is not None: return element if (folded := self._folded_member(node)) is not None: return folded if (character := self._folded_date_character(node)) is not None: return character if node.computed and node.object is not None and node.property is not None: prop_str = string_value(node.property) if prop_str is not None and is_valid_property_key(prop_str): set_value(node, 'computed', False) set_child(node, 'property', JsIdentifier(name=prop_str)) self.mark_changed() return None return None def visit_JsProperty(self, node: JsProperty): self.generic_visit(node) if node.computed and node.key is not None: key_str = string_value(node.key) if ( key_str is not None and is_valid_property_key(key_str) and (node.method or key_str != '__proto__') ): set_value(node, 'computed', False) set_child(node, 'key', JsIdentifier(name=key_str)) self.mark_changed() return None def _fresh_object_type(self, operand: Node | None) -> str | None: """ The `typeof` of the object *operand* allocates, when creating it has no other effect — `None` otherwise. The type is `allocated_object_type`'s syntactic answer; the freedom from effects is asked with the value discarded, because that is what folding does with it. Only the type or the truthiness survives, and the object itself does not. """ kind = allocated_object_type(operand) if kind is None: return None node = strip_parens(operand) if node is None: return None free = self.effects.is_side_effect_free( node, discarded=True, reads_may_throw=True, read_established=self._read_established) return kind if free else None def _member_key(self, node: JsMemberExpression) -> str | None: """ The property name the access at *node* reads, or `None` when nothing decides it. A dot access is named by its identifier and a computed one by the string its key converts to, which is the rule the interpreter's `_member_key` applies to an evaluated key and the reason a numeric one is asked with `denoted_value` rather than `string_value`: `'abc'[1]` reads the property named `'1'`, and a reader that only recognized a string key would decide every index access to be undecidable. Naming the property converts the key, and an array or an object converts through a prototype method the file can replace: `Array.prototype.join = () => '2'` makes `'abc'[[1]]` read `'c'`. A key that is no primitive is therefore converted only where that conversion is vouched for (`refinery.lib.scripts.js.deobfuscation.helpers.converted_operand`). """ if not node.computed: return node.property.name if isinstance(node.property, JsIdentifier) else None known, key = self._denoted(node.property) if not known: return None known, key = converted_operand(key, self._uninterceptable, self._effect_model, gated=True) if not known: return None return to_string(key) def _invoked_constructor(self, node: JsMemberExpression) -> Expression | None: """ The global name of the constructor the read *node* reaches where it is called or constructed with, as `''.constructor(5)` calls `String` (`_written_constructor`). The call then passes no receiver where the read passed its object, and no constructor reads one. """ known, value = self._denoted(node) if not known or not isinstance(value, BuiltinFunction): return None return self._written_constructor(node, value) def _literal_array_element(self, node: JsMemberExpression) -> Expression | None: """ The element an array literal of literals holds at the index a numeric literal names, handed back as the node the file spelled it with. """ array, index = node.object, node.property if not node.computed or not isinstance(array, JsArrayExpression): return None if not isinstance(index, JsNumericLiteral): return None idx = exact_integer(index.value) elements = array.elements if idx is None or not 0 <= idx < len(elements): return None if not all(e is not None and is_literal(e) for e in elements): return None return elements[idx] def _folded_member(self, node: JsMemberExpression) -> Expression | None: """ The value a property read off a decided value denotes, as a node, or `None` when the read is not one this decides (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_member`). `'abc'.length` is `3` and `'abc'[1]` is `'b'`, own properties of the string that no prototype can shadow, and `'abc'.nope` is `undefined` where the chain is the one the language describes and the file writes no built-in object behind the scan's back. The object is asked with `denoted_value`, which decides a literal, a global value name, and an operator or a read over those — every one of them free of effects — so nothing is discarded by replacing the access with what it reads, and no separate effect gate is needed. An index answers a string, and a string is the one value whose spelling a statement position reads: standing alone at the top of a body it is a directive. `'abc'[0];` is an ordinary statement that ends a Directive Prologue, and `'a';` continues one, which would make a `'use strict'` below it govern the file it merely stood in. Such a read is left alone. """ known, value = self._denoted(node) if not known: return None if isinstance(value, str) and self._stands_in_a_directive_prologue(node): return None return self._written(node, value) def _folded_date_character(self, node: JsMemberExpression) -> Expression | None: """ The character a read at a decided index off `p + Date()` holds whatever the time, where `p` is a decided primitive and `Date` the function the language installs, called without arguments. The string the engine of the host writes for the time (`refinery.lib.scripts.js.analysis.environment.JsEngine.date_character`) fixes some of its positions while the year has four digits, which the window of times the run assumes (`refinery.lib.scripts.js.options.clock_window`) guarantees, and a position inside `p` holds the character of `p`. Both operands are free of effects, so nothing is discarded by writing the character in place of the read. """ engine = self.model.environment.engine if engine is None or clock_window(self.options) is None or not node.computed: return None concatenation = strip_parens(node.object) if not isinstance(concatenation, JsBinaryExpression) or concatenation.operator != '+': return None call = strip_parens(concatenation.right) if ( not isinstance(call, JsCallExpression) or call.optional or call.arguments or self.effects.trusted_intrinsic(call.callee) != 'Date' ): return None known, prefix = self._denoted(concatenation.left) if not known or not converts_uninterceptably(prefix): return None known, key = self._denoted(node.property) if not known or not converts_uninterceptably(key): return None index = canonical_array_index(to_string(key)) if index is None: return None text = to_string(prefix) character = text[index] if index < len(text) else engine.date_character(index - len(text)) if character is None or self._stands_in_a_directive_prologue(node): return None return make_string_literal(character) def _stands_in_a_directive_prologue(self, node: Node) -> bool: """ Whether *node* is the whole of a statement that a Directive Prologue would take in were it spelled as a string literal. `joins_directive_prologue` decides that of the statement; what is decided here is that *node* is the whole of one. The brackets a read stands in are climbed through rather than trusted to keep it out of the prologue: `('abc'[0]);` is not a directive, but nothing keeps the printer from spelling the folded literal without them, and then it is. """ cursor: Node = node statement = cursor.parent while isinstance(statement, JsParenthesizedExpression): cursor, statement = statement, statement.parent if not isinstance(statement, JsExpressionStatement): return False if strip_parens(statement.expression) is not node: return False return joins_directive_prologue(statement) def _folded_array_length(self, node: JsMemberExpression) -> Expression | None: """ The `length` of an array literal, as a node, or `None` when the literal does not decide it. This is the one property a value domain cannot answer: `[a, b, c]` and `[1, , 3]` denote no value the folder can hold, because an element is not a literal or is not there at all, yet each is three elements long whatever those elements turn out to be. The count is read off the syntax, where the parser records an elision as an element that is absent and a trailing comma as no element at all, exactly as the language counts them. A spread is the one element whose count is not the literal's to know — `[...'abc']` is three elements from one — so a literal holding any is declined rather than counted wrong. That stays stated here even though the effect gate below happens to refuse a spread too: what makes a spread wrong is the count, not the iterating, and a later effect model that learned to trust iterating an array would otherwise start answering `[...[1, 2]].length` with `1`. Only the length survives, so the array and every element in it is discarded, and evaluating an element may therefore do nothing at all. Every element must be a literal or an elision, which is the strongest thing the folder can say and the only one that is true: asking `is_side_effect_free` instead — the question `_fresh_object_type` asks of an allocation — answers yes for a name no binding resolves and for an operator that runs `valueOf`, so `[zzz].length` would print `1` where the file threw a `ReferenceError` and `[+o].length` would swallow what reading `o` writes. """ if self._member_key(node) != 'length': return None array = strip_parens(node.object) if not isinstance(array, JsArrayExpression): return None if any(isinstance(element, JsSpreadElement) for element in array.elements): return None if not all(element is None or is_literal(element) for element in array.elements): return None return value_to_node(len(array.elements)) def _uninterceptable(self, value: Value) -> bool: """ Whether converting *value* runs no code the program installed (`refinery.lib.scripts.js.deobfuscation.helpers.coerces_uninterceptably`), asked of this pass's effect model. """ return coerces_uninterceptably(self.effects, value) def _builtin_arguments(self, node: JsCallExpression, builtin: RegisteredBuiltin) -> list[Value] | None: """ The values of the arguments of *node* for a call of *builtin*, or `None` where one of them denotes nothing or may convert through a method the program replaced. A built-in may convert any argument it is handed, as `Number([5])` runs `Array.prototype.join`, so each is asked `refinery.lib.scripts.js.deobfuscation.helpers.coerces_uninterceptably`. A built-in that reads every argument as a string gets each converted as an operator converts an operand (`refinery.lib.scripts.js.deobfuscation.helpers.converted_operand`), which gives a function the language installs the text the host's engine writes for it. """ args: list[Value] = [] for argument in node.arguments: if builtin.reads_strings: known, value = self._denoted(argument) if known: known, value = converted_operand(value, self._uninterceptable, self._effect_model, gated=True) else: known, value = denoted_value(argument, self.model, self._uninterceptable) known = known and self._uninterceptable(value) if not known: return None args.append(value) return args def _deletion_answer(self, node: JsUnaryExpression) -> bool | None: """ The value the `delete` at *node* gives where it changes nothing anything reads, so that only that value remains, or `None` where the `delete` stays. It is `true` where the `delete` removes a property that nothing in the program can read, the answer for every configurable and every absent property: the deletion is a dead store. The object literal keeps the property, which is sound precisely because no reader of it survives; editing the literal instead would have to be ordered against every read, while leaving it needs no ordering at all. It is `false` for the `length` of an array, which the array owns and cannot lose, where the code cannot run as strict code; strict code throws there, and the `delete` stays. The object must be a container held by a local that nothing else can reach: a single unwritten declaration bound to a freshly allocated literal, immutable across the rest of the program, and referenced only through plain reads of statically named properties other than the deleted one (`refinery.lib.scripts.js.analysis.model.ContainerRole.MEMBER_READ`). That last condition is what rules out every reader — a method call, a spread, a `for-in`, or an escape into a call would each be able to observe the property, and none of them is a plain named read. A method call names a key of its own, as `a.join('-')` names `join`, and still reads every element, and a named read runs an accessor where the literal declares one or installs a prototype through `__proto__:`, which may read the deleted key through `this`. """ member = strip_parens(node.operand) if not isinstance(member, JsMemberExpression): return None base = member.object key = access_key(member) if key is None or not isinstance(base, JsIdentifier): return None binding = self.model.resolve(base) if binding is None or binding.writes or len(binding.declarations) != 1: return None value = self.model.singular_value(binding) if not isinstance(value, (JsObjectExpression, JsArrayExpression)): return None if isinstance(value, JsObjectExpression) and object_member_access_runs_accessor(value): return None if not self.effects.binding_is_immutable_container(binding, exclude=node): return None for reference in self.model.references(binding): if reference is base: continue access = reference.parent if not isinstance(access, JsMemberExpression) or access.object is not reference: return None if access_key(access) in (None, key): return None if container_reference_role(reference) is not ContainerRole.MEMBER_READ: return None if isinstance(value, JsArrayExpression) and key == 'length': return None if self.model.may_run_as_strict(node) else False return True def _typeof_of_global_read(self, operand: Node | None) -> str | None: """ The string `typeof <operand>` yields when *operand* is a bare read of a global name the run's host settles — `None` otherwise. `refinery.lib.scripts.js.analysis.environment.typeof_of_global` answers it against the pinned host: a name whose `typeof` is host-independent (`String`, `globalThis`, `escape`) folds in every host, and a host-conditional alias (`window`, `Buffer`) folds only where the host proves it present — to its type — or absent — to `'undefined'`, and abstains under the default `universal` host. This is what lets a `typeof globalThis !== 'undefined'` guard fold to `true`, so `refinery.lib.scripts.js.deobfuscation.deadcode.JsDeadCodeElimination` can prune the arm it protects and a global-object finder reduce to `return globalThis`, and what lets `-e node` fold a `typeof Buffer` guard the same way. The question is asked at the `typeof` (`refinery.lib.scripts.js.analysis.effects.AnchoredTrust.global_typeof`), which refuses a name the program binds or writes anywhere, one a reflection surface could reach before the `typeof` runs, and one a module the program loads before it may set up. The interpreter's `typeof` fold asks the same question, so a fold made here and one made there cannot disagree. """ node = strip_parens(operand) if not isinstance(node, JsIdentifier): return None return self.cache.trust_at(node).global_typeof(node.name) def visit_JsUnaryExpression(self, node: JsUnaryExpression): """ Fold a unary operator against its operand. Everything that is a function of the operand's value is the value the whole expression denotes (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`), which applies the shared `UNARY_OPS` kernel, so that a fold performed here and the same operator applied by the interpreter cannot disagree, and which refuses to convert an array whose conversion the program may have replaced. What is left is the two questions a value cannot answer — the type of an object whose identity no literal spells, and whether a `delete` may be dropped. A third, `typeof` of a pristine guaranteed global, is a value the operator hides rather than one the operand denotes, so it is decided by name (`_typeof_of_global_read`). A node that already spells its value the way a fold writes it is left alone (`_written`): `void 0` is how `undefined` is written, so folding it produces the expression it replaces, and the pass would rewrite the same node for as long as it is allowed to run. """ self.generic_visit(node) operand = node.operand if operand is None: return None op = node.operator if op == 'delete': answer = self._deletion_answer(node) return None if answer is None else JsBooleanLiteral(value=answer) kind = self._fresh_object_type(operand) if kind is not None: if op == 'typeof': return make_string_literal(kind) if op == '!': return JsBooleanLiteral(value=False) if op == 'typeof': typed = self._typeof_of_global_read(operand) if typed is not None: return make_string_literal(typed) known, value = self._denoted(node) if not known: return None return self._written(node, value) def visit_JsStringLiteral(self, node: JsStringLiteral): """ Spell a literal the shortest way that denotes the same text, where the spelling it was given states nothing of its own. Two spellings state something. One the source never closed has no spelling at all, and writing one hands it the quote nobody wrote — the file stops being the file that was read. And a spelling a rule reads may not be traded for one the same rule reads differently: an escape hiding a space in `'use\\x20strict'` is what keeps that line from being a directive, and re-spelling it makes every line behind it strict code. """ if not node.terminated or node.value is None: return None quote = node.raw[0] if node.raw else '\'' rebuilt = quote + escape_js_string(node.value, quote) + quote if rebuilt == node.raw: return None if spelling_states(rebuilt[1:-1]) != spelling_states(node.body): return None set_value(node, 'raw', rebuilt) self.mark_changed() return None def visit_JsLogicalExpression(self, node: JsLogicalExpression): self.generic_visit(node) if node.left is None or node.right is None: return None if node.operator == '||' and self.effects.intrinsic_of(node.left) is not None: return node.left op = node.operator if op == '??': nullish = is_nullish(node.left, self.model, self._uninterceptable) if nullish is None: return None return self._discarding(node, node.left, node.right) if nullish else node.left truthy = is_truthy(node.left, self.cache) if truthy is None: return None if op == '&&': return self._discarding(node, node.left, node.right) if truthy else node.left if op == '||': return node.left if truthy else self._discarding(node, node.left, node.right) return NoneAncestors
Instance variables
var cache-
The models a decision reads (
_facts).Expand source code Browse git
@property def cache(self) -> ModelCache: """ The models a decision reads (`_facts`). """ return self._facts() var model-
Expand source code Browse git
@property def model(self) -> SemanticModel: return self.cache.model var assignment-
Expand source code Browse git
@property def assignment(self) -> DefiniteAssignmentModel: return self.cache.assignment var effects-
Expand source code Browse git
@property def effects(self) -> EffectModel: return self.cache.effects var dominance-
Expand source code Browse git
@property def dominance(self) -> DominanceModel: return self.cache.dominance var reaching-
Expand source code Browse git
@property def reaching(self) -> ReachingModel: return self.cache.reaching
Methods
def visit(self, node)-
Visit node, and make a renewal due where the replacement a fold returns for it reads as a key node did not read as, a string, a number, a boolean or
null, and that key shows the held models a fact (_shows_the_models_a_name). Every fold returns its replacement through here, so a key thatString.fromCharCode, ajoin, a sequence or a conditional collapses to is seen as surely as one an operator computes. The dispatch ofVisitor.visit()is written out rather than called, so that a deep tree costs this pass no more stack than it costs any other transform.The check climbs from node, which still stands in the tree. A replacement returned while a renewal is due is written in its place at once: the walk of
Transformer.generic_visit()writes the items of a list only once it has visited all of them, so the renewal the next item's decision makes would otherwise build models of the tree the fold has not changed yet, and count itself done. Every other replacement is left to the walk, since writing one in place scans the fields of its parent, and a list of folded items would pay its square.Expand source code Browse git
def visit(self, node: Node) -> Node | None: """ Visit *node*, and make a renewal due where the replacement a fold returns for it reads as a key *node* did not read as, a string, a number, a boolean or `null`, and that key shows the held models a fact (`_shows_the_models_a_name`). Every fold returns its replacement through here, so a key that `String.fromCharCode`, a `join`, a sequence or a conditional collapses to is seen as surely as one an operator computes. The dispatch of `refinery.lib.scripts.Visitor.visit` is written out rather than called, so that a deep tree costs this pass no more stack than it costs any other transform. The check climbs from *node*, which still stands in the tree. A replacement returned while a renewal is due is written in its place at once: the walk of `refinery.lib.scripts.Transformer.generic_visit` writes the items of a list only once it has visited all of them, so the renewal the next item's decision makes would otherwise build models of the tree the fold has not changed yet, and count itself done. Every other replacement is left to the walk, since writing one in place scans the fields of its parent, and a list of folded items would pay its square. """ kind = type(node) handler = self._dispatch.get(kind) if handler is None: handler = self._dispatch[kind] = getattr(self, F'visit_{kind.__name__}', self.generic_visit) replacement = handler(node) if replacement is None: return None if not self._renewal_due: value = expression_key(replacement) if value is not None and expression_key(node) != value: string = static_string(replacement) is not None self._renewal_due = _shows_the_models_a_name(self.held_models(), node, value, string) if self._renewal_due: _replace_in_parent(node, replacement) return replacement def visit_JsScript(self, node)-
Attach the shared model cache for the whole script, then rewrite with the models held for the length of the pass (
PinnedTransformer), renewed only where a fold shows them a fact. Rebuilding them per rewrite is what made deobfuscation cost the product of tree size and rewrite count; a fold gate is consulted on nearly every rewrite, so each rewrite paid for a full rebuild of the semantic and effect models.Holding them is sound because this pass cannot loosen the facts those models report. It only ever removes bindings, never adds one, so a name reported as locally bound stays bound and reading shadowing from a held model can only over-preserve a global-alias access, never collapse one a local now captures. In the same way it cannot reveal an intrinsic as patched: an install is attributed to every name its target may denote, before any fold collapses the target to a plain name, so
_globals_writtenandglobal_pristinealready account for the rewrites this pass performs. Were that not so, a held model would report a patched built-in as pristine and admit a fold against it."Every name its target may denote" spans two kinds of collapse, and both must already be accounted for. One is syntactic —
0 || MathnamesMathbefore the fold that reduces it — and the other is an alias:var m = Math; m.floor = fnamesMaththough the assignment mentions onlym, so inlining the alias reveals nothing the held set lacked.A write need not be in the file at all for the same argument to be needed. An intrinsic handed to code whose writes cannot be enumerated —
patch(Math)for an unresolvablepatch— is recorded as written when it escapes, and escaping is decided through the same value-forwarding forms, so a fold that collapsesp(0 || Math)top(Math)finds the name already recorded. What the pass can do is remove an escape, by deleting a dead branch containing one; that shrinks the set, which is the direction a held answer may safely be stale in.A built-in object a read reaches without spelling its owner is one the scan cannot attribute a write through, and a value that may be one is unattributed (
IntrinsicWrites.followable): a fold that reads such a value away, or collapses a key it was read under to a name the scan does not count, leaves fewer of them, never more.Two edits do show the models a fact the held ones lack: a string written where an answer assumed a key it could not read is none of the names its reader looked for, or a string spelling a name whose readers look at every string, which any fold collapsing a key may write (
visit); and the name of a constructor written where the scan then records a write or a hand-off through it. Such a fold renews the models before the next decision (cache).The same argument covers the freshness the effect model reports for a container base. A binding is judged fresh only when this pass owns every value it takes, and the pass adds no value to any binding; the array guarantee that an allocating method's result depends on is withdrawn by a write to
constructoror__proto__, where a key this pass cannot read counts as possibly naming one. Folding a computed key to a literal therefore moves that answer from withdrawn to withdrawn — never from granted to withdrawn — which is the direction a held model may be stale in.The pin also has to keep every model the window reads consistent with its entry. A model that reads the live tree would otherwise be built at the first gate that consults it, which can fall after an earlier fold's edit, layering it over held base models from an earlier tree — the one state no unpinned run builds, which the pin's exit refuses. Building the models at entry, before the first fold, forecloses that; a model derived only from them stays a pure function of the held bases wherever it is first built.
Expand source code Browse git
def visit_JsScript(self, node: JsScript): """ Attach the shared model cache for the whole script, then rewrite with the models held for the length of the pass (`refinery.lib.scripts.js.deobfuscation.helpers.PinnedTransformer`), renewed only where a fold shows them a fact. Rebuilding them per rewrite is what made deobfuscation cost the product of tree size and rewrite count; a fold gate is consulted on nearly every rewrite, so each rewrite paid for a full rebuild of the semantic and effect models. Holding them is sound because this pass cannot loosen the facts those models report. It only ever removes bindings, never adds one, so a name reported as locally bound stays bound and reading shadowing from a held model can only over-preserve a global-alias access, never collapse one a local now captures. In the same way it cannot reveal an intrinsic as patched: an install is attributed to every name its target may denote, before any fold collapses the target to a plain name, so `_globals_written` and `global_pristine` already account for the rewrites this pass performs. Were that not so, a held model would report a patched built-in as pristine and admit a fold against it. "Every name its target may denote" spans two kinds of collapse, and both must already be accounted for. One is syntactic — `0 || Math` names `Math` before the fold that reduces it — and the other is an alias: `var m = Math; m.floor = f` names `Math` though the assignment mentions only `m`, so inlining the alias reveals nothing the held set lacked. A write need not be in the file at all for the same argument to be needed. An intrinsic handed to code whose writes cannot be enumerated — `patch(Math)` for an unresolvable `patch` — is recorded as written when it *escapes*, and escaping is decided through the same value-forwarding forms, so a fold that collapses `p(0 || Math)` to `p(Math)` finds the name already recorded. What the pass can do is remove an escape, by deleting a dead branch containing one; that shrinks the set, which is the direction a held answer may safely be stale in. A built-in object a read reaches without spelling its owner is one the scan cannot attribute a write through, and a value that may be one is unattributed (`refinery.lib.scripts.js.analysis.intrinsics.IntrinsicWrites.followable`): a fold that reads such a value away, or collapses a key it was read under to a name the scan does not count, leaves fewer of them, never more. Two edits do show the models a fact the held ones lack: a string written where an answer assumed a key it could not read is none of the names its reader looked for, or a string spelling a name whose readers look at every string, which any fold collapsing a key may write (`visit`); and the name of a constructor written where the scan then records a write or a hand-off through it. Such a fold renews the models before the next decision (`cache`). The same argument covers the freshness the effect model reports for a container base. A binding is judged fresh only when this pass owns every value it takes, and the pass adds no value to any binding; the array guarantee that an allocating method's result depends on is withdrawn by a write to `constructor` or `__proto__`, where a key this pass cannot read counts as possibly naming one. Folding a computed key to a literal therefore moves that answer from withdrawn to withdrawn — never from granted to withdrawn — which is the direction a held model may be stale in. The pin also has to keep every model the window reads consistent with its entry. A model that reads the live tree would otherwise be built at the first gate that consults it, which can fall after an earlier fold's edit, layering it over held base models from an earlier tree — the one state no unpinned run builds, which the pin's exit refuses. Building the models at entry, before the first fold, forecloses that; a model derived only from them stays a pure function of the held bases wherever it is first built. """ with self.holding_models(node): self.held_models() self.generic_visit(node) return None def visit_JsBinaryExpression(self, node)-
Expand source code Browse git
def visit_JsBinaryExpression(self, node: JsBinaryExpression): self.generic_visit(node) if node.left is None or node.right is None: return None op = node.operator if (folded := self._fold_known_operands(node)) is not None: return folded if op == '+' and (merged := self._merge_concat_tail(node)) is not None: return merged if (decided := self._clock_comparison(node)) is not None: return JsBooleanLiteral(value=decided) if op == 'in' and isinstance(node.left, JsStringLiteral) and node.left.value is not None: result = self._resolve_in(node, node.left.value) if result is not None: return JsBooleanLiteral(value=result) return None def visit_JsCallExpression(self, node)-
Expand source code Browse git
def visit_JsCallExpression(self, node: JsCallExpression): self.generic_visit(node) fn = strip_parens(node.callee) if isinstance(fn, JsFunctionExpression): self._facts() replacement = try_inline_trivial_function(fn, node.arguments, transformer=self) if replacement is None: return None return spelled_for_the_callee_position(node, replacement) return ( self._try_fold_static_method(node) or self._try_fold_free_function(node) or self._try_fold_instance_method(node) or self._try_fold_split(node) or self._try_fold_join(node) ) def visit_JsConditionalExpression(self, node)-
Expand source code Browse git
def visit_JsConditionalExpression(self, node: JsConditionalExpression): self.generic_visit(node) if node.test is None: return None truthy = is_truthy(node.test, self.cache) if truthy is None: return None return self._discarding(node, node.test, node.consequent if truthy else node.alternate) def visit_JsSequenceExpression(self, node)-
Expand source code Browse git
def visit_JsSequenceExpression(self, node: JsSequenceExpression): self.generic_visit(node) if not node.expressions: return None filtered = [ e for i, e in enumerate(node.expressions) if i == len(node.expressions) - 1 or not is_simple_expression(e) or self.effects.read_throws(e, self._read_established) ] if len(filtered) == len(node.expressions): return None if len(filtered) == 1: if is_invocation_target(node) and callee_form_sensitive(filtered[0], node): return None return filtered[0] set_child_list(node, 'expressions', filtered) self.mark_changed() return None def visit_JsParenthesizedExpression(self, node)-
Expand source code Browse git
def visit_JsParenthesizedExpression(self, node: JsParenthesizedExpression): self.generic_visit(node) inner = node.expression if inner is None: return None if isinstance(inner, ( JsSequenceExpression, JsFunctionExpression, JsArrowFunctionExpression, JsObjectExpression, JsClassExpression, )): return None if parens_required(inner, node.parent, node): return None return inner def visit_JsMemberExpression(self, node)-
Replace an access by what it reads, where that is decided and standing in its place means the same thing.
A target that is assigned, updated, deleted, or destructured is not such a place at all: it is somewhere to store into, a constant is not, and every arm below is refused there.
Where the value is applied, the arms part company, because they write down different things. The alias arm writes another way of naming the same function, so it has only to keep the receiver a call reads off a member and the scope a direct
evalneeds. The constructor arm writes the name of the constructor a read reaches (_invoked_constructor), which no call reads a receiver of. The arms that write a constant have nothing to keep: a constant is neither callable nor a constructor, so the application throws whatever they do, and folding only changes which text theTypeErrornames — which is why they refuse anewthe alias arm is free to fold under.Expand source code Browse git
def visit_JsMemberExpression(self, node: JsMemberExpression): """ Replace an access by what it reads, where that is decided and standing in its place means the same thing. A target that is assigned, updated, deleted, or destructured is not such a place at all: it is somewhere to store into, a constant is not, and every arm below is refused there. Where the value is applied, the arms part company, because they write down different things. The alias arm writes another way of naming the same function, so it has only to keep the receiver a call reads off a member and the scope a direct `eval` needs. The constructor arm writes the name of the constructor a read reaches (`_invoked_constructor`), which no call reads a receiver of. The arms that write a constant have nothing to keep: a constant is neither callable nor a constructor, so the application throws whatever they do, and folding only changes which text the `TypeError` names — which is why they refuse a `new` the alias arm is free to fold under. """ self.generic_visit(node) in_read_position = not is_member_write_target(node) reads_a_value = in_read_position and not is_constructed_or_invoked(node) if ( not node.computed and isinstance(node.object, JsIdentifier) and isinstance(node.property, JsIdentifier) and (self._names_a_global(node) is not None or self._global_object_alias_base(node)) and not self._resolves_to_local(node, node.property.name) and self._alias_collapse_is_sound(node, node.object, node.property.name) ): if in_read_position and not is_invocation_target(node): return node.property return None if in_read_position and is_constructed_or_invoked(node): if (constructor := self._invoked_constructor(node)) is not None: return constructor if reads_a_value: if (folded := self._folded_array_length(node)) is not None: return folded if (element := self._literal_array_element(node)) is not None: return element if (folded := self._folded_member(node)) is not None: return folded if (character := self._folded_date_character(node)) is not None: return character if node.computed and node.object is not None and node.property is not None: prop_str = string_value(node.property) if prop_str is not None and is_valid_property_key(prop_str): set_value(node, 'computed', False) set_child(node, 'property', JsIdentifier(name=prop_str)) self.mark_changed() return None return None def visit_JsProperty(self, node)-
Expand source code Browse git
def visit_JsProperty(self, node: JsProperty): self.generic_visit(node) if node.computed and node.key is not None: key_str = string_value(node.key) if ( key_str is not None and is_valid_property_key(key_str) and (node.method or key_str != '__proto__') ): set_value(node, 'computed', False) set_child(node, 'key', JsIdentifier(name=key_str)) self.mark_changed() return None def visit_JsUnaryExpression(self, node)-
Fold a unary operator against its operand. Everything that is a function of the operand's value is the value the whole expression denotes (
denoted_value()), which applies the sharedUNARY_OPSkernel, so that a fold performed here and the same operator applied by the interpreter cannot disagree, and which refuses to convert an array whose conversion the program may have replaced. What is left is the two questions a value cannot answer — the type of an object whose identity no literal spells, and whether adeletemay be dropped. A third,typeofof a pristine guaranteed global, is a value the operator hides rather than one the operand denotes, so it is decided by name (_typeof_of_global_read).A node that already spells its value the way a fold writes it is left alone (
_written):void 0is howundefinedis written, so folding it produces the expression it replaces, and the pass would rewrite the same node for as long as it is allowed to run.Expand source code Browse git
def visit_JsUnaryExpression(self, node: JsUnaryExpression): """ Fold a unary operator against its operand. Everything that is a function of the operand's value is the value the whole expression denotes (`refinery.lib.scripts.js.deobfuscation.helpers.denoted_value`), which applies the shared `UNARY_OPS` kernel, so that a fold performed here and the same operator applied by the interpreter cannot disagree, and which refuses to convert an array whose conversion the program may have replaced. What is left is the two questions a value cannot answer — the type of an object whose identity no literal spells, and whether a `delete` may be dropped. A third, `typeof` of a pristine guaranteed global, is a value the operator hides rather than one the operand denotes, so it is decided by name (`_typeof_of_global_read`). A node that already spells its value the way a fold writes it is left alone (`_written`): `void 0` is how `undefined` is written, so folding it produces the expression it replaces, and the pass would rewrite the same node for as long as it is allowed to run. """ self.generic_visit(node) operand = node.operand if operand is None: return None op = node.operator if op == 'delete': answer = self._deletion_answer(node) return None if answer is None else JsBooleanLiteral(value=answer) kind = self._fresh_object_type(operand) if kind is not None: if op == 'typeof': return make_string_literal(kind) if op == '!': return JsBooleanLiteral(value=False) if op == 'typeof': typed = self._typeof_of_global_read(operand) if typed is not None: return make_string_literal(typed) known, value = self._denoted(node) if not known: return None return self._written(node, value) def visit_JsStringLiteral(self, node)-
Spell a literal the shortest way that denotes the same text, where the spelling it was given states nothing of its own.
Two spellings state something. One the source never closed has no spelling at all, and writing one hands it the quote nobody wrote — the file stops being the file that was read. And a spelling a rule reads may not be traded for one the same rule reads differently: an escape hiding a space in
'use\x20strict'is what keeps that line from being a directive, and re-spelling it makes every line behind it strict code.Expand source code Browse git
def visit_JsStringLiteral(self, node: JsStringLiteral): """ Spell a literal the shortest way that denotes the same text, where the spelling it was given states nothing of its own. Two spellings state something. One the source never closed has no spelling at all, and writing one hands it the quote nobody wrote — the file stops being the file that was read. And a spelling a rule reads may not be traded for one the same rule reads differently: an escape hiding a space in `'use\\x20strict'` is what keeps that line from being a directive, and re-spelling it makes every line behind it strict code. """ if not node.terminated or node.value is None: return None quote = node.raw[0] if node.raw else '\'' rebuilt = quote + escape_js_string(node.value, quote) + quote if rebuilt == node.raw: return None if spelling_states(rebuilt[1:-1]) != spelling_states(node.body): return None set_value(node, 'raw', rebuilt) self.mark_changed() return None def visit_JsLogicalExpression(self, node)-
Expand source code Browse git
def visit_JsLogicalExpression(self, node: JsLogicalExpression): self.generic_visit(node) if node.left is None or node.right is None: return None if node.operator == '||' and self.effects.intrinsic_of(node.left) is not None: return node.left op = node.operator if op == '??': nullish = is_nullish(node.left, self.model, self._uninterceptable) if nullish is None: return None return self._discarding(node, node.left, node.right) if nullish else node.left truthy = is_truthy(node.left, self.cache) if truthy is None: return None if op == '&&': return self._discarding(node, node.left, node.right) if truthy else node.left if op == '||': return node.left if truthy else self._discarding(node, node.left, node.right) return None
Inherited members
- It names the models it reads in