Module refinery.lib.scripts.js.deobfuscation.b91strings
Decode the strings an obfuscator conceals behind base91 decoders. Every string literal becomes a call of an accessor, which decodes an element of a shared table of encoded strings once and keeps the result in a cache:
var table = ["fOg=r", ...];
var cache = {};
function decode(str) { var alphabet = "..."; ...; return bufferToString(bytes); }
function accessor(index) {
if (typeof cache[index] === "undefined") { return cache[index] = decode(table[index]); }
return cache[index];
}
Every scope gets a decoder with an alphabet of its own. A decoder and an accessor are recognized by
their templates (refinery.lib.scripts.js.deobfuscation.templates), whatever their names are and
wherever they stand, through the names that hold them. A call of an accessor whose decoder, table
and cache are in place before it runs becomes the string it decodes, and the machinery goes once
nothing names it any longer.
Expand source code Browse git
"""
Decode the strings an obfuscator conceals behind base91 decoders. Every string literal becomes a
call of an accessor, which decodes an element of a shared table of encoded strings once and keeps
the result in a cache:
var table = ["fOg=r", ...];
var cache = {};
function decode(str) { var alphabet = "..."; ...; return bufferToString(bytes); }
function accessor(index) {
if (typeof cache[index] === "undefined") { return cache[index] = decode(table[index]); }
return cache[index];
}
Every scope gets a decoder with an alphabet of its own. A decoder and an accessor are recognized by
their templates (`refinery.lib.scripts.js.deobfuscation.templates`), whatever their names are and
wherever they stand, through the names that hold them. A call of an accessor whose decoder, table
and cache are in place before it runs becomes the string it decodes, and the machinery goes once
nothing names it any longer.
"""
from __future__ import annotations
from typing import Iterator, NamedTuple
from refinery.lib.scripts import Node, _remove_from_parent, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.dominance import DominanceModel
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
Binding,
SemanticModel,
enclosing_operator,
statement_list_of,
)
from refinery.lib.scripts.js.deobfuscation.helpers import (
ScriptLevelTransformer,
denoted_value,
make_string_literal,
nothing_still_names,
reaches_script_completion,
remove_declarator,
walk_scope,
)
from refinery.lib.scripts.js.deobfuscation.templates import Template, matches
from refinery.lib.scripts.js.model import (
FUNCTION_NODES,
JsAssignmentExpression,
JsCallExpression,
JsExpressionStatement,
JsFunctionDeclaration,
JsFunctionNode,
JsIdentifier,
JsMemberExpression,
JsNumericLiteral,
JsObjectExpression,
JsReturnStatement,
JsScript,
JsSequenceExpression,
JsStringLiteral,
JsVariableDeclarator,
strip_parens,
)
from refinery.lib.scripts.js.numbers import exact_integer
from refinery.lib.scripts.js.options import preserves_script_return
from refinery.lib.scripts.js.utf16 import to_code_units
def _decode_base91(encoded: str, alphabet: str) -> str:
"""
Decode a base91-encoded string using the given 91-character alphabet. The encoding pairs
characters from the alphabet into 13-or-14-bit values that are accumulated into a byte stream,
which is then interpreted as UTF-8. The result is the string a program holds, one UTF-16 code
unit to a character, so a character above the basic plane is its surrogate pair.
"""
result: list[int] = []
b = 0
n = 0
v = -1
for ch in encoded:
p = alphabet.find(ch)
if p == -1:
continue
if v < 0:
v = p
else:
v += p * 91
b |= v << n
n += 13 if (v & 8191) > 88 else 14
while n > 7:
result.append(b & 0xFF)
b >>= 8
n -= 8
v = -1
if v > -1:
result.append((b | v << n) & 0xFF)
return to_code_units(bytes(result).decode('utf-8'))
def _is_base91_alphabet(s: str) -> bool:
return len(s) == 91 and len(set(s)) == 91
_DECODER_BODY = (
'var raw = "" + (str || ""); var len = raw.length; var ret = []; var b = 0; var n = 0; var v = -1;'
' for (var i = 0; i < len; i++) { var p = ALPHABET.indexOf(raw[i]); if (p === -1) continue;'
' if (v < 0) { v = p; } else { v += p * 91; b |= v << n; n += (v & 8191) > 88 ? 13 : 14;'
' do { ret.push(b & 0xff); b >>= 8; n -= 8; } while (n > 7); v = -1; } }'
' if (v > -1) { ret.push((b | (v << n)) & 0xff); } return BUFFER(ret);'
)
_DECODER_BODY_READING_A_VARIABLE = _DECODER_BODY.replace('ALPHABET.', 'table.')
_DECODER_TEMPLATES = (
Template(
F'function DECODE(str) {{ {_DECODER_BODY} }}',
frozenset({'ALPHABET', 'BUFFER'}),
),
Template(
F'function DECODE(str) {{ var table = ALPHABET; {_DECODER_BODY_READING_A_VARIABLE} }}',
frozenset({'ALPHABET', 'BUFFER'}),
),
)
"""
The decoder as the obfuscator writes it, with its alphabet inlined and in a variable of its own.
`ALPHABET` stands for the literal holding the alphabet and `BUFFER` for the callee of the call that
turns the decoded bytes into a string.
"""
_ACCESSOR_TEMPLATES = (
Template(
'function ACCESSOR(index) { if (typeof CACHE[index] === "undefined") {'
' return CACHE[index] = DECODE(TABLE[index]); } return CACHE[index]; }',
frozenset({'CACHE', 'DECODE', 'TABLE'}),
),
Template(
'function ACCESSOR(index) { if (typeof CACHE[index] === "undefined") {'
' return CACHE[index] = DECODE(TABLE[index]); } else { return CACHE[index]; } }',
frozenset({'CACHE', 'DECODE', 'TABLE'}),
),
)
"""
The accessor as the obfuscator writes it, its last `return` after the `if` or in an `else`.
`CACHE`, `DECODE` and `TABLE` stand for the names holding the cache, the decoder and the table.
"""
class _Held(NamedTuple):
"""
A function a name holds as its one value, and the node installing it there, which goes when the
function goes: the declaration, the declarator, or the statement assigning it.
"""
binding: Binding
function: JsFunctionNode
definition: Node | None
class _Decoder(NamedTuple):
held: _Held
alphabet: str
class _Parts(NamedTuple):
"""
The names standing where the accessor template has its decoder, its table and its cache.
"""
decoder: Binding
table: Binding
cache: Binding
class _Accessor(NamedTuple):
"""
An accessor, the decoder it calls, the table it reads, the strings that table holds, and the
cache it keeps the decoded strings in.
"""
held: _Held
decoder: _Decoder
table: Binding
strings: list[str]
cache: Binding
def _definition(function: JsFunctionNode) -> Node | None:
if isinstance(function, JsFunctionDeclaration):
return function
parent = enclosing_operator(function)
if isinstance(parent, JsVariableDeclarator):
return parent
if isinstance(parent, JsAssignmentExpression) and isinstance(parent.parent, JsExpressionStatement):
return parent.parent
return None
def _held_functions(model: SemanticModel, root: Node) -> Iterator[_Held]:
"""
Every function that the one name invoking it holds as its only value.
"""
for node in root.walk():
if not isinstance(node, FUNCTION_NODES):
continue
binding = model.invocation_binding(node)
if binding is None or len(binding.declarations) != 1:
continue
if strip_parens(model.singular_value(binding)) is not node:
continue
yield _Held(binding, node, _definition(node))
def _declared_outside(model: SemanticModel, function: Node, callee: Node | None) -> bool:
"""
Whether *callee* names something *function* does not declare: a name or fixed keys read off
one, possibly behind numbers a comma operator discards, as in `(0, Buffer.from)`.
"""
callee = strip_parens(callee)
if isinstance(callee, JsSequenceExpression):
*discarded, callee = callee.expressions
if not all(isinstance(strip_parens(part), JsNumericLiteral) for part in discarded):
return False
callee = strip_parens(callee)
while isinstance(callee, JsMemberExpression) and not callee.computed:
callee = strip_parens(callee.object)
if not isinstance(callee, JsIdentifier):
return False
binding = model.resolve(callee)
if binding is None:
return True
scope = binding.scope.node
return scope is not function and not scope.is_descendant_of(function)
def _decoder(model: SemanticModel, held: _Held) -> _Decoder | None:
"""
The decoder *held* is, when it is the decoder template: its alphabet is the one string literal
of its own that is a base91 alphabet, and the call it returns through calls a function it does
not declare, which turns the decoded bytes into a string.
"""
function = held.function
if len(function.params) != 1:
return None
alphabets = [
node for node in walk_scope(function, include_root_body=True)
if isinstance(node, JsStringLiteral) and node.value is not None and _is_base91_alphabet(node.value)
]
values = {node.value for node in alphabets if node.value is not None}
if len(values) != 1:
return None
statements = statement_list_of(function)
if not statements or not isinstance(last := statements[-1], JsReturnStatement):
return None
call = strip_parens(last.argument)
if not isinstance(call, JsCallExpression) or len(call.arguments) != 1:
return None
if not _declared_outside(model, function, call.callee):
return None
nodes = {id(node): 'ALPHABET' for node in alphabets}
nodes[id(call.callee)] = 'BUFFER'
if not matches(model, function, _DECODER_TEMPLATES, {}, nodes):
return None
return _Decoder(held, values.pop())
def _parts(model: SemanticModel, function: JsFunctionNode) -> _Parts | None:
"""
The names *function* reads an element of at its one parameter, the one of them it hands to a
call taking one argument, and that call's callee: the cache, the table and the decoder, if
*function* is an accessor.
"""
if len(function.params) != 1 or not isinstance(index := function.params[0], JsIdentifier):
return None
parameter = model.binding_of(index)
owners: dict[int, Binding] = {}
decoder = table = None
for node in walk_scope(function, include_root_body=True):
if not isinstance(node, JsMemberExpression) or not node.computed:
continue
owner = strip_parens(node.object)
key = strip_parens(node.property)
if not isinstance(owner, JsIdentifier) or not isinstance(key, JsIdentifier):
continue
if model.resolve(key) is not parameter or (binding := model.resolve(owner)) is None:
continue
owners[id(binding)] = binding
call = enclosing_operator(node)
if not isinstance(call, JsCallExpression) or len(call.arguments) != 1:
continue
callee = strip_parens(call.callee)
if strip_parens(call.arguments[0]) is node and isinstance(callee, JsIdentifier):
decoder = model.resolve(callee)
table = binding
if decoder is None or table is None or len(owners) != 2:
return None
cache = next(binding for binding in owners.values() if binding is not table)
if decoder is cache or decoder is table:
return None
return _Parts(decoder, table, cache)
def _is_accessor(model: SemanticModel, function: JsFunctionNode, parts: _Parts) -> bool:
placeholders = {
id(parts.cache): 'CACHE',
id(parts.decoder): 'DECODE',
id(parts.table): 'TABLE',
}
return matches(model, function, _ACCESSOR_TEMPLATES, placeholders)
def _table_strings(effects: EffectModel, table: Binding) -> list[str] | None:
"""
The strings *table* holds wherever it is in place: the one array of at least ten strings the
text gives it, in a container nothing changes or hands out
(`refinery.lib.scripts.js.analysis.effects.EffectModel.binding_is_immutable_container`).
The table is machinery under a name the obfuscator makes up, like the decoder, the accessor and
the cache. A constant of the program is also asked whether a host or code running in the global
scope may reach it (`refinery.lib.scripts.js.deobfuscation.helpers.binding_constant`), while
the table is asked only about code in its own scope the model cannot read: a direct `eval`
there, or a span the parser could not read, which is what
`refinery.lib.scripts.js.analysis.model.SemanticModel.reflection_can_reach` answers for a name
scoped to the file. A script the obfuscator writes stores on the global object under keys this
very table holds, and the wider question would refuse the table of every script.
"""
model = effects.model
known, value = denoted_value(model.singular_value(table), model)
if not known or not isinstance(value, list) or len(value) < 10:
return None
strings = [string for string in value if isinstance(string, str)]
if len(strings) != len(value) or not effects.binding_is_immutable_container(table):
return None
if model.reflection_can_reach(table, module_scope=True):
return None
return strings
def _accessor(
effects: EffectModel,
held: _Held,
decoders: dict[int, _Decoder],
tables: dict[int, list[str] | None],
) -> _Accessor | None:
"""
The accessor *held* is, when it is the accessor template over a decoder of *decoders*, a table
holding at least ten strings, and a cache holding an empty object. The strings of each table
are kept in *tables* by the identity of its binding, since every accessor reads the one table.
"""
model = effects.model
function = held.function
statements = statement_list_of(function)
if not statements or len(statements) > 2:
return None
parts = _parts(model, function)
if parts is None or (decoder := decoders.get(id(parts.decoder))) is None:
return None
if not _is_accessor(model, function, parts):
return None
if (key := id(parts.table)) not in tables:
tables[key] = _table_strings(effects, parts.table)
if (strings := tables[key]) is None:
return None
cache = strip_parens(model.singular_value(parts.cache))
if not isinstance(cache, JsObjectExpression) or cache.properties:
return None
return _Accessor(held, decoder, parts.table, strings, parts.cache)
def _cache_confined(model: SemanticModel, cache: Binding) -> bool:
"""
Whether nothing but the accessor template reads or writes *cache*: every reference, a `with`
body's included, lies in its declarator, in a write establishing its value, or in a function
that is the accessor template over it. Such a function need not be held by a name: the
moved-declaration setting hands an accessor to a function through a parameter.
"""
declarator = cache.declarations[0].parent if len(cache.declarations) == 1 else None
sites = model.binding_establishment_sites(cache) or []
accessors: dict[int, bool] = {}
for reference in [*model.references(cache), *model.dynamic_references(cache)]:
if declarator is not None and reference.is_descendant_of(declarator):
continue
if any(reference is site or reference.is_descendant_of(site) for site in sites):
continue
function = reference.parent
while function is not None and not isinstance(function, FUNCTION_NODES):
function = function.parent
if function is None:
return False
if (known := accessors.get(id(function))) is None:
parts = _parts(model, function)
known = parts is not None and parts.cache is cache and _is_accessor(model, function, parts)
accessors[id(function)] = known
if not known:
return False
return True
def _decoded_calls(dominance: DominanceModel, accessor: _Accessor) -> Iterator[tuple[JsCallExpression, str]]:
"""
Every call of *accessor* that passes a number literal indexing its table, after the accessor,
the decoder, the table and the cache are in place, with the string the call returns. A string
that does not decode or starts with a byte order mark is left a call: the function turning the
bytes into a string strips a byte order mark where the host has a `TextDecoder` and keeps it
elsewhere.
"""
bindings = (accessor.held.binding, accessor.decoder.held.binding, accessor.table, accessor.cache)
for read in accessor.held.binding.reads:
if not isinstance(read, JsIdentifier):
continue
call = enclosing_operator(read)
if not isinstance(call, JsCallExpression) or strip_parens(call.callee) is not read or len(call.arguments) != 1:
continue
argument = call.arguments[0]
if not isinstance(argument, JsNumericLiteral):
continue
index = exact_integer(argument.value)
if index is None or not 0 <= index < len(accessor.strings):
continue
if not all(dominance.binding_established_before(binding, call) for binding in bindings):
continue
try:
decoded = _decode_base91(accessor.strings[index], accessor.decoder.alphabet)
except (UnicodeDecodeError, ValueError):
continue
if decoded.startswith('\N{BYTE ORDER MARK}'):
continue
yield call, decoded
def _stands_in_a_list(definition: Node) -> bool:
"""
Whether *definition* can be taken out of the tree without leaving a hole: a statement standing
in a statement list, or a declarator of a declaration that does. One written under an `export`
or a label stands in a slot of its own, which nothing may be left empty in.
"""
statement = definition.parent if isinstance(definition, JsVariableDeclarator) else definition
holder = None if statement is None else statement.parent
return holder is not None and any(item is statement for item in statement_list_of(holder) or ())
def _remove_machinery(
model: SemanticModel,
accessors: list[_Accessor],
replaced: list[Node],
root: JsScript,
keeps_the_end_value: bool,
) -> None:
"""
Remove the accessors, then the decoders, then the tables and caches whose every reference, a
`with` body's included, lies in a replaced call or in a definition removed with them, when
`refinery.lib.scripts.js.deobfuscation.helpers.nothing_still_names` agrees for all of them. A
name an importer reads stays, and so does a definition that does not stand in a statement list
(`_stands_in_a_list`) or, where *keeps_the_end_value* asks for the value the script ends on, a
statement assigning one that may set that value. Each decoder, table and cache is asked about
once, however many accessors share it.
"""
inside: set[int] = {id(node) for call in replaced for node in call.walk()}
removed: dict[int, Node] = {}
def remove(definition: Node | None, binding: Binding) -> None:
if definition is None or id(definition) in removed:
return
if binding.exported or not _stands_in_a_list(definition):
return
if (
keeps_the_end_value
and isinstance(definition, JsExpressionStatement)
and reaches_script_completion(definition, root)
):
return
own = {id(node) for node in definition.walk()}
for reference in [*model.references(binding), *model.dynamic_references(binding)]:
if id(reference) not in inside and id(reference) not in own:
return
removed[id(definition)] = definition
inside.update(own)
for accessor in accessors:
remove(accessor.held.definition, accessor.held.binding)
decoders = {id(accessor.decoder.held.binding): accessor.decoder.held for accessor in accessors}
for held in decoders.values():
remove(held.definition, held.binding)
stores = {id(store): store for accessor in accessors for store in (accessor.table, accessor.cache)}
for store in stores.values():
declarator = store.declarations[0].parent if len(store.declarations) == 1 else None
if isinstance(declarator, JsVariableDeclarator):
remove(declarator, store)
if not removed or not nothing_still_names(model, [*removed.values(), *replaced]):
return
for node in removed.values():
if isinstance(node, JsVariableDeclarator):
remove_declarator(node)
else:
_remove_from_parent(node)
class JsBase91StringDecoder(ScriptLevelTransformer):
"""
Replace every call of a base91 string accessor the module describes by the string it returns,
and remove the machinery nothing names any longer.
"""
def _process_script(self, node: JsScript):
cache = model_cache(self, node)
model = cache.model
held = list(_held_functions(model, node))
decoders: dict[int, _Decoder] = {}
for item in held:
if (decoder := _decoder(model, item)) is not None:
decoders[id(item.binding)] = decoder
if not decoders:
return
accessors: list[_Accessor] = []
tables: dict[int, list[str] | None] = {}
confined: dict[int, bool] = {}
replacements: list[tuple[JsCallExpression, str]] = []
for item in held:
if id(item.binding) in decoders:
continue
if (accessor := _accessor(cache.effects, item, decoders, tables)) is None:
continue
accessors.append(accessor)
if (known := confined.get(id(accessor.cache))) is None:
known = confined[id(accessor.cache)] = _cache_confined(model, accessor.cache)
if known:
replacements.extend(_decoded_calls(cache.dominance, accessor))
if not replacements:
return
for call, decoded in replacements:
_replace_in_parent(call, make_string_literal(decoded))
replaced: list[Node] = [call for call, _ in replacements]
_remove_machinery(model, accessors, replaced, node, preserves_script_return(self.options))
self.mark_changed()
Classes
class JsBase91StringDecoder-
Replace every call of a base91 string accessor the module describes by the string it returns, and remove the machinery nothing names any longer.
Expand source code Browse git
class JsBase91StringDecoder(ScriptLevelTransformer): """ Replace every call of a base91 string accessor the module describes by the string it returns, and remove the machinery nothing names any longer. """ def _process_script(self, node: JsScript): cache = model_cache(self, node) model = cache.model held = list(_held_functions(model, node)) decoders: dict[int, _Decoder] = {} for item in held: if (decoder := _decoder(model, item)) is not None: decoders[id(item.binding)] = decoder if not decoders: return accessors: list[_Accessor] = [] tables: dict[int, list[str] | None] = {} confined: dict[int, bool] = {} replacements: list[tuple[JsCallExpression, str]] = [] for item in held: if id(item.binding) in decoders: continue if (accessor := _accessor(cache.effects, item, decoders, tables)) is None: continue accessors.append(accessor) if (known := confined.get(id(accessor.cache))) is None: known = confined[id(accessor.cache)] = _cache_confined(model, accessor.cache) if known: replacements.extend(_decoded_calls(cache.dominance, accessor)) if not replacements: return for call, decoded in replacements: _replace_in_parent(call, make_string_literal(decoded)) replaced: list[Node] = [call for call, _ in replacements] _remove_machinery(model, accessors, replaced, node, preserves_script_return(self.options)) self.mark_changed()Ancestors
Inherited members