Module refinery.lib.scripts.ps1.deobfuscation.substitution
The single route by which a PowerShell cleanup pass puts one part of the tree in another's place.
refinery.lib.scripts.ps1.deobfuscation.removal owns the other half of the same question: what a
pass takes out of a statement list. Between them there was nothing, so a pass that rewrote a node
sitting in a slot reached for the tree primitives directly, and any rule about what such a rewrite
must not throw away had to be restated at every site that could break it. The sites disagree, which
is how a redirected call came to be folded into its value in three different passes and left the
file it named uncreated.
The entry points are named for the slot the node sits in — a direct field, one entry of a child
list, the whole of one — rather than for the passes that reach them, because the slot is the only
thing they differ in. substituted() is named for none of them: a pass that decides by returning a
replacement from visit_X never touches a slot, because
Transformer.generic_visit() installs the answer for it, so what it asks for is
a verdict rather than an edit. substitute_statement() is the exception in that it edits nothing
itself: a statement list is a body, a body is edited as a batch, and
Ps1RemovalPlan is that batch. It is here so that a
pass substituting inside a body has an owner to ask rather than a BodyEdit to
build, and it refuses an empty replacement: whether a statement may go is the other module's
question, and answering it here would be a route around every guard that module holds.
The two owners divide by what the pass is claiming, and the division is what makes the rule below
sound. A substitution claims the value is unchanged, so everything the original did still has to
happen. A removal claims the code does not run at all — Ps1DeadCodeElimination resolving a
constant if deletes the branch that was never taken, redirections and all — and that claim is
Ps1RemovalPlan's to check. One rule over both would
refuse the second for no reason.
Expand source code Browse git
"""
The single route by which a PowerShell cleanup pass puts one part of the tree in another's place.
`refinery.lib.scripts.ps1.deobfuscation.removal` owns the other half of the same question: what a
pass takes *out* of a statement list. Between them there was nothing, so a pass that rewrote a node
sitting in a slot reached for the tree primitives directly, and any rule about what such a rewrite
must not throw away had to be restated at every site that could break it. The sites disagree, which
is how a redirected call came to be folded into its value in three different passes and left the
file it named uncreated.
The entry points are named for the slot the node sits in — a direct field, one entry of a child
list, the whole of one — rather than for the passes that reach them, because the slot is the only
thing they differ in. `substituted` is named for none of them: a pass that decides by returning a
replacement from `visit_X` never touches a slot, because
`refinery.lib.scripts.Transformer.generic_visit` installs the answer for it, so what it asks for is
a verdict rather than an edit. `substitute_statement` is the exception in that it edits nothing
itself: a statement list is a body, a body is edited as a batch, and
`refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan` is that batch. It is here so that a
pass substituting inside a body has an owner to ask rather than a `refinery.lib.scripts.BodyEdit` to
build, and it refuses an empty replacement: whether a statement may go is the other module's
question, and answering it here would be a route around every guard that module holds.
The two owners divide by what the pass is claiming, and the division is what makes the rule below
sound. A substitution claims the value is unchanged, so everything the original did still has to
happen. A removal claims the code does not run at all — `Ps1DeadCodeElimination` resolving a
constant `if` deletes the branch that was never taken, redirections and all — and that claim is
`refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan`'s to check. One rule over both would
refuse the second for no reason.
"""
from __future__ import annotations
from collections.abc import Iterable
from refinery.lib.scripts import (
Node,
Statement,
_replace_in_parent,
reattach,
set_child,
set_child_list,
)
from refinery.lib.scripts.ps1.deobfuscation.removal import Ps1RemovalPlan
#: What every entry point below accepts on either side of a substitution: a node, nothing, or a
#: list of either. A pass that splits one statement into several has a list on one side and a single
#: node on the other, and nothing about the rule changes between the two.
Part = Node | None | Iterable['Part']
def _nodes(part: Part) -> Iterable[Node]:
"""
Every node `part` holds, however deeply it is wrapped.
The unwrapping follows `Part` rather than the list and tuple two callers happen to pass:
a shape this did not unwrap yields nothing, and nothing on the `removed` side of
`may_substitute` is a redirection-free original — the one answer this module must never give by
accident.
"""
if isinstance(part, Node):
yield part
elif isinstance(part, Iterable) and not isinstance(part, (str, bytes)):
for item in part:
yield from _nodes(item)
def carried_redirections(*parts: Part) -> list:
"""
Every redirection written anywhere in the subtrees at `parts`.
The whole subtree is read and not the nodes themselves, because the carrier is rarely the node a
pass is holding: `iex 'x' > C:\\o.txt` writes the redirection on the invocation, and a pass
replacing the expression statement around it is handed a node whose own list is empty. The list
is read by name rather than matched against the model types known to carry one, so a carrier
added later is covered without being enumerated here — and the failure of the alternative is
asymmetric, since a carrier this did not recognize reads as nothing being lost.
"""
found = []
for part in parts:
for node in _nodes(part):
for descendant in node.walk():
found.extend(getattr(descendant, 'redirections', None) or ())
return found
def may_substitute(removed: Part, installed: Part, moved: Part = ()) -> bool:
"""
Whether `installed` may take the place of `removed` without losing what the original wrote.
A replacement expression carries no redirections, so folding `f > C:\\log` into the value `f`
returns puts that value on the console and leaves the file uncreated — PowerShell opens the
target as it sets the redirection up, so the file is touched even by a command that writes
nothing at all. **Every spelling is refused, including the merges that neither move output nor
open a file.** `f 2>&1` folds error records into the output stream, so dropping it is
observationally identical only when `f` writes nothing to stderr, and nothing here can know
that. What this asks is whether the replacement can carry the redirection, and it never can.
`moved` names nodes the caller lifts out of `removed` and re-installs elsewhere within the same
edit. `Ps1ExpandableStringHoist` rewrites `"a$($z = f > C:\\o.txt)b"` by replacing the string
with its text and hoisting the assignment into the enclosing body, so the redirection leaves
this substitution and returns one statement later; without saying so the pass would be refused
for an edit that loses nothing. Nothing checks the claim — a caller that names a node here and
then drops it loses exactly what this exists to keep — so it is owed by the caller, the way
`refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlans.propose_in` owes the list it
names.
Identity decides whether a redirection survived, not equality. A pass that rebuilt one would be
refused although it wrote the same thing down, which is the conservative direction: what a
wrong answer the other way costs is a payload deleted into a file that is never created.
The original is read first because almost nothing carries a redirection, and a substitution that
takes none away cannot lose one whatever the replacement holds.
"""
lost = carried_redirections(removed)
if not lost:
return True
kept = {id(redirection) for redirection in carried_redirections(installed, moved)}
return all(id(redirection) in kept for redirection in lost)
def _release(part: Part) -> bool:
"""
Put everything `part` holds back in order and report the refusal.
Building a replacement adopts the parts of the original it reuses, so a rejected one leaves
those parts standing in the tree while naming a holder that does not hold them, and everything
that reads upward from inside one then walks out of the tree.
`refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan.propose` makes the same repair
for the same reason.
"""
for node in _nodes(part):
reattach(node)
return False
def substituted(old: Node, new: Node | None, moved: Part = ()) -> Node | None:
"""
The replacement a `visit_X` method may return for `old`, or `None` to leave it standing.
This is the route with no slot to edit: `refinery.lib.scripts.Transformer.generic_visit`
installs whatever a `visit_X` returns, so by the time the node reaches a slot the pass has
already decided, and a pass that keeps bookkeeping on the way cannot express the refusal as an
early return. Asking here is the same rule the other entry points hold, answered one step
earlier.
A refused replacement is released for the reason `_release` gives: building it adopted the parts
of `old` it reuses, and those parts are still standing in the tree.
"""
if new is None:
return None
if not may_substitute(old, new, moved):
_release(old)
return None
return new
def substitute(old: Node, new: Node, moved: Part = ()) -> bool:
"""
Put `new` where `old` stands, reporting whether it landed. Refused when the swap would lose a
redirection; see `may_substitute`, whose `moved` argument this forwards.
`old` may sit in a direct field, in a child list, or in a tuple inside one, and the search is
the same one a removal makes, so a pass that found its node by walking the tree does not have to
know which of the three it is looking at.
A slot that is not found is a refusal like any other, and it is released the same way: the
caller has built `new` over parts of `old` either way, and reporting the two the same while
repairing only one leaves the caller no way to tell which it got.
"""
if not may_substitute(old, new, moved):
return _release(old)
if not _replace_in_parent(old, new):
return _release(old)
return True
def substitute_field(parent: Node, attr: str, new: Node | None) -> bool:
"""
Put `new` in the single-node field `parent.<attr>`, reporting whether it landed.
A `None` clears the field, which is how a pass drops an optional sub-node: a loop's condition
that has been proved constant, or the expression of a statement whose work has been folded into
a neighbour. Clearing is where the rule earns its keep here — what leaves is everything the
field held and what arrives is nothing at all.
"""
old = getattr(parent, attr, None)
if not may_substitute(old, new):
return _release(old)
set_child(parent, attr, new)
return True
def substitute_list(parent: Node, attr: str, items: list) -> bool:
"""
Replace the contents of the child list `parent.<attr>` with `items`, reporting whether they
landed.
The new contents are given whole rather than as a diff, so a reordering and a wholesale rewrite
arrive here in the same shape, and what either of them takes out of the tree is read off the two
lists rather than taken from the caller's account of it.
"""
old = list(getattr(parent, attr, None) or [])
if not may_substitute(old, items):
return _release(old)
set_child_list(parent, attr, items)
return True
def substitute_statement(
container: Node,
statement: Statement,
replacement: list[Statement],
moved: Part = (),
) -> bool:
"""
Put `replacement` where `statement` stands in `container.body`, reporting whether the body
moved.
`replacement` has to hold something. A pass that wants the statement gone opens a
`refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan` itself, because the guards that
decide whether a statement may go belong to that class, and every one of them would be skipped
by a caller that spelled its removal as a substitution with nothing on the other side.
The rule is asked of the statement against the whole replacement list and not against any one
entry of it, because a pass that splits one statement into several routinely puts the redirected
part in a different entry than the value.
"""
if not replacement:
raise ValueError('an empty replacement is a removal; open a Ps1RemovalPlan for it')
if not may_substitute(statement, replacement, moved):
return _release(statement)
plan = Ps1RemovalPlan(container)
plan.propose(statement, replacement)
return plan.commit()
Global variables
var Part-
What every entry point below accepts on either side of a substitution: a node, nothing, or a list of either. A pass that splits one statement into several has a list on one side and a single node on the other, and nothing about the rule changes between the two.
Functions
def carried_redirections(*parts)-
Every redirection written anywhere in the subtrees at
parts.The whole subtree is read and not the nodes themselves, because the carrier is rarely the node a pass is holding:
iex 'x' > C:\o.txtwrites the redirection on the invocation, and a pass replacing the expression statement around it is handed a node whose own list is empty. The list is read by name rather than matched against the model types known to carry one, so a carrier added later is covered without being enumerated here — and the failure of the alternative is asymmetric, since a carrier this did not recognize reads as nothing being lost.Expand source code Browse git
def carried_redirections(*parts: Part) -> list: """ Every redirection written anywhere in the subtrees at `parts`. The whole subtree is read and not the nodes themselves, because the carrier is rarely the node a pass is holding: `iex 'x' > C:\\o.txt` writes the redirection on the invocation, and a pass replacing the expression statement around it is handed a node whose own list is empty. The list is read by name rather than matched against the model types known to carry one, so a carrier added later is covered without being enumerated here — and the failure of the alternative is asymmetric, since a carrier this did not recognize reads as nothing being lost. """ found = [] for part in parts: for node in _nodes(part): for descendant in node.walk(): found.extend(getattr(descendant, 'redirections', None) or ()) return found def may_substitute(removed, installed, moved=())-
Whether
installedmay take the place ofremovedwithout losing what the original wrote.A replacement expression carries no redirections, so folding
f > C:\loginto the valuefreturns puts that value on the console and leaves the file uncreated — PowerShell opens the target as it sets the redirection up, so the file is touched even by a command that writes nothing at all. Every spelling is refused, including the merges that neither move output nor open a file.f 2>&1folds error records into the output stream, so dropping it is observationally identical only whenfwrites nothing to stderr, and nothing here can know that. What this asks is whether the replacement can carry the redirection, and it never can.movednames nodes the caller lifts out ofremovedand re-installs elsewhere within the same edit.Ps1ExpandableStringHoistrewrites"a$($z = f > C:\o.txt)b"by replacing the string with its text and hoisting the assignment into the enclosing body, so the redirection leaves this substitution and returns one statement later; without saying so the pass would be refused for an edit that loses nothing. Nothing checks the claim — a caller that names a node here and then drops it loses exactly what this exists to keep — so it is owed by the caller, the wayPs1RemovalPlans.propose_in()owes the list it names.Identity decides whether a redirection survived, not equality. A pass that rebuilt one would be refused although it wrote the same thing down, which is the conservative direction: what a wrong answer the other way costs is a payload deleted into a file that is never created.
The original is read first because almost nothing carries a redirection, and a substitution that takes none away cannot lose one whatever the replacement holds.
Expand source code Browse git
def may_substitute(removed: Part, installed: Part, moved: Part = ()) -> bool: """ Whether `installed` may take the place of `removed` without losing what the original wrote. A replacement expression carries no redirections, so folding `f > C:\\log` into the value `f` returns puts that value on the console and leaves the file uncreated — PowerShell opens the target as it sets the redirection up, so the file is touched even by a command that writes nothing at all. **Every spelling is refused, including the merges that neither move output nor open a file.** `f 2>&1` folds error records into the output stream, so dropping it is observationally identical only when `f` writes nothing to stderr, and nothing here can know that. What this asks is whether the replacement can carry the redirection, and it never can. `moved` names nodes the caller lifts out of `removed` and re-installs elsewhere within the same edit. `Ps1ExpandableStringHoist` rewrites `"a$($z = f > C:\\o.txt)b"` by replacing the string with its text and hoisting the assignment into the enclosing body, so the redirection leaves this substitution and returns one statement later; without saying so the pass would be refused for an edit that loses nothing. Nothing checks the claim — a caller that names a node here and then drops it loses exactly what this exists to keep — so it is owed by the caller, the way `refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlans.propose_in` owes the list it names. Identity decides whether a redirection survived, not equality. A pass that rebuilt one would be refused although it wrote the same thing down, which is the conservative direction: what a wrong answer the other way costs is a payload deleted into a file that is never created. The original is read first because almost nothing carries a redirection, and a substitution that takes none away cannot lose one whatever the replacement holds. """ lost = carried_redirections(removed) if not lost: return True kept = {id(redirection) for redirection in carried_redirections(installed, moved)} return all(id(redirection) in kept for redirection in lost) def substituted(old, new, moved=())-
The replacement a
visit_Xmethod may return forold, orNoneto leave it standing.This is the route with no slot to edit:
Transformer.generic_visit()installs whatever avisit_Xreturns, so by the time the node reaches a slot the pass has already decided, and a pass that keeps bookkeeping on the way cannot express the refusal as an early return. Asking here is the same rule the other entry points hold, answered one step earlier.A refused replacement is released for the reason
_releasegives: building it adopted the parts ofoldit reuses, and those parts are still standing in the tree.Expand source code Browse git
def substituted(old: Node, new: Node | None, moved: Part = ()) -> Node | None: """ The replacement a `visit_X` method may return for `old`, or `None` to leave it standing. This is the route with no slot to edit: `refinery.lib.scripts.Transformer.generic_visit` installs whatever a `visit_X` returns, so by the time the node reaches a slot the pass has already decided, and a pass that keeps bookkeeping on the way cannot express the refusal as an early return. Asking here is the same rule the other entry points hold, answered one step earlier. A refused replacement is released for the reason `_release` gives: building it adopted the parts of `old` it reuses, and those parts are still standing in the tree. """ if new is None: return None if not may_substitute(old, new, moved): _release(old) return None return new def substitute(old, new, moved=())-
Put
newwhereoldstands, reporting whether it landed. Refused when the swap would lose a redirection; seemay_substitute(), whosemovedargument this forwards.oldmay sit in a direct field, in a child list, or in a tuple inside one, and the search is the same one a removal makes, so a pass that found its node by walking the tree does not have to know which of the three it is looking at.A slot that is not found is a refusal like any other, and it is released the same way: the caller has built
newover parts ofoldeither way, and reporting the two the same while repairing only one leaves the caller no way to tell which it got.Expand source code Browse git
def substitute(old: Node, new: Node, moved: Part = ()) -> bool: """ Put `new` where `old` stands, reporting whether it landed. Refused when the swap would lose a redirection; see `may_substitute`, whose `moved` argument this forwards. `old` may sit in a direct field, in a child list, or in a tuple inside one, and the search is the same one a removal makes, so a pass that found its node by walking the tree does not have to know which of the three it is looking at. A slot that is not found is a refusal like any other, and it is released the same way: the caller has built `new` over parts of `old` either way, and reporting the two the same while repairing only one leaves the caller no way to tell which it got. """ if not may_substitute(old, new, moved): return _release(old) if not _replace_in_parent(old, new): return _release(old) return True def substitute_field(parent, attr, new)-
Put
newin the single-node fieldparent.<attr>, reporting whether it landed.A
Noneclears the field, which is how a pass drops an optional sub-node: a loop's condition that has been proved constant, or the expression of a statement whose work has been folded into a neighbour. Clearing is where the rule earns its keep here — what leaves is everything the field held and what arrives is nothing at all.Expand source code Browse git
def substitute_field(parent: Node, attr: str, new: Node | None) -> bool: """ Put `new` in the single-node field `parent.<attr>`, reporting whether it landed. A `None` clears the field, which is how a pass drops an optional sub-node: a loop's condition that has been proved constant, or the expression of a statement whose work has been folded into a neighbour. Clearing is where the rule earns its keep here — what leaves is everything the field held and what arrives is nothing at all. """ old = getattr(parent, attr, None) if not may_substitute(old, new): return _release(old) set_child(parent, attr, new) return True def substitute_list(parent, attr, items)-
Replace the contents of the child list
parent.<attr>withitems, reporting whether they landed.The new contents are given whole rather than as a diff, so a reordering and a wholesale rewrite arrive here in the same shape, and what either of them takes out of the tree is read off the two lists rather than taken from the caller's account of it.
Expand source code Browse git
def substitute_list(parent: Node, attr: str, items: list) -> bool: """ Replace the contents of the child list `parent.<attr>` with `items`, reporting whether they landed. The new contents are given whole rather than as a diff, so a reordering and a wholesale rewrite arrive here in the same shape, and what either of them takes out of the tree is read off the two lists rather than taken from the caller's account of it. """ old = list(getattr(parent, attr, None) or []) if not may_substitute(old, items): return _release(old) set_child_list(parent, attr, items) return True def substitute_statement(container, statement, replacement, moved=())-
Put
replacementwherestatementstands incontainer.body, reporting whether the body moved.replacementhas to hold something. A pass that wants the statement gone opens aPs1RemovalPlanitself, because the guards that decide whether a statement may go belong to that class, and every one of them would be skipped by a caller that spelled its removal as a substitution with nothing on the other side.The rule is asked of the statement against the whole replacement list and not against any one entry of it, because a pass that splits one statement into several routinely puts the redirected part in a different entry than the value.
Expand source code Browse git
def substitute_statement( container: Node, statement: Statement, replacement: list[Statement], moved: Part = (), ) -> bool: """ Put `replacement` where `statement` stands in `container.body`, reporting whether the body moved. `replacement` has to hold something. A pass that wants the statement gone opens a `refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan` itself, because the guards that decide whether a statement may go belong to that class, and every one of them would be skipped by a caller that spelled its removal as a substitution with nothing on the other side. The rule is asked of the statement against the whole replacement list and not against any one entry of it, because a pass that splits one statement into several routinely puts the redirected part in a different entry than the value. """ if not replacement: raise ValueError('an empty replacement is a removal; open a Ps1RemovalPlan for it') if not may_substitute(statement, replacement, moved): return _release(statement) plan = Ps1RemovalPlan(container) plan.propose(statement, replacement) return plan.commit()