Module refinery.lib.scripts.ps1.deobfuscation.unflatten

Recover original control flow from control-flow-flattened PowerShell scripts.

Control flow flattening replaces sequential and branching code with a dispatcher loop: a while loop containing a single switch on a state variable, where each case sets the state variable to determine the next case to execute. This transformer identifies the dispatcher pattern, extracts the state machine, and recovers the original structure.

Expand source code Browse git
"""
Recover original control flow from control-flow-flattened PowerShell scripts.

Control flow flattening replaces sequential and branching code with a dispatcher loop: a while loop
containing a single switch on a state variable, where each case sets the state variable to
determine the next case to execute. This transformer identifies the dispatcher pattern, extracts
the state machine, and recovers the original structure.
"""
from __future__ import annotations

from collections import deque
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable, Generator, NamedTuple

from refinery.lib.scripts import Block, Node, Statement, Transformer, tree_root
from refinery.lib.scripts.ps1.analysis.cache import Ps1ModelCache, model_cache
from refinery.lib.scripts.ps1.analysis.effects import Ps1BodyReads, reads_in_bodies
from refinery.lib.scripts.ps1.analysis.naming import Ps1NameRole, named_references, reads_unreadable_name
from refinery.lib.scripts.ps1.analysis.opaque import runs_unreadable_code, writes_nobody_can_attribute
from refinery.lib.scripts.ps1.analysis.values import integer_of, read
from refinery.lib.scripts.ps1.ast import binding_key, get_body, is_builtin_variable, unwrap_parens
from refinery.lib.scripts.ps1.data import COMPARISON_OPS
from refinery.lib.scripts.ps1.deobfuscation.emulator import evaluate_truthy
from refinery.lib.scripts.ps1.deobfuscation.helpers import inside_value_producing_context
from refinery.lib.scripts.ps1.deobfuscation.removal import Ps1RemovalPlan
from refinery.lib.scripts.ps1.model import (
    Expression,
    Ps1AssignmentExpression,
    Ps1BinaryExpression,
    Ps1BreakStatement,
    Ps1ExpressionStatement,
    Ps1IfStatement,
    Ps1IntegerLiteral,
    Ps1ParenExpression,
    Ps1RealLiteral,
    Ps1ScopeModifier,
    Ps1Script,
    Ps1StringLiteral,
    Ps1SwitchStatement,
    Ps1UnaryExpression,
    Ps1Variable,
    Ps1WhileLoop,
)

_MAX_STATES = 500
_MAX_UNROLL_ITERATIONS = 500

if TYPE_CHECKING:
    from refinery.lib.scripts.ps1.analysis.callgraph import Ps1CallGraph
    _VarKey = tuple[str, Ps1ScopeModifier]
    _StateKey = int | float | str


def _is_bool_literal(node: Node) -> bool | None:
    """
    Check if a node is a `$True` or `$False` variable literal. Returns the boolean value,
    or `None` if the node is not a boolean literal.
    """
    if is_builtin_variable(node, frozenset({'true', 'false'})):
        return node.name.lower() == 'true'
    return None


def _integer_state_key(node: Ps1IntegerLiteral) -> int | None:
    """
    The integer PowerShell gives an integer literal, or `None` for a numeral too wide to type. This
    is not `node.value`, which is the numeral's magnitude: a hexadecimal literal is typed by its
    width and carries that width's sign, so `0xFFFFFFFF` is the Int32 -1 rather than four billion,
    and a state machine that matches the magnitude routes itself into a case the host never enters.
    The value the host assigns is what `read` types and `integer_of` reads back.
    """
    return integer_of(read(node))


def _unwrap_constant(node) -> _StateKey | None:
    """
    Extract a constant value (int, float, or string) from an AST node.
    """
    node = unwrap_parens(node) if isinstance(node, Expression) else node
    if isinstance(node, Ps1IntegerLiteral):
        return _integer_state_key(node)
    if isinstance(node, Ps1RealLiteral):
        return node.value
    if isinstance(node, Ps1StringLiteral):
        return node.value
    if isinstance(node, Ps1UnaryExpression) and node.operator == '-':
        inner = unwrap_parens(node.operand) if isinstance(node.operand, Expression) else node.operand
        if isinstance(inner, Ps1IntegerLiteral):
            magnitude = _integer_state_key(inner)
            return None if magnitude is None else -magnitude
        if isinstance(inner, Ps1RealLiteral):
            return -inner.value
    if is_builtin_variable(node, frozenset({'null'})):
        return 0
    return None


@dataclass
class _LinearTransition:
    target: _StateKey

    @property
    def successors(self) -> tuple[_StateKey, ...]:
        return (self.target,)


@dataclass
class _ConditionalTransition:
    condition: Expression
    true_target: _StateKey
    false_target: _StateKey
    true_prefix: list[Statement] = field(default_factory=list)
    false_prefix: list[Statement] = field(default_factory=list)

    @property
    def successors(self) -> tuple[_StateKey, ...]:
        return (self.true_target, self.false_target)


@dataclass
class _ExitTransition:

    @property
    def successors(self) -> tuple[_StateKey, ...]:
        return ()


if TYPE_CHECKING:
    _Transition = _LinearTransition | _ConditionalTransition | _ExitTransition


@dataclass
class _StateBlock:
    state_id: _StateKey
    statements: list[Statement]
    transition: _Transition


@dataclass
class _DispatcherMatch:
    state_var_name: str
    state_var_scope: Ps1ScopeModifier
    condition: Expression
    switch: Ps1SwitchStatement


def _var_key(node: Ps1Variable) -> _VarKey:
    return (node.name.lower(), node.scope)


def _collect_variables(node: Node) -> set[_VarKey]:
    """
    Collect all distinct variable keys referenced in an expression tree.
    """
    result: set[_VarKey] = set()
    for child in node.walk():
        if isinstance(child, Ps1Variable):
            if child.scope != Ps1ScopeModifier.NONE:
                continue
            lower = child.name.lower()
            if lower in ('true', 'false', 'null'):
                continue
            result.add(_var_key(child))
    return result


def _make_exit_check(
    condition: Expression,
    var_name: str,
) -> Callable[[_StateKey], bool]:
    """
    Return a predicate that checks whether assigning a given state value to the state variable
    would make the while condition falsy (the loop would exit). Uses the emulator to evaluate the
    condition with the state variable bound.

    A condition the emulator cannot evaluate is read as one that does not exit, which is sound only
    because `_exit_condition_is_total` refuses the whole recovery unless the condition resolves at
    every state value the predicate is consulted on. Without that gate a loop whose exit depends on
    an unevaluable value — `while ($s -ne (Get-Random))` — would recover as one that never exits.
    """
    def is_exit(state_value: _StateKey) -> bool:
        bindings = {var_name: state_value}
        result = evaluate_truthy(condition, bindings)
        if result is None:
            return False
        return not result
    return is_exit


def _exit_condition_is_total(
    condition: Expression,
    var_name: str,
    states: dict[_StateKey, _StateBlock],
) -> bool:
    """
    Whether the loop condition resolves to a definite truth value at every state the machine can
    hold — each case label and each transition target, which together are exactly the values
    `_make_exit_check`'s predicate is asked about. A machine undecidable at one of them cannot be
    shown to exit there, so recovering it would run a body the host may never run.
    """
    values: set[_StateKey] = set(states)
    for block in states.values():
        values.update(block.transition.successors)
    return all(
        evaluate_truthy(condition, {var_name: value}) is not None
        for value in values
    )


def _is_state_assignment(
    stmt: Statement,
    var_name: str,
    var_scope: Ps1ScopeModifier,
) -> _StateKey | None:
    """
    If the statement is an assignment to the state variable with a constant value, return that
    value. Otherwise return `None`.
    """
    result = _get_simple_assignment(stmt)
    if result is None:
        return None
    key, value = result
    if key != (var_name, var_scope):
        return None
    return _unwrap_constant(value)


def _get_simple_assignment(
    stmt: Statement,
) -> tuple[_VarKey, Node] | None:
    """
    If the statement is a simple assignment ($var = <value>), return the variable key
    and the value node. Returns `None` for non-assignments or compound operators.
    """
    if not isinstance(stmt, Ps1ExpressionStatement):
        return None
    expr = stmt.expression
    if not isinstance(expr, Ps1AssignmentExpression):
        return None
    if expr.operator != '=':
        return None
    target = expr.target
    if not isinstance(target, Ps1Variable):
        return None
    if expr.value is None:
        return None
    return (_var_key(target), expr.value)


def _resolve_value(
    node: Node,
    env: dict[_VarKey, _StateKey | bool],
) -> _StateKey | None:
    if not isinstance(node, Expression):
        return None
    expr = unwrap_parens(node)
    value = _unwrap_constant(expr)
    if value is not None:
        return value
    if isinstance(expr, Ps1Variable):
        key = _var_key(expr)
        val = env.get(key)
        if isinstance(val, (int, float, str)):
            return val
    return None


def _resolve_bool(
    node: Node,
    env: dict[_VarKey, _StateKey | bool],
) -> bool | None:
    if not isinstance(node, Expression):
        return None
    expr = unwrap_parens(node)
    literal = _is_bool_literal(expr)
    if literal is not None:
        return literal
    if isinstance(expr, Ps1Variable):
        key = _var_key(expr)
        val = env.get(key)
        if isinstance(val, bool):
            return val
    if (
        isinstance(expr, Ps1BinaryExpression)
        and (_l := expr.left) is not None
        and (_r := expr.right) is not None
    ):
        if comparator := COMPARISON_OPS.get(op := expr.operator.lower()):
            lhs = _resolve_value(_l, env)
            rhs = _resolve_value(_r, env)
            if lhs is not None and rhs is not None:
                if type(lhs) is not type(rhs):
                    return None
                return comparator(lhs, rhs)
        else:
            lhs = _resolve_bool(_l, env)
            rhs = _resolve_bool(_r, env)
            if lhs is None:
                lhs, rhs = rhs, lhs
            if op == '-and':
                return lhs and rhs
            if op == '-or':
                return lhs or rhs
    if (
        isinstance(expr, Ps1UnaryExpression)
        and (_v := expr.operand) is not None
        and (op := expr.operator.lower()) in ('-not', '!')
        and (bv := _resolve_bool(_v, env)) is not None
    ):
        return not bv
    return None


def _match_dispatcher(loop: Ps1WhileLoop) -> _DispatcherMatch | None:
    """
    Check whether a while loop matches the CFF dispatcher pattern: a while loop whose condition
    involves a single variable, and whose body is a single switch on that variable.
    """
    cond = loop.condition
    if cond is None:
        return None
    body = loop.body
    if body is None or len(body.body) != 1:
        return None
    switch = body.body[0]
    if not isinstance(switch, Ps1SwitchStatement):
        return None
    if switch.file:
        return None
    switch_val = switch.value
    if not isinstance(switch_val, Ps1Variable):
        return None
    var_name = switch_val.name.lower()
    var_scope = switch_val.scope
    cond_vars = _collect_variables(cond)
    if not cond_vars:
        return None
    state_key = (var_name, var_scope)
    if state_key not in cond_vars:
        return None
    if len(cond_vars) > 1:
        return None
    return _DispatcherMatch(
        state_var_name=var_name,
        state_var_scope=var_scope,
        condition=cond,
        switch=switch,
    )


def _find_state_init(
    body: list[Statement],
    loop_index: int,
    var_name: str,
    var_scope: Ps1ScopeModifier,
    calls: Ps1CallGraph,
) -> tuple[int, _StateKey] | None:
    """
    Scan backwards from the while loop to the statement that seeds the state variable with a
    constant, and return its index and the constant; `None` where there is none, or where a
    statement in between may write the variable (`_may_write_the_state`), since the loop then does
    not start from the seed.
    """
    for i in range(loop_index - 1, -1, -1):
        value = _is_state_assignment(body[i], var_name, var_scope)
        if value is not None:
            return (i, value)
        if _may_write_the_state(body[i], var_name, calls):
            return None
    return None


