Module refinery.lib.scripts.au3.deobfuscation.unused
The write-only store sweep: a plain assignment whose target nothing reads leaves the tree. The
read census of refinery.lib.scripts.au3.analysis.usage answers the headline question — how often
each storage is read, the variables of unread text among its readers — and every other fact the
rules ask is owned by the module that pinned it: which storage a write targets, whether it may
reach a caller's storage, and which names are declared constants by
refinery.lib.scripts.au3.analysis.scopes, whether a write may target a global and whether an
implicit write is fatal by refinery.lib.scripts.au3.analysis.existence, and whether the
script's entry is open by Au3ModelCache.entry_is_open.
A store leaves in one of two shapes. An effect-free right-hand side is dropped whole: building a discarded value writes nothing observable. Any other right-hand side is spliced — it becomes the statement's whole expression — because its evaluation, and the termination it may cause, are what the program does; only a right-hand side holding an array literal is never spliced, a bare array literal being legal only where a declaration initializes. A pure array-literal right-hand side takes the drop: building a discarded array writes nothing observable either.
Dropping a store removes the reads of its right-hand side — within an expression every variable
occurrence is a read; assignments exist only in statement position — so the sweep runs as one
cascade: counters seeded from the census, every name one of whose counters reaches zero admitted
with its own stores, and all decisions made before the first edit runs. The census counts reads
per storage — one function's local, or the global — through the one classifier that resolves
which storages a read may reach, so a read of one function's Local $x keeps no store to another
function's own. The recall limits that survive: position within a storage is ignored — a read
before the store keeps it — a read whose claim shares its statement protects both storages, and
a Local claim that follows an implicit write still counts as protecting it.
The guards beside the read counts: a name declared constant — its write is a fatal the removal
would hide — one of the command-line constants every script starts with, whose write is fatal
the same way — a ByRef parameter of the enclosing scope, whose write reaches the caller's
storage — and a name some string or directive spells: a computed name is built from string data,
so a spelled name is one no removal may take away. The counts protect per the storage the write
targets: a provable local waits on the reads that may reach its own function's local, a store at
script level on the reads that may reach the global from any scope, an implicit write that may
target a global on both, and an implicit write that creates a local only on every read standing
in its function — the implicit local is invisible outside it, and a name missing in another
scope is missing whether or not the store leaves. The binding rules gate on the same storage:
a local keeps its store unless its own scope holds a reading channel, because a dynamic payload
runs in the scope it stands in and can name a local of it; a global store needs an open entry,
no reading channel anywhere, and implicit writes that are not fatal; and an implicit write
inside a function adds the question whether a global of the name may exist — when one may, the
write could target it and the anywhere gate applies, and when none may, the write creates a
local only and the in-scope gate answers.
Expand source code Browse git
"""
The write-only store sweep: a plain assignment whose target nothing reads leaves the tree. The
read census of `refinery.lib.scripts.au3.analysis.usage` answers the headline question — how often
each storage is read, the variables of unread text among its readers — and every other fact the
rules ask is owned by the module that pinned it: which storage a write targets, whether it may
reach a caller's storage, and which names are declared constants by
`refinery.lib.scripts.au3.analysis.scopes`, whether a write may target a global and whether an
implicit write is fatal by `refinery.lib.scripts.au3.analysis.existence`, and whether the
script's entry is open by `refinery.lib.scripts.au3.analysis.cache.Au3ModelCache.entry_is_open`.
A store leaves in one of two shapes. An effect-free right-hand side is dropped whole: building
a discarded value writes nothing observable. Any other right-hand side is spliced — it becomes the
statement's whole expression — because its evaluation, and the termination it may cause, are what
the program does; only a right-hand side holding an array literal is never spliced, a bare array
literal being legal only where a declaration initializes. A pure array-literal right-hand side
takes the drop: building a discarded array writes nothing observable either.
Dropping a store removes the reads of its right-hand side — within an expression every variable
occurrence is a read; assignments exist only in statement position — so the sweep runs as one
cascade: counters seeded from the census, every name one of whose counters reaches zero admitted
with its own stores, and all decisions made before the first edit runs. The census counts reads
per storage — one function's local, or the global — through the one classifier that resolves
which storages a read may reach, so a read of one function's `Local $x` keeps no store to another
function's own. The recall limits that survive: position within a storage is ignored — a read
before the store keeps it — a read whose claim shares its statement protects both storages, and
a `Local` claim that follows an implicit write still counts as protecting it.
The guards beside the read counts: a name declared constant — its write is a fatal the removal
would hide — one of the command-line constants every script starts with, whose write is fatal
the same way — a `ByRef` parameter of the enclosing scope, whose write reaches the caller's
storage — and a name some string or directive spells: a computed name is built from string data,
so a spelled name is one no removal may take away. The counts protect per the storage the write
targets: a provable local waits on the reads that may reach its own function's local, a store at
script level on the reads that may reach the global from any scope, an implicit write that may
target a global on both, and an implicit write that creates a local only on every read standing
in its function — the implicit local is invisible outside it, and a name missing in another
scope is missing whether or not the store leaves. The binding rules gate on the same storage:
a local keeps its store unless its own scope holds a reading channel, because a dynamic payload
runs in the scope it stands in and can name a local of it; a global store needs an open entry,
no reading channel anywhere, and implicit writes that are not fatal; and an implicit write
inside a function adds the question whether a global of the name may exist — when one may, the
write could target it and the anywhere gate applies, and when none may, the write creates a
local only and the in-scope gate answers.
"""
from __future__ import annotations
from dataclasses import dataclass
from refinery.lib.scripts import Node, Statement, Transformer, set_child
from refinery.lib.scripts.au3 import ast
from refinery.lib.scripts.au3.analysis.cache import model_cache
from refinery.lib.scripts.au3.analysis.existence import Au3ExistenceModel
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 (
Au3Assignment,
Au3ExpressionStatement,
Au3FunctionDeclaration,
Au3Script,
Au3Variable,
Expression,
)
_Store = tuple[Au3ExpressionStatement, Au3Assignment, Au3FunctionDeclaration | None]
@dataclass
class _ReadCensus:
"""
The sweep's mutable copy of the per-storage read counts: the reads that may reach one
function's local, the reads standing in one scope that may reach the global, and the
per-name sum of the global arm. A dropped store releases its right-hand side's reads through
the same classifier that seeded them, and a counter reaching zero re-admits its name.
"""
local_reads: dict[tuple[Au3FunctionDeclaration, str], int]
global_reads: dict[tuple[Au3FunctionDeclaration | None, str], int]
global_totals: dict[str, int]
class Au3UnusedVariableRemoval(Transformer):
"""
Removes a plain assignment whose target no read reaches, one sweep per visit.
"""
def __init__(self):
super().__init__()
self._scopes: Au3ScopeModel | None = None
self._usage: Au3UsageModel | None = None
self._existence: Au3ExistenceModel | None = None
self._entry_open: bool = False
self._reading_scopes: frozenset[Au3FunctionDeclaration] = frozenset()
self._dropped: frozenset[Statement] = frozenset()
self._spliced: dict[Statement, Au3Assignment] = {}
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._existence = cache.existence
self._entry_open = cache.entry_is_open
assert self._usage is not None
self._reading_scopes = frozenset(filter(None, (
ast.enclosing_function(channel) for channel in self._usage.reading_channels
)))
stores: dict[str, list[_Store]] = {}
self._collect(script, stores)
assert self._scopes is not None
assert self._existence is not None
census = _ReadCensus(
local_reads={
(scope, name): count
for scope, counts in self._usage.local_read_counts.items()
for name, count in counts.items()
},
global_reads={
(scope, name): count
for scope, counts in self._usage.global_read_counts.items()
for name, count in counts.items()
},
global_totals={},
)
for (_, name), count in census.global_reads.items():
census.global_totals[name] = census.global_totals.get(name, 0) + count
dropped: set[Statement] = set()
spliced: dict[Statement, Au3Assignment] = {}
worklist = list(stores)
while worklist:
name = worklist.pop()
editable = [
(statement, assignment, scope)
for statement, assignment, scope in stores[name]
if statement not in dropped
and statement not in spliced
and self._store_is_unread(statement, scope, name, census)
and self._store_is_removable(statement, scope, name)
]
if not editable:
continue
if (
name in self._scopes.constant_names
or self._existence.write_is_fatal(name)
or self._usage.spells(name)
):
continue
for statement, assignment, scope in editable:
value = assignment.value
assert value is not None
if self._usage.effect_free(value):
dropped.add(statement)
self._release_reads(scope, value, census, stores, worklist)
elif not ast.contains_an_array_literal(value):
spliced[statement] = assignment
if not dropped and not spliced:
return
self._dropped = frozenset(dropped)
self._spliced = spliced
if rebuild_statements(script, 'body', None, self._replacement):
self.mark_changed()
def _collect(self, script: Au3Script, stores: dict[str, list[_Store]]) -> None:
"""
Every plain assignment the tree holds, with the scope it stands in: an expression
statement whose expression is an assignment with a plain variable target. Assignments exist
only in statement position, so no other shape is a candidate.
"""
for scope, body in ast.scope_bodies(script):
for statement in ast.scope_nodes(body):
if not isinstance(statement, Au3ExpressionStatement):
continue
assignment = ast.unwrap_parens(statement.expression)
if isinstance(assignment, Au3Assignment) and assignment.operator == '=':
target = ast.unwrap_parens(assignment.target)
if isinstance(target, Au3Variable) and assignment.value is not None:
stores.setdefault(target.name, []).append((statement, assignment, scope))
def _store_is_unread(
self,
statement: Au3ExpressionStatement,
scope: Au3FunctionDeclaration | None,
name: str,
census: _ReadCensus,
) -> bool:
"""
Whether no read may still observe the store on *name* in *scope*: every counter
protecting the storage the write targets has reached zero. A provable local is protected
by the reads that may reach its own function's local, a store at script level by the
reads that may reach the global from any scope, an implicit write that may target a
global by both, and an implicit write that creates a local only by every read standing
in its function.
"""
assert self._scopes is not None
assert self._existence is not None
binding = self._scopes.binding(scope, statement, name)
if binding is Binding.GLOBAL:
return census.global_totals.get(name, 0) == 0
assert scope is not None
if binding is Binding.LOCAL:
return census.local_reads.get((scope, name), 0) == 0
if self._existence.write_may_target_global(binding, name, True):
return (
census.global_totals.get(name, 0) == 0
and census.local_reads.get((scope, name), 0) == 0
)
return (
census.local_reads.get((scope, name), 0) == 0
and census.global_reads.get((scope, name), 0) == 0
)
def _store_is_removable(
self,
statement: Au3ExpressionStatement,
scope: Au3FunctionDeclaration | None,
name: str,
) -> bool:
"""
Whether the store *statement* performs on *name* may leave: a write that may reach a
caller's storage never does, and the binding rules gate on the storage the scope model
resolves the write to.
"""
assert self._scopes is not None
assert self._usage is not None
assert self._existence is not None
if self._scopes.writes_caller_storage(scope, name):
return False
binding = self._scopes.binding(scope, statement, name)
if binding is Binding.LOCAL:
return scope not in self._reading_scopes
if not self._entry_open or self._existence.implicit_writes_are_fatal:
return False
if self._existence.write_may_target_global(binding, name, True):
return not self._usage.reading_channels
return scope not in self._reading_scopes
def _release_reads(
self,
scope: Au3FunctionDeclaration | None,
value: Expression,
census: _ReadCensus,
stores: dict[str, list[_Store]],
worklist: list[str],
) -> None:
"""
The reads of a dropped right-hand side leave with it: every variable occurrence in an
expression is a read, so each one decrements the counters of the storages it may reach —
resolved by the same classifier that seeded them — and a counter reaching zero re-admits
its name with its stores.
"""
assert self._scopes is not None
for node in value.walk():
if not isinstance(node, Au3Variable):
continue
for storage in self._scopes.read_storages(scope, node, node.name):
if storage is Binding.GLOBAL:
census.global_reads[(scope, node.name)] -= 1
census.global_totals[node.name] -= 1
empty = census.global_reads[(scope, node.name)] == 0
else:
assert scope is not None
census.local_reads[(scope, node.name)] -= 1
empty = census.local_reads[(scope, node.name)] == 0
if empty and node.name in stores:
worklist.append(node.name)
def _replacement(
self,
statement: Statement,
scope: Au3FunctionDeclaration | None,
) -> list[Statement] | None:
"""
The planned edit of *statement*: a dropped store leaves, a spliced one stands with its
right-hand side as its whole expression, and any other statement stands as it is.
"""
if statement in self._dropped:
return []
assignment = self._spliced.get(statement)
if assignment is None:
return None
set_child(statement, 'expression', assignment.value)
return [statement]
Classes
class Au3UnusedVariableRemoval-
Removes a plain assignment whose target no read reaches, one sweep per visit.
Expand source code Browse git
class Au3UnusedVariableRemoval(Transformer): """ Removes a plain assignment whose target no read reaches, one sweep per visit. """ def __init__(self): super().__init__() self._scopes: Au3ScopeModel | None = None self._usage: Au3UsageModel | None = None self._existence: Au3ExistenceModel | None = None self._entry_open: bool = False self._reading_scopes: frozenset[Au3FunctionDeclaration] = frozenset() self._dropped: frozenset[Statement] = frozenset() self._spliced: dict[Statement, Au3Assignment] = {} 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._existence = cache.existence self._entry_open = cache.entry_is_open assert self._usage is not None self._reading_scopes = frozenset(filter(None, ( ast.enclosing_function(channel) for channel in self._usage.reading_channels ))) stores: dict[str, list[_Store]] = {} self._collect(script, stores) assert self._scopes is not None assert self._existence is not None census = _ReadCensus( local_reads={ (scope, name): count for scope, counts in self._usage.local_read_counts.items() for name, count in counts.items() }, global_reads={ (scope, name): count for scope, counts in self._usage.global_read_counts.items() for name, count in counts.items() }, global_totals={}, ) for (_, name), count in census.global_reads.items(): census.global_totals[name] = census.global_totals.get(name, 0) + count dropped: set[Statement] = set() spliced: dict[Statement, Au3Assignment] = {} worklist = list(stores) while worklist: name = worklist.pop() editable = [ (statement, assignment, scope) for statement, assignment, scope in stores[name] if statement not in dropped and statement not in spliced and self._store_is_unread(statement, scope, name, census) and self._store_is_removable(statement, scope, name) ] if not editable: continue if ( name in self._scopes.constant_names or self._existence.write_is_fatal(name) or self._usage.spells(name) ): continue for statement, assignment, scope in editable: value = assignment.value assert value is not None if self._usage.effect_free(value): dropped.add(statement) self._release_reads(scope, value, census, stores, worklist) elif not ast.contains_an_array_literal(value): spliced[statement] = assignment if not dropped and not spliced: return self._dropped = frozenset(dropped) self._spliced = spliced if rebuild_statements(script, 'body', None, self._replacement): self.mark_changed() def _collect(self, script: Au3Script, stores: dict[str, list[_Store]]) -> None: """ Every plain assignment the tree holds, with the scope it stands in: an expression statement whose expression is an assignment with a plain variable target. Assignments exist only in statement position, so no other shape is a candidate. """ for scope, body in ast.scope_bodies(script): for statement in ast.scope_nodes(body): if not isinstance(statement, Au3ExpressionStatement): continue assignment = ast.unwrap_parens(statement.expression) if isinstance(assignment, Au3Assignment) and assignment.operator == '=': target = ast.unwrap_parens(assignment.target) if isinstance(target, Au3Variable) and assignment.value is not None: stores.setdefault(target.name, []).append((statement, assignment, scope)) def _store_is_unread( self, statement: Au3ExpressionStatement, scope: Au3FunctionDeclaration | None, name: str, census: _ReadCensus, ) -> bool: """ Whether no read may still observe the store on *name* in *scope*: every counter protecting the storage the write targets has reached zero. A provable local is protected by the reads that may reach its own function's local, a store at script level by the reads that may reach the global from any scope, an implicit write that may target a global by both, and an implicit write that creates a local only by every read standing in its function. """ assert self._scopes is not None assert self._existence is not None binding = self._scopes.binding(scope, statement, name) if binding is Binding.GLOBAL: return census.global_totals.get(name, 0) == 0 assert scope is not None if binding is Binding.LOCAL: return census.local_reads.get((scope, name), 0) == 0 if self._existence.write_may_target_global(binding, name, True): return ( census.global_totals.get(name, 0) == 0 and census.local_reads.get((scope, name), 0) == 0 ) return ( census.local_reads.get((scope, name), 0) == 0 and census.global_reads.get((scope, name), 0) == 0 ) def _store_is_removable( self, statement: Au3ExpressionStatement, scope: Au3FunctionDeclaration | None, name: str, ) -> bool: """ Whether the store *statement* performs on *name* may leave: a write that may reach a caller's storage never does, and the binding rules gate on the storage the scope model resolves the write to. """ assert self._scopes is not None assert self._usage is not None assert self._existence is not None if self._scopes.writes_caller_storage(scope, name): return False binding = self._scopes.binding(scope, statement, name) if binding is Binding.LOCAL: return scope not in self._reading_scopes if not self._entry_open or self._existence.implicit_writes_are_fatal: return False if self._existence.write_may_target_global(binding, name, True): return not self._usage.reading_channels return scope not in self._reading_scopes def _release_reads( self, scope: Au3FunctionDeclaration | None, value: Expression, census: _ReadCensus, stores: dict[str, list[_Store]], worklist: list[str], ) -> None: """ The reads of a dropped right-hand side leave with it: every variable occurrence in an expression is a read, so each one decrements the counters of the storages it may reach — resolved by the same classifier that seeded them — and a counter reaching zero re-admits its name with its stores. """ assert self._scopes is not None for node in value.walk(): if not isinstance(node, Au3Variable): continue for storage in self._scopes.read_storages(scope, node, node.name): if storage is Binding.GLOBAL: census.global_reads[(scope, node.name)] -= 1 census.global_totals[node.name] -= 1 empty = census.global_reads[(scope, node.name)] == 0 else: assert scope is not None census.local_reads[(scope, node.name)] -= 1 empty = census.local_reads[(scope, node.name)] == 0 if empty and node.name in stores: worklist.append(node.name) def _replacement( self, statement: Statement, scope: Au3FunctionDeclaration | None, ) -> list[Statement] | None: """ The planned edit of *statement*: a dropped store leaves, a spliced one stands with its right-hand side as its whole expression, and any other statement stands as it is. """ if statement in self._dropped: return [] assignment = self._spliced.get(statement) if assignment is None: return None set_child(statement, 'expression', assignment.value) return [statement]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