Module refinery.lib.scripts.js.deobfuscation.iifeaccessor
Promote IIFE-bound function accessors to plain function declarations.
Recognizes the pattern
var X = function () {
var c1 = <literal>;
var c2 = <literal-array>;
return function (p) { ... uses c1, c2 ... };
}();
and rewrites it as
function X(p) {
var c1 = <literal>;
var c2 = <literal-array>;
... uses c1, c2 ...
}
This is a common obfuscator pattern where a string-decoding accessor is built once and bound to a
variable through the result of an IIFE that closes over a constant lookup table and a few scalar
parameters. JsFunctionEvaluator only registers
JsFunctionDeclaration nodes in its scope tree, so call sites like
X(0x3a2) are otherwise never folded. The rewrite is only applied when the returned function
assigns no captured variable and only reads through a captured list or object, and no list or
object such a read answers leaves the call (_closed_over_containers_stay_unchanged); under that
condition building the captured values afresh at every call computes what the one build the IIFE
ran did. After the rewrite, the function evaluator folds each call site to its computed string
literal.
The declaration has to stand at the top level of a function, a module or a script, exported or
not, where a function declaration names the scope the declarator named. In a block it would name
the block, which strict code keeps it in and sloppy code copies it out of (Annex B.3.3), and neither
is where a var, a let or a const there puts its name.
Expand source code Browse git
"""
Promote IIFE-bound function accessors to plain function declarations.
Recognizes the pattern
var X = function () {
var c1 = <literal>;
var c2 = <literal-array>;
return function (p) { ... uses c1, c2 ... };
}();
and rewrites it as
function X(p) {
var c1 = <literal>;
var c2 = <literal-array>;
... uses c1, c2 ...
}
This is a common obfuscator pattern where a string-decoding accessor is built once and bound to a
variable through the result of an IIFE that closes over a constant lookup table and a few scalar
parameters. `refinery.lib.scripts.js.deobfuscation.evaluator.JsFunctionEvaluator` only registers
`refinery.lib.scripts.js.model.JsFunctionDeclaration` nodes in its scope tree, so call sites like
`X(0x3a2)` are otherwise never folded. The rewrite is only applied when the returned function
assigns no captured variable and only reads through a captured list or object, and no list or
object such a read answers leaves the call (`_closed_over_containers_stay_unchanged`); under that
condition building the captured values afresh at every call computes what the one build the IIFE
ran did. After the rewrite, the function evaluator folds each call site to its computed string
literal.
The declaration has to stand at the top level of a function, a module or a script, exported or
not, where a function declaration names the scope the declarator named. In a block it would name
the block, which strict code keeps it in and sloppy code copies it out of (Annex B.3.3), and neither
is where a `var`, a `let` or a `const` there puts its name.
"""
from __future__ import annotations
from typing import Iterable, Iterator, NamedTuple
from refinery.lib.scripts import (
Node,
_clone_node,
_replace_in_parent,
)
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.model import (
FUNCTION_NODES,
Binding,
ContainerRole,
container_reference_role,
enclosing_operator,
hoists_its_functions,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
ScriptLevelTransformer,
extract_identifier_params,
is_literal,
references_receiver_this,
walk_receiver_scope,
walk_scope,
)
from refinery.lib.scripts.js.keys import key_text
from refinery.lib.scripts.js.model import (
JsArrayExpression,
JsArrowFunctionExpression,
JsAssignmentExpression,
JsBlockStatement,
JsCallExpression,
JsExportNamedDeclaration,
JsFunctionDeclaration,
JsFunctionExpression,
JsIdentifier,
JsMemberExpression,
JsObjectExpression,
JsProperty,
JsReturnStatement,
JsScript,
JsUpdateExpression,
JsVariableDeclaration,
JsVariableDeclarator,
is_async_function,
is_generator_function,
object_sets_prototype,
strip_parens,
wraps_return,
)
from refinery.lib.scripts.js.strict import directive_prologue
def _is_literal_initializer(node: Node | None) -> bool:
if node is None:
return False
if is_literal(node):
return True
if isinstance(node, JsArrayExpression):
return all(el is not None and _is_literal_initializer(el) for el in node.elements)
if isinstance(node, JsObjectExpression):
for prop in node.properties:
if not isinstance(prop, JsProperty):
return False
if prop.computed and (prop.key is None or not is_literal(prop.key)):
return False
if prop.value is None or not _is_literal_initializer(prop.value):
return False
return True
return False
class _Pattern(NamedTuple):
declaration: JsVariableDeclaration
name: str
closure_decls: list[JsVariableDeclaration]
inner_func: JsFunctionExpression | JsArrowFunctionExpression
def _detect(declarator: JsVariableDeclarator) -> _Pattern | None:
if not isinstance(declarator.id, JsIdentifier):
return None
declaration = declarator.parent
if not isinstance(declaration, JsVariableDeclaration):
return None
if len(declaration.declarations) != 1:
return None
statement: Node = declaration
if isinstance(statement.parent, JsExportNamedDeclaration):
statement = statement.parent
if (holder := statement.parent) is None or not hoists_its_functions(holder):
return None
init = strip_parens(declarator.init)
if not isinstance(init, JsCallExpression):
return None
if init.arguments:
return None
callee = strip_parens(init.callee)
if not isinstance(callee, (JsFunctionExpression, JsArrowFunctionExpression)):
return None
if wraps_return(callee):
return None
if callee.params:
return None
body = callee.body
if not isinstance(body, JsBlockStatement) or not body.body:
return None
closure_decls: list[JsVariableDeclaration] = []
inner_func: JsFunctionExpression | JsArrowFunctionExpression | None = None
for stmt in body.body:
if inner_func is not None:
return None
if isinstance(stmt, JsVariableDeclaration):
for decl in stmt.declarations:
if not isinstance(decl, JsVariableDeclarator):
return None
if not isinstance(decl.id, JsIdentifier):
return None
if not _is_literal_initializer(decl.init):
return None
closure_decls.append(stmt)
continue
if isinstance(stmt, JsReturnStatement):
ret_arg = strip_parens(stmt.argument)
if not isinstance(ret_arg, (JsFunctionExpression, JsArrowFunctionExpression)):
return None
if not isinstance(ret_arg.body, JsBlockStatement):
return None
inner_func = ret_arg
continue
return None
if inner_func is None:
return None
return _Pattern(declaration, declarator.id.name, closure_decls, inner_func)
def _closure_names(closure_decls: list[JsVariableDeclaration]) -> set[str]:
names: set[str] = set()
for decl in closure_decls:
for d in decl.declarations:
if isinstance(d, JsVariableDeclarator) and isinstance(d.id, JsIdentifier):
names.add(d.id.name)
return names
def _is_safe_to_promote(
inner: JsFunctionExpression | JsArrowFunctionExpression,
closure_names: set[str],
) -> bool:
param_names = extract_identifier_params(inner.params)
if param_names is None:
return False
if any(p in closure_names for p in param_names):
return False
body = inner.body
if not isinstance(body, JsBlockStatement):
return False
inner_name: str | None = None
if isinstance(inner, JsFunctionExpression) and isinstance(inner.id, JsIdentifier):
inner_name = inner.id.name
if references_receiver_this(body):
return False
for node in walk_receiver_scope(body):
if isinstance(node, JsIdentifier) and node.name == 'arguments':
return False
for node in walk_scope(body):
if inner_name is not None and isinstance(node, JsIdentifier) and node.name == inner_name:
return False
for node in body.walk():
if isinstance(node, JsAssignmentExpression):
if isinstance(node.left, JsIdentifier) and node.left.name in closure_names:
return False
if isinstance(node, JsUpdateExpression):
if (
isinstance(node.argument, JsIdentifier)
and node.argument.name in closure_names
):
return False
if isinstance(node, JsVariableDeclarator):
if isinstance(node.id, JsIdentifier) and node.id.name in closure_names:
return False
if isinstance(node, FUNCTION_NODES) and node is not inner:
for p in node.params:
if isinstance(p, JsIdentifier) and p.name in closure_names:
return False
if (
isinstance(node, JsFunctionDeclaration)
and isinstance(node.id, JsIdentifier)
and node.id.name in closure_names
):
return False
return True
_Container = JsArrayExpression | JsObjectExpression
def _containers(values: Iterable[Node]) -> list[_Container]:
return [value for value in values if isinstance(value, (JsArrayExpression, JsObjectExpression))]
def _held_values(literal: _Container) -> list[Node]:
if isinstance(literal, JsArrayExpression):
return [element for element in literal.elements if element is not None]
return [
prop.value for prop in literal.properties
if isinstance(prop, JsProperty) and prop.value is not None
]
def _containers_within(literal: _Container) -> Iterator[_Container]:
yield literal
for held in _containers(_held_values(literal)):
yield from _containers_within(held)
def _values_read_under(literal: _Container, key: str | None) -> list[Node]:
"""
The values the closure built that a read of *key* off *literal* may answer: the one it holds
under that key, or every one it holds where the key has no text. A key the literal does not hold
answers a primitive or a value of the language, such as `length` or a method of a prototype,
which no call of the accessor builds.
"""
if key is None:
return _held_values(literal)
if isinstance(literal, JsArrayExpression):
if not key.isascii() or not key.isdigit() or str(int(key)) != key:
return []
index = int(key)
elements = literal.elements
if index >= len(elements) or (element := elements[index]) is None:
return []
return [element]
return [
prop.value for prop in literal.properties
if isinstance(prop, JsProperty) and prop.value is not None and key_text(prop) in (key, None)
]
def _read_values_stay_in_the_call(cache: ModelCache, binding: Binding, held: list[_Container]) -> bool:
"""
Whether no list or object the closure built leaves a call through a reference of *binding*,
which holds one of *held* and was already found to be only read through
(`refinery.lib.scripts.js.analysis.effects.EffectModel.binding_is_immutable_container`). A
reference only tests the binding, reads through it, calls a method on it where *held* holds no
list or object, or is the value of a variable that is itself only read through
(`_stored_where_it_is_only_read_through`). A read that may answer a list or object of *held* is
held to the same rule.
"""
holds_containers = any(_containers(_held_values(container)) for container in held)
if binding.dynamic_refs and holds_containers:
return False
for reference in cache.model.references(binding):
role = container_reference_role(reference)
if role is ContainerRole.VERDICT_READ:
continue
if role is ContainerRole.MEMBER_CALL and not holds_containers:
continue
if role is ContainerRole.ESCAPE:
if not _stored_where_it_is_only_read_through(cache, reference, held):
return False
continue
if role is not ContainerRole.MEMBER_READ:
return False
read: Node = reference
values: list[Node] = list(held)
while (
isinstance(member := enclosing_operator(read), JsMemberExpression)
and strip_parens(member.object) is read
):
key = key_text(member)
values = [value for container in _containers(values) for value in _values_read_under(container, key)]
read = member
if not (answered := _containers(values)):
continue
if container_reference_role(read) is ContainerRole.VERDICT_READ:
continue
if not _stored_where_it_is_only_read_through(cache, read, answered):
return False
return True
def _stored_where_it_is_only_read_through(
cache: ModelCache,
read: Node,
answered: list[_Container],
) -> bool:
"""
Whether *read*, which may answer one of the lists or objects *answered*, initializes a variable
declared once, written nowhere else and only read through
(`refinery.lib.scripts.js.analysis.effects.EffectModel.binding_is_immutable_container`), with no
list or object its own references answer leaving the call either. A second declaration with an
initializer writes the variable as well, and no binding records it among its writes.
"""
declarator = enclosing_operator(read)
if not isinstance(declarator, JsVariableDeclarator) or strip_parens(declarator.init) is not read:
return False
if not isinstance(name := declarator.id, JsIdentifier):
return False
variable = cache.model.binding_of(name)
return (
variable is not None
and len(variable.declarations) == 1
and not variable.writes
and cache.effects.binding_is_immutable_container(variable)
and _read_values_stay_in_the_call(cache, variable, answered)
)
def _closed_over_containers_stay_unchanged(
cache: ModelCache,
closure_decls: list[JsVariableDeclaration],
) -> bool:
"""
Whether every list or object the closure builds stays as it was and inside each call. The IIFE
builds such a value once, so what one call writes into it, and its identity, reach the next
call, which the promoted declaration starts from a fresh value instead. The list or object a
captured variable holds is only read through: never written into or given to a method that may
change it
(`refinery.lib.scripts.js.analysis.effects.EffectModel.binding_is_immutable_container`), and
never handed out, not even to a variable outside the call that is only read through
(`_read_values_stay_in_the_call`). A call on a value it holds is vouched for where every such
value is a primitive, which no call can change. A list or object it holds is one the closure
built as well, so no read may hand one out either; an object literal setting its prototype is
declined, because a read of a key it does not hold may then answer a value the prototype holds.
"""
for statement in closure_decls:
for declarator in statement.declarations:
if not isinstance(declarator, JsVariableDeclarator):
return False
literal = declarator.init
if not isinstance(literal, (JsArrayExpression, JsObjectExpression)):
continue
if not isinstance(name := declarator.id, JsIdentifier):
return False
if (binding := cache.model.binding_of(name)) is None:
return False
nested = bool(_containers(_held_values(literal)))
inert = None if nested else frozenset()
if not cache.effects.binding_is_immutable_container(binding, inert_methods=inert):
return False
if nested and any(
isinstance(container, JsObjectExpression) and object_sets_prototype(container)
for container in _containers_within(literal)
):
return False
if not _read_values_stay_in_the_call(cache, binding, [literal]):
return False
return True
class JsIIFEAccessorPromoter(ScriptLevelTransformer):
"""
Detect accessor variables built from an IIFE that closes over literal lookup tables, and
rewrite each one into a plain named function declaration so that the function evaluator can
fold its call sites.
"""
def _process_script(self, node: JsScript) -> None:
"""
Every accessor is decided against the tree the pass entered on and promoted afterwards, so
no decision reads models of a tree an earlier promotion already changed.
"""
cache = model_cache(self, node)
promotions: list[_Pattern] = []
for declarator in list(node.walk()):
if not isinstance(declarator, JsVariableDeclarator):
continue
pattern = _detect(declarator)
if pattern is None:
continue
closure_names = _closure_names(pattern.closure_decls)
if not _is_safe_to_promote(pattern.inner_func, closure_names):
continue
if not _closed_over_containers_stay_unchanged(cache, pattern.closure_decls):
continue
promotions.append(pattern)
for pattern in promotions:
self._promote(pattern)
def _promote(self, pattern: _Pattern) -> None:
"""
Rewrite the accessor as a function declaration of the same kind, moving the closure's
declarations into the inner function's body. The kind is carried rather than defaulted: an
accessor that was `async` returns a promise and one that was a generator returns an iterator,
and a declaration that dropped either would compute a different value — a `yield` left in a
body no longer marked `*` does not even parse.
They go behind that body's Directive Prologue rather than at its head. A declaration written
above a `'use strict'` ends the prologue before it is reached, and the promoted function runs
sloppy where the source wrote it strict. Behind the prologue is a position the declarations
can always take: a prologue is string literals, which read nothing and so cannot observe a
binding that is now introduced after them. That is the reason, and not that the declarations
happen to be `var`s — `_detect` accepts any `JsVariableDeclaration`, `let` and `const`
included.
"""
inner = pattern.inner_func
inner_body = inner.body
if not isinstance(inner_body, JsBlockStatement):
return
carried = [_clone_node(s) for s in inner_body.body]
prologue = len(directive_prologue(inner_body))
new_body_stmts = carried[:prologue]
new_body_stmts.extend(_clone_node(d) for d in pattern.closure_decls)
new_body_stmts.extend(carried[prologue:])
new_func = JsFunctionDeclaration(
id=JsIdentifier(name=pattern.name),
params=[_clone_node(p) for p in inner.params],
body=JsBlockStatement(body=new_body_stmts),
generator=is_generator_function(inner),
is_async=is_async_function(inner),
)
_replace_in_parent(pattern.declaration, new_func)
self.mark_changed()
Classes
class JsIIFEAccessorPromoter-
Detect accessor variables built from an IIFE that closes over literal lookup tables, and rewrite each one into a plain named function declaration so that the function evaluator can fold its call sites.
Expand source code Browse git
class JsIIFEAccessorPromoter(ScriptLevelTransformer): """ Detect accessor variables built from an IIFE that closes over literal lookup tables, and rewrite each one into a plain named function declaration so that the function evaluator can fold its call sites. """ def _process_script(self, node: JsScript) -> None: """ Every accessor is decided against the tree the pass entered on and promoted afterwards, so no decision reads models of a tree an earlier promotion already changed. """ cache = model_cache(self, node) promotions: list[_Pattern] = [] for declarator in list(node.walk()): if not isinstance(declarator, JsVariableDeclarator): continue pattern = _detect(declarator) if pattern is None: continue closure_names = _closure_names(pattern.closure_decls) if not _is_safe_to_promote(pattern.inner_func, closure_names): continue if not _closed_over_containers_stay_unchanged(cache, pattern.closure_decls): continue promotions.append(pattern) for pattern in promotions: self._promote(pattern) def _promote(self, pattern: _Pattern) -> None: """ Rewrite the accessor as a function declaration of the same kind, moving the closure's declarations into the inner function's body. The kind is carried rather than defaulted: an accessor that was `async` returns a promise and one that was a generator returns an iterator, and a declaration that dropped either would compute a different value — a `yield` left in a body no longer marked `*` does not even parse. They go behind that body's Directive Prologue rather than at its head. A declaration written above a `'use strict'` ends the prologue before it is reached, and the promoted function runs sloppy where the source wrote it strict. Behind the prologue is a position the declarations can always take: a prologue is string literals, which read nothing and so cannot observe a binding that is now introduced after them. That is the reason, and not that the declarations happen to be `var`s — `_detect` accepts any `JsVariableDeclaration`, `let` and `const` included. """ inner = pattern.inner_func inner_body = inner.body if not isinstance(inner_body, JsBlockStatement): return carried = [_clone_node(s) for s in inner_body.body] prologue = len(directive_prologue(inner_body)) new_body_stmts = carried[:prologue] new_body_stmts.extend(_clone_node(d) for d in pattern.closure_decls) new_body_stmts.extend(carried[prologue:]) new_func = JsFunctionDeclaration( id=JsIdentifier(name=pattern.name), params=[_clone_node(p) for p in inner.params], body=JsBlockStatement(body=new_body_stmts), generator=is_generator_function(inner), is_async=is_async_function(inner), ) _replace_in_parent(pattern.declaration, new_func) self.mark_changed()Ancestors
Inherited members