Module refinery.lib.scripts.ps1.deobfuscation.expandable
Hoist void subexpressions out of expandable strings, replacing the expandable
string with a plain string literal of its text parts. The hoisted statements
are inserted around the parent statement preserving their side effects. Only
operates on expandable strings where ALL subexpressions are void-producing
(assignments only — a command inside $( ... ) contributes its output to the
string, so it is never hoisted).
Safety constraint: subexpressions from leftmost expandable strings are inserted BEFORE the parent statement (they were going to run first anyway). Subexpressions from other expandable strings are inserted AFTER the parent statement to preserve execution order.
Expand source code Browse git
"""
Hoist void subexpressions out of expandable strings, replacing the expandable
string with a plain string literal of its text parts. The hoisted statements
are inserted around the parent statement preserving their side effects. Only
operates on expandable strings where ALL subexpressions are void-producing
(assignments only — a command inside `$( ... )` contributes its output to the
string, so it is never hoisted).
Safety constraint: subexpressions from leftmost expandable strings are
inserted BEFORE the parent statement (they were going to run first anyway).
Subexpressions from other expandable strings are inserted AFTER the parent
statement to preserve execution order.
"""
from __future__ import annotations
from refinery.lib.scripts import Block, Transformer
from refinery.lib.scripts.ps1.ast import get_body
from refinery.lib.scripts.ps1.deobfuscation.helpers import make_string_literal
from refinery.lib.scripts.ps1.deobfuscation.substitution import substitute, substitute_statement
from refinery.lib.scripts.ps1.model import (
Ps1AssignmentExpression,
Ps1BinaryExpression,
Ps1Code,
Ps1ExpandableString,
Ps1ExpressionStatement,
Ps1StringLiteral,
Ps1SubExpression,
)
class Ps1ExpandableStringHoist(Transformer):
"""
Extract void subexpressions from expandable strings into preceding or following statements,
then replace the expandable string with a plain string literal.
"""
def visit(self, node):
for container in list(node.walk()):
body = get_body(container)
if body is None:
continue
i = 0
while i < len(body):
before, after, replaced = self._extract_void_subexpressions(body[i])
if before or after:
hoisted = [*before, body[i], *after]
if substitute_statement(container, body[i], hoisted):
i += len(before) + len(after)
if replaced:
self.mark_changed()
i += 1
return None
@staticmethod
def _is_void_statement(stmt) -> bool:
"""
A statement is void when it contributes nothing to an interpolating string. Only assignments
qualify: a command invocation inside `$( ... )` writes its output into the surrounding
string, so hoisting it out would drop that interpolated value and leak the command's output
to the pipeline.
"""
if not isinstance(stmt, Ps1ExpressionStatement):
return False
return isinstance(stmt.expression, Ps1AssignmentExpression)
@staticmethod
def _is_leftmost(node) -> bool:
"""
Check whether *node* sits in the leftmost evaluation position of its enclosing expression
tree. An expandable string is leftmost when every ancestor
`refinery.lib.scripts.ps1.model.Ps1BinaryExpression` has it (or the subtree containing it)
as its `left` operand. This guarantees the subexpressions would have been the first thing
evaluated, so hoisting them before the statement does not change execution order.
"""
child = node
parent = node.parent
while parent is not None:
if isinstance(parent, Ps1BinaryExpression):
if parent.left is not child:
return False
if isinstance(parent, (Ps1ExpressionStatement, Ps1Code, Block)):
break
child = parent
parent = parent.parent
return True
def _extract_void_subexpressions(self, stmt) -> tuple[list, list, bool]:
"""
Walk the statement tree, find expandable strings whose every part is either literal text or
a void subexpression, replace them with string literals, and return
`(before_stmts, after_stmts, replaced)`.
Every part has to be accounted for and not only the subexpressions. A part that is neither
— the `$env:APPDATA` in `"$env:APPDATA$($z=1)\\dropper.exe"` — has no text this can write
down, so rewriting the string around it would silently delete what it interpolated and leave
a literal the script never said.
`replaced` is reported separately from the two lists because a subexpression with an empty
body hoists no statement and still rewrites the string, so the lists alone cannot say
whether the tree moved.
The hoisted statements are collected only once the string has actually been replaced. The
walk is a snapshot, so a string nested inside one already rewritten is no longer reachable
from its parent, and hoisting its subexpressions out while it stands would run them twice.
"""
before: list = []
after: list = []
replaced = False
for node in list(stmt.walk_in_order()):
if not isinstance(node, Ps1ExpandableString):
continue
if not all(
isinstance(part, (Ps1StringLiteral, Ps1SubExpression))
for part in node.parts
):
continue
subs = [p for p in node.parts if isinstance(p, Ps1SubExpression)]
if not subs:
continue
if not all(
all(self._is_void_statement(s) for s in sub.body)
for sub in subs
):
continue
text_parts: list[str] = []
for part in node.parts:
if isinstance(part, Ps1StringLiteral):
text_parts.append(part.value)
collected: list = []
for sub in subs:
collected.extend(sub.body)
leftmost = self._is_leftmost(node)
literal = make_string_literal(''.join(text_parts))
if not substitute(node, literal, moved=collected):
continue
if leftmost:
before.extend(collected)
else:
after.extend(collected)
replaced = True
return before, after, replaced
Classes
class Ps1ExpandableStringHoist-
Extract void subexpressions from expandable strings into preceding or following statements, then replace the expandable string with a plain string literal.
Expand source code Browse git
class Ps1ExpandableStringHoist(Transformer): """ Extract void subexpressions from expandable strings into preceding or following statements, then replace the expandable string with a plain string literal. """ def visit(self, node): for container in list(node.walk()): body = get_body(container) if body is None: continue i = 0 while i < len(body): before, after, replaced = self._extract_void_subexpressions(body[i]) if before or after: hoisted = [*before, body[i], *after] if substitute_statement(container, body[i], hoisted): i += len(before) + len(after) if replaced: self.mark_changed() i += 1 return None @staticmethod def _is_void_statement(stmt) -> bool: """ A statement is void when it contributes nothing to an interpolating string. Only assignments qualify: a command invocation inside `$( ... )` writes its output into the surrounding string, so hoisting it out would drop that interpolated value and leak the command's output to the pipeline. """ if not isinstance(stmt, Ps1ExpressionStatement): return False return isinstance(stmt.expression, Ps1AssignmentExpression) @staticmethod def _is_leftmost(node) -> bool: """ Check whether *node* sits in the leftmost evaluation position of its enclosing expression tree. An expandable string is leftmost when every ancestor `refinery.lib.scripts.ps1.model.Ps1BinaryExpression` has it (or the subtree containing it) as its `left` operand. This guarantees the subexpressions would have been the first thing evaluated, so hoisting them before the statement does not change execution order. """ child = node parent = node.parent while parent is not None: if isinstance(parent, Ps1BinaryExpression): if parent.left is not child: return False if isinstance(parent, (Ps1ExpressionStatement, Ps1Code, Block)): break child = parent parent = parent.parent return True def _extract_void_subexpressions(self, stmt) -> tuple[list, list, bool]: """ Walk the statement tree, find expandable strings whose every part is either literal text or a void subexpression, replace them with string literals, and return `(before_stmts, after_stmts, replaced)`. Every part has to be accounted for and not only the subexpressions. A part that is neither — the `$env:APPDATA` in `"$env:APPDATA$($z=1)\\dropper.exe"` — has no text this can write down, so rewriting the string around it would silently delete what it interpolated and leave a literal the script never said. `replaced` is reported separately from the two lists because a subexpression with an empty body hoists no statement and still rewrites the string, so the lists alone cannot say whether the tree moved. The hoisted statements are collected only once the string has actually been replaced. The walk is a snapshot, so a string nested inside one already rewritten is no longer reachable from its parent, and hoisting its subexpressions out while it stands would run them twice. """ before: list = [] after: list = [] replaced = False for node in list(stmt.walk_in_order()): if not isinstance(node, Ps1ExpandableString): continue if not all( isinstance(part, (Ps1StringLiteral, Ps1SubExpression)) for part in node.parts ): continue subs = [p for p in node.parts if isinstance(p, Ps1SubExpression)] if not subs: continue if not all( all(self._is_void_statement(s) for s in sub.body) for sub in subs ): continue text_parts: list[str] = [] for part in node.parts: if isinstance(part, Ps1StringLiteral): text_parts.append(part.value) collected: list = [] for sub in subs: collected.extend(sub.body) leftmost = self._is_leftmost(node) literal = make_string_literal(''.join(text_parts)) if not substitute(node, literal, moved=collected): continue if leftmost: before.extend(collected) else: after.extend(collected) replaced = True return before, after, replacedAncestors
Methods
def visit(self, node)-
Expand source code Browse git
def visit(self, node): for container in list(node.walk()): body = get_body(container) if body is None: continue i = 0 while i < len(body): before, after, replaced = self._extract_void_subexpressions(body[i]) if before or after: hoisted = [*before, body[i], *after] if substitute_statement(container, body[i], hoisted): i += len(before) + len(after) if replaced: self.mark_changed() i += 1 return None
Inherited members