The Interpreter

Inside Kasada's ips.js Bytecode Virtual Machine

Today we'll be delving into Kasada's ips.js bytecode virtual machine, disassembling it, and lifting it into pseudo-code.

So over the past few months, I've been learning about reverse engineering bytecode virtual machines buried in JavaScript. Most modern anti-bots nowadays use virtual machine protection as it's a much more difficult puzzle to solve compared to standard obfuscation of JavaScript, and it raises costs for attackers significantly. I walked into this with the intention of doing all the reversing myself, and using AI to help write tools to speed up the process.

Unlike your typical compiled program that you run on your desktop, these VMs are custom built, with their own ops, which means the attacker has to figure out what each op number actually does, figure out where the dispatch lives, get the decoded bytecode tape, etc.

Most posts I've seen covering Kasada's bytecode VM do the bare minimum. They will show how to get the decoded bytecode tape, the overall structure of the VM, but not actually sitting down and mapping the ops, disassembling the program, and lifting it into pseudo-code to read the machine. Today I'll be going through how I was able to do it.

Finding the tape

As soon as you open the file, it's pretty easy to notice what the encoded tape is. var F is a ~682,000-character-long variable, clearly the tape.

var F = "Deni>en78uneJHD48=wujา•unFqene|enAOun7^..."
var O = "length";
var t = A(F, "WD2Nn85|า•T1ECYh=dyoR0pg^>OZLPfkJq~Is+bzKxaB3H9jw<MFuUe67GQt$rmiSXv4VcAl", 44);

The tape decoder: A

F is then used in the A function call below, along with two other variables. Let's see what A is.

var A = function(u, r, e) {
    for (var n = r.length, t = n - e, a = 9, i = [Math.round(+new Date / 18000081) * a], o = 1; o < 2; o++)
        i.push(i[0] - a * o),
        i.push(i[0] + a * o);
    for (var f = 4247693328, v = new Map, d = 0; d < n; d++)
        v.set(r[d], d);
    for (var c = function(P) {
            for (var A = function(u) {
                    var r = Array.from(u.toString(), Number),
                        e = r.length,
                        n = r.reduce(function(t, a) {
                            return t + a
                        }, 0);
                    return function(t) {
                        return t - r[n++ % e]
                    }
                }(P), s = [], N = 0; N < u.length;) {
                var m = 0,
                    g = 1;
                if (f !== void 0 && s.length === 13) {
                    for (var _ = -556664562 + 825724963 - 188479890 + 2085555750, M = new TextEncoder().encode(s.join(",")), l = 0; l < M.length; l++)
                        _ ^= M[l],
                        _ *= 1483151 - 4986177 + 1425612 + 10489205 + 8989144 - 12154394 + 11531078;
                    if (_ >>>= 0,
                        _ !== f)
                        return;
                    f = void 0
                }
                for (;;) {
                    var L = v.get(u[N++]) || 0;
                    if (L < e) {
                        m += g * L,
                            s.push(A(m | 0));
                        break
                    }
                    m += g * (L % e + e),
                        g *= t
                }
            }
            return s
        }, T = i.length, E = 0; E < T; E++) {
        var h = c(i[E]);
        if (E === i.length - 1 && (f = void 0),
            h !== void 0)
            return h
    }
    return []
};

So A is one decoder for the tape. The 2nd string in the callee is a 71-character substitution alphabet, and the 44 is a termination threshold. Alphabet indices below 44 finish the current integer, indices 44 and above continue it. The continuation multiplier here is 71 - 44 = 77. The seed is Math.round(Date.now() / 1800001 * 9). It tries that seed, then seed -9, then seed + 9. Each decoded integer has a seed digit subtracted, cycling from sumOfDigits % digitCount. The expected hash is 4247693328.

Finding the dispatcher

After a bit more digging around, I wanted to hunt for the dispatcher. At this point I was still a bit unsure on whether or not there was a second decode layer, but we'll get to that. At the bottom of the script, we can see function m.

    function m(e) {
        var n = [s, [M, W], t, u];
        var r = [j, D, h, m, L, G];
        while (true) {
            var T, E;
            var i = void 0;
            i = u[t[e.r[0]++]];
            try {
                var N = i(e, X, B, f, n, r);
                if (N === null) {
                    break
                }
            } catch (o) {
                D(e, o)
            }
        }
    }
    m(I)
}
)();

A second decode layer

function m is a while loop, and the dispatch is i = u[t[e.r[0]++]]. The next thing I want to know is what var N = i(e, X, B, f, n, r); is. Following the callee i leads us to:

u = new Proxy("9VNSH9ZSNd3J3EaSHEN9MSZd9Mz3S5EKdiSZEHs4J3SHqadHn6RQJH9MdidHJ4Sunk...".split("2"),(function() {
        var e = new Map;
        var n = Array.from({
            length: 182
        }, function(_, a) {
            return a
        });
        var r = "";
        var T = "GpezWjh79EdsqJSnO6XfcYRLoCxAtwVT08m1vPgFrBylUbDIMua54ZiK3HkQN";
        var E = t[Math.floor(c / 2)];
        var i = function(_, a) {
            var C = a ? E : 5 - 14 + 21 - 5 + 52 - 22 - 8 + 25;
            return d(_, T, 48).map(function(S, R) {
                return String.fromCharCode(S ^ C + R % 13)
            }).join("")
        };
        var N = function(_, a) {
            var C = Number(a);
            if (Number.isNaN(C) && a !== v) {
                return function(V) {
                    E = V
                }
            }
            if (!Number.isFinite(C)) {
                return n
            }
            var S = n[C];
            var R = e.get(S);
            if (R !== void 0) {
                return R
            }
            var w = _[S];
            var Y = w.slice(-1) === T[1];
            var b = new Function(r + i(w.slice(0, -1), Y) + "}")();
            e.set(S, b);
            return b
        };
        var o = {
            get: function(_, a) {
                r = i(_.pop(), false);
                o.get = N;
                return N(_, a)
            }
        };
        return o
    }
    )());

Now we can see my hunch was confirmed. There is a second decode layer. The handler table u is a Proxy over a huge string split on 2. 182 lazily decoded chunks, each one handler. Its own scheme (different alphabet, split 48, XOR keyed off the tape), unrelated to the tape's. There's the tape layer, then there's the handler layer, and that layer is keyed off the tape.

Building the sandbox

Then this is where I started running into issues. Firstly, I built a sandbox to run the script, and the tape kept ending up as []. A bit more digging showed that A actually has a self-defense mechanism that I glossed over. A captured script only decodes near mint time, and I had to make my sandbox pin Date. There's about a ~5hr bucket in which you can decode the script; anything after, you won't be able to without pinning Date.

A register machine, and learning the primitives

After I'd done that, I hooked the dispatcher to get the opcode -> handler source, and at the handler call, I wrapped X (reads) and B (stores) so for the opcodes I point at, every operand value is logged. But this is where I ran into a wall. I can read the handler JS, but I don't actually know the VM operation. Not only that, over the past several months I'd been working with stack machines only, and every piece of tooling I built was made for stack machines. Kasada's VM is a register machine. Luckily, though, most of it was transferable, and I just had to sit down and learn the primitive helpers (e/a/v/G/p, the i[] pools, the o[] services) until each op became plain JS to me.

I created a short table of what each helper actually does, so let's go through it.

The frame array n.r:

slot meaning
n.r[0] PC, program counter, index into tape t
n.r[1] scope object (the "activation record")
n.r[2] return-value
n.r[3] arguments
n.r[4..] registers / locals

Scope object (n.r[1]) fields:

The six handler params function(n, e, a, v, i, o):

param real fn exact body inline it as
n frame --- the frame; n.r[...]
e X @206 e=t[PC++]; if(e&1) return e>>1; ... fetch next source operand value
a B @122 e.r[ t[PC++]>>5 ] = x reg[dst] = x (register write)
v f @74 return e.r[1] the scope object (n.r[1])
i array [s, [M,W], t, u] data pools (see below)
o array [j, D, h, m, L, G] services (see below)

G = o[5] @270: return e.r[ t[PC++]>>5 ] -> read a register value

p @87: return t[e.r[0]++]>>5 -> the register index used by both a (write) and G (read)

a and G are mirrors:

e(n) and G(n) both mean "a source value".

i[] data pools:

o[] services:

And one thing you have to be careful of during op mapping is the shadowing traps. The handlers reuse i/o as for-loop vars and the dispatcher's own e,n,i,r are a different scope from the handler's n,e,i,o. Only the handler column above matters when reading a handler body.

Mapping the opcodes

After I'd done that, it was time to sit down and actually start mapping these opcodes. I started off with the easy ones like arithmetic ops, then worked progressively through them. After about 3 hours, every single op was named, but not without a few wrong. I had about 11 that were wrong, and had to go fix them. Then I ended up at the last one, op105, and this one was genuinely difficult. I had AI help me out on this op. It was a try/finally/exception dispatcher (.Y/.T/.H/.s/.z + n.e), with op44 SAVE / op96 RESTORE. It was too much to hold in my head at once, and it was by far the hardest op in the VM.

op 105  (x526)   =>  ENDFINALLY (completion dispatch: throw n.e | finally .T/.H | return .Y | throw saved h)
    function(n, e, a, v, i, o) {
        var r=o[0], l=o[1], u=e(n), _=v(n), h=_==null?void 0:_.M[u];
        _&&(_.M[u]=void 0), n.e?(_&&(_.T=void 0, _.H=void 0), l(n, n.e.X)):(_==null?void 0:_.T)!==void 0?(_.H=_.H-1, _.H===0?(n.r[0]=_.T, _.T=void 0, _.H=void 0):n.r[0]=_.s):_!=null&&_.Y?r(n, _.Y.X):h&&l(n, h.X)
    }

Disassembly and lifting

Now that the op name mapping is finished, it's time to move on to the disassembly and lifter. I recovered the disassembly by instrumenting the VM's own operand readers, then lifted per-opcode from the ISA. Then I ran into an issue, and a big finding. The table is self-constructing and reuses byte values. On a clean run, 176 distinct opcode bytes execute but resolve to only 172 distinct handlers, because 23 bytes run two different handlers at different points as the table redefines itself. So I had to change the design. A byte-keyed op map is simply wrong for reused bytes, so the lifter keys on handler source, not opcode byte. For 15 of the 23 reused bytes, the first-seen label is the rarer handler. Byte 147 first reads as DIV (e(n)/e(n), fires once) but is dominantly a GETVAR scope-chain walk (for(โ€ฆo=o.c)if(i in o.M), 791,794 fires). Byte 173 first reads as a service slot fetch i[3] (once) but is dominantly GETPROP e(n)[e(n)] (203,390). Byte 20's dominant handler is ADD r(n)+r(n) (1,763) with the first-seen one firing 3 times. The first-seen pass also missed 19 handlers that only materialize later, which I had to go back and map once more. This pins the op count to ~182 handler chunks, 176 bytes execute, 172 distinct handlers, and 23 dual-use.

Lifted pseudo-code

After switching the design, I ended up with the lifted output, which was ~47k instructions, 95.6% fully lifted, and structured into 1,441 functions/120 loops. Now I can finally dive into what exactly the VM collects off the browser, and how it folds that into the token it POSTs. I had AI build a semantic-map tool. It buckets the VM's 2,394 string constants by category and tags each of the 1,441 functions by what it touches, turning the huge wall into an addressable file.

String table

Signals

I won't be mapping every single signal Kasada collects, but I will be diving into a few of them. First off, in the photo above we can immediately see native-code checks. Basically, they'll call the source of a function with .toString(), and if it exists it'll return [native code]. Here's an example:

Function.prototype.toString.call(window.fetch);
// "function fetch() { [native code] }"

Not only does it check if it exists, it also checks if it's been tampered with. Instead of returning [native code] it'll return the source of the actual function. Attackers avoid this by hooking Function.prototype.toString itself and making it return a native-looking string so the detector can't see that the function has been tampered with, but this is only one among many other signals.

4 4x "scroll" @15271->f15254 @154523->f153677 @270761->f270337 @292241->f292031
3 1x "onmousedown" @46749->f46669
3 1x "onmousemove" @46749->f46669
3 1x "onmouseup"   @46793->f46669

Here we can see mouse movements are being used as a signal. I haven't added synthetic mouse movements to the sandbox so these likely never fire.

11 10x "innerWidth" @10281->f10225 @38866->f25150 ...
10 9x "innerHeight" @10289->f10225 @49517->f49444 ...
7 6x "outerWidth" @10299->f10225 @57294->f57291 ...
7 6x "outerHeight" @10315->f10225 @57326->f57323 ...
6 5x "devicePixelRatio" @10341->f10225 @57423->f57420 ...

They read the browser dimensions and devicePixelRatio in the same function, so let's take a peek into the pseudo-code for this one.

  r12[0] = r11;
  r2 = r10.apply(r8, r12);
  r9 = r2;
  r6[0] = r9;
  r2 = r7.apply("", r6);
  r5 = r2;
  r4["srcdoc"] = r5;
  r4 = M[469];
  r5 = r4["contentWindow"];
  r4 = !r5;
  if (!r4) {
    goto L10269;
  }
  L10269:
  r5 = M[469];
  r6 = r5["contentWindow"];
  M[470] = r6;
  r4 = M[470];
  r5 = r4["innerWidth"];
  M[471] = r5;
  r5 = M[470];
  r4 = r5["innerHeight"];
  M[472] = r4;
  r4 = M[470];
  r5 = r4["outerWidth"];
  M[473] = r5;
  r4 = M[470];
  r7 = r4["outerHeight"];
  M[474] = r7;
  r4 = M[470];
  r6 = r4["screenX"];
  M[475] = r6;
  r4 = M[470];
  r5 = r4["screenY"];
  M[476] = r5;
  r4 = M[470];
  r5 = r4["devicePixelRatio"];
  M[477] = r5;
  r4 = M[470];
  r5 = r4["pageXOffset"];
  M[478] = r5;
  r5 = M[470];
  r4 = r5["pageYOffset"];
  M[479] = r4;
  r4 = new Array(9);
  r6 = M[471];
  r4[0] = r6;
  r6 = M[472];
  r4[1] = r6;
  r10 = M[473];
  r4[2] = r10;
  r6 = M[474];
  r4[3] = r6;
  r6 = M[475];
  r4[4] = r6;
  r6 = M[476];
  r4[5] = r6;
  r5 = M[477];
  r4[6] = r5;
  r5 = M[478];
  r4[7] = r5;
  r5 = M[479];
  r4[8] = r5;
  return;

Here we can see some HTML being set on an iframe, then it collects nine window measurements from that iframe's contentWindow. This is collected to compare with the main window or check for inconsistent overrides. We can't see that here, but that is typically what these values are used for.

20 14x "getContext" @32602->f32428 @43737->f43631 ...
7 3x "getImageData" @33031->f32428 @75516->f75161 ...

Here we can see canvas-related properties being read. Let's take a look at function 32428:

  r5 = new Array(12);
  r5[0] = "๐Ÿ”ซ";
  r5[1] = "๐Ÿ”";
  r5[2] = "๐Ÿ˜ฌ";
  r5[3] = "๐Ÿ˜ฑ";
  r5[4] = "๐Ÿ’ƒ";
  r5[5] = "๐Ÿค“";
  r5[6] = "๐Ÿฅบ";
  r5[7] = "๐Ÿฅฐ";
  r5[8] = "๐Ÿป";
  r5[9] = "๐Ÿฉ";
  r5[10] = "๐ŸŠ";
  r5[11] = "๐Ÿช…";
  M[9586] = r5;
  r5 = global["document"];
  r7 = r5["createElement"];
  r8 = new Array(1);
  r8[0] = "canvas";
  r2 = r7.apply(r5, r8);
  r4 = r2;
  M[9587] = r4;
  r5 = M[9587];
  r5["width"] = 32;
  r4 = M[9587];
  r4["height"] = 32;
  r4 = M[9587];
  r6 = r4["getContext"];
  r7 = new Array(2);
  r7[0] = "2d";
  r10 = {};
  r10["willReadFrequently"] = true;
  r7[1] = r10;
  r2 = r6.apply(r4, r7);
  r5 = r2;
  M[9588] = r5;
  r4 = M[9588];
  r5 = !r4;
  if (!r5) {
    goto L32667;
  }
  L32667:
  r4 = M[9588];
  r4["font"] = "45px Arial";
  r5 = M[9588];
  r5["textAlign"] = "center";
  r4 = M[9588];
  r4["textBaseline"] = "middle";
  r5 = 32 / 2;
  M[9589] = r5;
  r4 = 32 / 2;
  M[9590] = r4;
  ...

This draws a layered emoji image on a canvas, then reads its pixels. This function is incredibly long, so let me simplify it:

const emojis = [
    "๐Ÿ”ซ", "๐Ÿ”", "๐Ÿ˜ฌ", "๐Ÿ˜ฑ", "๐Ÿ’ƒ", "๐Ÿค“",
    "๐Ÿฅบ", "๐Ÿฅฐ", "๐Ÿป", "๐Ÿฉ", "๐ŸŠ", "๐Ÿช…"
];

const canvas = document.createElement("canvas");
canvas.width = canvas.height = 32;

const ctx = canvas.getContext("2d", { willReadFrequently: true });
ctx.font = "45px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";

for (let i = 0; i < emojis.length; i++) {
    ctx.globalAlpha = i === 0 ? 1 : 0.15;

    ctx.save();
    ctx.translate(16, 16);
    ctx.rotate(i * 36 * Math.PI / 180);
    ctx.fillText(emojis[i], 0, 0);
    ctx.restore();
}

const pixels = ctx.getImageData(0, 0, 32, 32).data;

for (let i = 0; i < pixels.length; i += 4) {
    const alpha = pixels[i + 3];
    if (alpha === 0) continue;

    // goto L33158 /* unobserved */
}

12 emojis overlap on one 32x32 canvas, each rotated an additional 36 degrees. There is no clearing between draws. The first emoji is fully opaque, and the others use 15% opacity. It reads 4,096 RGBA bytes and skips fully transparent pixels. The final fragment converts a value to hexadecimal padded to at least two characters: value.toString(16).padStart(2, "0"). The subsequent processing of nontransparent pixels was not recovered as my sandbox did not take the rest of the path; therefore my lift marked it unobserved. The point of this post is to show what sort of signals are being collected, along with how to decompile a production VM of this magnitude, not a handover for a solver, so I won't be making my solver observe specific JMPs.

The last signal I'll be looking at that caught my eye:

14 14x "webdriver" @41135->f41046 @41357->f41046 @41405->f41046 @41458->f41046 
1 1x "runHeadlessFixes" @47187->f46657
1 1x "playwright" @47201->f46657
1 1x "^__playwright" @47404->f46657
1 1x "PhantomJS/" @150483->f149521
1 1x "HeadlessChrome/" @150576->f149521

Here we can see multiple different automation suites being tested for. We'll be looking at f46657.

  r6 = r5["qjd"];
  r8 = [];
  r2 = r6.apply(r5, r8);
  r4 = r2;
  r5 = r4["contentWindow"];
  M[9391] = r5;
  r5 = global["navigator"];
  r6 = r5["userAgent"];
  r5 = r6["indexOf"];
  r7 = new Array(1);
  r7[0] = "ADG/";
  r2 = r5.apply(r6, r7);
  r4 = r2;
  r5 = 0 - 1;
  r6 = r4 !== r5;
  M[9392] = r6;
  r5 = new Array(3);
  r5[0] = "runHeadlessFixes";
  r5[1] = "overrideStatic";
  r5[2] = "playwright";
  r6 = r5["concat"];
  r7 = new Array(2);
  r8 = M[53];
  r10 = M[9392];
  r11 = !r10;
  if (!r11) {
    goto L47247; /*unobserved*/
  }
  r13 = new Array(2);
  r13[0] = "CefSharp";
  r13[1] = "cefSharp";
  r10 = r13;
  ...

Once again the function is quite large, so let me simplify it:

const iframeWindow = M[7909].qjd().contentWindow;
const hasADG = navigator.userAgent.includes("ADG/");

const names = [
    "runHeadlessFixes",
    "overrideStatic",
    "playwright",

    // Included on the shown path when hasADG is false,
    // through helper M[53]:
    "CefSharp",
    "cefSharp",

    "privateRoot",
    "Hlclient",
    "transjson",
    "sasdssffssfffs",
    "xdsdsdsdsfsffsf",
    "shouldAutoSolveCaptcha",
    "registerCaptchaWidget",
    "getCaptchaWidget",
    "isCaptchaWidgetRegistered",
    "resetCaptchaWidget",
    "getSolverButton"
];

// Shown filter callback:
const matches = names.filter(
    name => name in window || name in iframeWindow
);

const prefixTests = [
    /^__pw/.test.bind(/^__pw/),
    /^__playwright/.test.bind(/^__playwright/)
];

The routine checks the main window and an iframe's window for a predefined set of property names associated with automation suites, browser embedding, as well as CAPTCHA-solving integrations. It also prepares prefix checks for __pw and __playwright, though their actual application isn't visible here. The user-agent marker ADG/ controls a branch affecting the candidate list; the other branch is not recovered.

Now I want to get into the defensive side. Honestly, I have to give credit where credit is due. Kasada has done a solid job with this VM, and it's the best-engineered client-side VM I've taken apart. It's genuinely polymorphic; the self-constructing loader is a good add; the mint-time decode gate was also a new one I haven't seen before. They've moved basically all fingerprinting into this behemoth of a VM. We've only just scratched the surface of what they collect. As I said previously, I'm pretty new to VM reversing, but this was a genuine grind to just decompile the VM itself. The time tax was real, and to run this VM at massive scale for a solver would be very costly on a CPU. Now, because the client is only ever a cost-raiser, none of the real security can live here. The VM's job is to make the collection expensive to forge faithfully, and the verdict must live server-side. It took me about four days to reverse the VM, but this is not a failure on Kasada's part; it's just the ceiling of client-side defense showing, which is exactly why vendors are pushing the decision server-side. This design specifically is good because it treats the client as a cost-raiser, not a secret-keeper.

This was a fun journey, and I hope you learned something along the way. Stay tuned for the next post.

Finished Opcode Map