Dissecting Shape Security's Virtual Machine


Introduction

Disclaimer: The content within this blog post draws from my experience with Shape Security in the summer of 2023.

Formerly known as Shape Security, now under the umbrella of F5, the company specializes in safeguarding web and mobile applications for big organizations against bots, fraud, and unauthorized automation. This blog post explores their product, F5 Distributed Cloud Bot Defense, known for its effectiveness in protecting against automated threats.

Numerous industry giants such as Starbucks, Target, Chase Bank, and Citi Bank have opted to integrate Shape Security’s bot protection solutions into their platforms.

For this blog post, we will focus our analysis on Target’s implementation of Shape Security’s bot protection mechanisms.

Basics

Once the page is loaded, two files are fetched: https://assets.targetimg1.com/ssx/ssx.mod.js?async and https://assets.targetimg1.com/ssx/ssx.mod.js?seed=<some long id>. The initial file, referred to as the launcher, subsequently calls the second file, referred to as the VM.

(function(a) {
    var d = document
      , w = window
      , u = "https://assets.targetimg1.com/ssx/ssx.mod.js?seed=AADr37WMAQAA4EtllGxX1MxkHfubQjnnk1LVlNZhB7SWVg_HeNFSEHKKpYiO&X-GyJwza5Z--z=q"
      , v = "PQYkPufJN"
      , i = "278df0f3c290b2faafb393cd34fba2d5";
    var s = d.currentScript;
    addEventListener(v, function f(e) {
        e.stopImmediatePropagation();
        removeEventListener(v, f, !0);
        e.detail.init("AyxA7bWMAQAAT4lrbr7gOVGQU89YBzC8Tw3hGBN06WTBujaupc3reN7-VsFaAbz8xuiucuKDwH9eCOfvosJeCA==", "zepy_0YC=F3lcMRaPxmoBk7EWfZ19AjivTNIrsKJ5VD42SwGb6Lhng-8UqdXQOHut", [], [325146353, 1410566792, 2110237658, 1100024986, 99746088, 1498902388, 43378282, 695899181], document.currentScript && document.currentScript.nonce || "x9Vfhgia1WanHpU3HXEtt5WD", document.currentScript && document.currentScript.nonce || "x9Vfhgia1WanHpU3HXEtt5WD", [], a)
    }, !0);
    var o = s && s.nonce ? s.nonce : "";
    try {
        s && s.parentNode.removeChild(s)
    } catch (e) {}
    {
        var n = d.createElement("script");
        n.id = i;
        n.src = u;
        n.async = !0;
        n.nonce = o;
        d.head.appendChild(n)
    }
}(typeof arguments === "undefined" ? void 0 : arguments))

An example of Shape’s launcher code.

I already kind of ruined the surprise, but the “VM file” functions as a bytecode interpreter, also known as a virtual machine. Before delving into further details, it’s essential to establish key concepts.

A virtual machine serves as a specialized program designed to interpret bytecode, which represents an intermediate form of program code expressed as a sequence of bytes. During compilation, all of the program’s source code is translated into bytecode and subsequently interpreted by the virtual machine. However, bytecode alone lacks meaningful interpretation without an understanding of the virtual machine’s instruction set.

Therefore, the strategy for reverse engineering such abstraction involves constructing a disassembler for the given instruction set to effectively decode the bytecode to an assembly-like structure for easier understanding and further decompilation.

Virtual machines are composed of the following components:

  1. Entry: The entry block, responsible for initializing the virtual machine state.
  2. Dispatcher: The central block that iterates through the bytecode, retrieving the next byte, decoding it, and executing the corresponding handler.
  3. Handlers: Functional blocks that embody the semantics of instructions, such as addition or subtraction.

The dispatcher operates within a loop until it encounters a state necessitating the termination of the virtual machine, which we’ll refer to as an exit block.

In this blog post, our focus will be on analyzing Shape’s instruction set. Shape’s instruction set consists of approximately 230 handlers. With a new version of the script uploaded every 30 minutes, over 80 randomly selected handlers experience modification.

