Module refinery.lib.scripts.ps1.deobfuscation.typecast
PowerShell type cast simplification transforms.
Expand source code Browse git
"""
PowerShell type cast simplification transforms.
"""
from __future__ import annotations
import string
from refinery.lib.scripts import Node, Transformer, canonical
from refinery.lib.scripts.ps1.analysis.cache import model_cache
from refinery.lib.scripts.ps1.analysis.dataflow import Ps1VariableFlow
from refinery.lib.scripts.ps1.analysis.separator import coerced_text_at
from refinery.lib.scripts.ps1.analysis.values import (
Ps1Outcome,
collect_integers,
convert,
make_string_literal,
read,
render,
text_of,
)
from refinery.lib.scripts.ps1.data import named_type, resolve_type
from refinery.lib.scripts.ps1.deobfuscation.helpers import unwrap_single_paren
from refinery.lib.scripts.ps1.model import (
Expression,
Ps1BinaryExpression,
Ps1CastExpression,
Ps1Script,
Ps1TypeExpression,
)
#: The three targets this pass treats as something other than a value conversion: one that names a
#: type rather than producing a value of one, and two whose answer the conversion grid does not
#: carry, because it was captured over scalar targets only. See the arm each belongs to.
_TYPE = named_type('System.Type')
_STRING = named_type('System.String')
_CHAR_ARRAY = named_type('char[]')
class Ps1TypeCasts(Transformer):
"""
A cast written back as the value it produces, asked of
`refinery.lib.scripts.ps1.analysis.values` rather than decided here.
What stood here was a table of integer ranges and a dispatch on the accelerator's spelling, and
it lost a type on every fold whose target the language spells no literal for: `[byte] 5` became
`5`, which re-reads as an Int32. It read a String operand with Python's own `int(text, 0)`,
which knows a digit separator and the `0b` and `0o` prefixes that 5.1 has never had, so
`[int] '1_0'` answered ten for a script that stops. And it folded a `[char]` to a one-character
String, which is a different value from a Char and not merely a different type.
`-as` is **not** rewritten into a cast. The two are different expressions, measured:
`'abc' -as [int]` is `$null` where `[int] 'abc'` throws, and `300 -as [byte]` is `$null` where
`[byte] 300` throws. `Ps1Outcome` is what tells them apart, and a conversion that may throw
folds neither of them — so an `-as` this cannot answer is left standing rather than turned into
the cast that stops the script.
One arm is still read syntactically and is a ledgered defect rather than a fold that could be
asked of the domain: `[char[]]` of a list of numbers. The conversion grid was captured over
scalar targets only, so there is no cell to read for an array target, and it retires with the
capture that adds the column. `[string]` of a collection stood beside it until the separator
became a question that could be asked — see `_joined_collection`.
Every question here is asked of one step. The operand has already been visited, so `read` names
whatever it came to and `convert` answers the cast over that; `evaluate` would walk the operand
again at every node, which is quadratic over a tree a visitor is already descending.
"""
def __init__(self):
super().__init__()
self._flow: Ps1VariableFlow | None = None
self._entry = False
def visit(self, node: Node):
"""
Captured once rather than per cast: every fold below marks the pass changed, which drops the
cache, so a per-site lookup would rebuild the control-flow graphs of the whole script once
per folded cast. This pass replaces an expression with the value it produces and neither
adds nor removes a statement, so the graphs it would rebuild are the graphs it already has,
and the writes it would find are the same writes.
Dropped again when the walk it was captured for ends, for the reason
`refinery.lib.scripts.ps1.deobfuscation.typenames.VariableTypeAwareTransformer` states: a
second walk over a tree the first one rewrote enters on the guarded arm and would otherwise
be answered from the first walk's graphs.
"""
if self._entry or not isinstance(node, Ps1Script):
return super().visit(node)
self._entry = True
try:
self._flow = model_cache(self, node).variable_flow
return super().visit(node)
finally:
self._entry = False
self._flow = None
def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
self.generic_visit(node)
if node.operator.lower() != '-as' or node.left is None:
return None
if not isinstance(node.right, Ps1TypeExpression):
return None
target = resolve_type(node.right.name)
if target is None:
return None
return _spelled(node, convert(read(node.left), target))
def visit_Ps1CastExpression(self, node: Ps1CastExpression):
self.generic_visit(node)
target = resolve_type(node.type_name)
if target is None:
return None
return (
self._named_type(node, target)
or _spelled(node, convert(read(node.operand), target))
or self._joined_collection(node, target)
or self._characters(node, target)
)
@staticmethod
def _named_type(node: Ps1CastExpression, target) -> Expression | None:
"""
`[type] 'X'`, the one arm here that names a type rather than producing a value of one. What
the expression evaluates to is a `System.RuntimeType`, which the domain deliberately carries
no element for, so what this reads out of the operand is a name and what it writes is the
type literal naming the same thing.
"""
if target != _TYPE:
return None
named = text_of(read(node.operand))
return None if named is None else Ps1TypeExpression(offset=node.offset, name=named)
def _joined_collection(self, node: Ps1CastExpression, target) -> Expression | None:
"""
`[string]` of a collection, whose elements 5.1 separates with `$OFS`. The conversion grid
was captured over scalar targets only, so the domain has no cell to read for this, and the
separator is not a property of the value in any case:
`refinery.lib.scripts.ps1.analysis.separator` is what answers it, at the point the cast
stands, and refuses wherever a run could have written the name something else.
"""
if target != _STRING or node.operand is None or self._flow is None:
return None
text = coerced_text_at(unwrap_single_paren(node.operand), node, self._flow)
return None if text is None else make_string_literal(text)
@staticmethod
def _characters(node: Ps1CastExpression, target) -> Expression | None:
"""
`[char[]]` of a list of numbers, the second ledgered defect standing here: 5.1 builds a
`Char[]` and this writes a String, so the container type and the element type are both lost.
The conversion grid was captured over scalar targets only, so the domain has no cell to read
for an array one and cannot answer it at all; the capture that adds the column retires this
with `collect_integers`.
"""
if target != _CHAR_ARRAY or node.operand is None:
return None
numbers = collect_integers(unwrap_single_paren(node.operand))
if numbers is None:
return None
try:
text = bytes(numbers).decode('ascii')
except (ValueError, UnicodeDecodeError, OverflowError):
return None
if not all(c in string.printable or c.isspace() for c in text):
return None
return make_string_literal(text)
def _spelled(node: Expression, outcome: Ps1Outcome) -> Expression | None:
"""
The expression an outcome is written as, or `None` where nothing is written.
An outcome that may throw is never folded, because the script's throw is part of what it does
and replacing it with the value the operation would have had deletes that. A fact that names no
value has no spelling. And a value the node *already* spells is left alone: replacing a tree
with an equal one is a rewrite that never converges, and `refinery.lib.scripts.canonical` is the
model's own answer to whether two trees spell the same program.
"""
if outcome.may_throw:
return None
spelled = render(outcome.value)
if spelled is None or canonical(spelled) == canonical(node):
return None
return spelled
Classes
class Ps1TypeCasts-
A cast written back as the value it produces, asked of
refinery.lib.scripts.ps1.analysis.valuesrather than decided here.What stood here was a table of integer ranges and a dispatch on the accelerator's spelling, and it lost a type on every fold whose target the language spells no literal for:
[byte] 5became5, which re-reads as an Int32. It read a String operand with Python's ownint(text, 0), which knows a digit separator and the0band0oprefixes that 5.1 has never had, so[int] '1_0'answered ten for a script that stops. And it folded a[char]to a one-character String, which is a different value from a Char and not merely a different type.-asis not rewritten into a cast. The two are different expressions, measured:'abc' -as [int]is$nullwhere[int] 'abc'throws, and300 -as [byte]is$nullwhere[byte] 300throws.Ps1Outcomeis what tells them apart, and a conversion that may throw folds neither of them — so an-asthis cannot answer is left standing rather than turned into the cast that stops the script.One arm is still read syntactically and is a ledgered defect rather than a fold that could be asked of the domain:
[char[]]of a list of numbers. The conversion grid was captured over scalar targets only, so there is no cell to read for an array target, and it retires with the capture that adds the column.[string]of a collection stood beside it until the separator became a question that could be asked — see_joined_collection.Every question here is asked of one step. The operand has already been visited, so
readnames whatever it came to andconvertanswers the cast over that;evaluatewould walk the operand again at every node, which is quadratic over a tree a visitor is already descending.Expand source code Browse git
class Ps1TypeCasts(Transformer): """ A cast written back as the value it produces, asked of `refinery.lib.scripts.ps1.analysis.values` rather than decided here. What stood here was a table of integer ranges and a dispatch on the accelerator's spelling, and it lost a type on every fold whose target the language spells no literal for: `[byte] 5` became `5`, which re-reads as an Int32. It read a String operand with Python's own `int(text, 0)`, which knows a digit separator and the `0b` and `0o` prefixes that 5.1 has never had, so `[int] '1_0'` answered ten for a script that stops. And it folded a `[char]` to a one-character String, which is a different value from a Char and not merely a different type. `-as` is **not** rewritten into a cast. The two are different expressions, measured: `'abc' -as [int]` is `$null` where `[int] 'abc'` throws, and `300 -as [byte]` is `$null` where `[byte] 300` throws. `Ps1Outcome` is what tells them apart, and a conversion that may throw folds neither of them — so an `-as` this cannot answer is left standing rather than turned into the cast that stops the script. One arm is still read syntactically and is a ledgered defect rather than a fold that could be asked of the domain: `[char[]]` of a list of numbers. The conversion grid was captured over scalar targets only, so there is no cell to read for an array target, and it retires with the capture that adds the column. `[string]` of a collection stood beside it until the separator became a question that could be asked — see `_joined_collection`. Every question here is asked of one step. The operand has already been visited, so `read` names whatever it came to and `convert` answers the cast over that; `evaluate` would walk the operand again at every node, which is quadratic over a tree a visitor is already descending. """ def __init__(self): super().__init__() self._flow: Ps1VariableFlow | None = None self._entry = False def visit(self, node: Node): """ Captured once rather than per cast: every fold below marks the pass changed, which drops the cache, so a per-site lookup would rebuild the control-flow graphs of the whole script once per folded cast. This pass replaces an expression with the value it produces and neither adds nor removes a statement, so the graphs it would rebuild are the graphs it already has, and the writes it would find are the same writes. Dropped again when the walk it was captured for ends, for the reason `refinery.lib.scripts.ps1.deobfuscation.typenames.VariableTypeAwareTransformer` states: a second walk over a tree the first one rewrote enters on the guarded arm and would otherwise be answered from the first walk's graphs. """ if self._entry or not isinstance(node, Ps1Script): return super().visit(node) self._entry = True try: self._flow = model_cache(self, node).variable_flow return super().visit(node) finally: self._entry = False self._flow = None def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression): self.generic_visit(node) if node.operator.lower() != '-as' or node.left is None: return None if not isinstance(node.right, Ps1TypeExpression): return None target = resolve_type(node.right.name) if target is None: return None return _spelled(node, convert(read(node.left), target)) def visit_Ps1CastExpression(self, node: Ps1CastExpression): self.generic_visit(node) target = resolve_type(node.type_name) if target is None: return None return ( self._named_type(node, target) or _spelled(node, convert(read(node.operand), target)) or self._joined_collection(node, target) or self._characters(node, target) ) @staticmethod def _named_type(node: Ps1CastExpression, target) -> Expression | None: """ `[type] 'X'`, the one arm here that names a type rather than producing a value of one. What the expression evaluates to is a `System.RuntimeType`, which the domain deliberately carries no element for, so what this reads out of the operand is a name and what it writes is the type literal naming the same thing. """ if target != _TYPE: return None named = text_of(read(node.operand)) return None if named is None else Ps1TypeExpression(offset=node.offset, name=named) def _joined_collection(self, node: Ps1CastExpression, target) -> Expression | None: """ `[string]` of a collection, whose elements 5.1 separates with `$OFS`. The conversion grid was captured over scalar targets only, so the domain has no cell to read for this, and the separator is not a property of the value in any case: `refinery.lib.scripts.ps1.analysis.separator` is what answers it, at the point the cast stands, and refuses wherever a run could have written the name something else. """ if target != _STRING or node.operand is None or self._flow is None: return None text = coerced_text_at(unwrap_single_paren(node.operand), node, self._flow) return None if text is None else make_string_literal(text) @staticmethod def _characters(node: Ps1CastExpression, target) -> Expression | None: """ `[char[]]` of a list of numbers, the second ledgered defect standing here: 5.1 builds a `Char[]` and this writes a String, so the container type and the element type are both lost. The conversion grid was captured over scalar targets only, so the domain has no cell to read for an array one and cannot answer it at all; the capture that adds the column retires this with `collect_integers`. """ if target != _CHAR_ARRAY or node.operand is None: return None numbers = collect_integers(unwrap_single_paren(node.operand)) if numbers is None: return None try: text = bytes(numbers).decode('ascii') except (ValueError, UnicodeDecodeError, OverflowError): return None if not all(c in string.printable or c.isspace() for c in text): return None return make_string_literal(text)Ancestors
Methods
def visit(self, node)-
Captured once rather than per cast: every fold below marks the pass changed, which drops the cache, so a per-site lookup would rebuild the control-flow graphs of the whole script once per folded cast. This pass replaces an expression with the value it produces and neither adds nor removes a statement, so the graphs it would rebuild are the graphs it already has, and the writes it would find are the same writes.
Dropped again when the walk it was captured for ends, for the reason
VariableTypeAwareTransformerstates: a second walk over a tree the first one rewrote enters on the guarded arm and would otherwise be answered from the first walk's graphs.Expand source code Browse git
def visit(self, node: Node): """ Captured once rather than per cast: every fold below marks the pass changed, which drops the cache, so a per-site lookup would rebuild the control-flow graphs of the whole script once per folded cast. This pass replaces an expression with the value it produces and neither adds nor removes a statement, so the graphs it would rebuild are the graphs it already has, and the writes it would find are the same writes. Dropped again when the walk it was captured for ends, for the reason `refinery.lib.scripts.ps1.deobfuscation.typenames.VariableTypeAwareTransformer` states: a second walk over a tree the first one rewrote enters on the guarded arm and would otherwise be answered from the first walk's graphs. """ if self._entry or not isinstance(node, Ps1Script): return super().visit(node) self._entry = True try: self._flow = model_cache(self, node).variable_flow return super().visit(node) finally: self._entry = False self._flow = None def visit_Ps1BinaryExpression(self, node)-
Expand source code Browse git
def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression): self.generic_visit(node) if node.operator.lower() != '-as' or node.left is None: return None if not isinstance(node.right, Ps1TypeExpression): return None target = resolve_type(node.right.name) if target is None: return None return _spelled(node, convert(read(node.left), target)) def visit_Ps1CastExpression(self, node)-
Expand source code Browse git
def visit_Ps1CastExpression(self, node: Ps1CastExpression): self.generic_visit(node) target = resolve_type(node.type_name) if target is None: return None return ( self._named_type(node, target) or _spelled(node, convert(read(node.operand), target)) or self._joined_collection(node, target) or self._characters(node, target) )
Inherited members