Skip to content

11. Key Extraction Attack Paths

Security testing document. This is an attack-path analysis of our own system, written for the security review. It describes, step by step, how a motivated attacker obtains the AES-128 key, decrypts the .ts segments, and produces a file that plays anywhere — then maps each path to its fix. Doc 5 named these gaps; this doc is the “prove it” companion: the exact mechanism, the diagram, and the evidence a pentest should capture.

Strip away everything else and the whole protection scheme is one fact:

attacker needs: KEY (16 bytes for this content_id) + IV (in the manifest)
└── if they have these, ffmpeg does the rest ──┘

The IV is not secret — it is printed in the #EXT-X-KEY line of every media playlist. The segments are not secret — the CDN serves them to anyone who can reach it (only the key host 403s without headers). So every attack path in this document is just a different way of answering one question: how do I get 16 bytes out of this system?

#EXT-X-KEY:METHOD=AES-128,
URI="https://kms.chorki.net/v1/keys?content_id=<uuid>" ← the 16 bytes live here
IV=0xe1be0390e0d28b09cdb56aecf9d592cc ← this is free
Persona Capability Which path they use
Link-sharer Has a URL None — the design holds
Tooling author Has the APK/IPA, a PC, an afternoon, one account (free tier suffices) A (static secrets + token layer)
Device holder Rooted Android / jailbroken iPhone, or any phone + Wi-Fi B (runtime), C (downloads), D (cast proxy)

Path A is the one that scales: it produces a server-side script that fetches keys for arbitrary content_ids (see “The token layer” below for how far the x-entitlement / x-content-access scheme constrains that), and everything built on it needs no device. The token layer raised this bar from zero accounts to one account — a one-time, scriptable cost.

The foundation fact: the key is per-content, not per-user

Section titled “The foundation fact: the key is per-content, not per-user”

Whatever path the attacker takes, they take it once per title:

one key ──decrypts──► every .ts of that title
one key ──valid for──► every user, every device, forever
(no rotation, no revocation, no per-user derivation)

This is what turns a single successful extraction into a permanently playable, freely redistributable file. The token layer does not change this property. x-entitlement and x-content-access gate the front door (key issuance); they say nothing about the key once it is out. All four paths below still land on the same 16 permanent bytes; the tokens only change how hard the front door is to walk through (Path A). Fixing the per-content/no-rotation property — per-session keys or real DRM — shrinks every path at once.


Path A — Extract the static secrets from the binary (offline, permanent)

Section titled “Path A — Extract the static secrets from the binary (offline, permanent)”

Difficulty: low. Equipment: the APK, jadx, curl. Result: keys for any content, indefinitely, from a server.

This is the path that matters most, because it does not need a device, an account, or the app ever running again. The entire chain of “obfuscation” in doc 1 unwinds statically.

Step 1 — Decompile, find two string literals

Section titled “Step 1 — Decompile, find two string literals”
APK ──jadx / strings──► EncryptionHelper
├── 'G0T!P4T#_Q3Y' ← passphrase → AES key
└── 'G0T!P4T#_VL0CK' ← passphrase → AES IV

The literals are in the Dart AOT snapshot; string search finds them, and the class structure (sha256.convert(utf8.encode(...)).substring(...)) reads plainly in a disassembler. The exact derivation, from encryption_helper.dart:12-13:

key = sha256("G0T!P4T#_Q3Y").hex[0:32] ← AES-256-CBC key
iv = sha256("G0T!P4T#_VL0CK").hex[0:16] ← fixed, per-build

Step 2 — Reimplement decrypt() and unwrap the settings payload

Section titled “Step 2 — Reimplement decrypt() and unwrap the settings payload”

The attacker calls the public settings endpoint themselves:

curl https://api-static.chorki.net/v2/settings
│
▼
json["ytiruces"] ← reversed key: "security"
│ for each field: reverse the field name, base64-decode the value
▼
AES-256-CBC decrypt with the Step-1 key/iv
│
▼
referrer = "https://<site>" plaintext
secret = "<CDN auth token>" plaintext

Nothing here is authenticated — /v2/settings is a static, public GET. The reversal (ytiruces, sreyalp_rerrefer) and the CBC layer are obfuscation, and doc 5 already says so; this path is the proof.

Step 3 — Compute the one signature that matters

Section titled “Step 3 — Compute the one signature that matters”

vpsid = HMAC-SHA1("<platform>|<referrer>", secret).toHex() — and because platform ∈ {android, ios} and referrer/secret are fixed per build:

┌────────────────────────────────────────────────────────────┐
│ the entire "proof of app-ness" = TWO hex strings │
│ per release: one for android, one for ios │
│ │
│ extract once ──► replay forever, for every content_id │
└────────────────────────────────────────────────────────────┘

Step 4 — Become the KMS’s favourite customer

Section titled “Step 4 — Become the KMS’s favourite customer”
GET https://kms.chorki.net/v1/keys?content_id=<any uuid>
referer: https://<site>
vpsid: <the one hex string>
platform: android
──────────────────────────────────────────────────────────────►
◄── 200 OK <16 raw bytes>

From here a pirate-site backend is ~30 lines: harvest content_ids from any share of a manifest URL (the KMS URI is in every playlist), fetch the key, cache it.

APK/IPA ─┐
│ static analysis (no device needed)
▼
[1] literals ─► key/iv ─► [2] GET /v2/settings ─► referrer + secret
│
▼
[3] vpsid = HMAC(platform|referrer, secret)
│ (2 values per build)
▼
┌──────────────── kms.chorki.net ◄───────┘
│ GET /v1/keys?content_id=* (any title)
▼
16 bytes ──────────────────► pirate tooling, forever

The token layer: what x-entitlement / x-content-access change

Section titled “The token layer: what x-entitlement / x-content-access change”

Docs 6–10 added two signed tokens to every KMS request. They must be assessed separately, because they change Path A and leave B/C/D alone.

x-entitlement x-content-access
Issued by GET /v1/user/entitlements — authenticated (bearer) content_access_token field on the ordinary content-page response
Scope one user one title (per route)
Format PASETO v4.public / JWT, opaque to client, has exp/iat same; isTokenExpired checked client-side
Client cache SharedPreferences, plaintext (entitlement_token_<userId>, as {token, fetched_at}) in-memory map, 10-min maxAge
Anonymous? no — needs a signed-in user yes — AVOD titles issue it to anyone

The pre-token world (the original Path A above): zero accounts, zero API calls beyond /v2/settings, keys for every title forever. That is gone — if and only if the KMS enforces both tokens server-side, unconditionally. With enforcement on:

extract vpsid alone ──► 403. You now also need:
• a live entitlement token (a real account)
• a content-access token (the title's page)

What it does not close — the adapted attack, Path A′

Section titled “What it does not close — the adapted attack, Path A′”

The tokens are bearer tokens. Nothing in them is bound to a device, a session, or a one-time use; possession is authority until exp. So the attacker’s script just grows two steps:

[1–3] static vpsid, exactly as before (still required, still static)
│
▼
[4] sign up / log in ONE account via the ordinary REST API
│ ◄── free tier may suffice for AVOD; paid for premium
▼
[5] GET /v1/user/entitlements ──► x-entitlement (bearer, replays till exp)
[6] GET /v1/:type/:slug ──► x-content-access (per title, anonymous-OK for AVOD)
│
▼
[7] GET kms.chorki.net/v1/keys?content_id=<uuid>
with referer + vpsid + platform + both tokens
│
▼
16 bytes ──► ffmpeg ──► out.mp4 (still permanent: key ≠ token)

Cost analysis of the raise:

  • Zero accounts → one account. A single entitled account unlocks the whole catalogue it can see — tokens are per-user and per-title, but not per-play in a way a script can’t repeat: re-fetch and replay.
  • AVOD content stays at zero accounts: anonymous x-content-access plus static vpsid is the complete credential set for free titles.
  • Replay window: both tokens travel on every KMS request, so the same MITM that captures the key (Path B1) captures the tokens too; the 10-min content-token cache and 60s expiry leeway widen, not narrow, the window in which a captured token still works.
  • Plaintext at rest: the entitlement token sits in SharedPreferences — the same backup that grabs the download folder (Path C) grabs a live bearer token for the victim’s account.
  • Client flag ≠ server enforcement: kms_token_headers_enabled is read from settings (main.dart:258) and only controls whether the client attaches the headers. If the KMS’s enforcement is tied to the same toggle, an attacker who simply omits the headers regains the pre-token behaviour. Enforcement must be unconditional on the KMS side — that is the single assumption this whole layer rests on, and the first thing the pentest should verify.
Path A │ raised: one account + two API calls + replay window;
│ AVOD still zero-account; assumption: KMS enforces always
Path B1 │ unchanged — and now also harvests both tokens in transit
Path B2 │ unchanged (memory dump ignores transport entirely)
Path C │ unchanged — and the backup also yields the entitlement token
Path D │ unchanged — proxy caches keys (_keyCache) after first fetch;
│ later LAN peers don't even trigger a KMS check

Path B — Intercept the key in flight or in memory (runtime)

Section titled “Path B — Intercept the key in flight or in memory (runtime)”

Difficulty: low-medium. Equipment: rooted/jailbroken device, Frida; or user CA + proxy if the app trusts user certificates. Result: the key for whatever is playing.

The key request is a plain HTTPS GET whose response body is the key. Two sub-paths:

app ──GET key──► mitmproxy ──► kms.chorki.net
▲ │
│ ▼
attacker reads the 16-byte response body

Anything that lets the attacker read the TLS channel gives them the key: a user-installed CA (if the network security config permits it), or a Frida script that un-pins / un-verifies TLS. No crypto needs to be broken; the plaintext key simply transits the device.

The native player holds the key in ordinary memory (no secure video path — that is the L1/DRM line from doc 1):

ExoPlayer: hook Aes128DataSource / media3's DefaultDrmSession n/a →
the key bytes are an argument on the stack during
segment decryption
AVPlayer: hook AVAssetResourceLoader delegate callbacks, or scan
process memory for the 16 bytes matching the manifest IV's
decrypt-verify (try-decrypt-first-segment oracle)

A generic “dump memory, try each 16-byte window as the key on segment 0” script works on any AES-128 HLS app — no reverse engineering needed. That is why this class of attack cannot be fixed inside AES-128 at all; it is the argument for Widevine/FairPlay, where the key never exists in app-readable memory.

(Historical note: doc 5 listed the signature and playlist bodies being printed to logcat/Console in release builds — a zero-effort variant of this path. As of this writing those print() calls are gone from main; re-verify per release build, since it costs nothing to regress.)


Path C — Copy a completed download (at rest)

Section titled “Path C — Copy a completed download (at rest)”

Difficulty: trivial. Equipment: any access to the device’s files. No root needed if backups/file sharing are reachable. Result: a folder that plays in VLC on any machine, forever.

Doc 5 called this the big one; it is still the sharpest edge on main (the fix lives on feature/anti-piracy-hardening, unmerged).

<Documents>/<slug>/
├── video.m3u8 ── "key is at key.bin, IV is this"
├── key.bin ── the 16 bytes, PLAINTEXT, same folder
├── segment_000.ts ── "protected" by the file beside it
└── ...

Exfiltration routes — none requiring root:

device files ──► iTunes/Finder backup ──► unarchive on PC ──┐
──► Android Auto Backup / adb backup ├──► VLC opens
──► any file-sharing/backup app with broad scope ┘ the folder
as-is

There is nothing to “extract”: the key travels with the ciphertext, and the manifest already names it. The x-entitlement scheme does not touch this path — the folder is self-contained.


Path D — Ask the phone’s cast proxy for the key (LAN)

Section titled “Path D — Ask the phone’s cast proxy for the key (LAN)”

Difficulty: low. Equipment: any device on the same Wi-Fi. No app, no account, no exploit. Result: keys (and segments) for whatever the victim is casting.

Because a TV cannot send the auth headers, the phone signs and re-serves everything (cast_lan_proxy_server_impl.dart). The proxy that makes casting work is, from the LAN’s point of view, an unauthenticated key dispenser:

same Wi-Fi
┌─────────────┐ GET /k/<key-uri> ┌──────────────────────────┐
│ attacker │ ──────────────────► │ victim's phone │
│ (any device)│ ◄────────────────── │ 0.0.0.0:<ephemeral port> │
└─────────────┘ 200 OK, 16 bytes │ (InternetAddress.anyIPv4)│
└──────────────────────────┘
the phone, on behalf of the TV:
adds referer/vpsid/platform (+ entitlement headers)
fetches from kms.chorki.net, caches, re-serves to anyone who asks

Verified in code: HttpServer.bind(InternetAddress.anyIPv4, 0) binds all interfaces; requests carry no authentication of the * requester* — by design, since the TV cannot authenticate. The port is ephemeral but discoverable (mDNS advertisement for cast discovery, or a quick LAN scan). During any cast session, everyone on the network can pull the key for the title being cast — and via /m/... the segments too.


The payoff: from 16 bytes to a playable file

Section titled “The payoff: from 16 bytes to a playable file”

Every path ends holding the same thing. The conversion is one command and is built into ffmpeg:

attacker's loot: what they do:
┌────────────────────┐ ┌──────────────────────────────────┐
│ manifest.m3u8 │ │ 1. save key to key.key │
│ segment_*.ts │ ────► │ 2. rewrite #EXT-X-KEY URI to │
│ key (16 bytes) │ │ "key.key" (local file) │
└────────────────────┘ │ 3. ffmpeg -allowed_extensions ALL│
│ -i manifest.m3u8 │
│ -c copy out.mp4 │
└──────────────────────────────────┘
│
▼
out.mp4 — unencrypted, plays anywhere,
uploadable anywhere, permanent

No re-encode even happens (-c copy); it is a byte-level AES-128-CBC decrypt of each segment. This step is generic, well-tooled, and cannot be defended against from inside the AES-128 choice.

End-to-end attack chain (the one diagram for the report)

Section titled “End-to-end attack chain (the one diagram for the report)”
LEGIT │ ATTACK
───── │ ─────
subscriber taps play │
│ │
▼ │
content API ─► m3u8 URL │
│ └─► x-content- │ ┌──────── PATH A/A′ ─────────────────────┐
│ access token │ │ APK ─► literals ─► settings ─► vpsid │
▼ │ │ + one account (zero for AVOD) │
/user/entitlements ─► │ │ ──► both tokens via plain REST │
x-entitlement │ └───────────────┬────────────────────────┘
│ │ ┌──────── PATH B ────────────────────────┐
▼ │ │ mitmproxy / Frida reads key response │
native player fetches │ │ (and both tokens in the same capture) │
manifest, sees EXT-X-KEY │ └───────────────┼────────────────────────┘
│ │ ┌──────── PATH C ────────────────────────┐
▼ │ │ copy Documents/<slug>/ (key.bin inside)│
GET kms.chorki.net/v1/keys │ └───────────────┼────────────────────────┘
referer/vpsid/platform │ ┌──────── PATH D ────────────────────────┐
+ both tokens │ │ LAN peer asks cast proxy for /k/... │
│ │ │ (cached keys skip KMS entirely) │
▼ │ └───────────────┼────────────────────────┘
│ ▼
│ the 16 bytes
│ + IV from manifest
│ + .ts from CDN (no auth)
│ ▼
│ ffmpeg -c copy
│ ▼
│ out.mp4 — plays anywhere
Path Skill Needs device? Needs account? Scales to catalogue? Detection possible?
A — static secrets alone low no (APK only) no yes, forever — blocked only if KMS enforces tokens unconditionally only via KMS-side anomaly heuristics
A′ — secrets + token layer low no (APK + REST) one (zero for AVOD) yes, while that account is entitled token issuance anomalies, per-account key-fetch volume
B1 — MITM low yes yes per-title, live; also harvests both tokens no
B2 — memory/hook medium yes (rooted) yes per-title, live no
C — download copy trivial yes (files only) yes per-title, offline; also yields entitlement token no
D — cast proxy low victim’s phone on LAN no per-title, while casting; cached keys skip KMS entirely no

Path A′ is the report’s headline: low skill, no device, one (possibly free) account, catalogue-wide. The static vpsid remains the root cause — the tokens are additive credentials that a script collects with two more API calls, not a replacement for it.

Path Real fix Status / cost
A/A′ Bind key issuance to something a script cannot replay: per-play or per-session key requests with a server-checked nonce/timestamp; short token TTLs with no exp leeway grace for replay; unconditional KMS-side enforcement of both tokens (never tied to the client’s kms_token_headers_enabled flag); anomaly limits on key-fetches per account KMS/backend work — the client half already ships (docs 6–10)
A/A′ Make vpsid per-session/per-title with a server-checked timestamp or nonce, so an extracted value dies in minutes (doc 5 §4) Backend work; the KMS must reject replayed signatures
A/B1 Anything static in the binary must be treated as public. Raise extraction cost with obfuscation if wanted, but assume the secrets are out Mindset + the fixes above
A′ (storage) Move the entitlement token out of plaintext SharedPreferences into Keychain/Keystore (rides along with the same work as the download-key wrapping) Low-medium; entitlement_token_store.dart
B2 Only real DRM closes it: Widevine (L1) / FairPlay — keys never enter app-readable memory, secure video path blocks screen capture. Both stacks already exist, dead, in kvideo; needs licence server + packaging (doc 5 §5) Large project; licensor-driven
C Key wrapping at rest (per-install AES-256-GCM, Keychain/Keystore) — written and sitting on feature/anti-piracy-hardening, unmerged. Merging it is the single highest-value change in this doc Low: merge + test
D Require a per-session token on the LAN proxy (show a PIN on the TV / derive from the cast handshake), bind it to the session key cache, and stop serving /k/ to unauthenticated peers; consider loopback + explicit pairing Medium; contained in cast_lan_proxy_server_impl.dart
all Per-user or per-session content keys + rotation, so one extraction serves one person, once — the token layer gates issuance, not usage; only this gates usage KMS-side design change

When executing this as a test plan (own staging environment only), the report should capture, per path:

  1. A / A′ — the two extracted literals, the derived key/iv, one decrypted settings field, the computed vpsid. Then the token-layer matrix:
    • key fetch with vpsid only (no tokens) → expect 403 if KMS enforces unconditionally — this one result validates or invalidates the whole token layer’s premise;
    • key fetch with vpsid + anonymous x-content-access for an AVOD title (zero accounts) → documents the free-tier exposure;
    • key fetch with vpsid + both tokens from one staging account for a title that account can see → documents the one-account scaling;
    • replay of the same tokens from a different IP after N minutes → documents the replay window;
    • key fetch with headers omitted entirely → verifies enforcement is not tied to the client flag.
  2. B — the mitmproxy transcript of the key response body, or the Frida hook log showing the 16 bytes (and note the harvested tokens).
  3. C — a directory listing of Documents/<slug>/ from a backup, the folder playing in desktop VLC unmodified, and the plaintext entitlement_token_<userId> row from the same backup.
  4. D — a curl from a second LAN machine against the proxy port returning the key during a cast session, and again after the cast session ends (documents the _keyCache lifetime).
  5. All — the ffmpeg command and out.mp4 mediainfo, proving the end-to-end break.

Re-run 1 and 4 after any KMS-side change: those are the paths the token work claims to close, and item 1’s first bullet is the load-bearing test.

The token layer is a real step up: the pre-token world handed keys to anyone with the APK and a curl, and that world is gone — provided the KMS enforces both tokens unconditionally. But examine what remains: the credentials a script needs are one static HMAC (two values per build) plus two bearer tokens that ordinary REST calls hand out to any single account — and zero accounts for AVOD. Meanwhile the key itself is unchanged: per-content, never rotated, never revoked, sitting in ordinary memory (B), beside its own ciphertext on disk (C), and behind an unauthenticated LAN proxy while casting (D). The fixes are ranked and mostly already scoped: merge the at-rest key wrapping, move the entitlement token into Keychain/Keystore, verify and harden KMS-side enforcement (nonce-bound, replay-limited), authenticate the cast proxy, and accept that only real DRM and per-session content keys end the era of the permanent 16 bytes.