11. Key Extraction Attack Paths
11. Key Extraction Attack Paths
Section titled “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
.tssegments, 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.
The single asset everything reduces to
Section titled “The single asset everything reduces to”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 freeAttacker personas
Section titled “Attacker personas”| 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 IVThe 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-buildStep 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>" plaintextNothing 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.
Path A diagram (pre-token era)
Section titled “Path A diagram (pre-token era)” 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, foreverThe 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.
What each token is (verified in code)
Section titled “What each token is (verified in code)”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 |
What it genuinely closes
Section titled “What it genuinely closes”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-accessplus staticvpsidis 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_enabledis 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.
Verdict per path
Section titled “Verdict per path” 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 checkPath 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:
B1 — MITM the app’s own traffic
Section titled “B1 — MITM the app’s own traffic” app ──GET key──► mitmproxy ──► kms.chorki.net ▲ │ │ ▼ attacker reads the 16-byte response bodyAnything 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.
B2 — Hook the player / dump memory
Section titled “B2 — Hook the player / dump memory”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-isThere 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 asksVerified 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, permanentNo 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 anywhereRisk summary
Section titled “Risk summary”| 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.
Mitigation map
Section titled “Mitigation map”| 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 |
Pentest evidence checklist
Section titled “Pentest evidence checklist”When executing this as a test plan (own staging environment only), the report should capture, per path:
- 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
vpsidonly (no tokens) → expect 403 if KMS enforces unconditionally — this one result validates or invalidates the whole token layer’s premise; - key fetch with
vpsid+ anonymousx-content-accessfor 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.
- key fetch with
- B — the mitmproxy transcript of the key response body, or the Frida hook log showing the 16 bytes (and note the harvested tokens).
- C — a directory listing of
Documents/<slug>/from a backup, the folder playing in desktop VLC unmodified, and the plaintextentitlement_token_<userId>row from the same backup. - D — a
curlfrom a second LAN machine against the proxy port returning the key during a cast session, and again after the cast session ends (documents the_keyCachelifetime). - All — the
ffmpegcommand andout.mp4mediainfo, 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 takeaway
Section titled “The takeaway”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.