Skip to content
This repository was archived by the owner on Apr 8, 2026. It is now read-only.

rxob/akamai-deob

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Obfuscation

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.

Operator Wrappers

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 NOT

They are used like: NR(In, VW)

Singletons

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 wrapper

Every 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 call

No 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.

Control Flow Flattening

All logic lives in switch dispatch loops. Two types are handled.

Simple Dispatcher

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.

Loop Dispatcher

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.

Integrity Constant (SBSD only)

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 >>>= 0

The 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) % 256

Modify 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.

String Decryption

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.

SBSD

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.

Sensor

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 VM

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.

VM Class Structure

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

Blob Loading and Opcode Resolution

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) % 256

Opcode 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]()); }; // SHL

Each 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.

Dispatch Loop

// 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.

Instruction Set

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.

Embedding in SBSD

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.

Embedding in Sensor

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 context

Inside 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);

SBSD VM (19 Blobs)

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.

Sensor VM (2 Blobs)

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors