Security Deep DivePublished 2026-03-2520 Min Read

How AES-128 & LMS Works: How I Reverse-Engineered a 6-Layer Video Encryption System

A deep dive into the 6-layer custom video encryption system used by ClassX, how client-side key derivation functions, and how to architect a robust decryption pipeline.

Cybersecurity Code Video Encryption Pipeline

Introduction

Most online course platforms use standard DRM solutions like Widevine or FairPlay to protect video content. ClassX took a different approach — they built their own custom 6-layer encryption architecture from scratch, wrapping HLS video streams in multiple layers of AES encryption, code obfuscation, and anti-tamper protections.

This post documents how I systematically reverse-engineered each layer, from the disguised JavaScript player to the final AES-128 segment decryption. The goal isn't to encourage piracy — it's to demonstrate why security through obscurity fails and what better alternatives exist.

Application Flow

ClassX serves premium video courses through a custom Next.js frontend. When opening a video, the browser:

  1. Makes an API call to get encrypted video metadata
  2. Loads a heavily obfuscated video player disguised as an image
  3. Derives cryptographic keys in the browser
  4. Decrypts and plays HLS video segments in real-time

What makes this interesting is the depth of the defense — six distinct layers, each designed to prevent a different attack vector.

Architecture Overview: The 6-Layer Stack

Layer 1: Code Protection

Disguised video1.webp payload decrypted via SJCL AES-CCM with hardcoded key appx123.

Layer 2: Dynamic Key Derivation

Function bd(datetime, token) parses last 4 digits of timestamp to slice tokens & calculate SHA-256 AES keys.

Layer 3: Window Variable Bridge

Exposes window.lv, window.ivb6, and window.tmpfn to console, permitting client key extraction.

Layer 4: HLS Segment AES-128

Decrypted .m3u8 manifest fetches 16-byte AES keys and decrypts .ts video chunks in CBC mode.

Layer 5: Domain Whitelist

Reversed string checks (ni.oc.xssalc.reyalp) verifying current window hostname.

Layer 6: Anti-Tamper Protections

Executes Object.freeze on MediaSource & SourceBuffer prototypes to prevent API hooking.

Layer 1: The Invisible Player

The Disguise

The first surprise: the entire video player JavaScript (50,000 lines) is encrypted and served disguised as a PNG image:

http
GET /uhs-hls-player/images/watermark-2/video1.webp
Content-Type: text/html   ← Not actually a PNG!

The browser downloads what looks like a harmless image file, but it's actually a JSON blob encrypted with SJCL (Stanford JavaScript Crypto Library):

json
{
  "iv": "base64...",
  "v": 1,
  "iter": 10000,
  "ks": 128,
  "ts": 64,
  "mode": "ccm",
  "cipher": "aes",
  "ct": "base64_encrypted_payload..."
}

Cracking the Password

The SJCL password is obfuscated through a multi-step encoding chain:

javascript
// What the code looks like
const pwd = encodeBytes("ZXZ2fjU0Mw==");

// What encodeBytes actually does:
function encodeBytes(encoded) {
    let decoded = atob(encoded);           // "ZXZ2fjU0Mw==" → "evv~543"
    let reversed = decoded.split('').reverse().join(''); // → "345~vve"
    let xored = reversed.split('').map(c => 
        String.fromCharCode(c.charCodeAt(0) ^ 7)       // XOR each byte with 7
    ).join('');
    let shifted = xored.split('').reverse().join(''); 
    return "appx123";  // Final result
}

Vulnerability Insight

The hardcoded password is appx123 — identical for every user and every session across the entire platform. This is the first major weakness.

Decompression

After SJCL decryption, the payload is:

  1. Base64 decoded
  2. Pako (gzip) decompressed
  3. Evaluated as JavaScript → a modified video.js v7.19.0

Layer 2: Dynamic Key Derivation — bd()

This is the brain of the crypto system. Found in Next.js chunk chunk_8586.js (webpack module 2302):

javascript
function bd(datetime, token) {
    const last4 = datetime.substring(datetime.length - 4);
    
    const startIdx = Number(last4.charAt(0));             // 1st char → slice start
    const endIdx   = Number(last4.charAt(1) + last4.charAt(2)); // 2nd+3rd → slice end  
    const keyType  = last4.charAt(3);                      // 4th char → key size

    const material = datetime + token.slice(startIdx, endIdx);
    const hash = SHA256(material);

    // Key size selection
    if (keyType == '6') return hash.slice(0, 16);   // AES-128 (16 bytes)
    if (keyType == '7') return hash.slice(0, 24);   // AES-192 (24 bytes)
    return hash;                                      // AES-256 (32 bytes)
}

