Module refinery.lib.scripts.js.deobfuscation.protospelling

Rewrite the spellings that reach an intrinsic prototype without naming it to Owner.prototype.

A prototype pollution gadget writes Object.prototype through a literal rather than through the name — ({}).__proto__.z = 9, Object.getPrototypeOf({}).z = 9, ({}).constructor.prototype.z = 9 — and every one of them means the same object as Object.prototype. Spelling it that way instead is what lets the rest of the pipeline see the write at all: a write is attributed to the name at the root of the member chain it is written through, so a chain rooted in a literal is attributed to nothing and every question about that prototype goes on being answered from a table of what the language puts there.

The rewrite is the same shape as JsGlobalFinderInlining, which already rewrites a call that computes the global object to globalThis, and it earns its place the same way twice over: the file the analyst reads says which object is being patched, and every existing check sees the write with no new analysis behind it.

The identity is not unconditional and each gate below was established by running Node. It holds because the mechanism each spelling goes through is the one the language installs, so a file that replaces that mechanism is asking for something else — Object.prototype.constructor = C makes ({}).constructor.prototype mean C.prototype, and replacing the __proto__ accessor or Object.getPrototypeOf redirects the other two. Those are asked of the effect model per key, since asking per name would refuse every file this exists for: a file that writes Object.prototype.z has written Object.

Expand source code Browse git
"""
Rewrite the spellings that reach an intrinsic prototype without naming it to `Owner.prototype`.

A prototype pollution gadget writes `Object.prototype` through a literal rather than through the
name — `({}).__proto__.z = 9`, `Object.getPrototypeOf({}).z = 9`,
`({}).constructor.prototype.z = 9` — and every one of them means the same object as
`Object.prototype`. Spelling it that way instead is what lets the rest of the pipeline see the
write at all: a write is attributed to the name at the root of the member chain it is written
through, so a chain rooted in a literal is attributed to nothing and every question about that
prototype goes on being answered from a table of what the language puts there.

The rewrite is the same shape as
`refinery.lib.scripts.js.deobfuscation.globalfinder.JsGlobalFinderInlining`, which already rewrites
a call that computes the global object to `globalThis`, and it earns its place the same way twice
over: the file the analyst reads says which object is being patched, and every existing check sees
the write with no new analysis behind it.

The identity is not unconditional and each gate below was established by running Node. It holds
because the mechanism each spelling goes through is the one the language installs, so a file that
replaces that mechanism is asking for something else — `Object.prototype.constructor = C` makes
`({}).constructor.prototype` mean `C.prototype`, and replacing the `__proto__` accessor or
`Object.getPrototypeOf` redirects the other two. Those are asked of the effect model per key,
since asking per name would refuse every file this exists for: a file that writes
`Object.prototype.z` has written `Object`.
"""
from __future__ import annotations

from refinery.lib.scripts import Node, _replace_in_parent
from refinery.lib.scripts.js.analysis.cache import model_cache
from refinery.lib.scripts.js.analysis.effects import EffectModel
from refinery.lib.scripts.js.analysis.model import (
    Role,
    SemanticModel,
    crosses_dynamic_scope,
    reference_role,
)
from refinery.lib.scripts.js.deobfuscation.helpers import ScriptLevelTransformer, access_key
from refinery.lib.scripts.js.model import (
    JsArrayExpression,
    JsCallExpression,
    JsIdentifier,
    JsMemberExpression,
    JsObjectExpression,
    JsScript,
    JsSpreadElement,
    strip_parens,
)

_PROTO_KEY = '__proto__'

_GET_PROTOTYPE_OF = 'getPrototypeOf'

_CONSTRUCTOR_KEY = 'constructor'


class JsPrototypeSpellingNormalization(ScriptLevelTransformer):
    """
    Replace each expression that reaches an intrinsic prototype through a literal with the
    `Owner.prototype` that names it. See the module documentation for the spellings recognized and
    the gates the identity rests on.
    """

    self_converging = True

    def _process_script(self, node: JsScript) -> None:
        """
        Rewrite every recognized spelling in *node*.

        The models are re-read per rewrite rather than held for the walk, which is the one direction
        the pinning contract in `refinery.lib.scripts.js.analysis.cache.ModelCache.pinned` leaves
        open to a pass whose rewrites make the facts it reads **more** restrictive. Every rewrite
        here attributes a write to a name that had none — `({}).__proto__.constructor = C` becomes
        a write of `constructor` on `Object`, which is exactly what the gate on the next spelling
        asks about — so a held model would answer that gate from a program state its own rewrites
        have already left behind, and clear a rewrite the current facts refuse.

        A candidate an earlier rewrite has taken out of the tree is passed over. Its holder is no
        longer reachable from the script, so replacing it would write into a subtree the output
        does not contain and report a change nothing can observe.
        """
        cache = model_cache(self, node)
        for candidate in list(node.walk()):
            if not candidate.is_descendant_of(node):
                continue
            owner = self._owner_whose_prototype_is_named(candidate, cache.model, cache.effects)
            if owner is None:
                continue
            _replace_in_parent(candidate, _a_prototype_of(owner))
            self.mark_changed()

    def _owner_whose_prototype_is_named(
        self,
        node: Node,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The name of the intrinsic whose `prototype` *node* denotes, or `None` where *node* is not
        one of the recognized spellings or a gate refuses it.

        The identity is one between *values*, so it settles only a spelling read as a value. A
        spelling standing where the language wants a reference — the target of an assignment, the
        operand of a `delete`, the head of a `for-in` — is a different question with a different
        answer: `({}).__proto__ = x` sets the prototype of an object nothing else can reach, while
        `Object.prototype = x` writes a property the language made neither writable nor
        configurable, which a module answers with a `TypeError` and the original answered with
        nothing at all.
        """
        if isinstance(node, JsMemberExpression):
            if reference_role(node) is not Role.READ:
                return None
            key = access_key(node)
            if key == _PROTO_KEY:
                return self._owner_read_through_proto(node, model, effects)
            if key == 'prototype':
                return self._owner_read_through_constructor(node, model, effects)
            return None
        if isinstance(node, JsCallExpression):
            return self._owner_read_through_get_prototype_of(node, model, effects)
        return None

    def _owner_read_through_proto(
        self,
        node: JsMemberExpression,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The owner of `<literal>.__proto__`. The accessor that answers it is installed on
        `Object.prototype` whatever the receiver is, so replacing it there redirects the spelling
        for an array literal as much as for an object one.

        Installing it *closer* redirects the spelling too, and only for that receiver: the key is
        found by an ordinary chain walk, so an own `__proto__` on `Array.prototype` shadows the
        accessor for every array and leaves it answering for everything else. Both names are asked
        because a write to either is one the read goes through.
        """
        owner = _owner_of_literal(node.object, effects)
        if owner is None:
            return None
        if effects.global_key_written('Object', _PROTO_KEY):
            return None
        if effects.global_key_written(owner, _PROTO_KEY):
            return None
        return owner if _names_the_intrinsic(owner, node, model) else None

    def _owner_read_through_constructor(
        self,
        node: JsMemberExpression,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The owner of `<literal>.constructor.prototype`. The `constructor` the receiver inherits is a
        data property of the owner's own prototype, so a file that writes that key is naming its own
        function rather than the intrinsic.
        """
        inner = strip_parens(node.object)
        if not isinstance(inner, JsMemberExpression) or access_key(inner) != _CONSTRUCTOR_KEY:
            return None
        owner = _owner_of_literal(inner.object, effects)
        if owner is None or effects.global_key_written(owner, _CONSTRUCTOR_KEY):
            return None
        return owner if _names_the_intrinsic(owner, node, model) else None

    def _owner_read_through_get_prototype_of(
        self,
        node: JsCallExpression,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The owner of `Object.getPrototypeOf(<literal>)`, where `Object` is the intrinsic and the
        method is the one it was given.
        """
        callee = strip_parens(node.callee)
        if not isinstance(callee, JsMemberExpression) or access_key(callee) != _GET_PROTOTYPE_OF:
            return None
        base = strip_parens(callee.object)
        if not isinstance(base, JsIdentifier) or base.name != 'Object':
            return None
        if len(node.arguments) != 1:
            return None
        owner = _owner_of_literal(node.arguments[0], effects)
        if owner is None or effects.global_key_written('Object', _GET_PROTOTYPE_OF):
            return None
        if not _names_the_intrinsic('Object', node, model):
            return None
        return owner if _names_the_intrinsic(owner, node, model) else None


def _owner_of_literal(node: Node | None, effects: EffectModel) -> str | None:
    """
    The intrinsic whose prototype a literal receiver inherits, or `None` for a receiver whose
    prototype is not knowable from the syntax alone or whose evaluation the rewrite may not drop.

    An array literal always inherits `Array.prototype`: no spelling of one says otherwise, an
    elision included. An object literal is taken only when it is empty, because every way of
    writing a `__proto__` key changes what the receiver answers — written with a colon it sets the
    prototype, and written as a shorthand or a computed key it installs an own property that
    shadows the accessor — and a spread carries one in from a value the syntax does not show.

    The receiver is the one part of the spelling the rewrite does not keep, so the elements have to
    be free of anything the program could miss: `[f()].__proto__` runs `f` and `Array.prototype`
    does not. A spread is refused outright rather than asked, since it iterates its argument and
    `[...null]` is a `TypeError` where the array it would build is not.
    """
    node = strip_parens(node)
    if isinstance(node, JsArrayExpression):
        if any(isinstance(element, JsSpreadElement) for element in node.elements):
            return None
        if any(
            element is not None and not effects.is_side_effect_free(element, discarded=True)
            for element in node.elements
        ):
            return None
        return 'Array'
    if isinstance(node, JsObjectExpression) and not node.properties:
        return 'Object'
    return None


def _names_the_intrinsic(name: str, at: Node, model: SemanticModel) -> bool:
    """
    Whether *name* denotes the intrinsic at *at* rather than something the program can have given a
    meaning of its own. A file declaring `var Object = {prototype: {}}` names its own object with
    it, and rewriting a spelling to `Object.prototype` there would name that one instead of the
    prototype the spelling reaches.

    This is `refinery.lib.scripts.js.deobfuscation.helpers.name_is_unbound` asked of a name that is
    not written anywhere yet, so it cannot be asked through a reference to one. The three ways that
    predicate lists are all asked here: a binding, which `lookup` reports and which is crossed
    deliberately so that a `with` between the two does not hide it; the `with` itself, whose object
    may carry a property of the name and answer the read from it; and a direct `eval`, which
    declares bindings no reference records.
    """
    scope = model.scope_of(at)
    if scope is None:
        return False
    if model.lookup(name, scope, cross_dynamic=True) is not None:
        return False
    if crosses_dynamic_scope(scope):
        return False
    return not model.free_name_reachable_by_direct_eval(at)


def _a_prototype_of(owner: str) -> JsMemberExpression:
    return JsMemberExpression(
        object=JsIdentifier(name=owner),
        property=JsIdentifier(name='prototype'),
        computed=False,
    )

Classes

class JsPrototypeSpellingNormalization

Replace each expression that reaches an intrinsic prototype through a literal with the Owner.prototype that names it. See the module documentation for the spellings recognized and the gates the identity rests on.

Expand source code Browse git
class JsPrototypeSpellingNormalization(ScriptLevelTransformer):
    """
    Replace each expression that reaches an intrinsic prototype through a literal with the
    `Owner.prototype` that names it. See the module documentation for the spellings recognized and
    the gates the identity rests on.
    """

    self_converging = True

    def _process_script(self, node: JsScript) -> None:
        """
        Rewrite every recognized spelling in *node*.

        The models are re-read per rewrite rather than held for the walk, which is the one direction
        the pinning contract in `refinery.lib.scripts.js.analysis.cache.ModelCache.pinned` leaves
        open to a pass whose rewrites make the facts it reads **more** restrictive. Every rewrite
        here attributes a write to a name that had none — `({}).__proto__.constructor = C` becomes
        a write of `constructor` on `Object`, which is exactly what the gate on the next spelling
        asks about — so a held model would answer that gate from a program state its own rewrites
        have already left behind, and clear a rewrite the current facts refuse.

        A candidate an earlier rewrite has taken out of the tree is passed over. Its holder is no
        longer reachable from the script, so replacing it would write into a subtree the output
        does not contain and report a change nothing can observe.
        """
        cache = model_cache(self, node)
        for candidate in list(node.walk()):
            if not candidate.is_descendant_of(node):
                continue
            owner = self._owner_whose_prototype_is_named(candidate, cache.model, cache.effects)
            if owner is None:
                continue
            _replace_in_parent(candidate, _a_prototype_of(owner))
            self.mark_changed()

    def _owner_whose_prototype_is_named(
        self,
        node: Node,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The name of the intrinsic whose `prototype` *node* denotes, or `None` where *node* is not
        one of the recognized spellings or a gate refuses it.

        The identity is one between *values*, so it settles only a spelling read as a value. A
        spelling standing where the language wants a reference — the target of an assignment, the
        operand of a `delete`, the head of a `for-in` — is a different question with a different
        answer: `({}).__proto__ = x` sets the prototype of an object nothing else can reach, while
        `Object.prototype = x` writes a property the language made neither writable nor
        configurable, which a module answers with a `TypeError` and the original answered with
        nothing at all.
        """
        if isinstance(node, JsMemberExpression):
            if reference_role(node) is not Role.READ:
                return None
            key = access_key(node)
            if key == _PROTO_KEY:
                return self._owner_read_through_proto(node, model, effects)
            if key == 'prototype':
                return self._owner_read_through_constructor(node, model, effects)
            return None
        if isinstance(node, JsCallExpression):
            return self._owner_read_through_get_prototype_of(node, model, effects)
        return None

    def _owner_read_through_proto(
        self,
        node: JsMemberExpression,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The owner of `<literal>.__proto__`. The accessor that answers it is installed on
        `Object.prototype` whatever the receiver is, so replacing it there redirects the spelling
        for an array literal as much as for an object one.

        Installing it *closer* redirects the spelling too, and only for that receiver: the key is
        found by an ordinary chain walk, so an own `__proto__` on `Array.prototype` shadows the
        accessor for every array and leaves it answering for everything else. Both names are asked
        because a write to either is one the read goes through.
        """
        owner = _owner_of_literal(node.object, effects)
        if owner is None:
            return None
        if effects.global_key_written('Object', _PROTO_KEY):
            return None
        if effects.global_key_written(owner, _PROTO_KEY):
            return None
        return owner if _names_the_intrinsic(owner, node, model) else None

    def _owner_read_through_constructor(
        self,
        node: JsMemberExpression,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The owner of `<literal>.constructor.prototype`. The `constructor` the receiver inherits is a
        data property of the owner's own prototype, so a file that writes that key is naming its own
        function rather than the intrinsic.
        """
        inner = strip_parens(node.object)
        if not isinstance(inner, JsMemberExpression) or access_key(inner) != _CONSTRUCTOR_KEY:
            return None
        owner = _owner_of_literal(inner.object, effects)
        if owner is None or effects.global_key_written(owner, _CONSTRUCTOR_KEY):
            return None
        return owner if _names_the_intrinsic(owner, node, model) else None

    def _owner_read_through_get_prototype_of(
        self,
        node: JsCallExpression,
        model: SemanticModel,
        effects: EffectModel,
    ) -> str | None:
        """
        The owner of `Object.getPrototypeOf(<literal>)`, where `Object` is the intrinsic and the
        method is the one it was given.
        """
        callee = strip_parens(node.callee)
        if not isinstance(callee, JsMemberExpression) or access_key(callee) != _GET_PROTOTYPE_OF:
            return None
        base = strip_parens(callee.object)
        if not isinstance(base, JsIdentifier) or base.name != 'Object':
            return None
        if len(node.arguments) != 1:
            return None
        owner = _owner_of_literal(node.arguments[0], effects)
        if owner is None or effects.global_key_written('Object', _GET_PROTOTYPE_OF):
            return None
        if not _names_the_intrinsic('Object', node, model):
            return None
        return owner if _names_the_intrinsic(owner, node, model) else None

Ancestors

Inherited members