def _may_write_the_state(statement: Node, var_name: str, calls: Ps1CallGraph) -> bool:
    """
    Whether running *statement* may store into the state variable *var_name*: a variable of that
    name stands in it under any scope, a command in it writes the name it spells
    (`refinery.lib.scripts.ps1.analysis.naming.named_references`), something in it writes where
    no reading of the source names the target
    (`refinery.lib.scripts.ps1.analysis.opaque.writes_nobody_can_attribute`), or something in it
    may run a body of the script (`Ps1CallGraph.may_run_a_body`), which may write any name.
    """
    for node in statement.walk():
        if isinstance(node, Ps1Variable) and binding_key(node) == var_name:
            return True
        if writes_nobody_can_attribute(node) or calls.may_run_a_body(node):
            return True
        if any(
            reference.key == var_name and reference.role is not Ps1NameRole.READS
            for reference in named_references(node)
        ):
            return True
    return False


class _StateReads:
    """
    Which statements of one body may read which variables, read once for the body: a statement may
    read a variable of a name that stands in it under any scope or that a command in it names;
    where something in it may run a body of the script (`Ps1CallGraph.may_run_a_body`), a variable
    a function or a stored script block reads (*bodies*); and every variable where it reads a name
    no reading of the source spells or runs code nobody reads.
    """

    def __init__(self, body: list[Statement], calls: Ps1CallGraph, bodies: Ps1BodyReads):
        self._first: dict[str, int] = {}
        self._last: dict[str, int] = {}
        self._every: list[int] = []
        self._running: list[int] = []
        self._read_in_bodies = bodies.names
        for index, statement in enumerate(body):
            every = running = False
            for node in statement.walk():
                if isinstance(node, Ps1Variable):
                    self._mark(binding_key(node), index)
                for reference in named_references(node):
                    self._mark(reference.key, index)
                if reads_unreadable_name(node) or runs_unreadable_code(node):
                    every = True
                elif calls.may_run_a_body(node):
                    running = True
            if every or running and bodies.dynamic:
                self._every.append(index)
            elif running:
                self._running.append(index)

    def _mark(self, name: str, index: int) -> None:
        self._first.setdefault(name, index)
        self._last[name] = index

    def _runs_a_reader(self, var_name: str) -> list[int]:
        return self._running if var_name in self._read_in_bodies else []

    def after(self, index: int, var_name: str) -> bool:
        """
        Whether a statement after the one at *index* may read *var_name*.
        """
        later = [self._last.get(var_name, -1), *self._every[-1:], *self._runs_a_reader(var_name)[-1:]]
        return max(later) > index

    def first(self, var_name: str) -> int | None:
        """
        The index of the first statement that may read *var_name*, or `None` where none may.
        """
        found = [*self._every[:1], *self._runs_a_reader(var_name)[:1]]
        if (mentioned := self._first.get(var_name)) is not None:
            found.append(mentioned)
        return min(found, default=None)


def _strip_trailing_break(stmts: list[Statement]) -> list[Statement]:
    """
    Remove a trailing break statement (part of the switch dispatch, not the original code).
    """
    if stmts and isinstance(stmts[-1], Ps1BreakStatement) and stmts[-1].label is None:
        return stmts[:-1]
    return stmts


def _extract_transition(
    stmts: list[Statement],
    var_name: str,
    var_scope: Ps1ScopeModifier,
    is_exit: Callable[[_StateKey], bool],
) -> tuple[list[Statement], _Transition] | None:
    """
    Separate a switch case body into side-effect statements and a state transition. Returns
    (side_effects, transition) or `None` if the pattern is not recognized.
    """
    if not stmts:
        return None
    last = stmts[-1]
    state_val = _is_state_assignment(last, var_name, var_scope)
    if state_val is not None:
        side_effects = list(stmts[:-1])
        if is_exit(state_val):
            return (side_effects, _ExitTransition())
        return (side_effects, _LinearTransition(target=state_val))
    if isinstance(last, Ps1IfStatement) and last.else_block is not None and len(last.clauses) == 1:
        condition, true_block = last.clauses[0]
        false_block = last.else_block
        true_body = _strip_trailing_break(list(true_block.body))
        false_body = _strip_trailing_break(list(false_block.body))
        if not true_body or not false_body:
            return None
        true_state = _is_state_assignment(true_body[-1], var_name, var_scope)
        false_state = _is_state_assignment(false_body[-1], var_name, var_scope)
        if true_state is None or false_state is None:
            return None
        true_prefix = true_body[:-1]
        false_prefix = false_body[:-1]
        side_effects = list(stmts[:-1])
        return (side_effects, _ConditionalTransition(
            condition=condition,
            true_target=true_state,
            false_target=false_state,
            true_prefix=true_prefix,
            false_prefix=false_prefix,
        ))
    return None


def _extract_state_machine(
    match: _DispatcherMatch,
    is_exit: Callable[[_StateKey], bool],
) -> dict[_StateKey, _StateBlock] | None:
    """
    Parse all switch cases into a state machine dictionary. Returns `None` on failure.
    """
    states: dict[_StateKey, _StateBlock] = {}
    var_name = match.state_var_name
    var_scope = match.state_var_scope
    for condition, block in match.switch.clauses:
        if condition is None:
            continue
        state_id = _unwrap_constant(condition)
        if state_id is None:
            return None
        body = _strip_trailing_break(list(block.body))
        result = _extract_transition(body, var_name, var_scope, is_exit)
        if result is None:
            return None
        side_effects, transition = result
        states[state_id] = _StateBlock(
            state_id=state_id,
            statements=side_effects,
            transition=transition,
        )
    if len(states) > _MAX_STATES:
        return None
    return states


def _negate_condition(cond: Expression) -> Expression:
    """
    Return the logical negation of a condition expression. Tries to simplify where possible (e.g.,
    flipping -eq to -ne) rather than wrapping in -Not.
    """
    unwrapped = unwrap_parens(cond)
    if not isinstance(unwrapped, Expression):
        return Ps1UnaryExpression(operator='-Not', operand=cond, prefix=True)
    cond = unwrapped
    if isinstance(cond, Ps1BinaryExpression):
        flipped = {
            '-eq': '-NE',
            '-ne': '-EQ',
            '-lt': '-GE',
            '-ge': '-LT',
            '-gt': '-LE',
            '-le': '-GT',
        }.get(cond.operator.lower())
        if flipped is not None:
            return Ps1BinaryExpression(
                left=cond.left,
                operator=flipped,
                right=cond.right,
            )
    if (
        isinstance(cond, Ps1UnaryExpression)
        and cond.operator.lower() in ('-not', '!')
        and cond.operand is not None
    ):
        return cond.operand
    return Ps1UnaryExpression(operator='-Not', operand=Ps1ParenExpression(expression=cond))


def _build_if(
    condition: Expression,
    true_body: list[Statement],
    false_body: list[Statement],
) -> Ps1IfStatement:
    """
    Build an if/else statement. Omits the else block if the false body is empty; negates the
    condition if only the true body is empty. Where both are empty, the statement still evaluates
    its condition, which may write or fail, so it is kept with an empty body.
    """
    if not true_body and not false_body:
        return Ps1IfStatement(clauses=[(condition, Block(body=[]))])
    if not true_body:
        return Ps1IfStatement(
            clauses=[(_negate_condition(condition), Block(body=false_body))],
        )
    if not false_body:
        return Ps1IfStatement(
            clauses=[(condition, Block(body=true_body))],
        )
    return Ps1IfStatement(
        clauses=[(condition, Block(body=true_body))],
        else_block=Block(body=false_body),
    )


def _find_back_edges(
    states: dict[_StateKey, _StateBlock],
    entry: _StateKey,
    is_exit: Callable[[_StateKey], bool],
) -> dict[_StateKey, _StateKey]:
    """
    Walk the state graph from the entry and find back-edges using DFS. Returns a mapping from back-
    edge source state to back-edge target state (the loop header).
    """
    back_edges: dict[_StateKey, _StateKey] = {}
    visiting: set[_StateKey] = set()
    visited: set[_StateKey] = set()

    def _dfs(state: _StateKey):
        if is_exit(state) or state not in states:
            return
        if state in visited:
            return
        visiting.add(state)
        block = states[state]
        for target in block.transition.successors:
            if is_exit(target) or target not in states:
                continue
            if target in visiting:
                back_edges[state] = target
            elif target not in visited:
                _dfs(target)
        visiting.discard(state)
        visited.add(state)

    _dfs(entry)
    return back_edges


def _find_join_point(
    states: dict[_StateKey, _StateBlock],
    true_start: _StateKey,
    false_start: _StateKey,
    is_exit: Callable[[_StateKey], bool],
    back_edge_targets: set[_StateKey],
) -> _StateKey | None:
    """
    Find the first state reachable from both arms of a conditional (the join point). Returns `None`
    if no common state is found (one or both arms exit).
    """
    true_reach: list[_StateKey] = []
    false_reach: list[_StateKey] = []
    true_queue: deque[_StateKey] = deque([true_start])
    false_queue: deque[_StateKey] = deque([false_start])
    true_seen: set[_StateKey] = set()
    false_seen: set[_StateKey] = set()

    def _expand(
        queue: deque[_StateKey],
        seen: set[_StateKey],
        reach: list[_StateKey],
    ):
        while queue:
            s = queue.popleft()
            if is_exit(s) or s not in states:
                continue
            if s in seen:
                continue
            if s in back_edge_targets:
                reach.append(s)
                continue
            seen.add(s)
            reach.append(s)
            for succ in states[s].transition.successors:
                queue.append(succ)

    _expand(true_queue, true_seen, true_reach)
    _expand(false_queue, false_seen, false_reach)
    true_set = set(true_reach)
    for s in false_reach:
        if s in true_set:
            return s
    return None


def _collect_loop_states(
    states: dict[_StateKey, _StateBlock],
    header: _StateKey,
    is_exit: Callable[[_StateKey], bool],
    latches: set[_StateKey],
) -> set[_StateKey]:
    """
    Collect all state IDs that belong to a loop (reachable from header without leaving through
    latches).
    """
    loop_states: set[_StateKey] = set()
    queue: deque[_StateKey] = deque([header])
    while queue:
        s = queue.popleft()
        if is_exit(s) or s not in states or s in loop_states:
            continue
        loop_states.add(s)
        if s in latches and s != header:
            continue
        queue.extend(states[s].transition.successors)
    return loop_states


def _collect_internal_vars(
    states: dict[_StateKey, _StateBlock],
    loop_states: set[_StateKey],
    state_var_key: _VarKey,
) -> set[_VarKey]:
    """
    Identify internal dispatch variables within the loop. These are variables that are only
    assigned constant values (integers, floats, strings, or booleans) or copies of other internal
    variables. They are artifacts of the flattening and should be suppressed in output.
    """
    candidates: set[_VarKey] = {state_var_key}
    changed = True
    while changed:
        changed = False
        for sid in loop_states:
            block = states[sid]
            for stmts in _all_statement_lists(block):
                for stmt in stmts:
                    result = _get_simple_assignment(stmt)
                    if result is None:
                        continue
                    key, value = result
                    if key in candidates:
                        continue
                    if isinstance(value, Expression):
                        value = unwrap_parens(value)
                    if _unwrap_constant(value) is not None:
                        candidates.add(key)
                        changed = True
                    elif isinstance(value, Ps1Variable):
                        if _is_bool_literal(value) is not None:
                            candidates.add(key)
                            changed = True
                        elif _var_key(value) in candidates:
                            candidates.add(key)
                            changed = True
    # A variable referenced by an emitted (non-suppressed) statement is real data, not a dispatch
    # artifact: suppressing its assignment would leave the recovered code reading an undefined
    # variable. Drop such variables from the internal set (the state variable always stays internal).
    used_by_emitted: set[_VarKey] = set()
    for sid in loop_states:
        block = states[sid]
        for stmts in _all_statement_lists(block):
            for stmt in stmts:
                result = _get_simple_assignment(stmt)
                if result is not None and result[0] in candidates:
                    continue
                for node in stmt.walk():
                    if isinstance(node, Ps1Variable):
                        used_by_emitted.add(_var_key(node))
    candidates -= used_by_emitted
    candidates.add(state_var_key)
    return candidates


def _all_statement_lists(block: _StateBlock) -> Generator[list[Statement], None, None]:
    """
    Yield all statement lists within a state block (main statements plus conditional transition
    prefixes).
    """
    yield block.statements
    t = block.transition
    if isinstance(t, _ConditionalTransition):
        yield t.true_prefix
        yield t.false_prefix


def _update_env(
    env: dict[_VarKey, _StateKey | bool],
    key: _VarKey,
    value: Node,
):
    if isinstance(value, Expression):
        value = unwrap_parens(value)
    const_val = _unwrap_constant(value)
    if const_val is not None:
        env[key] = const_val
        return
    if isinstance(value, Ps1Variable):
        bool_val = _is_bool_literal(value)
        if bool_val is not None:
            env[key] = bool_val
            return
        src_key = _var_key(value)
        if src_key in env:
            env[key] = env[src_key]
            return
    env.pop(key, None)


def _simulate_statements(
    stmts: list[Statement],
    env: dict[_VarKey, _StateKey | bool],
    internal_vars: set[_VarKey],
    output: list[Statement],
):
    """
    Process a list of statements during simulation. Updates env for internal assignments, and
    appends non-internal statements to output.
    """
    for stmt in stmts:
        result = _get_simple_assignment(stmt)
        if result is not None:
            key, value = result
            if key in internal_vars:
                _update_env(env, key, value)
                continue
        output.append(stmt)


def _simulate_arm(
    states: dict[_StateKey, _StateBlock],
    start: _StateKey,
    header: _StateKey,
    is_exit: Callable[[_StateKey], bool],
    latches: set[_StateKey],
    env: dict[_VarKey, _StateKey | bool],
    internal_vars: set[_VarKey],
    output: list[Statement],
) -> tuple[list[Statement], _StateKey | None] | None:
    """
    Simulate execution of a single arm (branch) during loop unrolling. Follows the state graph
    linearly, resolving conditions where possible. Returns (statements, next_state) or `None` if a
    condition cannot be resolved.
    """
    current: _StateKey | None = start
    step_count = 0
    while current is not None and step_count < _MAX_STATES * 2:
        step_count += 1
        if is_exit(current):
            return (output, None)
        if current not in states:
            return None
        if current == header:
            return (output, header)
        if current in latches:
            cur_block = states[current]
            _simulate_statements(cur_block.statements, env, internal_vars, output)
            t = cur_block.transition
            if isinstance(t, _LinearTransition) and t.target == header:
                return (output, header)
            return None

        cur_block = states[current]
        _simulate_statements(cur_block.statements, env, internal_vars, output)
        t = cur_block.transition

        if isinstance(t, _ExitTransition):
            return (output, None)
        if isinstance(t, _LinearTransition):
            current = t.target
            continue
        if isinstance(t, _ConditionalTransition):
            branch = _resolve_bool(t.condition, env)
            if branch is True:
                _simulate_statements(t.true_prefix, env, internal_vars, output)
                current = t.true_target
            elif branch is False:
                _simulate_statements(t.false_prefix, env, internal_vars, output)
                current = t.false_target
            else:
                return None
            continue
        return None
    return None


def _seed_env_from_preamble(
    states: dict[_StateKey, _StateBlock],
    entry: _StateKey,
    header: _StateKey,
    is_exit: Callable[[_StateKey], bool],
    internal_vars: set[_VarKey],
) -> dict[_VarKey, _StateKey | bool] | None:
    """
    Walk the linear chain from entry to header, simulating assignments to build the initial
    variable environment. Returns the env or `None` if the path from entry to header is not a
    simple linear chain.
    """
    env: dict[_VarKey, _StateKey | bool] = {}
    discard: list[Statement] = []
    current = entry
    visited: set[_StateKey] = set()
    while current != header:
        if is_exit(current) or current not in states or current in visited:
            return None
        visited.add(current)
        block = states[current]
        _simulate_statements(block.statements, env, internal_vars, discard)
        if isinstance(block.transition, _LinearTransition):
            current = block.transition.target
        else:
            return None
    return env


def _merge_next_state(
    true_next: _StateKey | None,
    false_next: _StateKey | None,
) -> _StateKey | None:
    if true_next == false_next:
        return true_next
    if true_next is None:
        return false_next
    if false_next is None:
        return true_next
    return true_next


def _try_unroll_loop(
    states: dict[_StateKey, _StateBlock],
    entry: _StateKey,
    header: _StateKey,
    is_exit: Callable[[_StateKey], bool],
    back_edges: dict[_StateKey, _StateKey],
    loop_headers: set[_StateKey],
) -> tuple[list[Statement], set[_VarKey]] | None:
    """
    Attempt to unroll a loop by symbolically executing it. Returns

        (unrolled_statements, internal_vars)

    if successful, or `None` if the loop cannot be fully resolved.
    """
    block = states[header]
    if not isinstance(block.transition, _ConditionalTransition):
        return None
    cond_trans = block.transition

    latches: set[_StateKey] = set()
    for latch, target in back_edges.items():
        if target == header:
            latches.add(latch)

    loop_states = _collect_loop_states(states, header, is_exit, latches)

    cond = cond_trans.condition
    loop_var_key: _VarKey | None = None
    cond_unwrapped = unwrap_parens(cond)
    if isinstance(cond_unwrapped, Ps1BinaryExpression):
        left = unwrap_parens(cond_unwrapped.left) if cond_unwrapped.left is not None else None
        right = (
            unwrap_parens(cond_unwrapped.right) if cond_unwrapped.right is not None else None
        )
        if isinstance(left, Ps1Variable):
            loop_var_key = _var_key(left)
        elif isinstance(right, Ps1Variable):
            loop_var_key = _var_key(right)

    if loop_var_key is None:
        return None

    internal_vars = _collect_internal_vars(states, loop_states, loop_var_key)

    direction = _determine_loop_direction(states, cond_trans, header, is_exit, loop_headers)
    if direction is None:
        return None
    body_entry = direction.body_entry
    body_prefix_stmts = direction.prefix

    seed = _seed_env_from_preamble(states, entry, header, is_exit, internal_vars)
    env: dict[_VarKey, _StateKey | bool] = seed if seed is not None else {}
    result: list[Statement] = []

    for iteration in range(_MAX_UNROLL_ITERATIONS):
        cond_result = _resolve_bool(cond, env)
        if cond_result is False:
            return (result, internal_vars)
        if cond_result is None and iteration > 0:
            return None

        iteration_stmts: list[Statement] = []
        _simulate_statements(block.statements, env, internal_vars, iteration_stmts)
        _simulate_statements(body_prefix_stmts, env, internal_vars, iteration_stmts)

        current: _StateKey | None = body_entry
        step_count = 0
        while current is not None and step_count < _MAX_STATES * 2:
            step_count += 1
            if is_exit(current):
                result.extend(iteration_stmts)
                return (result, internal_vars)
            if current not in states:
                return None
            if current == header:
                break
            if current in latches:
                cur_block = states[current]
                _simulate_statements(
                    cur_block.statements, env, internal_vars, iteration_stmts,
                )
                t = cur_block.transition
                if isinstance(t, _LinearTransition) and t.target == header:
                    break
                return None

            cur_block = states[current]
            _simulate_statements(cur_block.statements, env, internal_vars, iteration_stmts)
            t = cur_block.transition

            if isinstance(t, _ExitTransition):
                result.extend(iteration_stmts)
                return (result, internal_vars)

            if isinstance(t, _LinearTransition):
                current = t.target
                continue

            if isinstance(t, _ConditionalTransition):
                branch = _resolve_bool(t.condition, env)
                if branch is True:
                    _simulate_statements(
                        t.true_prefix, env, internal_vars, iteration_stmts,
                    )
                    current = t.true_target
                    continue
                if branch is False:
                    _simulate_statements(
                        t.false_prefix, env, internal_vars, iteration_stmts,
                    )
                    current = t.false_target
                    continue
                true_stmts: list[Statement] = []
                false_stmts: list[Statement] = []
                true_env = dict(env)
                false_env = dict(env)
                _simulate_statements(
                    t.true_prefix, true_env, internal_vars, true_stmts,
                )
                _simulate_statements(
                    t.false_prefix, false_env, internal_vars, false_stmts,
                )
                true_arm_result = _simulate_arm(
                    states,
                    t.true_target,
                    header,
                    is_exit,
                    latches,
                    true_env,
                    internal_vars,
                    true_stmts,
                )
                false_arm_result = _simulate_arm(
                    states,
                    t.false_target,
                    header,
                    is_exit,
                    latches,
                    false_env,
                    internal_vars,
                    false_stmts,
                )
                if true_arm_result is None or false_arm_result is None:
                    return None
                true_arm_body, true_next = true_arm_result
                false_arm_body, false_next = false_arm_result
                iteration_stmts.append(_build_if(t.condition, true_arm_body, false_arm_body))
                for key in set(env.keys()) | set(true_env.keys()) | set(false_env.keys()):
                    tv = true_env.get(key)
                    fv = false_env.get(key)
                    if tv == fv and tv is not None:
                        env[key] = tv
                    else:
                        env.pop(key, None)
                current = _merge_next_state(true_next, false_next)
                continue
            return None

        result.extend(iteration_stmts)

    return None


def _recover_structure(
    states: dict[_StateKey, _StateBlock],
    entry: _StateKey,
    is_exit: Callable[[_StateKey], bool],
) -> list[Statement] | None:
    """
    Walk the state graph from the entry state and emit the recovered AST. Returns `None` if the
    structure cannot be recovered (e.g., irreducible control flow).
    """
    back_edges = _find_back_edges(states, entry, is_exit)
    loop_headers: set[_StateKey] = set(back_edges.values())

    def _emit_arm(
        start: _StateKey,
        stop: _StateKey | None,
        claimed: set[_StateKey],
    ) -> tuple[list[Statement], _StateKey | None] | None:
        """
        Emit statements from start, stopping before the stop state (if given). Returns

            (statements, next_state)

        where next_state is the stop state or `None`.
        """
        result: list[Statement] = []
        current: _StateKey | None = start

        while current is not None:
            if is_exit(current):
                return (result, None)
            if current not in states:
                return None
            if stop is not None and current == stop:
                return (result, current)
            if current in claimed:
                return (result, current)
            if current in loop_headers:
                loop_result = _emit_loop(current, claimed)
                if loop_result is None:
                    return None
                loop_stmts, loop_next, loop_internals = loop_result
                if loop_internals is not None:
                    filtered: list[Statement] = []
                    for s in result:
                        assignment = _get_simple_assignment(s)
                        if assignment is None or assignment[0] not in loop_internals:
                            filtered.append(s)
                    result = filtered
                result.extend(loop_stmts)
                current = loop_next
                continue
            claimed.add(current)
            block = states[current]
            result.extend(block.statements)
            transition = block.transition

            if isinstance(transition, _ExitTransition):
                return (result, None)

            if isinstance(transition, _LinearTransition):
                current = transition.target
                continue

            if isinstance(transition, _ConditionalTransition):
                true_target = transition.true_target
                false_target = transition.false_target
                join = _find_join_point(
                    states, true_target, false_target, is_exit, loop_headers,
                )
                effective_stop = join if join is not None else stop
                true_result = _emit_arm(true_target, effective_stop, claimed)
                false_result = _emit_arm(false_target, effective_stop, claimed)
                if true_result is None or false_result is None:
                    return None
                true_stmts, true_next = true_result
                false_stmts, false_next = false_result
                true_stmts = list(transition.true_prefix) + true_stmts
                false_stmts = list(transition.false_prefix) + false_stmts
                result.append(_build_if(transition.condition, true_stmts, false_stmts))
                current = _merge_next_state(true_next, false_next)
                continue
        return (result, None)

    def _emit_loop(
        header: _StateKey,
        outer_claimed: set[_StateKey],
    ) -> tuple[list[Statement], _StateKey | None, set[_VarKey] | None] | None:
        """
        Emit a while loop rooted at the given header state. First attempts to fully unroll the loop
        via symbolic execution. Falls back to structural recovery if unrolling fails. Returns:

            (statements, next_state, internal_vars_or_None)
        """
        unrolled = _try_unroll_loop(
            states, entry, header, is_exit, back_edges, loop_headers,
        )
        if unrolled is not None:
            unrolled_stmts, unrolled_internals = unrolled
            for sid in _collect_loop_states(
                states,
                header,
                is_exit,
                {latch for latch, target in back_edges.items() if target == header},
            ):
                outer_claimed.add(sid)
            return (unrolled_stmts, None, unrolled_internals)

        block = states[header]
        loop_cond: Expression
        body_start: _StateKey
        exit_target: _StateKey | None = None

        if isinstance(block.transition, _ConditionalTransition):
            cond_trans = block.transition
            direction = _determine_loop_direction(
                states, cond_trans, header, is_exit, loop_headers,
            )
            if direction is not None:
                loop_cond = (
                    _negate_condition(cond_trans.condition) if direction.negated
                    else cond_trans.condition
                )
                body_start_stmts = list(block.statements) + direction.prefix
                body_start = direction.body_entry
                exit_target = direction.exit_target
            else:
                loop_cond = Ps1Variable(name='True', scope=Ps1ScopeModifier.NONE)
                body_start_stmts = list(block.statements)
                body_start = header
                outer_claimed.add(header)
                inner_claimed: set[_StateKey] = set()
                inner_claimed.add(header)
                body_result = _emit_arm(cond_trans.true_target, header, inner_claimed)
                if body_result is None:
                    return None
                body_stmts = body_start_stmts
                if_true, _ = body_result
                if_false_result = _emit_arm(cond_trans.false_target, header, inner_claimed)
                if if_false_result is None:
                    return None
                if_false, _ = if_false_result
                true_body = list(cond_trans.true_prefix) + if_true
                false_body = list(cond_trans.false_prefix) + if_false
                body_stmts.append(_build_if(cond_trans.condition, true_body, false_body))
                while_stmt = Ps1WhileLoop(
                    condition=loop_cond,
                    body=Block(body=body_stmts),
                )
                outer_claimed.update(inner_claimed)
                return ([while_stmt], exit_target, None)
        else:
            loop_cond = Ps1Variable(name='True', scope=Ps1ScopeModifier.NONE)
            body_start_stmts = list(block.statements)
            if isinstance(block.transition, _LinearTransition):
                body_start = block.transition.target
            else:
                return None
            exit_target = None

        outer_claimed.add(header)
        inner_claimed = set()
        inner_claimed.add(header)
        body_result = _emit_arm(body_start, header, inner_claimed)
        if body_result is None:
            return None
        body_stmts, _ = body_result
        all_body = body_start_stmts + body_stmts
        while_stmt = Ps1WhileLoop(
            condition=loop_cond,
            body=Block(body=all_body),
        )
        outer_claimed.update(inner_claimed)
        next_state: _StateKey | None = None
        if exit_target is not None and not is_exit(exit_target):
            next_state = exit_target
        return ([while_stmt], next_state, None)

    claimed: set[_StateKey] = set()
    result = _emit_arm(entry, None, claimed)
    if result is None:
        return None
    stmts, _ = result
    return stmts


class _LoopDirection(NamedTuple):
    body_entry: _StateKey
    exit_target: _StateKey | None
    prefix: list[Statement]
    negated: bool


def _determine_loop_direction(
    states: dict[_StateKey, _StateBlock],
    cond_trans: _ConditionalTransition,
    header: _StateKey,
    is_exit: Callable[[_StateKey], bool],
    loop_headers: set[_StateKey],
) -> _LoopDirection | None:
    """
    Determine which branch of a conditional loop header leads to the loop body
    and which leads to the exit. Returns `None` when direction is ambiguous (both
    branches reach the header, or neither does).
    """
    true_target = cond_trans.true_target
    false_target = cond_trans.false_target
    true_is_exit = is_exit(true_target) or true_target not in states
    false_is_exit = is_exit(false_target) or false_target not in states
    true_reaches = _state_reaches(
        states, true_target, header, is_exit, loop_headers - {header},
    )
    false_reaches = _state_reaches(
        states, false_target, header, is_exit, loop_headers - {header},
    )
    if true_reaches and (not false_reaches or false_is_exit):
        return _LoopDirection(true_target, false_target, list(cond_trans.true_prefix), False)
    if false_reaches and (not true_reaches or true_is_exit):
        return _LoopDirection(false_target, true_target, list(cond_trans.false_prefix), True)
    if false_is_exit:
        return _LoopDirection(true_target, false_target, list(cond_trans.true_prefix), False)
    if true_is_exit:
        return _LoopDirection(false_target, true_target, list(cond_trans.false_prefix), True)
    return None


def _state_reaches(
    states: dict[_StateKey, _StateBlock],
    start: _StateKey,
    target: _StateKey,
    is_exit: Callable[[_StateKey], bool],
    barriers: set[_StateKey],
) -> bool:
    """
    Check if the state graph can reach target from start without crossing barriers.
    """
    visited: set[_StateKey] = set()
    queue: deque[_StateKey] = deque([start])
    while queue:
        s = queue.popleft()
        if s == target:
            return True
        if is_exit(s) or s not in states or s in visited or s in barriers:
            continue
        visited.add(s)
        queue.extend(states[s].transition.successors)
    return False


class Ps1ControlFlowDeflattening(Transformer):
    """
    Recover original control flow from control-flow-flattened scripts.
    """

    def visit(self, node: Node):
        for parent in list(node.walk()):
            if inside_value_producing_context(parent):
                continue
            body = get_body(parent)
            if body is None:
                continue
            self._try_deflatten_body(body, parent)

    def _try_deflatten_body(self, body: list[Statement], parent: Node):
        """
        Dissolve the state machines of one body as one batch, decided against the tree as it stands
        (`refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan`): the models are read
        once, and only for a body that holds a machine, so a body of many machines costs one build.

        A machine is its dispatcher loop and the store that seeds it, and the two stand or fall
        together: a vetoed half would leave the machine partly dissolved. A machine claiming a
        seed another machine of the batch claims is left for the next run, and so is one whose
        state variable a later statement may read, since the recovered statements store nothing
        into it. A body other than the script's own may run again, and there a statement before the
        seed may read the state the machine left on the run before, unless a constant store into the
        variable comes first.
        """
        cache: Ps1ModelCache | None = None
        plan: Ps1RemovalPlan | None = None
        reads: _StateReads | None = None
        i = 0
        while i < len(body):
            stmt = body[i]
            if not isinstance(stmt, Ps1WhileLoop):
                i += 1
                continue
            match = _match_dispatcher(stmt)
            if match is None:
                i += 1
                continue
            if cache is None:
                cache = model_cache(self, parent)
            name = match.state_var_name
            scope = match.state_var_scope
            init = _find_state_init(body, i, name, scope, cache.call_graph)
            if init is None:
                i += 1
                continue
            init_index, entry_state = init
            if reads is None:
                reads = _StateReads(body, cache.call_graph, reads_in_bodies(tree_root(parent)))
            if reads.after(i, name):
                i += 1
                continue
            first = reads.first(name)
            if (
                not isinstance(parent, Ps1Script)
                and first is not None
                and first < init_index
                and _is_state_assignment(body[first], name, scope) is None
            ):
                i += 1
                continue
            is_exit = _make_exit_check(match.condition, name)
            machine = _extract_state_machine(match, is_exit)
            if machine is None:
                i += 1
                continue
            if entry_state not in machine:
                i += 1
                continue
            if not _exit_condition_is_total(match.condition, name, machine):
                i += 1
                continue
            recovered = _recover_structure(machine, entry_state, is_exit)
            if recovered is None:
                i += 1
                continue
            recovered = [s for s in recovered if _is_state_assignment(s, name, scope) is None]
            # Statements that sit between the state initialization and the dispatcher loop are
            # unrelated and stay where they are; only the loop and its `$state = ...` seed move.
            if plan is None:
                plan = Ps1RemovalPlan(
                    parent,
                    handlers=cache.handlers,
                    under_the_world=False,
                    error_state=cache.error_state,
                )
            plan.propose_together([(body[init_index], None), (stmt, recovered)])
            i += 1
        if plan is not None and plan.commit():
            self.mark_changed()

Classes

class Ps1ControlFlowDeflattening

Recover original control flow from control-flow-flattened scripts.

Expand source code Browse git
class Ps1ControlFlowDeflattening(Transformer):
    """
    Recover original control flow from control-flow-flattened scripts.
    """

    def visit(self, node: Node):
        for parent in list(node.walk()):
            if inside_value_producing_context(parent):
                continue
            body = get_body(parent)
            if body is None:
                continue
            self._try_deflatten_body(body, parent)

    def _try_deflatten_body(self, body: list[Statement], parent: Node):
        """
        Dissolve the state machines of one body as one batch, decided against the tree as it stands
        (`refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan`): the models are read
        once, and only for a body that holds a machine, so a body of many machines costs one build.

        A machine is its dispatcher loop and the store that seeds it, and the two stand or fall
        together: a vetoed half would leave the machine partly dissolved. A machine claiming a
        seed another machine of the batch claims is left for the next run, and so is one whose
        state variable a later statement may read, since the recovered statements store nothing
        into it. A body other than the script's own may run again, and there a statement before the
        seed may read the state the machine left on the run before, unless a constant store into the
        variable comes first.
        """
        cache: Ps1ModelCache | None = None
        plan: Ps1RemovalPlan | None = None
        reads: _StateReads | None = None
        i = 0
        while i < len(body):
            stmt = body[i]
            if not isinstance(stmt, Ps1WhileLoop):
                i += 1
                continue
            match = _match_dispatcher(stmt)
            if match is None:
                i += 1
                continue
            if cache is None:
                cache = model_cache(self, parent)
            name = match.state_var_name
            scope = match.state_var_scope
            init = _find_state_init(body, i, name, scope, cache.call_graph)
            if init is None:
                i += 1
                continue
            init_index, entry_state = init
            if reads is None:
                reads = _StateReads(body, cache.call_graph, reads_in_bodies(tree_root(parent)))
            if reads.after(i, name):
                i += 1
                continue
            first = reads.first(name)
            if (
                not isinstance(parent, Ps1Script)
                and first is not None
                and first < init_index
                and _is_state_assignment(body[first], name, scope) is None
            ):
                i += 1
                continue
            is_exit = _make_exit_check(match.condition, name)
            machine = _extract_state_machine(match, is_exit)
            if machine is None:
                i += 1
                continue
            if entry_state not in machine:
                i += 1
                continue
            if not _exit_condition_is_total(match.condition, name, machine):
                i += 1
                continue
            recovered = _recover_structure(machine, entry_state, is_exit)
            if recovered is None:
                i += 1
                continue
            recovered = [s for s in recovered if _is_state_assignment(s, name, scope) is None]
            # Statements that sit between the state initialization and the dispatcher loop are
            # unrelated and stay where they are; only the loop and its `$state = ...` seed move.
            if plan is None:
                plan = Ps1RemovalPlan(
                    parent,
                    handlers=cache.handlers,
                    under_the_world=False,
                    error_state=cache.error_state,
                )
            plan.propose_together([(body[init_index], None), (stmt, recovered)])
            i += 1
        if plan is not None and plan.commit():
            self.mark_changed()

Ancestors

Methods

def visit(self, node)
Expand source code Browse git
def visit(self, node: Node):
    for parent in list(node.walk()):
        if inside_value_producing_context(parent):
            continue
        body = get_body(parent)
        if body is None:
            continue
        self._try_deflatten_body(body, parent)

Inherited members