This implies that when comparing two scripts, approximately 150 handlers remain identical, while around 80 differ. However, it’s worth noting that as the instruction set is randomized, the number of identical handlers decreases with an increasing number of compared VM scripts.

Here’s the twist: within this set, approximately 90 handlers represent fundamental operations, referred to as atomic instructions. The remaining 140 or so handlers are classified as superoperators. These superoperators combine atomic instructions into larger, more semantically complex instructions.

Let’s take a look at some examples:

function OPCODE_HANDLER(_context) {
    const arg_$_b_right = _context.stack[_context.stack.length - 1];
    const arg_$_a_left = _context.stack[_context.stack.length - 2];
    _context.stack[_context.stack.length - 2] = arg_$_a_left & arg_$_b_right;
    _context.stack.length -= 1;
}

This is an example of an atomic instruction: it removes the topmost elements from the stack, performs a bitwise AND operation between them, and then places the result back onto the stack.

function OPCODE_HANDLER(_context) {
    var $_a = BYTECODE[_context.instructionPointer];
    _context.instructionPointer += 1;
    _context.stack[_context.stack.length] = _context.memory.get($_a);
}

This is another example of an atomic instruction: it reads a byte from the bytecode, increments the instruction pointer by 1, retrieves the value of the register corresponding to the fetched byte’s index, and then pushes this value onto the stack.

function OPCODE_HANDLER(_context) {
    var $_a = BYTECODE[_context.instructionPointer];
    _context.instructionPointer += 1;
    _context.stack[_context.stack.length] = $_a;
}

Here’s another straightforward atomic instruction: it reads a byte from the bytecode, increments the instruction pointer by 1, and then pushes the byte onto the stack.

Now, let’s delve into an example of a superoperator instruction:

function OPCODE_HANDLER(_context) {
    var $_a = BYTECODE[_context.instructionPointer];
    var $_b = BYTECODE[_context.instructionPointer + 1];
    _context.instructionPointer += 2;
    var $_c = _context.memory.get($_b);
    _context.stack[_context.stack.length] = $_a & $_c;
}

This instruction may appear relatively straightforward, yet it differs from atomic ones by integrating three previously mentioned instructions. Understanding this distinction is crucial, as it constitutes a fundamental concept in my analytical approach.

Analysis

In this section, I will outline my approach to simplifying instruction handlers implemented by Shape.

Let’s explore how we can rewrite our previous example to combine three handlers into a single one:

function OPCODE_HANDLER(_context) {
    var $_a = BYTECODE[_context.instructionPointer];
    _context.instructionPointer += 1;
    _context.stack[_context.stack.length] = $_a;

    var $_a = BYTECODE[_context.instructionPointer];
    _context.instructionPointer += 1;
    _context.stack[_context.stack.length] = _context.memory.get($_a);

    const arg_$_b_right = _context.stack[_context.stack.length - 1];
    const arg_$_a_left = _context.stack[_context.stack.length - 2];
    _context.stack[_context.stack.length - 2] = arg_$_a_left & arg_$_b_right;

    _context.stack.length -= 1;

}

As previously mentioned, the instruction set is randomized, leading to an infinite array of possible combinations for crafting superoperators. My approach involves expressing every potential combination through atomic operations, considering that each superoperator results from the merging of multiple atomic instructions.

To achieve this, I’ve employed a dependency graph. This graph serves as a directed representation of dependencies among various objects. By utilizing such a data structure, we can map out each line in our instruction handler as a single node and then analyze patterns among node dependencies.

Here’s the picture of the dependency graph for our example:

Graph

Disclaimer: Code lines 1, 4, and 9 are excluded from the dependency graph as they lack dependencies with other nodes.

Upon examining the graph, we observe three distinct code blocks. Consequently, we can proceed to analyze these code blocks and attempt to match them with individual atomic operations.

The Process

Thus, we’ve established our primary objective: to generate a dependency graph similar to the one illustrated above. This approach allows us to efficiently discern the atomic operations that make a specific instruction handler.

As previously demonstrated, our example instruction handler is:

function OPCODE_HANDLER(_context) {
    var $_a = BYTECODE[_context.instructionPointer];
    var $_b = BYTECODE[_context.instructionPointer + 1];
    _context.instructionPointer += 2;
    var $_c = _context.memory.get($_b);
    _context.stack[_context.stack.length] = $_a & $_c;
}

It’s starting dependency graph looks like this:

Graph

Initially, we identify the so-called ending nodes. In this instance, only node 4 serves as an ending node. Subsequently, we start traversing the graph upwards from that node, sequentially examining each subsequent node.

During each traversal, we determine the type of the node. This is accomplished by utilizing a comprehensive map of functions designed to assess whether a given statement satisfies specific criteria. This analysis involves inspecting the statement using an Abstract Syntax Tree (AST) to detect distinctive patterns unique to that statement. For instance, consider the following statement:

_context.stack[_context.stack.length - 2] = arg_$_a_left & arg_$_b_right

We can work out its type by checking if it contains the ”&” operator, indicating a bitwise AND operation.

Once we’ve determined the type of the statement, we proceed to evaluate its rule. Rules are accurately crafted objects tailored to each atomic operation, detailing the permissible input values for that operation. An illustrative example of a rule for a bitwise AND instruction is:

args: {
    "0": {
	type: "POP",
    },
    "1": {
	type: "POP",
    },
}

It is evident from the example that the bitwise AND instruction accepts two arguments, which must be “pop statements” – statements that are popped from the stack, for instance:

const arg_$_b_right = _context.stack[_context.stack.length - 1];

Anomalies refer to nodes that deviate from the specified type outlined in a rule. In our scenario, both nodes 0 and 3 qualify as anomalies since they do not correspond to statements of type “POP”.

We proceed with the process by continuing the traversal upwards, capturing all anomalies encountered in a specific object.

Upon completing the assessment of all anomalies, the next step involves repairing them individually. During this phase, we adjust each anomaly by reformatting the respective node to satisfy the appropriate rules.

Upon patching node 0, it now appears as follows:

function OPCODE_HANDLER(_context) {
    var $_a = BYTECODE[_context.instructionPointer];
    _context.stack[_context.stack.length] = $_a;
    var $_a = _context.stack.pop()

    var $_b = BYTECODE[_context.instructionPointer + 1];
    _context.instructionPointer += 2;
    var $_c = _context.memory.get($_b);
    _context.stack[_context.stack.length] = $_a & $_c;

}

This revised representation remains semantically equivalent to our original handler. The transformation involved simply pushing and popping elements. Why? There are two primary reasons:

  1. We have now fulfilled the requirement of our rule, requiring that the arguments for the bitwise AND operation must be “POP statements”.
  2. In effect, we have achieved our primary objective. By merging multiple atomic operations, the first two lines have transformed into a concise instruction in themselves.

Upon patching node 3, we now have the complete picture:

function OPCODE_HANDLER(_context) {
    var $_a = BYTECODE[_context.instructionPointer];
    _context.stack[_context.stack.length] = $_a;
    var $_a = _context.stack.pop()

    var $_b = BYTECODE[_context.instructionPointer + 1];
    _context.instructionPointer += 2;
    var $_c = _context.memory.get($_b);
    _context.stack[_context.stack.length] = $_c;
    var $_c = _context.stack.pop()

    _context.stack[_context.stack.length] = $_a & $_c;

}

Let’s examine the dependency graph of the currently reconstructed handler:

Graph

Now, we have precisely what was initially presented, enabling us to analyze the graph effortlessly by identifying segments that are not interconnected. These segments represent the atomic operations that were merged together.

Conclusion

In this post, we explored general concepts of virtual machines and delved into Shape Security’s custom virtual machine implementation. We examined how they craft their custom instruction set and analyzed the process of reconstructing it for subsequent decompilation.

However, it’s important to note that our analysis focused solely on simple instructions. There are numerous additional considerations to think about when adopting the approach I’ve outlined. These include ensuring the preservation of stack order, instruction pointer values, and other essential elements. Additionally, various instructions possess unique patterns and require specific modifications to effectively implement this approach.

If you think I got something wrong or you have any questions, feel free to contact me on Discord: @sveba