I’ve always been into JavaScript VMs and anti-bot solving. I’ve built solvers for pretty much every major anti-bot and captcha system out there (I’m not saying I always scaled them, but they always worked perfectly for my personal usage :)). But I never tackled one where the entire anti-bot runs inside a custom VM. There are a few that do this: Shape Security, Kasada, and Cloudflare. Others like DataDome and PerimeterX use a VM for one field in their fingerprint (PerimeterX might have even removed theirs recently), but the ones I just mentioned go all in. The whole thing is a virtual machine.
A friend of mine had already done Cloudflare, so I decided to go after Kasada.
Before I even finished the solver and started talking to potential clients, I was only thinking about Kick and Twitch. But I quickly learned that nobody actually needs a solver for those two sites. They’re already pretty bad at bot protection on their own. People are just using real browsers to generate the CT header and only reversing the CD part because it’s lightweight work. They’re basically already farming those sites without a full solver.
And if you’re reading that thinking “what the hell is CT? what is CD?” then good, that means you’re in the right place. Let me get into it.
What Even Is This Thing?
When you visit a site protected by this anti-bot, your browser gets redirected to an invisible iframe that loads a fingerprinting page. If you open DevTools on Kick and filter by 149e9513 (some kind of version identifier they use across all sites), you can see the whole flow:

There are 4 requests happening in sequence:
- p.js loads first. This is the initializer. It sets up the environment, kicks off the PoW (proof of work) parameters, and fires the
/mfcrequest. - /mfc runs in parallel. This fetches challenge parameters for the CD (proof of work) portion.
- ips.js is the big one. This is the actual payload generator. It contains the entire bytecode VM, all the fingerprint probes, the encryption logic, everything. p.js is the one that tells the browser where to fetch it from.
- /tl is the final POST. The encrypted fingerprint payload gets sent here. If the server likes what it sees, you get back a
{"reload":true}response, the iframe reloads, and you receive yourx-kpsdk-ctheader and cookie.
If we look at the /fp response that kicks this whole thing off, it’s just a barebones HTML page:

A KPSDK object gets initialized with timing info, a postMessage fires back to the parent, and then the ips.js script loads. That script URL changes on every request and contains unique parameters, namely x-kpsdk-v (the version number) and x-kpsdk-im (a static token extracted from the postMessage call in the /fp page, which you just pass through as a header when posting to /tl).
So the flow is simple: p.js sets up → ips.js generates the fingerprint payload → payload gets encrypted and sent to /tl → server decides if you’re human or bot.
The CT header (x-kpsdk-ct) is what you need to make authenticated requests to the site. It’s basically your “I’m human” token. The CD header (x-kpsdk-cd) is the proof of work answer, a SHA256 challenge that proves you spent some CPU cycles. Together they form the full anti-bot handshake.
Now the real question is: what’s actually inside ips.js?
The Script
If you open ips.js expecting to read some JavaScript, good luck. The whole file is ~477KB and it’s essentially three things glued together: a bytecode decoder, a massive encoded string containing the bytecodes, and a VM that executes them.
Here’s the decoder function in full:
function A(i, r, e) {
var n = r.length;
var t = n - e;
var a = 14;
var u = [Math.round(+new Date() / 18000081) * a];
for (var f = 1; f < 2; f++) {
u.push(u[0] - a * f);
u.push(u[0] + a * f);
}
var o = 4896681;
var c = new Map();
for (var v = 0; v < n; v++) c.set(r[v], v);
var d = function (P) {
var A = function (i) {
var r = new Proxy(Array.from(i.toString(), Number), function () {
var e = new Map();
var n;
var t = 0;
return {
get: function (a, u) {
if (u === "a") {
if (n === undefined) n = a.length;
return n;
}
if (u === "b") return t++;
var f = Number(u);
var o = e.get(f);
if (o !== undefined) return o;
var c = a.slice(0, f).reduce(function (v, d) {
return v + d;
}, 0);
e.set(f, c);
return c;
}
};
}());
return function (e) {
return e - r[r.b % r.a];
};
}(P);
var s = [];
for (var N = 0; N < i.length;) {
var m = 0, L = 1;
if (o !== undefined && s.length === 14) {
var _ = 2166136261;
var M = new TextEncoder().encode(s.join(","));
for (var h = 0; h < M.length; h++) {
_ ^= M[h];
_ *= 16777619;
}
_ >>>= 0;
if (_ !== o) return;
o = undefined;
}
while (true) {
var l = c.get(i[N++]) || 0;
if (l < e) { m += L * l; s.push(A(m | 0)); break; }
m += L * (l % e + e);
L *= t;
}
}
return s;
};
for (var T = u.length, E = 0; E < T; E++) {
var g = d(u[E]);
if (E === u.length - 1) o = undefined;
if (g !== undefined) return g;
}
return [];
}
It gets called like this:
var t = A(H, "sIkv2+OzS4BFn$PڣhubMEX87QKmZa1YRpUΛWoNT>Aw~teig39|0GJ6CldHr=Vy^qc5jfxLD<", 48);
Where H is the massive encoded string, the second argument is the alphabet (changes per script), and 48 is the radix (also changes per script). The output t is the decoded bytecode array, just an array of integers.
The encoding is custom. It’s not base64 or anything standard. Each character maps to a position in the alphabet, and values get decoded using a variable-length scheme with the radix. But the important part is the time-lock.
The seed is derived from the current time: Math.round(+new Date() / 18000081) * 14. That time divisor creates windows of about 5 hours. The decoder uses the seed’s digits as a prefix-sum lookup (notice the Proxy wrapping the digit array) and subtracts from each decoded value, so if your seed is wrong, everything comes out wrong. The script tries 3 seeds (current window, -1, +1) to handle clock drift.
After decoding 14 values, it runs an FNV-1a hash check against a hardcoded constant (4896681 in this version). If the hash doesn’t match, that seed was wrong and it tries the next one. This is their script expiry mechanism. After a few hours, the valid time window passes and the script becomes undecodable.
For my solver, I brute-force across 2000+ time windows so I can disassemble any script at any time, even old ones. If you want to debug a script you captured yesterday, you just need to find the right window. The whole brute-force takes about a millisecond. And if you’re lazy, you can just patch their f < 2 loop to f < 99999 in the script and the expiry is completely bypassed.
All of these parameters (the alphabet, radix, time divisor, FNV constant, seed multiplier) change between script versions, so my solver has to parse them fresh from each script.
The VM
Once the bytecodes are decoded, they get fed into the VM. Here’s how the handler system works:
u = new Proxy(handlerString.split("n"), function () {
var e = new Map();
var n = Array.from({ length: 172 }, function (_, a) { return a; });
var r = "";
var T = "ZuS2ebz5DgpsW0B8R1MAtJcoEjmLN3xGYv9KFOIClhkiTfV4yQawdXPHUr67q";
var E = t[Math.floor(f / 2)];
function i(_, a) {
var C = a ? E : 84;
return d(_, T, 50).map(function (S, R) {
return String.fromCharCode(S ^ C + R % 25);
}).join("");
}
function N(_, a) {
var C = Number(a);
if (!Number.isFinite(C)) return n;
var S = n[C];
var R = e.get(S);
if (R !== undefined) 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;
}());
The Proxy wraps an array of 172 handler strings (split by "n", the delimiter also changes per script). When the VM accesses a handler by index, the Proxy looks up the index through a permutation array n (initially [0, 1, 2, ..., 171]), decodes the handler string using the same radix decoder d with a different alphabet, XORs each byte with a key (84 for regular handlers, a different key E extracted from the bytecodes for flagged handlers), creates an executable function with new Function(), and caches it. There’s also a prefix function body r that gets prepended to every handler, extracted from the last element of the handler array on first access.
That XOR key changes between script updates. It used to be parseable straight from the JavaScript, but they moved it into the VM bootstrap so you’d need to actually disassemble the bytecodes to extract it. Instead of figuring that out, I just brute-force it. Here’s the actual function:
fn find_handler_xor_e(handler_strings: &[&str], flag_char: Option<char>,
handler_alphabet: &str, handler_radix: usize,
handler_xor_mod: i64) -> i64 {
let mut flagged: Vec<&str> = handler_strings.iter()
.filter(|s| flag_char.map_or(false, |fc| s.ends_with(fc)) && s.len() > 10)
.copied().collect();
flagged.sort_by(|a, b| b.len().cmp(&a.len()));
let samples: Vec<&str> = flagged.into_iter().take(10).collect();
let raw_ints = decoder::u_decode(&samples[0][..samples[0].len() - 1],
handler_alphabet, handler_radix);
let first_raw = raw_ints[0] as i64;
for first_char in 32i64..127 {
let candidate = first_raw ^ first_char;
let all_valid = samples.iter().all(|s| {
let stripped = &s[..s.len() - 1];
let decoded = decoder::decode_handler(stripped, handler_alphabet,
handler_radix, candidate,
handler_xor_mod);
decoded.len() > 5
&& decoded.chars().all(|c| c >= ' ' && c <= '~')
&& decoded.contains("(n")
});
if all_valid { return candidate; }
}
0
}
Try every printable ASCII value (32-126), XOR the first raw integer with it to get a candidate key, decode a few handler strings, and check if the result is valid JavaScript. First one that passes wins. A few microseconds of extra work and it means I never have to worry about their formatting changes breaking my parser.
The main execution loop:
function U(e) {
var n = [O, [M, W], t, u];
var r = [X, D, h, U, L, j];
while (true) {
var i = undefined;
i = u[t[e.X[0]++]];
try {
var N = i(e, G, F, s, n, r);
if (N === null) break;
} catch (o) {
D(e, o);
}
}
}
Read a bytecode value from t, use it to index into u (the Proxy), get a handler function, execute it. Notice how every single instruction runs inside a try-catch. That’s not error handling for safety, that’s the actual VM dispatcher. The catch block D is how the VM implements exceptions, scope unwinding, and control flow for try/catch/finally in the guest code. Smart design actually.
The VM is register-based. Each handler operates on a state object e that holds registers (.X), scope chain, exception handlers, and the bytecode pointer. The helper functions (F for writing registers, G for reading values, j for reading registers, s for getting current scope, B for reading bytecode as a register index) are passed into every handler call. All the code examples I’m showing are from a specific ips.js version I captured. I’ll have both the original and decompiled versions available at the end of the post if you want to poke around yourself.
The type system is encoded inline in the bytecodes. When the VM reads a value with function g:
function g(r, v, f, l) {
var a = r[v[0]++];
if (a & 1) return a >> 1; // odd = integer
if (a === f[2]) return null; // type tag match
if (a === f[5]) return false;
if (a === f[4]) { // string: XOR'd in string cache
if (l != null && l.d) return l.d(r[v[0]++], r[v[0]++]);
var p = "";
for (var x = r[v[0]++], n = 0; n < x; n++) {
var u = r[v[0]++];
p += String.fromCharCode(u & -64 | u * 41 & 63);
}
return p;
}
if (a !== f[0]) {
if (a === f[1]) return true;
if (a === f[3]) { // double: IEEE754 split
var h = r[v[0]++], t = r[v[0]++];
var M = h & -2147483648 ? -1 : 1;
var I = (h & 2146435072) >> 20;
var S = (h & 1048575) * Math.pow(2, 32) + (t < 0 ? t + Math.pow(2, 32) : t);
// ... reconstruct float from mantissa/exponent
}
return v[a >> 5]; // register reference
}
}
The type tag array f (called y in the script, [44, 18, 38, 46, 10, 22] in this version) maps to [void, true, null, double, string, false]. These tag values are completely dynamic and change per script version. Even the order of the checks in the g function changes. My solver parses them from the AST every time and remaps them internally:
pub struct TypeTagMap {
pub false_idx: usize,
pub true_idx: usize,
pub null_idx: usize,
pub void_idx: usize,
pub string_idx: usize,
pub double_idx: usize,
}
The string encoding is worth a mention too. Each character is stored XOR’d: (charCode & ~63) | (charCode * 41 & 63). The string cache itself is spliced out of the bytecodes at startup and stored separately. I don’t think I have to mention that those XOR constants are also dynamic per script.
The Permutation
This is the key to the whole thing.
Remember that identity array [0, 1, 2, ..., 169] in the Proxy? That’s the permutation table. It maps raw bytecode opcode values to actual handler indices. During bootstrap (the first few hundred bytecodes that run before the real program), the VM shuffles this array using a Fisher-Yates shuffle with seed values embedded in the bytecodes.
The shuffle uses a sin-based PRNG: frac(sin(seed) * 10000) where the seed increments by 1 each iteration. After the shuffle, opcode 42 in the bytecodes no longer means handler 42. It means whatever handler is now at permutation[42]. Every new script version ships with different seeds, so the permutation changes every time. Without the correct permutation, your disassembler outputs complete garbage.
My solver extracts the permutation dynamically. The seed values are embedded in the bytecodes as triplets: (register, index*2+1, value*2+1). The odd encoding is because of the type system I mentioned earlier (odd = inline integer). My solver scans the entire bytecode array for these patterns, groups them by register, and uses gap analysis to separate the tight bootstrap cluster from the main program noise that comes after:
fn find_seed_tables_dynamic(bytecode: &[i32], opcode_count: usize) -> Vec<Vec<usize>> {
let mut triplets_by_reg: HashMap<i32, Vec<(usize, usize, usize)>> = HashMap::new();
for i in 0..bytecode.len().saturating_sub(2) {
let reg = bytecode[i];
if reg < 0 { continue; }
let idx_raw = bytecode[i + 1];
let val_raw = bytecode[i + 2];
if idx_raw & 1 != 1 || val_raw & 1 != 1 || idx_raw < 0 || val_raw < 0 { continue; }
let idx = (idx_raw >> 1) as usize;
let val = (val_raw >> 1) as usize;
if idx < opcode_count && val < opcode_count {
triplets_by_reg.entry(reg).or_default().push((i, idx, val));
}
}
// cluster by median gap, extract seed table, resolve conflicts...
}
Here’s what the raw decoded bytecodes look like in the bootstrap region (before disassembly, before the permutation is applied). Register 160 is the permutation array in this script:
raw bytecodes (bootstrap region):
...
160, 1, 33 -> reg=160, seed[0] = 16 (1>>1=0, 33>>1=16)
160, 3, 135 -> reg=160, seed[1] = 67 (3>>1=1, 135>>1=67)
160, 5, 147 -> reg=160, seed[2] = 73 (5>>1=2, 147>>1=73)
160, 7, 83 -> reg=160, seed[3] = 41 (7>>1=3, 83>>1=41)
160, 9, 145 -> reg=160, seed[4] = 72 (9>>1=4, 145>>1=72)
160, 11, 71 -> reg=160, seed[5] = 35 (11>>1=5, 71>>1=35)
160, 13, 119 -> reg=160, seed[6] = 59 (13>>1=6, 119>>1=59)
160, 15, 139 -> reg=160, seed[7] = 69 (15>>1=7, 139>>1=69)
160, 17, 53 -> reg=160, seed[8] = 26 (17>>1=8, 53>>1=26)
160, 19, 41 -> reg=160, seed[9] = 20 (19>>1=9, 41>>1=20)
160, 21, 201 -> reg=160, seed[10] = 100 (21>>1=10, 201>>1=100)
...
See the pattern? Each triplet is (register, index*2+1, value*2+1). The odd encoding is because of the type system: odd values are inline integers (shifted right by 1 to decode). My solver scans the entire bytecode array for these patterns, groups them by register, and builds the full seed table:
extracted seed = [16, 67, 73, 41, 72, 35, 59, 69, 26, 20, 100, 55, 51,
10, 91, 39, 77, 44, 24, 40, 82, ...]
My solver feeds these seeds into the Fisher-Yates replay and validates the result:
[perm] best_walk=50000 best_start=100
perm=[16, 67, 73, 41, 72, 167, 59, 154, 165, 20, 145,
55, 51, 100, 91, 39, 132, 44, 24, 160, 141, 18, 69,
157, 153, 60, 158, 87, 171, 54, 97, 30, 40, 26, 21,
137, 80, 90, 93, 147, 151, 135, 36, 35, 149, 1, 2,
83, 4, 43, 6, 144, 128, 82, 161, 143, 7, 13, 34,
138, 155, 77, 14, 52, 9, 62, 22, 133, 58, 139, 19,
27, 136, 134, 31, 74, 15, 148, 68, 5, 168, 37, 152,
129, 8, 76, 42, 159, 17, 0, 169, 166, 28, 170, 32,
25, 70, 163, 29, 11, 142, 57, 47, 50, 33, 61, 65,
63, 38, 106, 66, 45, 102, 12, 96, 49, 48, 46, 53,
118, 3, 23, 112, 79, 86, 10, 71, 120, 84, 85, 124,
64, 88, 89, 81, 78, 92, 131, 94, 95, 114, 75, 56,
99, 98, 101, 122, 103, 104, 105, 109, 107, 108, 150,
110, 111, 146, 113, 140, 115, 116, 117, 119, 162, 127,
121, 156, 123, 130, 125, 126, 164]
50,000 valid instructions decoded. The entire program decodes cleanly. A wrong permutation wouldn’t even last 50.
The Opcodes
Now that we can decode the bodies, here’s what the opcodes actually look like. My solver logs every decoded body during disassembly. Here’s the full dump from this script (172 opcodes total, showing all unique bodies):
[0] e(n)[e(n)]=e(n);var o=e(n),l=e(n),_=e(n),h=r[4]; InvokeSetReg
if(l[h]&&l[h].C===l){var u;
n.X=[l[h].H,{Q:o,K:l,X:n.X,v:[],V:l[h].V,
G:(u=n.$)===null||u===void 0?void 0:u.get(l[h].G)},
void 0,function(){return arguments}.apply(void 0,_)];
for(var t=0;t<_.length;t++)n.X.push(_[t])
}else n.X[2]=l.apply(o,_)
[8] n.X[0]=e(n) Jmp
[9] a(n,e(n)+e(n)) Add
[11] ScopeGet+GetProp+NewArray (fused) FusedScopeGetGetPropNewArray
[12] ScopeGet+SetReg (fused, x2 loop) FusedScopeGetSetReg
[14] for(var i=e(n),r=v(n);r;r=r.V) ScopeGet
if(i in r.v){a(n,r.v[i]);return}throw 'ball'
[17] var i=e(n),r=v(n);r.v[i]=void 0 ScopeDeclare
[19] var o=e(n),l=e(n),_=e(n),h=r[4]; CreateFunc
if(l[h]&&l[h].C===l){var u;
n.X=[l[h].H,{Q:o,K:l,X:n.X,v:[],V:l[h].V,
G:(u=n.$)===null||u===void 0?void 0:u.get(l[h].G)},
void 0,function(){return arguments}.apply(void 0,_)];
for(var t=0;t<_.length;t++)n.X.push(_[t])
}else n.X[2]=l.apply(o,_)
[21] for(var a=0;a<3;a++)e(n)[e(n)]=e(n) SetReg(x3)
[28] a(n,i[3]) GetBytecodeRef
[29] var o=r[5];a(n,o(n)>>o(n)) ShrY
[35] var o=r[5];a(n,o(n)<e(n)) LtYR
[40] a(n,new Array(e(n))) NewArray
[42] a(n,e(n)/e(n)) Div
[44] var o=r[5];a(n,o(n)+e(n)) AddYR
[48] GetProp+ScopeGet+SetReg (fused) FusedGetPropScopeGetSetReg
[51] ScopeGet+ScopeGet+GetProp (fused) FusedScopeGetGetProp
[54] var o=r[5];a(n,o(n)%o(n)) ModY
[55] for(var i=v(n),r=0;r<3;r++){var o=e(n);i.v[o]=void 0} ScopeDeclare(x3)
[56] e(n)[e(n)]=e(n),e(n)[e(n)]=e(n) SetReg(x2)
[63] for(var i=e(n),r=e(n),o=v(n);o;o=o.V) ScopeChainSet
if(i in o.v){o.v[i]=r;return}throw 'ball'
[67] ScopeChainSet+ScopeGet (fused) FusedScopeChainSetScopeGet
[70] a(n,e(n)-e(n)) Sub
[76] return null Halt
[77] var o=r[5];a(n,o(n)+o(n)) AddY
[79] var o=r[5];a(n,e(n)<<o(n)) ShlYR
[80] ScopeGet+SetReg (fused) FusedScopeGetSetReg
[100] var o=r[5];a(n,o(n)<o(n)) LtY
[102] a(n,new Array(e(n))),e(n)[e(n)]=e(n) NewArrayInit
[105] a(n,e(n)) Identity
[106] a(n,e(n)+e(n)) Add
[107] e(n)?e(n):n.X[0]=e(n) JmpFalse
[110] for(var a=0;a<5;a++)e(n)[e(n)]=e(n) SetReg(x5)
[113] a(n,e(n)[e(n)]),a(n,e(n)[e(n)]),a(n,e(n)[e(n)]),a(...) GetProp(x4)
[120] ScopeGet+ScopeGet (fused, labeled loop) Fused2ScopeGet
[122] a(n,i[0][e(n)]),a(n,e(n)[e(n)]) GetGlobalProp
[128] a(n,i[0][e(n)]) GetGlobal
[131] a(n,e(n)-e(n)) Sub
[138] a(n,e(n)[e(n)]) GetProp
[148] a(n,e(n)^e(n)) BitXor
[152] for(var a=0;a<4;a++)e(n)[e(n)]=e(n) SetReg(x4)
[153] for(var i=v(n),r=0;r<2;r++){...i.v[o]=r===0?...} ScopeDeclareConditional
[157] a(n,e(n)[e(n)]),a(n,new Array(e(n))) FusedGetPropNewArray
[158] var o=r[5];a(n,o(n)-e(n)) SubYR
[166] e(n)[e(n)]=e(n) SetReg
[169] for(var i=v(n),r=0;r<2;r++){var o=e(n);i.v[o]=void 0} ScopeDeclare(x2)
[172] return function(n,e,a,v,i,r){ (prefix wrapper)
Every opcode is a tiny JavaScript function that operates on the VM state through a few helper functions:
e(n)reads the next value from the bytecode (integer, string, register reference, etc.)a(n, ...)writes a result to the next registerv(n)gets the current scope chainr[5]is an alternative read function (reads a register index from bytecode, used in theYvariant opcodes)i[0]is the global window objecti[3]is the handler array (the Proxy itself)
So a(n,e(n)+e(n)) reads two values and writes their sum. That’s the Add opcode. e(n)[e(n)]=e(n) reads an object, a property, and a value, then does a property set. That’s SetReg. return null stops the VM loop. That’s Halt. n.X[0]=e(n) sets the bytecode pointer to a new address. That’s Jmp.
The critical opcodes for understanding the program are:
- CreateFunc
[19]: Creates a VM function. Reads a start address, name, argument count, and captures the current scope. This is how all the fingerprint probe functions get defined. - Invoke/InvokeSetReg
[0]: Calls a function with arguments. If it’s a VM function, it pushes a new frame and jumps to its bytecode. If it’s a native function, it calls.apply(). - GetProp
[138]/ SetReg[166]: Property access and assignment.a(n,e(n)[e(n)])ande(n)[e(n)]=e(n). These are how the script readsnavigator.userAgent, writes to arrays, etc. - ScopeGet
[14]/ ScopeChainSet[63]: Variable access. The VM uses a linked-list scope chain (.Vis the parent link,.vis the variable map). Walking the chain withfor(var r=v(n);r;r=r.V)is how it resolves variable lookups. - JmpTrue/JmpFalse
[107]: Conditional jumps.e(n)?e(n):n.X[0]=e(n)reads a condition, and if true, consumes the jump target and continues. Otherwise it sets the bytecode pointer (jumps). - NewArray
[40]: Creates arrays.a(n,new Array(e(n))). This is important because the fingerprint template is anew Array(428). - Halt
[76]:return null. Stops the VM execution loop.
Each opcode also has a read/write sequence (rw_sequence) that tells my solver how many operands it consumes and which are reads vs writes. For example, a(n,e(n)+e(n)) has rw_sequence RRW (read, read, write). e(n)[e(n)]=e(n) is RRR (three reads, no write to register). My solver uses these patterns to walk the bytecodes correctly, consuming the right number of values per instruction.
One thing Kasada does to make life harder is fused opcodes. Instead of using simple one-operation handlers, they combine multiple operations into a single handler. For example:
[21] for(var a=0;a<3;a++)e(n)[e(n)]=e(n) -> SetReg(x3)
[56] e(n)[e(n)]=e(n),e(n)[e(n)]=e(n) -> SetReg(x2)
[110] for(var a=0;a<5;a++)e(n)[e(n)]=e(n) -> SetReg(x5)
[51] ScopeGet + ScopeGet + GetProp -> fused
[80] ScopeGet + SetReg -> fused
SetReg(x3) does three property assignments in one opcode. My solver detects the loop count or comma-separated repetitions automatically and names them accordingly. [51] does two scope lookups and a property get in one go. These fused opcodes mean a single value in the bytecodes can correspond to multiple logical operations. My solver identifies them by normalizing the body (replacing VM-specific property names with generic ones) and matching against a signature table.
My solver identifies opcodes in two stages. First, it normalizes each opcode body by replacing all VM-specific property names with canonical ones (since .X might become .n next week, .V might become .g, etc). These property names are detected dynamically from the handler bodies themselves. Then it hashes the normalized body with SHA256 and looks it up in a precomputed signature table:
fn signature(body: &str) -> String {
let inner = &body[body.find('{').unwrap() + 1..body.len() - 1];
let hash = Sha256::digest(inner.as_bytes());
hex::encode(&hash[..8])
}
fn build_signature_table() -> HashMap<&'static str, Op> {
let mut m = HashMap::new();
m.insert("0f63008cc75333bb", Op::Add);
m.insert("1253f0377c5a2c6c", Op::Identity);
m.insert("1db00b67f2c04a31", Op::JmpTrue);
// ... 188 total signatures
m
}
If the hash doesn’t match any known signature (new opcode from an update), it falls back to structural matching that figures out what the opcode does by counting patterns in the body rather than matching exact code. Between the two approaches, my solver handles script updates without breaking.
Control Flow Graph
At this point we have a flat list of disassembled instructions. That’s nice, but it’s just a stream of addresses. To understand what the program does, we need to recover the structure: which instructions belong to which function, where jumps go, what the branches are.
Here’s a real function from the disassembly. It’s a timing utility that picks the best available clock:
3410: GetGlobal "performance", r4
3415: Typeof r4, r6
3418: SneqYR r6, "undefined", r4
3424: JmpFalse r4, 3440
3427: GetGlobalProp "performance", r5, r5, "now", r6
3437: Identity r6, r4
3440: JmpFalse r4, 3481
3443: GetGlobal "performance", r4
3448: GetProp r4, "now", r5
3454: GetProp r5, "bind", r7
3460: NewArray 1, r9
3463: GetGlobal "performance", r4
3468: SetReg r9, 0, r4
3472: Invoke r5, r7, r9
3476: Identity r2, r6
3479: Return r6
3481: GetGlobalProp "Date", r4, r4, "now", r6
3491: JmpFalse r6, 3534
3494: GetGlobalProp "Date", r6, r6, "now", r7
3504: GetProp r7, "bind", r6
3510: NewArray 1, r8
3513: GetGlobal "Date", r11
3518: SetReg r8, 0, r11
3522: Invoke r7, r6, r8
3526: Identity r2, r9
3529: Identity r9, r4
3532: Jmp 3573
3534: CreateFuncSkip 3542, "", 0, r8, 3570
3570: Identity r8, r4
3573: Return r4
Flat. You can sort of read it if you follow the jump addresses manually, but with tens of thousands of instructions that’s not going to work. A control flow graph splits this into basic blocks (straight-line sequences of instructions that always execute together) and connects them by their jump relationships.
My solver scans for CreateFunc instructions (which define function start addresses), jump targets (Jmp, JmpTrue, JmpFalse), and splits the stream at those boundaries. Each block gets linked to its successors. Here’s what that same function looks like after CFG recovery:
fn_3410 (0 args):
block 870 succs=[874, 876]: // check if performance exists
3410: GetGlobal "performance", r4
3415: Typeof r4, r6
3418: SneqYR r6, "undefined", r4
3424: JmpFalse r4, 3440 --> block 876
block 874 succs=[876]: // performance.now exists
3427: GetGlobalProp "performance", r5, r5, "now", r6
3437: Identity r6, r4
block 876 succs=[877, 886]: // branch: use perf or fallback?
3440: JmpFalse r4, 3481 --> block 886
block 877 succs=[]: // return performance.now.bind(performance)
3443: GetGlobal "performance", r4
3448: GetProp r4, "now", r5
3454: GetProp r5, "bind", r7
3460: NewArray 1, r9
3463: GetGlobal "performance", r4
3468: SetReg r9, 0, r4
3472: Invoke r5, r7, r9
3476: Identity r2, r6
3479: Return r6
block 886 succs=[888, 897]: // check if Date.now exists
3481: GetGlobalProp "Date", r4, r4, "now", r6
3491: JmpFalse r6, 3534 --> block 897
block 888 succs=[907]: // return Date.now.bind(Date)
3494: GetGlobalProp "Date", r6, r6, "now", r7
3504: GetProp r7, "bind", r6
3510: NewArray 1, r8
3513: GetGlobal "Date", r11
3518: SetReg r8, 0, r11
3522: Invoke r7, r6, r8
3526: Identity r2, r9
3529: Identity r9, r4
3532: Jmp 3573 --> block 907
block 897 succs=[906]: // fallback: create fn_3542
3534: CreateFuncSkip 3542, "", 0, r8, 3570
block 906 succs=[907]:
3570: Identity r8, r4
block 907 succs=[]: // final return
3573: Return r4
children: [fn_3542] // nested function
fn_3542 (0 args): // new Date().getTime()
block 898 succs=[]:
3542: GetGlobal "Date", r7
3547: EmptyArray r6
3549: NewCall r7, r6, r5
3553: GetProp r5, "getTime", r8
3559: EmptyArray r6
3561: Invoke r5, r8, r6
3565: Identity r2, r4
3568: Return r4
Now you can read the logic. Block 870 checks if performance exists. If yes, block 877 returns performance.now.bind(performance). If not, block 886 checks for Date.now. If that exists, block 888 returns Date.now.bind(Date). Otherwise block 897 creates a child function fn_3542 that does new Date().getTime() and returns that as the fallback. The CFG also tracks parent-child function relationships through CreateFunc instructions.
Here’s what the same functions look like in the decompiled output:
function fn_3410() {
function fn_3542() {
var t5 = new Date(...[]);
var t8 = t5.getTime;
return typeof t8 === "function" ? t8.apply(t5, []) : undefined;
}
if (typeof performance !== "undefined" && performance.now) {
var t7 = performance.now.bind;
return typeof t7 === "function" ? t7.apply(performance.now, [performance]) : undefined;
} else {
if (Date.now) {
var t6 = Date.now.bind;
return typeof t6 === "function" ? t6.apply(Date.now, [Date]) : undefined;
} else {
return fn_3542;
}
}
}
That’s one utility function. The same CFG recovery works across the entire script, turning a flat instruction stream into structured functions with basic blocks and jump relationships.
Decompilation
Once we have the CFG, we can go one step further and turn the bytecodes back into readable JavaScript. The idea is straightforward: each disassembled instruction maps to an expression or statement, and we use an existing JavaScript code generator to produce the output.
Take a simple instruction like Add r5, r4, r6. That’s “read r5, read r4, write their sum to r6”. In my intermediate representation that becomes:
Stmt::Assign(Dest::Reg(6), Expr::BinOp(BinOp::Add, Expr::Reg(5), Expr::Reg(4)))
Then the emitter walks that IR and builds an actual JavaScript AST using oxc (a Rust-based JS toolchain). The BinOp::Add maps to BinaryOperator::Addition, registers become variable names (r5 → t5, r4 → t4), and oxc’s code generator prints it as:
t6 = t5 + t4;
The same logic handles everything: GetProp becomes obj[prop], Invoke becomes func.apply(this, args), ScopeGet becomes a variable reference, JmpFalse becomes an if statement. The emitter walks each basic block in order, emits the statements, and connects the blocks using the CFG’s successor relationships to reconstruct if/else branches and loops.
The register naming follows a simple convention: r2 is always __ret (the return value), r3 is arguments, registers 4 and up are function parameters (a0, a1, …), and everything else becomes t5, t6, etc.
The output is a mechanical translation, not a human-written decompilation. Could I make it cleaner? Sure. But honestly it looks pretty enough for me to understand what every function does, and that’s all I needed.
Fingerprints
This is what the whole script exists for. Everything before this (the VM, the permutation, the bytecode encoding) is just obfuscation. The actual purpose is to collect a massive fingerprint of your browser and send it to the server.
The Template
The script creates a template array with new Array(428). Most slots start at 0, but some have non-zero init values:
template[20] = 28
template[39] = false
template[94] = false
template[195] = 89
template[272] = true
template[284] = 85
template[415] = 56
My solver finds this array by scanning for new Array(428) and reading the SetReg instructions that follow to extract the init values.
The Batches
There are 20 batch functions. Each one wraps a group of probe functions in Promise.all and runs them in parallel. Here’s what it looks like in the decompiled output:
__ret = typeof t6 === "function" ? t6.apply(a0, [v358, [
fn_128403, fn_131858, fn_135848, fn_41032, fn_44072,
fn_48490, fn_234559, fn_10823, fn_14345, fn_24328,
fn_27753, fn_30768, fn_34132, fn_165324, fn_168357,
fn_172870, fn_175862, fn_178937, fn_182240, fn_184229
]]) : undefined;
20 functions passed into Promise.all. My solver finds these by scanning the bytecodes for NewArray(20) filled with CreateFunc references. It finds them unordered initially, but then sorts them by their index in the array to match the execution order. So the final order is fn_128403 first (index 0), then fn_131858 (index 1), then fn_135848 (index 2), and so on. This matters because each batch shuffles the template before writing its probe results.
What They Collect
427 probes. Here’s the full output from my solver, every single one identified:
batch 0: fn_128403 -> 23 slots [11:animation_end_time(fn_128837), 27:window_timezone_offset(fn_128929), 57:css_text(fn_129032), 89:window_nav_info_plugin_proto(fn_129157), 90:audio_wav_codec0(fn_129251), 94:prop_recorder_to_string(fn_129389), 105:math_random_floor_4096(fn_129586), 124:window_navigator_app_version(fn_129709), 127:iframe_own_props_idx0(fn_129797), 205:window_set_timeout(fn_129897), 221:prefers_color_scheme(fn_130049), 241:window_inner_height(fn_130155), 242:css_min(fn_130235), 275:iframe_global_privacy_control(fn_130361), 289:css_max(fn_130486), 293:window_global_privacy_control(fn_130608), 304:window_plugins_descriptor(fn_130733), 305:video_matroska_theora(fn_130980), 308:audio_wav_codec1_str(fn_131121), 337:audio_wave(fn_131263), 344:overflow_block(fn_131408), 348:css_get(fn_131526), 394:css_epub(fn_131652)]
batch 1: fn_131858 -> 24 slots [24:win_own_props_idx1(fn_132283), 34:css_justify(fn_132382), 43:gl_max_uniform_buffer_bindings(fn_132507), 50:iframe_keys_idx3(fn_132629), 60:css_scroll(fn_132727), 70:animation_playback_rate(fn_132851), 108:srcdoc_error_message(fn_132946), 166:device_posture(fn_133168), 169:css_shape(fn_133274), 192:iframe_attach_shadow_name(fn_133402), 208:iframe_set_timeout(fn_133499), 213:iframe_attach_shadow_tostring(fn_133647), 235:window_is_secure_context(fn_133806), 255:visual_viewport_offset_top(fn_133924), 264:video_webm_vorbis_vp9(fn_134027), 267:window_proxy_check(fn_134169), 273:iframe_timezone_string(fn_134266), 280:window_result_plugins(fn_134365), 310:css_background(fn_134460), 323:touch_event_check(fn_134582), 370:iframe_screen_color_depth(fn_134701), 383:emoji_wavy_dash(fn_134788), 392:system_colors(fn_134882), 424:video_mp4_mp4v20_mp4a40(fn_135621)]
batch 2: fn_135848 -> 25 slots [7:css_align(fn_37418), 11:device_width(fn_37542), 37:iframe_navigator_platform(fn_37660), 47:iframe_timezone_offset(fn_37746), 55:window_locationbar_visible(fn_37849), 56:css_color(fn_37993), 61:prop_recorder_own_descriptors(fn_38121), 102:window_nav_info_no_chrome(fn_38327), 122:prop_recorder_array_buffer(fn_38420), 125:media_resolution(fn_38642), 132:window_screen_avail_height(fn_38762), 166:iframe_body_client_width(fn_38847), 180:iterator_stop(fn_38997), 183:used_js_heap_size(fn_39120), 236:window_inner_width(fn_39325), 264:iframe_nav_info_no_chrome(fn_39408), 289:prop_recorder_blob(fn_39499), 295:video_mp4_mp4a(fn_39728), 299:video_mp4_mp4v(fn_39873), 308:window_set_interval_id(fn_40017), 314:srcdoc_getter(fn_40118), 350:prop_recorder_fetch(fn_40225), 352:gl_unmasked_renderer(fn_40475), 370:device_aspect_ratio(fn_40677), 403:math_random_uint16(fn_40798)]
batch 3: fn_41032 -> 21 slots [4:win_own_props_slice_idx4(fn_41467), 20:iframe_clear_timeout(fn_41588), 31:iframe_set_interval_id(fn_41736), 55:emoji_left_arrow(fn_41835), 56:video_mp4_bogus(fn_41929), 58:iframe_shadow_root_closed(fn_42071), 81:iframe_keys_idx0(fn_42229), 124:prop_recorder_input_event(fn_42327), 151:iframe_keys_length(fn_42549), 166:iframe_toolbar_visible(fn_42649), 188:iframe_outer_height(fn_42796), 204:audio_x_mpeg(fn_42880), 224:win_own_props_idx4(fn_43020), 259:navigator_fake_date_time_string(fn_43118), 311:iframe_nav_info_nav_language(fn_43244), 358:css_marker(fn_43342), 362:window_brave_check(fn_43468), 373:css_set(fn_43567), 385:window_location_origin(fn_43694), 417:mobile_network_hash(fn_43781), 422:window_screen_y(fn_43907)]
batch 4: fn_44072 -> 23 slots [10:math_random_sum(fn_44500), 24:iframe_screen_width(fn_44691), 29:prefers_reduced_motion(fn_44775), 79:prop_recorder_own_property_names(fn_44882), 103:input_time(fn_45096), 110:input_tel(fn_45191), 130:iframe_keys_slice_idx3(fn_45288), 132:iframe_page_x_offset(fn_45411), 146:document_visibility_state(fn_45491), 159:window_head_child_count(fn_45580), 183:aspect_ratio(fn_45672), 189:prop_recorder_error(fn_45792), 199:window_outer_height(fn_46007), 212:win_own_props_slice_idx2(fn_46089), 223:css_border(fn_46211), 232:prefers_contrast(fn_46335), 233:document_has_focus(fn_46449), 239:dynamic_challenge(fn_46543), 252:video_ogg_speex(fn_47830), 255:css_webkit(fn_47969), 291:iframe_nav_info_mime_count(fn_48096), 349:iframe_own_props_idx3(fn_48186), 379:win_keys_slice_idx4(fn_48283)]
batch 5: fn_48490 -> 22 slots [3:user_activation_has_been_active(fn_48916), 7:css_offset(fn_49090), 12:window_set_interval(fn_49217), 58:date_fingerprint(fn_49368), 93:iframe_device_memory(fn_49562), 141:prop_recorder_keys(fn_231861), 171:css_mask(fn_232068), 194:iframe_screen_orientation(fn_232195), 195:iframe_max_touch_points(fn_232292), 201:iframe_keys_idx1(fn_232377), 208:iframe_nav_info_mime_proto(fn_232475), 224:iframe_own_props_slice_idx3(fn_232570), 227:window_nav_info_mime_count(fn_232692), 234:animation_duration(fn_232786), 302:iframe_screen_height(fn_232879), 314:navigator_storage(fn_232964), 315:crypto_random_avg(fn_233685), 327:input_search(fn_233890), 328:prop_recorder_empty_func(fn_233985), 345:css_flood(fn_234198), 360:return_null(fn_234324), 385:permissions_query(fn_234385)]
batch 6: fn_234559 -> 24 slots [1:window_screen_orientation(fn_234985), 7:navigator_plugins(fn_235083), 46:intl_number_format(fn_235176), 55:window_m42(fn_235474), 99:iframe_inner_height(fn_235592), 108:media_color(fn_235671), 113:audio_mp4_mp4a40(fn_235789), 131:animation_overall_progress(fn_235936), 141:hourly_fingerprint(fn_8857), 154:iframe_device_pixel_ratio(fn_9047), 184:overflow_inline(fn_9127), 210:forced_colors(fn_9237), 226:iframe_pixel_depth(fn_9347), 234:win_own_props_slice_idx3(fn_9435), 250:color_index(fn_9556), 263:iframe_navigator_app_version(fn_9673), 264:css_font(fn_9757), 296:gl_max_fragment_uniform_vectors(fn_9878), 301:css_fill(fn_9998), 317:navigator_mime_types(fn_10124), 319:prop_recorder_string(fn_10217), 357:window_body_client_width(fn_10409), 369:css_stroke(fn_10526), 392:window_nav_info_nav_language(fn_10651)]
batch 7: fn_10823 -> 24 slots [11:audio_x_mpegurl(fn_11249), 28:css_transition(fn_11391), 36:window_body_child_count(fn_11515), 67:window_nav_info_lang_count(fn_11611), 68:device_height(fn_11705), 75:window_performance(fn_11822), 81:return_null(fn_11940), 95:gl_max_combined_uniform_blocks(fn_12004), 108:iframe_srcdoc_dimensions(fn_12124), 121:win_keys_idx4(fn_12564), 130:total_js_heap_size(fn_12665), 132:prop_recorder_assign(fn_12867), 156:iframe_clear_interval(fn_13081), 199:navigator_fake_ln(fn_13229), 208:console_debug_stack_trace(fn_13356), 274:audio_aac(fn_13725), 283:window_navigator_language(fn_13864), 285:audio_wav(fn_13948), 301:patched_func_string_err_msg(fn_14090), 325:any_hover(fn_14183), 332:webgl1_unmasked_vendor(fn_14290), 358:win_keys_slice_idx2(fn_70826), 383:script_transfer_rate(fn_70945), 421:visibility_state_entries(fn_71174)]
batch 8: fn_14345 -> 25 slots [1:iframe_screen_avail_width(fn_14771), 2:window_personalbar_visible(fn_14857), 41:iframe_navigator_language(fn_14999), 44:iframe_page_y_offset(fn_15085), 74:prop_recorder_array(fn_15165), 76:prop_recorder_function(fn_15360), 93:css_counter(fn_15553), 135:pointer(fn_15673), 140:visual_viewport_height(fn_15789), 157:iframe_navigator_webdriver(fn_15892), 174:iframe_set_interval(fn_15976), 215:css_margin(fn_16124), 230:video_mp4_avc1(fn_16251), 235:window_screen_height(fn_16394), 238:video_mp4(fn_16481), 258:window_shadow_root_closed(fn_16623), 298:window_device_memory(fn_16781), 320:window_max_touch_points(fn_16869), 326:iframe_set_timeout_id(fn_16955), 352:iframe_plugins_descriptor(fn_23182), 370:window_crypto(fn_23426), 376:prop_recorder_new_function(fn_23547), 378:css_break(fn_23766), 390:css_transform(fn_23888), 406:prop_recorder_error_constructor(fn_24016)]
batch 9: fn_24328 -> 25 slots [15:gl_version(fn_24752), 20:video_dynamic_range(fn_24872), 47:window_timezone_string(fn_24980), 51:prop_recorder_call(fn_25080), 61:ancestor_origins(fn_25295), 69:window_screen_color_depth(fn_25439), 94:css_unicode(fn_25520), 96:audio_wave_codec0(fn_25645), 99:dynamic_range(fn_25781), 141:css_perspective(fn_25890), 145:document_stylesheets_length(fn_26012), 162:crypto_random_raw(fn_26106), 168:body_check_visibility(fn_26216), 180:hover(fn_26346), 193:document_stylesheets_length(fn_26456), 229:iframe_scrollbars_visible(fn_26550), 248:window_plugins_list(fn_26695), 266:iframe_locationbar_visible(fn_26794), 272:visual_viewport_scale(fn_26938), 289:css_line(fn_27041), 308:input_range(fn_27163), 327:animation_progress(fn_27260), 351:iframe_outer_width(fn_27352), 404:audio_wave_codec2(fn_27431), 419:win_own_props_idx3(fn_27572)]
batch 10: fn_27753 -> 21 slots [1:input_file(fn_28177), 3:iframe_own_props_idx4(fn_28277), 4:css_ms(fn_28375), 51:iframe_hardware_concurrency(fn_28500), 91:window_parent_origin(fn_28588), 107:video_webm_vp8_vorbis(fn_28773), 108:iframe_body_client_height(fn_28918), 126:window_body_client_height(fn_29069), 152:css_object(fn_29185), 167:navigator_info_mime_proto(fn_29307), 193:iframe_own_props_slice_idx2(fn_29399), 229:iframe_inner_width(fn_29517), 245:win_keys_idx1(fn_29599), 247:css_animation(fn_29696), 260:window_device_pixel_ratio(fn_29823), 276:window_top_origin(fn_29901), 299:window_page_x_offset(fn_30090), 313:prop_recorder_request(fn_30172), 333:window_screen_pixel_depth(fn_30396), 410:iframe_own_props_length(fn_30485), 415:srcdoc_setter(fn_30585)]
batch 11: fn_30768 -> 21 slots [0:win_keys_slice_idx3(fn_31189), 8:prop_recorder_bind(fn_31312), 10:iframe_keys_slice_idx0(fn_31529), 23:window_attach_shadow_length(fn_31655), 40:visual_viewport_page_left(fn_31757), 63:prefers_reduced_transparency(fn_31857), 146:window_clear_interval(fn_31967), 154:navigator_fake_rml(fn_32117), 163:navigator_wireless_devices(fn_32237), 165:color_gamut(fn_32364), 183:window_set_timeout_id(fn_32481), 184:window_hardware_concurrency_direct(fn_32582), 216:intl_collator(fn_32683), 231:css_padding(fn_32952), 244:iframe_personalbar_visible(fn_33079), 264:window_screen_avail_width(fn_33222), 273:performance_event_counts(fn_33309), 310:document_hidden(fn_33526), 323:prop_recorder_object(fn_33611), 334:css_column(fn_33803), 384:parent_document_has_focus(fn_33928)]
batch 12: fn_34132 -> 24 slots [26:css_css(fn_34558), 29:window_page_y_offset(fn_34685), 69:iframe_screen_x(fn_34765), 79:uuid4(fn_34846), 110:emoji_white_circle(fn_34952), 116:prop_recorder_sort(fn_35043), 119:media_height(fn_35246), 144:video_3gpp_mp4v_samr(fn_35368), 147:window_outer_width(fn_35510), 170:window_duckduckgo(fn_35592), 212:animation_active_duration(fn_35686), 214:window_hardware_concurrency(fn_35777), 226:win_keys_length(fn_35864), 273:gl_aliased_line_width_range(fn_35964), 294:media_update(fn_36105), 309:css_moz(fn_36218), 310:prop_recorder_promise(fn_36341), 311:iframe_nav_info_plugin_proto(fn_36569), 345:input_reset(fn_36662), 353:video_mp4_avc1_mp4a(fn_36755), 371:audio_webm(fn_36899), 380:win_own_props_idx0(fn_37040), 399:iframe_is_secure_context(fn_37140), 415:media_orientation(fn_165136)]
batch 13: fn_165324 -> 20 slots [18:visual_viewport_width(fn_165752), 34:media_width(fn_165853), 61:iframe_body_child_count(fn_165972), 68:reflect_own_keys(fn_166067), 102:animation_play_state(fn_166272), 184:win_keys_slice_idx0(fn_166365), 191:intl_plural_rules(fn_166485), 210:any_pointer(fn_166750), 221:iframe_own_props_slice_idx0(fn_166864), 236:iframe_conn_rtt(fn_166984), 237:inverted_colors(fn_167076), 245:navigator_fake_rnd(fn_167187), 269:win_keys_idx0(fn_167314), 290:iframe_nav_info_lang_count(fn_167410), 291:webgl1_unmasked_renderer(fn_167502), 357:window_location_protocol(fn_167600), 359:css_image(fn_167688), 378:video_x_mpeg(fn_167807), 415:js_heap_size_limit(fn_167949), 427:gl_max_combined_vertex_uniform_components(fn_168154)]
batch 14: fn_168357 -> 24 slots [43:crypto_random_pow(fn_168792), 76:iframe_head_child_count(fn_168979), 108:css_word(fn_169070), 120:css_outline(fn_169189), 122:visual_viewport_offset_left(fn_169314), 143:window_attach_shadow_tostring(fn_169418), 146:iframe_navigator_user_agent(fn_169577), 151:win_own_props_slice_idx0(fn_169664), 158:css_overflow(fn_169783), 171:video_mp2t_avc1_mp4a(fn_169906), 179:patched_err_string_err_stack(fn_170049), 182:media_scripting(fn_170148), 243:keyboard_layout_map(fn_170259), 268:console_log_trap(fn_171030), 300:audio_ogg_vorbis(fn_171509), 313:intl_date_time_format(fn_171647), 314:visual_viewport_page_top(fn_171900), 334:input_color(fn_172005), 342:document_create_element(fn_172098), 356:window_scrollbars_visible(fn_172191), 359:window_navigator_platform(fn_172338), 369:iframe_brave_check(fn_172427), 389:location_href_guid(fn_172527), 427:audio_wav_codec2(fn_172645)]
batch 15: fn_172870 -> 21 slots [25:math_random_pow(fn_173302), 49:iframe_plugins_list(fn_173458), 73:prop_recorder_proxy(fn_173558), 87:user_activation_is_active(fn_173764), 107:emoji_double_exclamation(fn_173934), 118:iframe_own_props_slice_idx4(fn_174028), 164:window_clear_timeout(fn_174151), 181:win_keys_idx2(fn_174302), 192:css_overscroll(fn_174401), 219:css_clip(fn_174525), 238:window_navigator_webdriver(fn_174645), 257:win_own_props_idx2(fn_174735), 267:audio_wave_codec1(fn_174834), 289:webgl2_unmasked_vendor(fn_174975), 292:video_ogg_opus(fn_175075), 319:iframe_own_props_idx1(fn_175214), 331:win_keys_slice_idx1(fn_175311), 357:document_referrer(fn_175429), 361:return_second_value(fn_175514), 363:win_keys_idx3(fn_175582), 383:iframe_attach_shadow_length(fn_175683)]
batch 16: fn_175862 -> 22 slots [9:iframe_keys_slice_idx1(fn_176290), 14:math_random_sum_6(fn_176412), 32:date_modulo_1000(fn_176606), 42:audio_x_m4a(fn_176721), 65:css_place(fn_176860), 112:input_password(fn_176985), 160:emoji_up_arrow(fn_177077), 171:window_navigator_user_agent(fn_177174), 194:window_location_host(fn_177261), 229:patched_err_string_err_msg(fn_177348), 247:webgl2_unmasked_renderer(fn_177442), 301:input_checkbox(fn_177541), 307:iframe_screen_y(fn_177637), 356:iframe_shadow_root_open(fn_177715), 358:toolbar_visible(fn_177872), 363:css_list(fn_178020), 364:win_own_props_slice_idx1(fn_178145), 386:gl_aliased_point_size_range(fn_178267), 394:iframe_keys_idx2(fn_178409), 395:iframe_duckduckgo(fn_178508), 398:iframe_keys_slice_idx4(fn_178607), 412:css_grid(fn_178731)]
batch 17: fn_178937 -> 24 slots [6:audio_mpeg(fn_179363), 8:video_ogg_theora_speex(fn_179506), 14:window_conn_rtt(fn_179645), 15:iframe_own_props_idx2(fn_179737), 32:window_screen_width(fn_179835), 60:win_own_props_length(fn_179924), 113:navigator_fake_v(fn_180024), 137:iframe_keys_idx4(fn_180147), 139:css_inset(fn_180252), 140:patched_func_string_err_stack(fn_180376), 174:video_mpeg(fn_180472), 210:video_ogg_theora(fn_180613), 245:window_location_port(fn_180751), 255:css_box(fn_180838), 256:gl_max_combined_fragment_uniform_components(fn_180958), 278:css_flex(fn_181083), 312:css_page(fn_181205), 316:video_webm_vorbis(fn_181329), 376:iframe_screen_avail_height(fn_181469), 385:video_mp4_avc1_42c00d(fn_181555), 397:display_mode(fn_181698), 403:window_shadow_root_open(fn_181827), 413:input_submit(fn_181982), 419:window_screen_x(fn_182073)]
batch 18: fn_182240 -> 6 slots [36:window_attach_shadow_name(fn_182661), 37:iframe_keys_slice_idx2(fn_182758), 166:iframe_result_plugins(fn_182879), 240:speech_synthesis_voices(fn_182971), 261:gl_unmasked_vendor(fn_183823), 299:iframe_own_props_slice_idx1(fn_184023)]
batch 19: fn_184229 -> 8 slots [79:audio_fingerprint(fn_184654), 131:audio_context_max_channel_count(fn_185771), 138:audio_context_channel_interpretation(fn_185860), 142:audio_context_channel_count(fn_185945), 193:audio_context_output_latency(fn_186031), 231:audio_context_base_latency(fn_186117), 233:audio_context_sample_rate(fn_186201), 400:audio_context_channel_count_mode(fn_186290)]
mapped: 427/427 probes
All the names are self-explanatory. If you’re curious about what any specific probe does, search for its function name (like fn_185945) in the decompiled output and you’ll see the exact JavaScript it runs.
Identifying the Probes
Each probe is a small function in the CFG. But all the property names are obfuscated. window.navigator.userAgent doesn’t show up as a readable string. Instead it’s something like accessing .exg then some nested property on an object. So before I can identify any probe, I need to figure out what these obfuscated names mean.
The script has a setup phase where it creates objects with obfuscated property names and assigns real browser references to them. For example, there’s a “batch context” object that stores a reference to window under .exg and a reference to an iframe’s contentWindow under .ukz. My solver traces these assignments by following ScopeChainSet and ScopeGet instructions through the CFG, building a map of obfuscated name → real name:
=== Obfuscated Name Map ===
.an = animation_timing
.ap = audio_context
.c = webgl_context
.e = emoji_rendering
.t = timer_ids
batch_context.exg = window_ref
batch_context.ukz = iframe_ref
audio_context.rqd = sampleRate
audio_context.ykz = channelCount
...
This map changes every script version but the structure stays the same. My solver parses it dynamically every time.
Once I have the name map, identifying individual probes is about matching patterns. Each probe function has characteristic string tokens in its bytecodes. A probe that reads navigator.hardwareConcurrency will have the token "hardwareConcurrency" in its function body. A probe checking audio codec support will have "canPlayType" and "audio/wav". My solver counts these tokens per function and matches against known signatures:
- Token: single string must exist (e.g.,
"hardwareConcurrency") - TokenAll: multiple strings must all exist (e.g.,
["prepareStackTrace", "console", "debug"]) - Freq: exact token frequency counts (e.g.,
"attachShadow"appears exactly twice)
The Shuffle & Seed
Here’s the shuffle function from the decompiled output:
function fn_11022(a0) {
v1673 = a0;
t6 = v1663; // Math.sin
t8 = v1673;
v1673 = v1673 + 1;
a0 = t6(t8); // sin(seed)
v1674 = a0 * 1e4; // * 10000
t5 = v1664(v1674); // Math.floor
return a0 - t5; // fractional part
}
// seed extraction: default value if no argument provided
if (arguments[1] !== undefined) {
a0 = arguments[1];
} else {
a0 = 24590117; // <-- the seed
}
v1662 = a0;
v1663 = Math.sin;
v1664 = Math.floor;
// Fisher-Yates shuffle
v1666 = a0.slice(0); // copy the array
v1667 = v1666.length;
while (v1667) {
t7 = v1665(v1662); // fn_11022(seed) = frac(sin(seed) * 10000)
t10 = v1667;
v1667 = v1667 - 1;
v1669 = Math.floor(t7 * t10); // random index
// swap
v1668 = v1666[v1667];
v1666[v1667] = v1666[v1669];
v1666[v1669] = v1668;
v1662 = v1662 + 1; // increment seed
}
return v1666;
You can see the seed 24590117 right there as the default argument. That’s what my solver looks for: an Identity instruction with a large integer (millions range) inside the shuffle function’s scope. Different scripts ship different seeds, which means different shuffle orders and different slot positions.
For each batch (in execution order):
- The template gets shuffled once using this Fisher-Yates
- Each probe in that batch writes its result to a specific slot index
- The modified template carries over to the next batch
After 20 shuffles and 427 probe writes, there are a few more things to add before the payload is complete.
Two static strings get parsed from the script and prepended to the array. Here’s where they are in the decompiled output:
v4529 = "2:AAE7YOJb8KkbQ6BgyObtSet4xLpnlxH9OssLrV..."; // static_string_1
v4534 = "WlDYwxTNEMmYTM=U"; // static_string_2
There’s also a return_second_value probe that doesn’t read any browser property. It just returns a constant value that’s embedded in the script:
t4.xkz = "j4sF$"; // first_value (not used anywhere, probably there to make parsing harder)
t4.qjf = 38010; // second_value (this goes into the payload)
My solver parses second_value (38010) from the bytecodes and writes it to its probe slot.
And metadata gets injected at a specific slot with fabricated timing info. How do I find which slot? My solver scans for ScopeChainSet instructions and after it finds static_string_2, the next integer constant is the metadata slot. That’s it lol. It’s 130 in this version:
let perf_secs = ((now_ms % 180000) as f64 + 2000.0) / 1000.0;
let elapsed = 100.0 + (now_ms % 200) as f64 + (now_ms % 99) as f64 / 10.0;
let pid = format!("{:x}.{}", perf_secs as u64,
((perf_secs - perf_secs as u64 as f64) * 1000000.0).round() as u64);
template[meta_slot] = json!({"t": [pid, false, elapsed, "3.0"]});
The final payload is: [static_string_1, static_string_2, template[0], template[1], ..., template[427]]. Then it goes to encryption.
Collecting Real Fingerprints
Now here’s the thing. They built the script, so they know the shuffle order. They know exactly which slot maps to which probe after each shuffle round. But we’re on the outside looking in. We can identify the probes and replay the shuffles, but we still need actual fingerprint values to fill the template with.
You can look at the decompiled code and see the same array index 234 being used by multiple batches in different contexts:
__ret = typeof t13 === "function" ? t13.apply(null, [
fn_232786, [v1294, v1295, v1296], 234, v1303
]) : undefined;
__ret = typeof t11 === "function" ? t11.apply(null, [
fn_9435, [v1482, v1483, v1484], 234, v1491
]) : undefined;
Slot 234, two different probes, two different batches. They know which value goes where because they compiled it. We don’t. So I needed to capture what a real browser produces.
I wrote a MITM script that hooks into the browser:
Array = function() {
var arr = new _OrigArray(...arguments);
if (!_patched && arguments.length === 1
&& arguments[0] >= 428 && arguments[0] <= 500
&& new Error().stack.indexOf('ips.js') !== -1) {
_patched = true;
wrapArray(arr);
}
return arr;
};
It intercepts the Array(428) call, wraps every slot with Object.defineProperty to capture writes, hooks Math.sin to count shuffle iterations, and hooks .slice() to detect when a new batch starts. Every non-shuffle write gets recorded as [slot_index, value]. When the script posts to /tl, it downloads the full capture as JSON.
The capture looks like this:
[
["SHUFFLE"],
[11, [value]],
[27, [value]],
[57, [value]],
...
["SHUFFLE"],
[24, [value]],
[34, [value]],
...
["DONE"]
]
Each ["SHUFFLE"] marks a new batch. The values between shuffles are the probe results for that batch. Then a separate script runs my solver with --log to get the probe names mapped to slots, matches the captured values to the identified probes, and outputs a device.json with all the real fingerprint values.
You can find both the full collector script and the MITM script at the end of the post. One note: the collector doesn’t cover the 11 new probes they added recently, so if you’re planning to use it keep that in mind:
window_attach_shadow_name/iframe_attach_shadow_name(pattern:Element.prototype.attachShadow.name)window_attach_shadow_tostring/iframe_attach_shadow_tostring(pattern:Element.prototype.attachShadow.toString)window_shadow_root_closed/iframe_shadow_root_closed(pattern:document.createElement.attachShadow.shadowRoot.closed)window_shadow_root_open/iframe_shadow_root_open(pattern:document.createElement.attachShadow.shadowRoot.open)window_attach_shadow_length/iframe_attach_shadow_length(pattern:Element.prototype.attachShadow.length)console_debug_stack_trace(pattern:prepareStackTrace+console+debug)
Encryption & Key Extraction
The payload gets encrypted before it’s sent to /tl. The encryption is XTEA (32 rounds) with a custom CBC-like chaining mode. The interesting part isn’t the encryption itself, it’s how the key is embedded in the script.
Key Expansion
The key isn’t stored as a plain string. It’s stored as an array of integers in the bytecodes, and there are multiple “expansion” functions that can transform those integers into the actual 16-byte key. Here’s what it looks like in the decompiled output:
v4528 = "[2,23630,45919,10603,10275,10315,10203,10275,10275,10179,
10211,10251,10187,10147,10243,10171,10123,10163,10155,10179,
10091,10171,10155,10067,10147,10131,10043,10123,10107,10019,
10091,10107,9995,10027,10019,10051,9963,9995,10035,10043,
9931,9963,9971,9963,9899,9979,9987,9875,9907,9899,9923,
9843,9875,9875,9851,9811,9851,9843,9843,9779,9851,9843,
9755,9787,9787,9771,9723,9763,9747,9795,9691,9723,9747,
9731,9659,9691,9691,9739,9627,9715,9699,9603,9627,9587,
9611,9963]";
The first element is 2. That’s the selector. There are three expansion functions available:
v4456 = [fn_49893, fn_50394, fn_50884];
Selector 2 means we use fn_50884 (zero-indexed third function). Each expansion function takes the remaining integers and runs a series of bitwise rotations, shifts, XORs, and arithmetic to produce characters. Here’s what fn_50884 looks like:
function fn_50884(a0) {
v4480 = a0[0]; // first two elements are initial state
v4481 = a0[1];
v4482 = a0.slice(2); // rest is the data
v4484 = [];
v4485 = v4482.length - 1;
while (v4485 > -1) {
v4483 = v4482[v4485];
// series of bitwise rotations on v4480, v4481, v4483:
v4483 = (v4483 << 13 | (v4483 & 65535) >> 3) & 65535;
v4481 = (v4481 << 9 | (v4481 & 65535) >> 7) & 65535;
v4483 = v4483 + v4485;
v4483 = v4483 - 25811;
v4481 = (v4481 << 3 | (v4481 & 65535) >> 13) & 65535;
v4483 = v4483 + v4485;
v4480 = (v4480 << 2 | (v4480 & 65535) >> 14) & 65535;
v4483 = v4483 - v4485;
v4481 = (v4481 >> 13 | v4481 << 3) & 65535;
// output character
v4484.unshift(String.fromCharCode(v4483 & 65535));
v4485--;
}
return v4484.join("");
}
Bit rotations, magic constants like 25811, position-dependent additions and subtractions. The output for this script’s key array is [39,67,170,91,226,76,76,76,69,116,179,133,79,115,120,223,66,121,218,154,129,87,0,0], where the first 16 bytes are the XTEA key and the remaining 8 are the request start token. My solver finds these expansion functions by scanning for functions that contain both "fromCharCode" and "unshift" tokens, then emulates them by walking the CFG and executing the operations.
The Encryption
The encryption itself is XTEA. Here’s the raw JavaScript from the decompiled script (it’s literally embedded as a string that gets eval’d):
function r(i, l) {
var p = l[0], u = l[1];
var c = 0;
var d = 2654435769;
for (var f = 0; f < 32; f += 1) {
p = p + ((u << 4 ^ u >> 5) + u ^ c + i[c & 3]) | 0;
c = c + d | 0;
u = u + ((p << 4 ^ p >> 5) + p ^ c + i[c >> 11 & 3]) | 0;
}
return [p, u];
}
And my Rust implementation:
const DELTA: i32 = -1640531527; // 2654435769 as i32
fn xtea_enc(k: [i32; 4], v: [i32; 2]) -> [i32; 2] {
let [mut v0, mut v1] = v;
let mut s: i32 = 0;
for _ in 0..32 {
v0 = v0.wrapping_add(
((v1 << 4 ^ v1 >> 5).wrapping_add(v1))
^ (s.wrapping_add(k[(s & 3) as usize]))
);
s = s.wrapping_add(DELTA);
v1 = v1.wrapping_add(
((v0 << 4 ^ v0 >> 5).wrapping_add(v0))
^ (s.wrapping_add(k[((s >> 11) & 3) as usize]))
);
}
[v0, v1]
}
The chaining mode is where it gets unusual. Instead of standard CBC where blocks are encrypted sequentially, they use a queue-based approach. A queue of 4-5 data blocks gets initialized, and each iteration picks a block from the queue at a pseudo-random index (derived from the previous ciphertext), encrypts it with XOR-then-XTEA chaining, and replaces it with the next unprocessed block. The block order ends up non-sequential.
The final body that gets posted to /tl is: [0x00, 0x02] prefix + the 8-byte request start token (extracted alongside the key) + the encrypted payload. Content type is application/octet-stream.
Dynamic Challenge
One of the 427 probes is special: dynamic_challenge. Unlike the other probes that just read browser properties, this one computes a value from the script’s own bytecodes combined with device values. It’s their way of proving you actually executed the VM.
Here’s what the dynamic challenge looks like in the decompiled output:
function fn_46543(a0, a1) {
v1221 = a0.exg; // window_ref
v1222 = a0.ukz; // iframe_ref
v1223 = [];
// fn_46593: reads screen dimensions, multiplies/adds them
function fn_46593(a0) {
v1224 = 52113;
v1224 = v1224 * charCodeHash(v1222.screen.availWidth, 5);
v1224 = v1224 + charCodeHash(v1222.screen.availHeight, 3);
v1224 = v1224 * charCodeHash(v1221.window.outerHeight, 4);
v1224 = v1224 + charCodeHash(v1222.screen.width, 4);
v1223.push(v1224);
}
// fn_47020: string manipulation with shift/pop/push/concat
function fn_47020(a0) {
v1246 = ["IzGuZ","=1pw7k!u","zWOo2NFv","L"];
v1246 = v1246.concat(["M","lv","d"]);
v1246[5] = "af6";
v1246 = v1246.concat([117980,"WN8G$","izC"]);
// series of shift/pop/push operations with fn_47040 (add)
// produces a computed value from string concatenations
v1223.push(v1224);
}
fn_46593("J");
fn_47020(706439);
return { value: v1223 };
}
It reads screen.availWidth, screen.availHeight, outerHeight, screen.width from both the window and iframe references, hashes them with charCodeAt at different step sizes, multiplies and adds the results with magic constants like 52113, then does a bunch of string array manipulation with shift/pop/concat to produce a second value. The output is an array of computed values.
My solver emulates this with a mini interpreter:
struct Emu {
regs: HashMap<usize, V>,
scopes: HashMap<i32, V>,
op_map: HashMap<usize, usize>,
fallback: i64,
}
fn i32op(op: usize, a: &V, b: &V) -> V {
match op {
0 => V::I((a.i() as i32).wrapping_shl((b.i() & 31) as u32) as i64),
1 => js_add(a, b),
2 => V::I((a.i() as i32 | b.i() as i32) as i64),
3 => V::I((a.i() as f64 - b.i() as f64) as i64),
4 => V::I((a.i() as f64 * b.i() as f64) as i64),
5 => V::I((a.i() as i32).wrapping_shr((b.i() & 31) as u32) as i64),
6 => V::I((a.i() as i32 & b.i() as i32) as i64),
7 => V::I((a.i() as i32 ^ b.i() as i32) as i64),
_ => js_add(a, b),
}
}
The interpreter walks the challenge function’s CFG and emulates each instruction with JavaScript’s 32-bit integer semantics. When it hits a property access like v1222.screen.availWidth, it resolves it against the device.json values. It knows that v1222 is iframe_ref (from the .ukz property on the batch context), so screen.availWidth maps to iframe_screen_avail_width in the device profile. Same for v1221.window.outerHeight mapping to window_outer_height. It also handles the charCodeAt hashing, array operations (push, pop, shift, concat, slice, unshift), and string concatenation.
Keep in mind this is just one version of the dynamic challenge. Every script ships a completely different computation. Sometimes it’s screen dimension hashing, sometimes it’s string manipulation, sometimes it’s nested arithmetic with different magic constants and different operations. The structure changes every update. That’s exactly why I had to build a full interpreter instead of just hardcoding the math. Whoever designed this part honestly deserves a raise. Hey!! Hear me out!!
Timing Header (x-kpsdk-dt)
One more thing before PoW. Every /tl request includes an x-kpsdk-dt header that contains fabricated timing data. It’s supposed to represent how long each phase of the script execution took in the browser. The format is a base32-encoded string with randomized ordering of timing deltas, separated by w, x, y, or z characters.
fn to_base32(n: i64) -> String {
if n == 0 { return "0".into(); }
let neg = n < 0;
let mut v = n.unsigned_abs();
let chars = b"0123456789abcdefghijklmnopqrstuv";
let mut s = Vec::new();
while v > 0 {
s.push(chars[(v % 32) as usize]);
v /= 32;
}
s.reverse();
let r = String::from_utf8(s).unwrap();
if neg { format!("-{}", r) } else { r }
}
fn generate_kpsdk_dt(total_ms: u64, fp_ms: f64, ips_ms: f64) -> String {
let rng = || -> u64 {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap();
let mut s = t.as_nanos() as u64;
s ^= s >> 12; s ^= s << 25; s ^= s >> 27;
s.wrapping_mul(0x2545F4914F6CDD1D)
};
let r = |min: i64, max: i64| -> i64 {
min + (rng() % (max - min + 1) as u64) as i64
};
let vmj = 0i64;
let nvj = fp_ms as i64;
let ubk = nvj + ips_ms as i64;
let uyv = ubk + r(50, 200);
let ygq = uyv + r(20, 100);
let kjg = ygq + r(10, 50);
let zgq = kjg + r(50, 200);
let nwu = total_ms as i64;
let vhd = r(0, 50);
let mut deltas: Vec<[i64; 2]> = vec![
[0, nwu - vmj], [1, nvj - vmj], [2, ubk - nvj],
[3, uyv - ubk], [4, ygq - uyv], [5, kjg - ygq],
[6, zgq - kjg], [7, nwu - vhd],
];
let fetch_start = r(0, 20);
let req_start = fetch_start + r(30, 100);
let resp_start = req_start + r(50, 200);
let resp_end = resp_start + r(5, 50);
deltas.push([8, req_start - fetch_start]);
deltas.push([9, resp_start - req_start]);
deltas.push([10, resp_end - resp_start]);
deltas[0][1] += resp_end - fetch_start;
let r_fetch = r(0, 10);
let r_req = r_fetch + r(20, 80);
let r_resp_s = r_req + r(30, 150);
let r_resp_e = r_resp_s + r(10, 100);
deltas.push([11, r_req - r_fetch]);
deltas.push([12, r_resp_s - r_req]);
deltas.push([13, r_resp_e - r_resp_s]);
let seps = ['w', 'x', 'y', 'z'];
let mut result = "1".to_string();
let mut first = true;
let mut indices: Vec<usize> = (0..deltas.len()).collect();
while !indices.is_empty() {
let pick = (rng() as usize) % indices.len();
let entry = deltas[indices[pick]];
indices.remove(pick);
if !first {
result.push(seps[(rng() as usize) % 4]);
} else {
first = false;
}
result.push_str(&to_base32(entry[0]));
result.push_str(&to_base32(entry[1]));
}
result
}
The actual timing values fed into this are based on real solve times but inflated to look like browser execution (fp_ms * 2.5, ips_ms * 3.0, etc.).
Proof of Work
Remember p.js from the beginning? That script doesn’t just load ips.js. It also contains the proof of work parameters. p.js is itself a bytecode VM, same architecture as ips.js but an older, simpler version. No opcode fusion, no complex handler wrapping, just straightforward bytecodes. If anyone is interested in getting into VM reverse engineering, I’d actually recommend starting with p.js instead of ips.js. Much easier to wrap your head around.
My solver disassembles p.js and you can see the whole thing: it sets up a SHA256 implementation, configures the PoW parameters, and stores the seed phrase. Here’s a chunk of the disassembly showing the crypto setup and the seed phrase:
23746: Halt [Reg(4), Str("sha256"), Reg(5)]
23762: Halt [Reg(6), Str("sha224"), Reg(4)]
23771: Halt [Reg(5), Str("sha256"), Reg(4)]
23783: Halt [Reg(4), Str("hmac"), Reg(5)]
23823: Halt [Reg(4), Str("exports"), Reg(5)]
...
23988: Halt [Reg(10), Str("gjh"), Reg(9)]
23994: Halt [Reg(10), Str("uyz"), Str("j-1.2.381")]
24007: Halt [Reg(10), Str("yxd"), Reg(8)]
24015: Halt [Reg(8), Str("ktp"),
Str("f1a70f72d5da700e63df37b4258df651eee6abfd0053738ad7287fa86a0edf16")]
24025: Halt [Reg(9), Str("qef"), Int(10)] // default difficulty = 10
24031: Halt [Reg(9), Str("ujh"), Int(2)] // default sub_count = 2
24037: Halt [Reg(9), Str("lcs"), Str("")] // seed_suffix
24045: Halt [Reg(8), Str("uzr"), Reg(9)]
You can see the SHA256/HMAC setup, the version string j-1.2.381, and most importantly the seed phrase: f1a70f72d5da700e...edf16. That’s kick.com’s seed phrase. Right below it are the difficulty (10) and sub-challenge count (2), also under obfuscated property names.
My solver finds the seed phrase by disassembling p.js and scanning for a 64-character hex string:
for ins in &instrs {
if let Value::Str(val) = &ins.operands[2] {
if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(val.clone());
}
}
}
Each site has its own seed phrase, and it stays the same for that domain. My solver caches them with a 1-hour TTL so it doesn’t have to re-fetch and disassemble p.js every time:
{
"kick.com": {
"lhw": "f1a70f72d5da700e63df37b4258df651eee6abfd0053738ad7287fa86a0edf16"
},
"gql.twitch.tv": {
"lhw": "7ae9906d03439b1173e98ddb9342a718a535ab115a7f27138cd183737d7f7afe"
},
"www.nike.com": {
"lhw": "1760fa26d0fa116e2f26e90dc8b1379c3d9e946f650852b7f9925457ab77a14a"
},
"www.footlocker.de": {
"lhw": "d49a6b7bf59bb6f5ff2a17caae757f01bc7f5509551e2b34fa641922ca147525"
},
"www.whatnot.com": {
"lhw": "a0e03aaa9d2e8fa55973a75a066a42cd562fbf88f3d72ec8564e1d679f12d408"
},
"www.sportsbet.com.au": {
"lhw": "41fd3e20a608c5742f3d39289853d87c626ea38d2a2d659f4b27cccac74157a4"
}
}
Remember that /mfc request from the beginning? It runs in parallel with the fingerprinting and returns two important headers: x-kpsdk-fc and x-kpsdk-h. The x-kpsdk-h header gets passed through to the final response. The x-kpsdk-fc header is base64-encoded JSON that contains the PoW parameters:
{
"dynamicConfig": {
"frontend": {
"cryptoChallenge": {
"currentParameters": {
"difficulty": 10,
"subchallengeCount": 2,
"seedSuffix": ""
}
}
}
}
}
So p.js has the seed phrase, and /mfc has the difficulty and sub-challenge count. Together they define the PoW.
The PoW itself is SHA256 chaining. The seed is composed from multiple parts:
seed = "tp-v2-input" + ct_first_16_chars + ", " + work_time + ", "
+ solve_id + ", " + seed_phrase + ", " + seed_suffix
Where ct is the challenge token from the /tl response, work_time is the current timestamp with a small offset, solve_id is a random 32-char hex string, and seed_suffix comes from the /mfc response.
Here’s the full solve function:
pub fn solve_pow(ct: &str, lhw: &str, difficulty: f64,
sub_count: usize, seed_suffix: &str) -> CdResult {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH).unwrap()
.as_millis() as i64;
let d = 6 + (random_hex(1).as_bytes()[0] % 20) as i64;
let st = now_ms - d - 5000;
let rst = st + d;
let work_time = now_ms - d;
let solve_id = random_hex(32);
let kxd = format!("tp-v2-input{}", &ct[..ct.len().min(16)]);
let mut parts = vec![kxd, work_time.to_string(), solve_id.clone()];
if !lhw.is_empty() { parts.push(lhw.to_string()); }
if !seed_suffix.is_empty() { parts.push(seed_suffix.to_string()); }
let seed = parts.join(", ");
let mut current_hash = sha256_hex(&seed);
let threshold = difficulty / sub_count as f64;
let mut answers = Vec::new();
for _ in 0..sub_count {
let mut nonce: i64 = 1;
loop {
let candidate = sha256_hex(&format!("{}, {}", nonce, current_hash));
if check_difficulty(&candidate) >= threshold {
answers.push(nonce);
current_hash = candidate;
break;
}
nonce += 1;
}
}
let base = 10.0 + (random_hex(2).as_bytes()[0] as f64 % 35.0);
let frac = (random_hex(2).as_bytes()[1] as f64 % 10.0) / 10.0;
let duration = ((base + frac) * 10.0).round() / 10.0;
CdResult { work_time, id: solve_id, answers, duration, d, st, rst }
}
fn check_difficulty(hash_hex: &str) -> f64 {
let prefix = u64::from_str_radix(&hash_hex[..13], 16).unwrap_or(u64::MAX);
4503599627370496.0 / (prefix as f64 + 1.0)
}
Their Error Messages
When an error happens inside the script during execution, it gets sent to their /error and /r endpoints. But the error payload is “encrypted”. I’m putting that in quotes because the encryption is just XOR with a hardcoded UUID key: c139db69-c5a0-413e-8b58-90785319bc49. That’s it. Base64 decode, XOR with repeating key, done.
Here’s a real error payload:
exNATQUBXRsXQW4zdWl1cmcgaWVAGRpAXENEWVJWEwNAFQALUQYTUBdC
WFZZQ1RBVlhaUkcMQlZAGRpDWF1SGg8RZUASBnFLEV5BG0hAVVZJBhdb
AR0GTA==
Decrypted:
{"stack":"[REDACTED]","message":"v4227 is not a function","name":"TypeError","code":102}
There’s also a second key omgtopkek used for their /error endpoint responses (same XOR approach). Yes, that’s the actual key. Now you know where the omgtopkek meme comes from!! A sneakerdev mystery solved!!
Closing
I did this for the love of the game. I’ve always wanted to fully reverse a VM-based anti-bot system and this was the one. I hosted it on a server and sold access to a few people who were doing Twitch account generation, they had a bit of fun with it :) Though I ended up giving most people free solves and barely made anything back. Actually lost money on server costs lol.
The whole thing is written in Rust. Disassembly, decompilation, CFG recovery, fingerprint identification, payload generation, encryption, proof of work. Everything runs in a single binary. A full solve takes around 30-50ms depending on the script version. (yes I had to mention Rust everywhere, my bad guys)
Here’s a recording of the solver running 200 solves on Twitch:
As you can see I only explained my approach and shared the lowest amount of code I could while still making the post useful. If you guys from the antibot team are not happy with me releasing this, please reach out and we can talk about it.
- email: eemrovsky@proton.me
- discord: notemrovsky
- github: notemrovsky
- telegram: t.me/Emrovsky
First post on this site. Thanks to desq for recommending me the emro.cat domain.
If you want to poke around the code I analyzed in this post: