Module refinery.lib.scripts.js.deobfuscation.lzstrings

Decompress the strings a program keeps in the compressed form of the lz-string library. The library's minified build is inserted whole and called once with every string of the program joined into one:

var L = function () { var r = String.fromCharCode, ...; return i; }();
var strings = L.decompressFromUTF16("...").split("|");

A call of its decompressFromUTF16 with a string literal becomes the string it returns, where the name holds the library's object and nothing changes that object before the call. The library is recognized by its normal form (refinery.lib.scripts.js.deobfuscation.templates), in which a name it reads as a global is one that resolves to no binding. The deobfuscation's own rewrites change the library before the call can be decoded (dropping an initializer nothing reads, folding !0), so the run records the builds the input holds before its first pass (JsLZStringDecoder.read_input()); a rewrite keeps what a function computes, and a pass that builds a new function makes a node the record does not hold.

The template below is the minified build of the lz-string library, copyright (c) 2013 pieroxy, covered by an MIT license. Regardless of the license used for the binary refinery, this code file is also subject to the terms and conditions of that license, which is included in refinery.lib.lzstring.

Expand source code Browse git
"""
Decompress the strings a program keeps in the compressed form of the lz-string library. The
library's minified build is inserted whole and called once with every string of the program joined
into one:

    var L = function () { var r = String.fromCharCode, ...; return i; }();
    var strings = L.decompressFromUTF16("...").split("|");

A call of its `decompressFromUTF16` with a string literal becomes the string it returns, where the
name holds the library's object and nothing changes that object before the call. The library is
recognized by its normal form (`refinery.lib.scripts.js.deobfuscation.templates`), in which a name
it reads as a global is one that resolves to no binding. The deobfuscation's own rewrites change the
library before the call can be decoded (dropping an initializer nothing reads, folding `!0`), so the
run records the builds the input holds before its first pass (`JsLZStringDecoder.read_input`); a
rewrite keeps what a function computes, and a pass that builds a new function makes a node the
record does not hold.

The template below is the minified build of the lz-string library, copyright (c) 2013 pieroxy,
covered by an MIT license. Regardless of the license used for the binary refinery, this code file
is also subject to the terms and conditions of that license, which is included in
`refinery.lib.lzstring`.
"""
from __future__ import annotations

from weakref import WeakKeyDictionary

from refinery.lib.lzstring import decompress_from_utf16
from refinery.lib.scripts import Node, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import ModelCache, model_cache
from refinery.lib.scripts.js.analysis.model import enclosing_function
from refinery.lib.scripts.js.deobfuscation.helpers import (
    ScriptLevelTransformer,
    access_key,
    make_string_literal,
)
from refinery.lib.scripts.js.deobfuscation.interpreter import MAX_STRING_LEN
from refinery.lib.scripts.js.deobfuscation.templates import Template, matches
from refinery.lib.scripts.js.model import (
    JsArrowFunctionExpression,
    JsCallExpression,
    JsFunctionExpression,
    JsIdentifier,
    JsMemberExpression,
    JsNullLiteral,
    JsProperty,
    JsScript,
    JsStringLiteral,
    strip_parens,
)

_LIBRARY = (
    'function(){var r=String.fromCharCode,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",'
    'n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",e={};function t(r,o){if(!e[r]){e[r]={};'
    'for(var n=0;n<r.length;n++)e[r][r.charAt(n)]=n}return e[r][o]}var i={compressToBase64:function(r){if(null==r)'
    'return"";var n=i._compress(r,6,function(r){return o.charAt(r)});switch(n.length%4){default:case 0:return n;case 1:'
    'return n+"===";case 2:return n+"==";case 3:return n+"="}},decompressFromBase64:function(r){return null==r?"":""=='
    'r?null:i._decompress(r.length,32,function(n){return t(o,r.charAt(n))})},compressToUTF16:function(o){return null=='
    'o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(r){return null==r?"":""==r?'
    'null:i._decompress(r.length,16384,function(o){return r.charCodeAt(o)-32})},compressToUint8Array:function(r){for('
    'var o=i.compress(r),n=new Uint8Array(2*o.length),e=0,t=o.length;e<t;e++){var s=o.charCodeAt(e);n[2*e]=s>>>8,n[2*e'
    '+1]=s%256}return n},decompressFromUint8Array:function(o){if(null==o)return i.decompress(o);for(var n=new Array(o'
    '.length/2),e=0,t=n.length;e<t;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i'
    '.decompress(s.join(""))},compressToEncodedURIComponent:function(r){return null==r?"":i._compress(r,6,function(r)'
    '{return n.charAt(r)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ '
    '/g,"+"),i._decompress(r.length,32,function(o){return t(n,r.charAt(o))}))},compress:function(o){return i._compress'
    '(o,16,function(o){return r(o)})},_compress:function(r,o,n){if(null==r)return"";var e,t,i,s={},u={},a="",p="",c="'
    '",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<r.length;i+=1)if(a=r.charAt(i),Object.prototype.hasOwnProperty.call(s,a)||('
    's[a]=f++,u[a]=!0),p=c+a,Object.prototype.hasOwnProperty.call(s,p))c=p;else{if(Object.prototype.hasOwnProperty.ca'
    'll(u,c)){if(c.charCodeAt(0)<256){for(e=0;e<h;e++)m<<=1,v==o-1?(v=0,d.push(n(m)),m=0):v++;for(t=c.charCodeAt(0),e'
    '=0;e<8;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;e<h;e++)m=m<<1|t,v==o-1?(v=0,d.pu'
    'sh(n(m)),m=0):v++,t=0;for(t=c.charCodeAt(0),e=0;e<16;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}0==-'
    '-l&&(l=Math.pow(2,h),h++),delete u[c]}else for(t=s[c],e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t'
    '>>=1;0==--l&&(l=Math.pow(2,h),h++),s[p]=f++,c=String(a)}if(""!==c){if(Object.prototype.hasOwnProperty.call(u,c)'
    '){if(c.charCodeAt(0)<256){for(e=0;e<h;e++)m<<=1,v==o-1?(v=0,d.push(n(m)),m=0):v++;for(t=c.charCodeAt(0),e=0;e<8;'
    'e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;e<h;e++)m=m<<1|t,v==o-1?(v=0,d.push(n(m)'
    '),m=0):v++,t=0;for(t=c.charCodeAt(0),e=0;e<16;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}0==--l&&(l='
    'Math.pow(2,h),h++),delete u[c]}else for(t=s[c],e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;0='
    '=--l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<'
    '<=1,v==o-1){d.push(n(m));break}v++}return d.join("")},decompress:function(r){return null==r?"":""==r?null:i._dec'
    'ompress(r.length,32768,function(o){return r.charCodeAt(o)})},_decompress:function(o,n,e){var t,i,s,u,a,p,c,l=[],'
    'f=4,h=4,d=3,m="",v=[],g={val:e(0),position:n,index:1};for(t=0;t<3;t+=1)l[t]=t;for(s=0,a=Math.pow(2,2),p=1;p!=a;)'
    'u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;switch(s'
    '){case 0:for(s=0,a=Math.pow(2,8),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val='
    'e(g.index++)),s|=(u>0?1:0)*p,p<<=1;c=r(s);break;case 1:for(s=0,a=Math.pow(2,16),p=1;p!=a;)u=g.val&g.position,g.'
    'position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;c=r(s);break;case 2:return""}'
    'for(l[3]=c,i=c,v.push(c);;){if(g.index>o)return"";for(s=0,a=Math.pow(2,d),p=1;p!=a;)u=g.val&g.position,g.positio'
    'n>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;switch(c=s){case 0:for(s=0,a=Math.po'
    'w(2,8),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)'
    '*p,p<<=1;l[h++]=r(s),c=h-1,f--;break;case 1:for(s=0,a=Math.pow(2,16),p=1;p!=a;)u=g.val&g.position,g.position>>=1'
    ',0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;l[h++]=r(s),c=h-1,f--;break;case 2:return'
    ' v.join("")}if(0==f&&(f=Math.pow(2,d),d++),l[c])m=l[c];else{if(c!==h)return null;m=i+i.charAt(0)}v.push(m),l[h++'
    ']=i+m.charAt(0),i=m,0==--f&&(f=Math.pow(2,d),d++)}}};return i}'
)

_TEMPLATES = tuple(
    Template(F'function LZString{text[len("function"):]}')
    for written in (_LIBRARY, _LIBRARY.replace('i+=1', 'i++').replace('t+=1', 't++'))
    for text in (written, written.replace('m="",', 'm,'))
)
"""
The function of the library's minified build: as inserted, with its two counting loops written with
`++`, which is how a minifier rewrites them, and either of these with the initializer of `m` in
`_decompress` left out, which no read reaches and which the deobfuscation drops before the library
can be decoded.
"""

_METHODS = frozenset({
    'compressToBase64',
    'decompressFromBase64',
    'compressToUTF16',
    'decompressFromUTF16',
    'compressToUint8Array',
    'decompressFromUint8Array',
    'compressToEncodedURIComponent',
    'decompressFromEncodedURIComponent',
    'compress',
    '_compress',
    'decompress',
    '_decompress',
})
"""
The methods of the object the library returns. Each reads the object and none writes it.
"""

_WRITTEN: WeakKeyDictionary[ModelCache, dict[int, Node]] = WeakKeyDictionary()
"""
The builds of the library each run's input holds, by identity, recorded before the run's first
pass (`JsLZStringDecoder.read_input`). The nodes are held, so an identity stays theirs.
"""


def _library_builds(cache: ModelCache) -> dict[int, Node]:
    """
    The functions of the tree *cache* is created over that have the normal form of the library's
    build, by identity. The semantic model the normal form needs is asked for only where a function
    holds a property named as the library's.
    """
    builds: dict[int, Node] = {}
    for node in cache.root.walk():
        if not isinstance(node, JsProperty) or node.computed:
            continue
        key = node.key
        if not isinstance(key, JsIdentifier) or key.name != 'decompressFromUTF16':
            continue
        function = enclosing_function(node)
        if (
            isinstance(function, (JsFunctionExpression, JsArrowFunctionExpression))
            and id(function) not in builds
            and matches(cache.model, function, _TEMPLATES, {})
        ):
            builds[id(function)] = function
    return builds


def _is_the_library(cache: ModelCache, function: JsFunctionExpression | JsArrowFunctionExpression) -> bool:
    """
    Whether *function* is a build of the library: one the run's input held, or one that has the
    library's normal form now.
    """
    written = _WRITTEN.get(cache)
    if written is not None and written.get(id(function)) is function:
        return True
    return matches(cache.model, function, _TEMPLATES, {})


def _library_call(cache: ModelCache, call: JsCallExpression) -> JsStringLiteral | None:
    """
    The string literal *call* decompresses when it is `L.decompressFromUTF16("...")` and `L` holds
    the object the library's function returns, unchanged where *call* runs: the function is a build
    of the library (`_is_the_library`) and reads `String` and `Math` as globals; its value is a call
    of that function made where it is written; the name is established before *call*; no code
    touches the object it holds but a call of one of the object's own methods, which read the object
    and never write it, and a store no code observes
    (`refinery.lib.scripts.js.analysis.effects.EffectModel.binding_is_immutable_container`); and the
    built-ins the library reads, `String.fromCharCode` when it is built and `Math.pow` and the
    methods of strings and arrays when it decompresses, are those of the language at both points
    (`refinery.lib.scripts.js.analysis.cache.ModelCache.trust_at`).
    """
    model = cache.model
    effects = cache.effects
    callee = strip_parens(call.callee)
    if not isinstance(callee, JsMemberExpression) or access_key(callee) != 'decompressFromUTF16':
        return None
    if callee.optional or call.optional or len(call.arguments) != 1:
        return None
    if not isinstance(argument := strip_parens(call.arguments[0]), JsStringLiteral) or argument.value is None:
        return None
    if not isinstance(name := strip_parens(callee.object), JsIdentifier):
        return None
    if (binding := model.resolve(name)) is None or model.reflection_can_reach(binding):
        return None
    built = strip_parens(model.singular_value(binding))
    if not isinstance(built, JsCallExpression) or built.arguments:
        return None
    if not isinstance(function := strip_parens(built.callee), (JsFunctionExpression, JsArrowFunctionExpression)):
        return None
    if not _is_the_library(cache, function):
        return None
    for node in function.walk():
        if isinstance(node, JsIdentifier) and node.name in ('String', 'Math') and model.resolve(node) is not None:
            return None
    if not cache.dominance.binding_established_before(binding, call):
        return None
    if not effects.binding_is_immutable_container(binding, inert_methods=_METHODS):
        return None
    for anchor in (built, call):
        trust = cache.trust_at(anchor)
        if not trust.intrinsic_name('String') or not trust.intrinsic_name('Math'):
            return None
        if not trust.prototype(str) or not trust.prototype(list) or not trust.chain(list):
            return None
    return argument


class JsLZStringDecoder(ScriptLevelTransformer):
    """
    Replace every call decompressing a string literal with the lz-string library this module
    describes by the string it returns.
    """

    @staticmethod
    def read_input(models: ModelCache) -> None:
        """
        Record the builds of the library the tree *models* was created over holds, before the run's
        first pass changes them.
        """
        if models not in _WRITTEN:
            _WRITTEN[models] = _library_builds(models)

    def _process_script(self, node: JsScript):
        cache = model_cache(self, node)
        replacements: list[tuple[Node, Node]] = []
        for call in node.walk():
            if not isinstance(call, JsCallExpression):
                continue
            if (argument := _library_call(cache, call)) is None or argument.value is None:
                continue
            try:
                decompressed = decompress_from_utf16(argument.value, MAX_STRING_LEN)
            except ValueError:
                continue
            replacement = JsNullLiteral() if decompressed is None else make_string_literal(decompressed)
            replacements.append((call, replacement))
        if not replacements:
            return
        for call, replacement in replacements:
            _replace_in_parent(call, replacement)
        self.mark_changed()

Classes

class JsLZStringDecoder

Replace every call decompressing a string literal with the lz-string library this module describes by the string it returns.

Expand source code Browse git
class JsLZStringDecoder(ScriptLevelTransformer):
    """
    Replace every call decompressing a string literal with the lz-string library this module
    describes by the string it returns.
    """

    @staticmethod
    def read_input(models: ModelCache) -> None:
        """
        Record the builds of the library the tree *models* was created over holds, before the run's
        first pass changes them.
        """
        if models not in _WRITTEN:
            _WRITTEN[models] = _library_builds(models)

    def _process_script(self, node: JsScript):
        cache = model_cache(self, node)
        replacements: list[tuple[Node, Node]] = []
        for call in node.walk():
            if not isinstance(call, JsCallExpression):
                continue
            if (argument := _library_call(cache, call)) is None or argument.value is None:
                continue
            try:
                decompressed = decompress_from_utf16(argument.value, MAX_STRING_LEN)
            except ValueError:
                continue
            replacement = JsNullLiteral() if decompressed is None else make_string_literal(decompressed)
            replacements.append((call, replacement))
        if not replacements:
            return
        for call, replacement in replacements:
            _replace_in_parent(call, replacement)
        self.mark_changed()

Ancestors

Static methods

def read_input(models)

Record the builds of the library the tree models was created over holds, before the run's first pass changes them.

Expand source code Browse git
@staticmethod
def read_input(models: ModelCache) -> None:
    """
    Record the builds of the library the tree *models* was created over holds, before the run's
    first pass changes them.
    """
    if models not in _WRITTEN:
        _WRITTEN[models] = _library_builds(models)

Inherited members