The scripts have lot of polymorphism. With each new request you got different variable names, key strings, numeric constants, data arrays, and code placement. But same architectural patterns are in place (CFF structure, cipher types, VM embedding)
Deob pipeline: inline operator wrappers > constant fold > unflatten CFF > singleton mappign > string decryption.
Every binary and unary operator is wrapped in a named function:
var NR = function(hR, qW) { return hR < qW; }; // <
var fW = function(XQ, RQ) { return XQ != RQ; }; // !=
var qQ = function(H3, Kh) { return H3 !== Kh; }; // !==
var Af = function(T8, R6) { return T8 === R6; }; // ===
var TT = function(Vt, lY) { return Vt - lY; }; // -
var ZP = function(qm, lw) { return qm instanceof lw; }; // instanceof
var xm = function(Tn, BP) { return Tn in BP; }; // in
var SA = function(v) { return -v; }; // unary negate
var vR = function(v) { return !v; }; // logical not
var WOl = function(v) { return +v; }; // to-number
var b7 = function(v) { return ~v; }; // bitwise NOTThey are used like: NR(In, VW)
SBSD has two categories. The first is data singletons: a var-expression function that fires once and writes encrypted strings into a global array. An indexer wrapper takes a numeric arg and indexes it:
var tR = function() {
KE = ["\t", "&", "H\n*7/8[", /* 150+ encrypted strings */];
};
function DW(i) { return KE[i]; } // indexer wrapperEvery property name, method name, ciphertext, and constant comes from KE. DW(42) is replaced with KE[42] (still encrypted).
The second category is object singletons: lazy-initialized containers that self-replace on first call. They serve as namespaces for methods and state:
function pr() { // returns the mj depth-tracker array
var kL2 = [];
pr = function() { return kL2; };
return kL2;
}
function wX() { // returns the method/decrypt namespace object
var JG2 = Object.create(Object.prototype);
wX = function() { return JG2; };
return JG2;
}
function zP() { // returns a key-value map object
var Md2 = {};
zP = function() { return Md2; };
return Md2;
}pr() is mj. Calling pr().push(n) is the same as mj.push(n). wX() and zP() are bare objects that get decrypt functions and key material attached. ZS() returns [].entries(). All property name lookups go through HKl(), a 200-entry array of two-char strings; dr, zj, TP, Fr are all just HKl()[n] with different names.
Sensor uses a pattern from sbsd. Key arrays are assigned via var-expression functions that write to outer closure variables:
var NB = function() { hm = [/* 200+ entries */]; }; // populates hm on callNo FunctionDeclaration singletons. The key array hm (and five others) live in closure scope, not indexed through a global. Twelve decryptors are built over these arrays with hardcoded key derivation.
All logic lives in switch dispatch loops. Two types are handled.
Identified by 'use strict' directive. First param selects the case; second carries arguments. Cases are independent. No state flows between them:
var c6 = function rb(v6, qK) {
'use strict';
switch (v6) {
case 22: { n6.push(320); var Hv = window['Object'](qK[0]); ...; n6.pop(); return Hv; }
case 24: { n6.push(15); ...; n6.pop(); return xM; }
case 19: { n6.push(383); return '[object Generator]'; }
}
};
// called as: c6(22, [arg0, arg1])The numbers (22, 24, 19) are constant-folded integers. Each case becomes a named standalone function: c6_case_22(qK). Call sites c6(22, ...) are rewritten to c6_case_22(...). No if-else. No branching between cases.
Uses while(controlVar != sentinel). Each case jumps by mutating the control variable with =, +=, or -=:
var Hd = function Zv(Gs, w4) {
var Bk = Ml(new Number(Gs), ...); Bk.set(Gs); // Bk mirrors Gs numerically
while (Bk + Gs != VG) { // compound: 2*Gs != sentinel
switch (Bk + Gs) {
case D6: { Gs += jL; /* work */ break; } // jump: advance control var
case JB: { Qw.push(LC); return [...]; } // exit via return
case tn: { return Bc; } // exit via return
}
}
};After constant folding, D6 = 80, jL = 40, tn = 120, etc. The flow analyzer traces: case 80 sets Gs += 40 so next is case 120; case 120 returns. Chain: [80, 120]. Both bodies are merged into Hd_flow_80_isolated. Call sites Hd(80, w4) rewrite to Hd_flow_80_isolated(...).
Chain length varies. A case that exits directly is a singleton chain (1 step, extracted as _case_N_isolated). Longer chains cover logically connected sections of the original function, up to 10+ steps, all linearized into one body with no branching.
SBSD checksums its own source at runtime: arguments.callee.toString() is hashed with a MurmurHash3 variant, one character at a time, skipping whitespace (chars 10, 13, 32):
gM = (gM & 0xffff) * 0xCC9E2D51 + (((gM >>> 16) * 0xCC9E2D51 & 0xffff) << 16) & 0xffffffff;
gM = gM << 15 | gM >>> 17;
gM = (gM & 0xffff) * 0x1B873593 + (((gM >>> 16) * 0x1B873593 & 0xffff) << 16) & 0xffffffff;
FM ^= gM;
FM = FM << 13 | FM >>> 19;
// finalization: 0x85ebca6b, 0xc2b2ae35, then FM >>>= 0The hash is subtracted from a hardcoded literal in the file. A clean script yields integrityConst (e.g., 225 for input/9/body.js). It is baked into every cipher call offset:
// xorRev (deob/decrypt/ciphers.js)
ki = (((i + keyOff - dhLast + integrityConst) % key.length) + key.length) % key.length;
// xorFwd
ki = (((keyOff - dhLast + integrityConst) % mod) + mod) % mod;
// treeDec: starting code point
JR = keyOff - dhLast + integrityConst;Rename a variable, inject a console.log, modify whitespace: hash shifts, integrityConst is wrong, every decrypted string becomes garbage with no error thrown.
The VM blobs use a separate but analogous mechanism. Each blob XORs all its own bytes together (xorChecksum), then shifts all opcodes by (xorChecksum + xorKey) % 256:
// sep_vm.js: blob loading
var ZG = 0;
for (var Mp = 0; Mp < gM.length; Mp++) ZG ^= gM.charCodeAt(Mp);
// opcode resolver set to: this[218] = (rawByte) => (rawByte + (ZG + jg) % 256) % 256Modify any byte in a blob: XOR checksum shifts, all opcodes mismatch, the blob executes garbage. The two mechanisms defend against different attack surfaces and are independent.
Sensor has no integrity constant. The offset formula is simply keyOff - dhLast.
Every string in the script is ciphertext. Decrypting one in SBSD requires five things:
| Variable | What it is |
|---|---|
KE[index] |
ciphertext, from the data singleton |
cx.RV |
key string, initialized as 'lG{Z{/\$+k~SF?Aim' then extended with cx.RV += cx.RV |
keyOff |
integer literal baked into the source at this specific call site |
mj[mj.length - 1] |
dhLast, the live CFF depth-tracker top |
KB() |
returns integrityConst (225 for a clean build) |
There are three cipher types: XOR forward and XOR backward (both keyed by cx.RV), and a tree cipher which walks a code-point delta table (Q2l). XOR forward uses keyOff - dhLast + integrityConst as the starting key index; XOR backward incorporates the ciphertext position into the index per character; tree cipher uses the same offset to select an entry point in a diff-encoded traversal table. All three produce the final string character-by-character.
Every decrypt result is memoized on wX(). On first call the decryptor runs. Then it overwrites itself with a constant closure:
// installed during init on wX() under a computed property name
wX()['Rb'] = function(index, keyOff) {
var result = cx(index, keyOff);
wX()['Rb'] = function() { return result; }; // replace self
return result;
};
// second call: no cipher math, direct return
wX()['Rb']()Call sites in the obfuscated source use HKl() for the property name lookup:
// wX()[dr(wQ)](OP, MJ)
// dr(wQ) = HKl()[wQ] = two-char prop name, e.g. 'Rb'
// OP = keyOff (per-call-site integer literal)
// MJ = KE index (which encrypted string)
wX()[dr(wQ)](OP, MJ)Modifying any source byte that shifts the MurmurHash3 changes integrityConst. Every string decrypts to garbage. No error is thrown.
KE holds all encrypted strings by numeric index. cx.RV is set once at init. mj changes per CFF frame so the same literal keyOff produces a different offset depending on which frame is active. Three cipher functions cover all strings; 12 per-build decryptors are spread across these three types. Each wX() slot starts as a function and becomes a constant after one call.
Same memoization pattern. Key arrays live in closure scope, populated by var-expression functions (NB, q7, etc.) called once during init. Six array/key pairs cover 12 decryptors (six XOR, six tree). No integrity const.
n6 is the depth tracker. n6[n6.length - 1] is dhLast. Call form in the obfuscated source:
WX()[fD(index)](keyOff, strIdx, n6Top) // XOR variant (3 args)
WX()[fD(index)].call(null, a0, a1, a2, n6Top) // tree variant (4 args)WX() is the method singleton (same self-replacing pattern as SBSD's wX()). fD(index) = HKl()[index]. The caching replaces WX()['xy'] after first execution identically to SBSD.
The same engine class runs in both SBSD and sensor. Architecture is identical; opcode tables, blob counts, entry method names, and sentinel bytes differ between the two deployments.
The class is constructed inside a CFF loop dispatcher:
class Cg {
constructor() {
this[154] = []; // register file
this[28] = []; // bytecode array
this[12] = []; // value stack
jR_flow_34(34, [this]); // install opcode methods
this['e'] = Tn; // entry point
}
}All fields are numeric indices. Two constants are decoded from an obfuscated character map at runtime: QM.u = 197 (program counter index into this[154]) and QM.O = 10 (END_BLOCK sentinel byte).
Fixed method slots:
| Index | Role |
|---|---|
this[124]() |
nextByte: return this[28][this[154][197]++] |
this[227](b64, xorKey) |
blob loader: atob + XOR checksum + install resolver |
this[218](raw) |
opcode resolver: (raw + checksum) % 256 |
this[248]() |
inner loop: runs function body until END_BLOCK |
this[25](peek) |
stack pop/peek, unwraps proxy cell .y |
this[229] |
scope chain (xp push, f4.call pop, Qb.call lookup) |
this[206] |
external context object |
Each blob arrives as (b64, xorKey). The loader decodes it, XORs every byte, and locks in the opcode mapping:
// this[227] in sep_vm.js
var gM = atob(b64);
var ZG = 0;
for (var Mp = 0; Mp < gM.length; Mp++) {
gj[Yg] = gM.charCodeAt(Mp);
ZG ^= gj[Yg++]; // running XOR of all bytes
}
Ux_flow_34(34, [this, (ZG + xorKey) % 256]); // locks checksum
// sets: this[218] = (raw) => (raw + checksum) % 256Opcode methods are registered using the resolver at install time:
b4[b4[218](157)] = function() { this[12].push(this[25]() + this[25]()); }; // ADD
b4[b4[218](212)] = function() { this[12].push(this[25]() << this[25]()); }; // SHLEach method sits at (staticValue + checksum) % 256 in the instance. Raw bytecode already stores the shifted indices, so this[byte](this) dispatches with zero translation at runtime. Changing any byte shifts the XOR checksum, shifts every method slot, and the blob executes garbage with no error surfaced.
The two VMs use different static base values for the same opcodes, so a given resolved byte maps to a different instruction in SBSD vs. sensor.
// entry method this['e']
this[28] = this[227](b64, xorKey); // decode blob, install opcode map
this[206] = this[119](ctx); // wrap external context as scope root
this[229] = new xp(this); // init scope chain
this[177](197, 0); // PC = 0
while (this[154][197] < this[28].length) {
var bx = this[124](); // read byte at PC++
this[bx](this); // dispatch to opcode method
}Function bodies inside a blob are delimited by END_BLOCK (byte 10). FUNC followed by JMP creates a closure at the current PC, skipping the body. CALL then invokes this[248] which runs that body until END_BLOCK. TRY pushes three 4-byte targets (try offset, catch offset, finally offset); a JS try/catch in the outer dispatcher routes exceptions to the catch branch. Strings in the bytecode appear as plaintext; a disassembly of SBSD blob 0 shows PUSHS "getCc", PUSHS "substring", PUSHS "document" as literal bytes with no encryption.
The SBSD disassembler table has 35 opcodes. Sensor blobs add 6 more for heavier bitwise arithmetic.
| Category | SBSD | Sensor-only |
|---|---|---|
| Arithmetic | ADD SUB MUL MOD NEG |
DIV |
| Bitwise | SHL |
SHR SHRU XOR BITOR |
| Comparison | SEQ SNE LT LE GT GE |
|
| Logic | AND OR NOT |
|
| Stack | PUSH8 PUSH32 PUSHS PUSHU PUSHB PUSHF |
|
| Variables | LOAD MOV |
|
| Control | JMP JF JT FUNC CALL HLT TRY |
|
| Scope | PUSHSC POPSC |
|
| Objects | NEWOBJ MGET IN TYPEOF INC |
BUILDARGS |
Strings: 2-byte length prefix + character bytes. Float64: 8 raw IEEE 754 bytes. Jump targets: 4-byte int32.
SBSD constructs kg instances. Entry method is 'D', sentinel byte 167. A context object with six closures is built and passed as the external scope root:
// output/sbsd.js (per-blob invocation)
var wc = d5l_case_6_isolated(6, [
'getCc', function() { return PWl; }, // hex environment fingerprint
'getCgs', function() { return VQl; }, // supplementary signal string
'getCv', function() { return mWl; }, // accumulator getter
'setCv', function(v) { mWl = v; }, // accumulator setter
'getCd', function() { return DNl; }, // error state getter
'setCd', function(v) { DNl = v; }, // error state setter
]);
var xk = new kg();
xk['D'](wc, 'cQAAAdn5cQ...', 129);The blob accesses getCc() and friends via PUSHS + MGET + CALL sequences. From blob 0 disassembly (xorKey=129):
LOAD "KU" ; load context object (bound at dispatch time)
PUSHS "getCc" ; push accessor name as literal string
MGET thisBind: 0 ; context["getCc"] → function ref
CALL argc: 0 ; getCc() → hex env string on stack
PUSHS "substring"
MGET thisBind: 0 ; string["substring"]
PUSH8 55
PUSH8 58
CALL argc: 2 ; .substring(55, 58)
PUSHS "0x"
ADD ; "0x" + hex slice
; ... arithmetic on result ...
LOAD "KU"
PUSHS "setCv"
MGET thisBind: 0
CALL argc: 1 ; setCv(result)
Accessor names (getCc, getCv, setCv, getCd, setCd, getCgs) are literal strings in the bytecode with no obfuscation. "KU" is the scope variable holding the context object passed in at .D(ctx, b64, xorKey). The VM writes nothing back to the outer JS scope; all state passes through the six context closures.
Sensor constructs Np instances. Entry method 'r', sentinel byte 72. Exported functions are written onto the context object by name and destructured out after execution:
var Zhk = new Np();
Zhk['r'](Npd, 'wQAAAHo/37I...', 0);
({ XP: XP, Bd: Bd, lS: lS, Gc: Gc } = Npd); // pull exports from contextInside the blob, each function is created with FUNC and assigned at the root scope level. Since the VM's root scope IS the external context object, a top-level MOV sets a property on it directly. From blob 0 disassembly:
FUNC body 00005 ; create Bd closure (UA sanitizer)
PUSHS "Bd" ; property name, plaintext
MOV flag: 0 ; context.Bd = <fn>
FUNC body 00141 ; create XP closure (to-binary)
PUSHS "XP"
MOV flag: 0 ; context.XP = <fn>
FUNC body 00197 ; create Gc closure (DJB2a hash)
PUSHS "Gc"
MOV flag: 0 ; context.Gc = <fn>
; ... lS defined the same way at byte 00353 ...
After the VM exits, the outer JS destructures these off Npd. Function export and import use the same PUSHS + MGET/MOV mechanism; the only difference is direction (read vs. write).
Blob 1 is loaded separately:
var Ydd = new Np();
Ydd['r'](B2d, '...', 139);
({ fG: fG } = B2d);All 19 blobs operate on the shared context. Each reads one or two hex substrings from getCc(), optionally a slice from getCgs(), applies fixed arithmetic, and writes back through setCv. Decompiled examples from vm/sbsd_decompiled/:
// blob 0 (xorKey=129, 491 bytes, 126 instructions)
setCv((+('0x' + getCc().substring(55, 58)) + getCv() % 1002234) * 5);
// blob 1 (mixes getCgs() slice squared with getCc() hex)
let gs = +(getCgs().slice(10, 11));
setCv((gs * gs * 1 + 6 + +('0x' + getCc().substring(42, 43)) + getCv() % 1351234) * 4);
// blob 5 (mixed subtraction and getCgs() as numeric)
setCv((+('0x' + getCc().substring(41, 42)) - +(getCgs()) + +('0x' + getCc().substring(34, 37)) + getCv() % 1001564) * 6);
// blob 10 (two hex ranges, subtracted)
setCv(2 * (getCv() % 1004234 - 3 + +('0x' + getCc().substring(50, 51)) - +('0x' + getCc().substring(48, 50))));Every blob wraps its body in TRY with a catch and finally branch. Each uses a different environment guard (document.getElementById !== undefined, !!window, navigator.javaEnabled !== undefined, etc.). Guard failure:
setCv(getCv() - 32); // penalty constant varies per blob
setCd('undef#' + getCd());Exception path stamps 'err#' instead. If execution is clean across all 19 blobs, the final cv/cd pair is the challenge response. Any failed guard or caught exception marks the error state permanently. Two blobs deviate from the accumulator pattern: blob 15 collects DOM signals (plugin structure, mutation observer presence); blob 18 defines helper string functions JW/Cs and exports them through the context.
The sensor VM executes real computation rather than an integrity chain.
Blob 0 (673 instructions, xorKey=0) defines four functions exported through the context:
function Bd() { // UA sanitizer
return window.navigator.userAgent.split('\\').join('').split('"').join('');
}
function Gc(str) { // DJB2a hash
let h = 5381;
for (let i = 0; i < str.length; i++) h = (h * 33) ^ str.charCodeAt(i);
return h >>> 0;
}
// lS(deltaMs, signalData, rand, velocity) -- main fingerprint
// 1. sanitize UA via Bd()
// 2. seed = String(rand) + UA.slice(-32) + String(bmak.startTs) + String(velocity)
// 3. cc = Gc(seed); bitstring = cc.toString(2)
// 4. filter 31-char alphabet ('a3cd9efghiYjklm7opqrs1uvwQxyBz2') by bit positions
// 5. DOM detection: getElementsByTagName / ATTRIBUTE_NODE / baseURI presence
// --> FP (35 or 12-543), AC (-1 or 12-111), dP (+2 or +27)
// 6. six Gc-based mixing rounds using FP/AC/dP --> DF string
// 7. second pass: GQ = String(deltaMs+startTs)+String(rand), index into alphabet --> DQ
// return DF + DQ (or 'e' on exception)lS is called with the timing delta, serialized signals, a random value, and total velocity. Its return value populates the ajr field in the telemetry payload.
Blob 1 (383 instructions, xorKey=139) is a permutation cipher. Five 23-element permutation tables and five magic key constants are embedded as BUILDARGS sequences in the bytecode:
function fG(q, tF) {
const tables = [
[8,1,5,22,2,20,10,13,17,16,11,6,7,14,3,19,12,0,21,9,18,4,15],
[21,9,17,4,16,18,5,20,14,10,8,11,19,15,0,7,1,13,6,22,12,2,3],
[3,19,10,6,9,7,8,15,5,1,12,20,13,21,4,18,14,16,0,22,17,11,2],
[9,3,17,0,14,11,16,21,1,5,8,18,12,10,13,4,2,20,6,22,15,7,19],
[16,9,11,7,10,0,12,18,1,8,6,2,22,13,4,14,17,19,5,3,15,21,20],
];
const keys = [11352969, 3228881, -1082813, -2510377110, -53177195];
const idx = keys.indexOf(tF);
if (idx === -1) return q;
return tables[idx].map(i => q[i]);
}Called as fG(signalArray, key) where key is one of the five constants. The selected permutation reorders the 23 collected signal values before they are fed into lS.