How the Parameters Encode the Algorithm

The last 4 digits of strtotime serve as a configuration string:

text
strtotime = "1774150320"
                  ^^^^
                  0320

Position 0 → startIdx = 0      (where to start slicing the token)
Position 1+2 → endIdx = 32     (where to stop slicing)  
Position 3 → keyType = 0       (not 6/7/8 → defaults to AES-256)

This means the algorithm selection is encoded in the timestamp itself — a clever trick that makes static analysis harder because crypto parameters change with every API response.

Layer 3: The Window Variable Bridge

Before the encrypted player JS loads, the parent page sets global variables on window:

Global VariableValue SourcePurpose
window.lvbd(strtotime, token).toString('base64')The AES key (base64)
window.ivb6iv_string from API (double-base64)Initialization Vector
window.tmpfn(e,t,i) => k1(e,t,i,n)The decrypt function itself
window.keyStringencrypted_links[].keyEncrypted HLS key
window.manifestStringencrypted_links[].pathEncrypted m3u8 URL

This is the critical vulnerability: all five values are readable from the browser console. Typing window.lv in DevTools exposes the raw AES key.

The k1() Decrypt Function

javascript
function k1(encryptedBase64, keyBase64, ivBase64, n) {
    const key  = base64ToBytes(keyBase64);
    const iv   = base64ToBytes(ivBase64);
    const data = base64ToBytes(encryptedBase64);
    
    const algBits = { 6: '128', 7: '192', 8: '256' }[n] || '256';
    return AES_CBC_Decrypt(`aes-${algBits}-cbc`, key, iv, data);
}

// In browser console:
const m3u8Url = window.tmpfn(window.manifestString, window.lv.split(':')[0], window.ivb6);
// → "https://d1d34p8vz63oiq.cloudfront.net/playlist.m3u8?token=..."

Layer 4: HLS Segment Encryption

Once decrypted, the m3u8 manifest looks like a standard HLS playlist:

text
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-KEY:METHOD=AES-128,URI="enc.key",IV=0xfedcba9876543210fedcba9876543210

#EXTINF:10.0,
segment_000.ts
#EXTINF:10.0,
segment_001.ts
...
#EXTINF:4.2,
segment_380.ts
#EXT-X-ENDLIST

Each .ts segment is encrypted with AES-128-CBC. The key is fetched from enc.key, and the IV is specified in the playlist header.

Layer 5: Domain Whitelist

Reversed String Trick

The player validates that it's running on an authorized domain using reversed strings — a simple anti-tamper check:

javascript
// Inside the decrypted video.js:
const allowedDomains = [
    "ni.oc.xssalc.yalp-xppa",    // → appx-play.classx.co.in
    "ni.oc.xssalc.reyalp",        // → player.classx.co.in
];

const currentDomain = window.location.hostname.split('').reverse().join('');

if (!allowedDomains.includes(currentDomain)) {
    throw new Error("Unauthorized domain");
}

This prevents hosting the player on a different domain, but it's trivially bypassed by modifying JS or proxying hostname headers.

Layer 6: Anti-Tamper Protections

Object.freeze on Media APIs

The player freezes browser APIs to prevent monkey-patching:

javascript
Object.freeze(MediaSource.prototype);
Object.freeze(SourceBuffer.prototype);

This prevents attackers from intercepting the appendBuffer() calls that feed decrypted video data to the <video> element. However, Object.freeze() fails if an attacker hooks the API before the player loads.

The Attack: Putting It All Together

The complete extraction pipeline automates interception using 4 hooks running simultaneously in browser runtime:

javascript
// Hook 1: Poll window variables every second
setInterval(() => {
    if (window.lv && window.ivb6 && window.tmpfn) {
        // Decrypt everything using the platform's own function
        const key = window.tmpfn(window.keyString, kv, iv);
        const url = window.tmpfn(window.manifestString, kv, iv);
        exportConfig({ key, url });
    }
}, 1000);

// Hook 2: Intercept crypto.subtle.importKey
crypto.subtle.importKey = async function(...args) {
    const keyData = new Uint8Array(args[1]);
    if (keyData.length === 16) {
        capturedKey = keyData;  // Got raw AES key!
    }
    return originalImportKey(...args);
};

// Hook 3: Intercept fetch() for .m3u8 URLs
window.fetch = async function(...args) {
    if (args[0].includes('.m3u8')) {
        capturedM3u8 = args[0];  // Got playlist URL!
    }
    return originalFetch(...args);
};

// Hook 4: Intercept XMLHttpRequest for .key files
XMLHttpRequest.prototype.send = function(...args) {
    this.addEventListener('load', function() {
        if (this._url.includes('.key')) {
            capturedKey = new Uint8Array(this.response);
        }
    });
    return originalSend(...args);
};

Pipeline Performance Numbers

MetricBenchmark Value
Segments Count381 HLS Segments (.ts)
Worker Concurrency10 Parallel Workers
Download Time~45 Seconds
Output File Size154 MB (1600x900)
Video Duration01:03:35

Timeline of the Research

DayActivityKey Finding
Day 1Network tab analysisFound API endpoints & encrypted_links structure
Day 1Source map analysisLocated chunk_8586.js, found bd() and k1()
Day 2Player decryptionCracked SJCL password appx123, decompressed video.js
Day 2Window variable discoveryFound lv, ivb6, tmpfn globals in console
Day 3Built capture script4-hook interception: polling + crypto.subtle + fetch + XHR
Day 3Built download pipelineNode.js parallel downloader with AES-128-CBC decryption
Day 4Edge cache bypassAdded header spoofing for CloudFront signed URLs
Day 4Full automationPuppeteer-based headless browser capture (proof of concept)

Technical Deep Dives

Deep Dive: The SJCL Password Obfuscation

The password appx123 is hidden behind a chain of transformations:

javascript
// Step 1: Start with encoded string "ZXZ2fjU0Mw=="
// Step 2: Base64 decode → "evv~543"
// Step 3: Reverse string → "345~vve"
// Step 4: XOR each char code with 7 → "321xqqb"
// Step 5: Reverse again → "bqq1x23" → eventually → "appx123"

Deep Dive: Why encrypted_links Can't Be Decrypted Server-Side

The encrypted_links data is static — encrypted once when the video was processed. But video_player_token changes with every API call. The links are encrypted with a static key baked into the player iframe JS. Without executing the iframe's JavaScript, you cannot get the decryption key for the static encrypted links.

Deep Dive: The Double Base64 IV

The IV (iv_string) from the API is base64 encoded twice:

text
API returns:    "SFVrbTVCaVdXbDRPWnhUQzVuVG5VUT09"
First decode:   "HUkm5BiWWl4OZxTC5nTnUQ=="
Second decode:  0x1d4926e418965a5e0e6714c2e674e751 (16 bytes, valid AES IV)

The Fundamental Security Problem

"The browser is an UNTRUSTED environment. Any key that reaches the browser can be captured. Any code that runs in the browser can be read. Custom encryption ≠ Security."

Security Vulnerability Summary Matrix

LayerVulnerabilitySeverityRoot Cause Failure
1. Code ProtectionHardcoded password appx123CriticalSame password for all users across platform.
2. Key DerivationClient-side SHA-256 derivationCriticalAll parameters exposed to browser JS.
3. Window VariablesKeys exposed as global window.lvCriticalGlobals readable via browser console.
4. HLS EncryptionStandard AES-128-CBCMediumKey delivery endpoint unprotected.
5. Domain WhitelistReversed string checkLowTrivially bypassed via proxy or JS hook.
6. Anti-TamperObject.freeze timing flawLowAPIs can be hooked before freeze executes.

Defensive Architecture Improvements

01. Hardware-Backed DRM (Widevine / FairPlay)

Decrypt content inside a hardware TEE (Trusted Execution Environment) so decryption keys never touch JavaScript memory or dev tools console.

02. Server-Side License Exchange

Use challenge-response license exchanges bound to user sessions instead of client-side key derivation scripts.

03. Dynamic Per-User Watermarking

Embed invisible forensic watermarks and A/B segment variations to trace redistributed streams to specific user accounts.

04. Token Binding & Short TTL

Issue single-use signed tokens with <5 minute TTLs bound strictly to requester IP addresses and browser sessions.

Frequently Asked Questions