Module refinery.lib.scripts.au3.deobfuscation.eliminate
The Phase 4 transform: what no live code reaches leaves the tree. A function no call names and
no string spells, a declarator no occurrence binds or may bind, and an expression statement
whose evaluation writes nothing and cannot terminate the script are removed — each only when
no dynamic-name channel could observe the removal. What answers every question the rules ask is
the usage model of refinery.lib.scripts.au3.analysis.usage, which this transform reads and
never re-derives.
One sweep per visit: the census reads the usage model once, then a single tree edit runs over every scope. The group's run loop is what reaches the fixpoint — a function whose only callers this sweep removed keeps its call sites until the next sweep re-census — so the iteration a deeply nested obfuscator needs happens inside one execution of the cleanup group.
Three gates close parts of the sweep. The entry gate: a script with no executable top-level statement, and any script holding an include, parsed or unread, is a fragment whose functions and globals can be reached from outside the tree, so only its dead locals and effect-free statements leave. The channel gate: a computed-name channel outside the functions this sweep removes blocks function and global removal for the whole visit, because the name it computes may reach anything, and a channel inside a function blocks that function's local removal, because a dynamic payload runs in the scope it stands in and can name a local of it. Error text counts as a reference to every name it spells, and as a channel when it names one, which the usage model reads; a name whose function holds error text with an AutoIt block keyword is not removed — a removal takes every declaration of the name — because deleting the function could move where the real parser's block boundaries fall, and with them which code is live at all.
What a removal may not hide: an initializer or dimension that could terminate the script — a
division, an exponentiation, an array subscript, a call of the wrong arity — keeps its
declarator, and an Exit or an impure expression keeps a function out of the pure set the
statement rule reads.
Expand source code Browse git
"""
The Phase 4 transform: what no live code reaches leaves the tree. A function no call names and
no string spells, a declarator no occurrence binds or may bind, and an expression statement
whose evaluation writes nothing and cannot terminate the script are removed — each only when
no dynamic-name channel could observe the removal. What answers every question the rules ask is
the usage model of `refinery.lib.scripts.au3.analysis.usage`, which this transform reads and
never re-derives.
One sweep per visit: the census reads the usage model once, then a single tree edit runs over
every scope. The group's run loop is what reaches the fixpoint — a function whose only callers
this sweep removed keeps its call sites until the next sweep re-census — so the iteration a
deeply nested obfuscator needs happens inside one execution of the cleanup group.
Three gates close parts of the sweep. The entry gate: a script with no executable top-level
statement, and any script holding an include, parsed or unread, is a fragment whose functions and
globals can be reached from outside the tree, so only its dead locals and effect-free statements
leave. The channel gate: a computed-name channel outside the functions this sweep removes blocks
function and global removal for the whole visit, because the name it computes may reach anything,
and a channel inside a function blocks that function's local removal, because a dynamic payload
runs in the scope it stands in and can name a local of it. Error text counts as a reference to
every name it spells, and as a channel when it names one, which the usage model reads; a name
whose function holds error text with an AutoIt block keyword is not removed — a removal takes
every declaration of the name — because deleting the function could move where the real parser's
block boundaries fall, and with them which code is live at all.
What a removal may not hide: an initializer or dimension that could terminate the script — a
division, an exponentiation, an array subscript, a call of the wrong arity — keeps its
declarator, and an `Exit` or an impure expression keeps a function out of the pure set the
statement rule reads.
"""
from __future__ import annotations
from refinery.lib.scripts import Node, Statement, Transformer, set_child_list
from refinery.lib.scripts.au3 import ast
from refinery.lib.scripts.au3.analysis.cache import model_cache
from refinery.lib.scripts.au3.analysis.scopes import Au3ScopeModel, Binding
from refinery.lib.scripts.au3.analysis.usage import Au3UsageModel
from refinery.lib.scripts.au3.deobfuscation.helpers import rebuild_statements
from refinery.lib.scripts.au3.model import (
Au3EnumDeclaration,
Au3EnumDeclarator,
Au3ExpressionStatement,
Au3FunctionDeclaration,
Au3Script,
Au3VariableDeclaration,
Au3VariableDeclarator,
)
class Au3DeadCodeElimination(Transformer):
"""
Removes a function no live call reaches, a declarator no occurrence binds, and an
expression statement that is effect-free, one sweep per visit.
"""
def __init__(self):
super().__init__()
self._scopes: Au3ScopeModel | None = None
self._usage: Au3UsageModel | None = None
self._removable: frozenset[str] = frozenset()
self._globals_may_leave: bool = False
self._channel_scopes: frozenset[Au3FunctionDeclaration] = frozenset()
def visit(self, node: Node) -> Node | None:
if isinstance(node, Au3Script):
self._sweep(node)
return None
def _sweep(self, script: Au3Script) -> None:
cache = model_cache(self, script)
self._scopes = cache.scopes
self._usage = cache.usage
self._channel_scopes = frozenset(filter(None, (
ast.enclosing_function(channel) for channel in self._usage.channels
)))
entry_open = cache.entry_is_open
removable = self._uncalled() if entry_open else set()
removable = self._without_block_keyword_text(removable)
removable = self._without_nested_survivors(removable)
blocked = self._channels_block(removable)
if blocked:
removable = set()
self._removable = frozenset(removable)
self._globals_may_leave = entry_open and not blocked
if rebuild_statements(script, 'body', None, self._replacement):
self.mark_changed()
def _uncalled(self) -> set[str]:
"""
Every function no direct-identifier call in the tree names and no string or directive
spells.
"""
usage = self._usage
assert usage is not None
return {
name
for name in usage.functions
if usage.call_counts.get(name, 0) == 0
and not usage.spells(name)
}
def _without_block_keyword_text(self, removable: set[str]) -> set[str]:
"""
*removable* minus every name one of whose declarations holds error text with an AutoIt
block keyword, read from the usage model: a removal takes every declaration of the name,
and deleting one could move where the real parser's block boundaries fall, and with them
which code is live at all.
"""
usage = self._usage
assert usage is not None
blocked: set[str] = set()
for node, facts in usage.unparsed.items():
if not facts.block_keyword:
continue
scope = ast.enclosing_function(node)
if scope is not None and scope.name is not None:
blocked.add(scope.name.name)
return removable - blocked
def _without_nested_survivors(self, removable: set[str]) -> set[str]:
"""
*removable* minus every function whose subtree holds a function declaration that is not
itself removable, shrunk until stable: a candidate that would take a surviving nested
function with it is not removable this sweep.
"""
usage = self._usage
assert usage is not None
while True:
survivors = {
name
for name in removable
if all(
nested.name is None or nested.name.name in removable
for nested in usage.functions[name].walk()
if isinstance(nested, Au3FunctionDeclaration)
)
}
if survivors == removable:
return survivors
removable = survivors
def _channels_block(self, removable: set[str]) -> bool:
"""
Whether a computed-name channel stands outside the functions *removable* names: its
consumer survives the sweep, so the name it computes may reach anything.
"""
usage = self._usage
assert usage is not None
for channel in usage.channels:
scope = ast.enclosing_function(channel)
if scope is None or scope.name is None or scope.name.name not in removable:
return True
return False
def _replacement(
self,
statement: Statement,
scope: Au3FunctionDeclaration | None,
) -> list[Statement] | None:
"""
What stands in place of *statement*: nothing when it is dead, the declaration itself
when some of its declarators are, and `None` when it stands as it is.
"""
if self._statement_is_dead(statement, scope):
return []
if isinstance(statement, Au3VariableDeclaration):
alive = [
declarator
for declarator in statement.declarations
if not self._declarator_is_dead(statement, declarator, scope)
]
if len(alive) != len(statement.declarations):
if not alive:
return []
set_child_list(statement, 'declarations', alive)
return [statement]
return None
def _statement_is_dead(
self,
statement: Statement,
scope: Au3FunctionDeclaration | None,
) -> bool:
"""
Whether *statement* leaves as a whole: a function this sweep removes, an enum whose every
entry is dead, or an expression statement that is effect-free. A variable declaration
leaves per declarator instead, so `_replacement` answers it.
"""
assert self._usage is not None
if isinstance(statement, Au3FunctionDeclaration):
return statement.name is not None and statement.name.name in self._removable
if isinstance(statement, Au3EnumDeclaration):
return bool(statement.declarations) and all(
self._declarator_is_dead(statement, declarator, scope)
for declarator in statement.declarations
)
if isinstance(statement, Au3ExpressionStatement):
return self._usage.effect_free(statement.expression)
return False
def _declarator_is_dead(
self,
statement: Statement,
declarator: Au3VariableDeclarator | Au3EnumDeclarator,
scope: Au3FunctionDeclaration | None,
) -> bool:
"""
Whether *declarator* leaves: no occurrence binds or may bind the storage its statement's
keyword forces — `Binding.NONE` never leaves, its write proving nothing — its name is
spelled in no string, and its initializer and every dimension are effect-free. A local
leaves only when its own scope holds no channel: a dynamic `Execute` payload runs in the
scope it stands in and can name the local.
"""
assert self._scopes is not None
assert self._usage is not None
variable = declarator.variable
if variable is None:
return False
name = variable.name
binding = self._scopes.binding(scope, statement, name)
if binding is Binding.LOCAL:
assert scope is not None
if scope in self._channel_scopes:
return False
if name in self._usage.local_bindings.get(scope, frozenset()):
return False
elif binding is Binding.GLOBAL:
if not self._globals_may_leave:
return False
readers = self._usage.global_bindings.get(name, frozenset())
if any(
reader is None or reader.name is None or reader.name.name not in self._removable
for reader in readers
):
return False
else:
return False
if self._usage.spells(name):
return False
if not self._usage.effect_free(declarator.initializer):
return False
dimensions = declarator.dimensions if isinstance(declarator, Au3VariableDeclarator) else ()
return all(self._usage.effect_free(dimension) for dimension in dimensions)
Classes
class Au3DeadCodeElimination-
Removes a function no live call reaches, a declarator no occurrence binds, and an expression statement that is effect-free, one sweep per visit.
Expand source code Browse git
class Au3DeadCodeElimination(Transformer): """ Removes a function no live call reaches, a declarator no occurrence binds, and an expression statement that is effect-free, one sweep per visit. """ def __init__(self): super().__init__() self._scopes: Au3ScopeModel | None = None self._usage: Au3UsageModel | None = None self._removable: frozenset[str] = frozenset() self._globals_may_leave: bool = False self._channel_scopes: frozenset[Au3FunctionDeclaration] = frozenset() def visit(self, node: Node) -> Node | None: if isinstance(node, Au3Script): self._sweep(node) return None def _sweep(self, script: Au3Script) -> None: cache = model_cache(self, script) self._scopes = cache.scopes self._usage = cache.usage self._channel_scopes = frozenset(filter(None, ( ast.enclosing_function(channel) for channel in self._usage.channels ))) entry_open = cache.entry_is_open removable = self._uncalled() if entry_open else set() removable = self._without_block_keyword_text(removable) removable = self._without_nested_survivors(removable) blocked = self._channels_block(removable) if blocked: removable = set() self._removable = frozenset(removable) self._globals_may_leave = entry_open and not blocked if rebuild_statements(script, 'body', None, self._replacement): self.mark_changed() def _uncalled(self) -> set[str]: """ Every function no direct-identifier call in the tree names and no string or directive spells. """ usage = self._usage assert usage is not None return { name for name in usage.functions if usage.call_counts.get(name, 0) == 0 and not usage.spells(name) } def _without_block_keyword_text(self, removable: set[str]) -> set[str]: """ *removable* minus every name one of whose declarations holds error text with an AutoIt block keyword, read from the usage model: a removal takes every declaration of the name, and deleting one could move where the real parser's block boundaries fall, and with them which code is live at all. """ usage = self._usage assert usage is not None blocked: set[str] = set() for node, facts in usage.unparsed.items(): if not facts.block_keyword: continue scope = ast.enclosing_function(node) if scope is not None and scope.name is not None: blocked.add(scope.name.name) return removable - blocked def _without_nested_survivors(self, removable: set[str]) -> set[str]: """ *removable* minus every function whose subtree holds a function declaration that is not itself removable, shrunk until stable: a candidate that would take a surviving nested function with it is not removable this sweep. """ usage = self._usage assert usage is not None while True: survivors = { name for name in removable if all( nested.name is None or nested.name.name in removable for nested in usage.functions[name].walk() if isinstance(nested, Au3FunctionDeclaration) ) } if survivors == removable: return survivors removable = survivors def _channels_block(self, removable: set[str]) -> bool: """ Whether a computed-name channel stands outside the functions *removable* names: its consumer survives the sweep, so the name it computes may reach anything. """ usage = self._usage assert usage is not None for channel in usage.channels: scope = ast.enclosing_function(channel) if scope is None or scope.name is None or scope.name.name not in removable: return True return False def _replacement( self, statement: Statement, scope: Au3FunctionDeclaration | None, ) -> list[Statement] | None: """ What stands in place of *statement*: nothing when it is dead, the declaration itself when some of its declarators are, and `None` when it stands as it is. """ if self._statement_is_dead(statement, scope): return [] if isinstance(statement, Au3VariableDeclaration): alive = [ declarator for declarator in statement.declarations if not self._declarator_is_dead(statement, declarator, scope) ] if len(alive) != len(statement.declarations): if not alive: return [] set_child_list(statement, 'declarations', alive) return [statement] return None def _statement_is_dead( self, statement: Statement, scope: Au3FunctionDeclaration | None, ) -> bool: """ Whether *statement* leaves as a whole: a function this sweep removes, an enum whose every entry is dead, or an expression statement that is effect-free. A variable declaration leaves per declarator instead, so `_replacement` answers it. """ assert self._usage is not None if isinstance(statement, Au3FunctionDeclaration): return statement.name is not None and statement.name.name in self._removable if isinstance(statement, Au3EnumDeclaration): return bool(statement.declarations) and all( self._declarator_is_dead(statement, declarator, scope) for declarator in statement.declarations ) if isinstance(statement, Au3ExpressionStatement): return self._usage.effect_free(statement.expression) return False def _declarator_is_dead( self, statement: Statement, declarator: Au3VariableDeclarator | Au3EnumDeclarator, scope: Au3FunctionDeclaration | None, ) -> bool: """ Whether *declarator* leaves: no occurrence binds or may bind the storage its statement's keyword forces — `Binding.NONE` never leaves, its write proving nothing — its name is spelled in no string, and its initializer and every dimension are effect-free. A local leaves only when its own scope holds no channel: a dynamic `Execute` payload runs in the scope it stands in and can name the local. """ assert self._scopes is not None assert self._usage is not None variable = declarator.variable if variable is None: return False name = variable.name binding = self._scopes.binding(scope, statement, name) if binding is Binding.LOCAL: assert scope is not None if scope in self._channel_scopes: return False if name in self._usage.local_bindings.get(scope, frozenset()): return False elif binding is Binding.GLOBAL: if not self._globals_may_leave: return False readers = self._usage.global_bindings.get(name, frozenset()) if any( reader is None or reader.name is None or reader.name.name not in self._removable for reader in readers ): return False else: return False if self._usage.spells(name): return False if not self._usage.effect_free(declarator.initializer): return False dimensions = declarator.dimensions if isinstance(declarator, Au3VariableDeclarator) else () return all(self._usage.effect_free(dimension) for dimension in dimensions)Ancestors
Methods
def visit(self, node)-
Expand source code Browse git
def visit(self, node: Node) -> Node | None: if isinstance(node, Au3Script): self._sweep(node) return None
Inherited members