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.
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:
- Makes an API call to get encrypted video metadata
- Loads a heavily obfuscated video player disguised as an image
- Derives cryptographic keys in the browser
- 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
Disguised video1.webp payload decrypted via SJCL AES-CCM with hardcoded key appx123.
Function bd(datetime, token) parses last 4 digits of timestamp to slice tokens & calculate SHA-256 AES keys.
Exposes window.lv, window.ivb6, and window.tmpfn to console, permitting client key extraction.
Decrypted .m3u8 manifest fetches 16-byte AES keys and decrypts .ts video chunks in CBC mode.
Reversed string checks (ni.oc.xssalc.reyalp) verifying current window hostname.
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:
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):
{
"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:
// 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:
- Base64 decoded
- Pako (gzip) decompressed
- 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):
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:
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 Variable | Value Source | Purpose |
|---|---|---|
| window.lv | bd(strtotime, token).toString('base64') | The AES key (base64) |
| window.ivb6 | iv_string from API (double-base64) | Initialization Vector |
| window.tmpfn | (e,t,i) => k1(e,t,i,n) | The decrypt function itself |
| window.keyString | encrypted_links[].key | Encrypted HLS key |
| window.manifestString | encrypted_links[].path | Encrypted 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
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:
#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-ENDLISTEach .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:
// 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:
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:
// 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
| Metric | Benchmark Value |
|---|---|
| Segments Count | 381 HLS Segments (.ts) |
| Worker Concurrency | 10 Parallel Workers |
| Download Time | ~45 Seconds |
| Output File Size | 154 MB (1600x900) |
| Video Duration | 01:03:35 |
Timeline of the Research
| Day | Activity | Key Finding |
|---|---|---|
| Day 1 | Network tab analysis | Found API endpoints & encrypted_links structure |
| Day 1 | Source map analysis | Located chunk_8586.js, found bd() and k1() |
| Day 2 | Player decryption | Cracked SJCL password appx123, decompressed video.js |
| Day 2 | Window variable discovery | Found lv, ivb6, tmpfn globals in console |
| Day 3 | Built capture script | 4-hook interception: polling + crypto.subtle + fetch + XHR |
| Day 3 | Built download pipeline | Node.js parallel downloader with AES-128-CBC decryption |
| Day 4 | Edge cache bypass | Added header spoofing for CloudFront signed URLs |
| Day 4 | Full automation | Puppeteer-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:
// 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:
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
| Layer | Vulnerability | Severity | Root Cause Failure |
|---|---|---|---|
| 1. Code Protection | Hardcoded password appx123 | Critical | Same password for all users across platform. |
| 2. Key Derivation | Client-side SHA-256 derivation | Critical | All parameters exposed to browser JS. |
| 3. Window Variables | Keys exposed as global window.lv | Critical | Globals readable via browser console. |
| 4. HLS Encryption | Standard AES-128-CBC | Medium | Key delivery endpoint unprotected. |
| 5. Domain Whitelist | Reversed string check | Low | Trivially bypassed via proxy or JS hook. |
| 6. Anti-Tamper | Object.freeze timing flaw | Low | APIs 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.