Capture, decrypt, and restream toolkit with compiled-in app modules (RTE, TG4, BBC), on-device MITM proxy, streamd control plane, and www. BBC module.yaml is published (clear streams); other module values stay local.
8.3 KiB
Using the capture binary
drm capture runs an HTTPS MITM on the phone, drives the app, and records the
DRM exchange. It is both the production capture path and the tool you use to
discover everything a new module needs.
Device setup (once)
adb devices # phone listed and authorised
./bin/drm proxy build # cross-compile the on-device MITM (linux/arm64)
./bin/drm proxy install-ca --reinject
The phone must be rooted (Magisk). install-ca pushes the MITM CA and bind-mounts
it over the system trust store, including into already-running zygote namespaces —
a user-store CA is not enough, modern apps ignore it.
Verify the mount:
adb shell 'ls /system/etc/security/cacerts/ | grep 6c3578b4'
adb shell 'su -c "ls /apex/com.android.conscrypt/cacerts/6c3578b4.0"'
If an app still reports tls: unknown certificate, re-run the reinject and
force-stop the app so it inherits the mount:
./bin/drm proxy reinject-ca
adb shell am force-stop <package>
Keep the screen on for the whole session — UI automation cannot tap a dark screen:
adb shell svc power stayon true
adb shell settings put system screen_off_timeout 1800000
Restore the timeout afterwards (default is usually 60000).
Three modes
Passive — no module needed
./bin/drm capture --wait 300
Starts the MITM and nothing else. You open any app and play something; it records
whatever license, PSSH and manifest traffic appears and writes
outputs/capture/<stamp>/. This is the first thing to run against an unknown app.
Launch only
./bin/drm capture --app myapp --wvd data/device.wvd
Launches the app, then waits while you navigate by hand. Useful while you are still working out the UI sequence.
Full automation
./bin/drm capture --app myapp --channel main --auto-play --wvd data/device.wvd
Runs the module's Launch and AutoPlay, then waits for the required fields.
Flags
| Flag | Default | Purpose |
|---|---|---|
--app |
(empty) | module to drive; empty means passive |
--channel |
— | channel id, required by --auto-play |
--auto-play |
false | run the module's navigation |
--wait |
180 | seconds to wait for the capture to complete |
--wvd |
— | Widevine device file; required to fetch keys |
--python |
.venv |
interpreter for wvkey.py |
--key-mode |
from the module | force modulardrm or raw |
--user-agent |
— | UA for the license request |
--serial |
sole device | target a specific phone |
--close |
true | force-stop the app when done |
--<app>.<field> |
— | override any of that module's values |
What a run looks like
[*] Stopping any old appproxy...
[*] Pushing appproxy...
[+] appproxy pid=7604; capture -> /data/local/tmp/appproxy_cap.json
[*] Setting HTTP proxy -> 127.0.0.1:8080 (was "null")
[*] Re-injecting system CA (Magisk)...
[+] System CA present in conscrypt
[*] Launching rte (air.RTE.OSMF.Minimal)…
[*] Auto-play RTE 2…
[*] tap_ui "Live"/"Live tab, 2 out of 5" @ 324,2253
[*] tap chip @ 356,297
[*] tap Play @ 540,720
[+] air.RTE.OSMF.Minimal is PLAYING
[*] Waiting for license + PSSH + manifest…
[+] Capture:
pid: RKRPCEib8z9V
mpd: https://dai.google.com/linear/dash/pa/event/.../stream/...
license: https://widevine.entitlement.eu.theplatform.com/wv/web/ModularDrm?...
pssh: AAAASnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAACoiIGRmMTYzMzgyMWRk…
[+] key: 5490781a77572107ec251237e94e3b53:cee5fc45b70040ffea2b2576fb5abda5
[+] session: outputs/rte/20261005-220252/session.json
[*] Closing air.RTE.OSMF.Minimal…
[*] HTTP proxy cleared
Output
outputs/<app>/<stamp>/
session.json everything, machine-readable
mpd.txt manifest URL
key.txt KID:KEY (one per line for multi-key streams)
pssh.txt Widevine init data
auth.txt Authorization value, when the app uses one
pid.txt provider id, when the app uses one
outputs/<app>/latest/ a copy of the most recent run
Live log while a capture runs: .cache/appproxy-<serial>.log.
Discovering a new app
1. Record everything
./bin/drm proxy discover --install-ca --reinject
# play the app on the phone; Ctrl+C when done
Writes outputs/discover/<stamp>/:
| File | Contents |
|---|---|
appproxy_traffic.jsonl |
every HTTP(S) request/response, one JSON object per line |
appproxy_cap.json |
the structured fields the proxy recognised |
appproxy.log |
tagged lines: [MPD], [LIC], [PSSH], [MAN] |
2. Mine it
# the android package id
adb shell dumpsys window | grep mCurrentFocus
# license endpoint
grep -iE 'license|licence|widevine|/lic/' outputs/discover/*/appproxy.log
# manifest candidates
grep -oE 'https?://[^"]+\.(m3u8|mpd)' outputs/discover/*/appproxy_traffic.jsonl \
| sort -u
# the hosts involved, by frequency
grep -oE '"host":"[^"]+"' outputs/discover/*/appproxy_traffic.jsonl \
| sort | uniq -c | sort -rn | head -20
# config blobs that often carry ids and tokens
grep -oE 'https?://[^"]+(config|playback)[^"]*\.json' \
outputs/discover/*/appproxy_traffic.jsonl | sort -u
What to pull out, and where it goes in module.yaml:
| Found | Key |
|---|---|
| package id | package |
| license endpoint | license_url |
| CDN host serving the manifest | score.hosts |
| analytics / EPG hosts polluting the results | score.deny |
| account ids, policy keys, video ids | provider-specific keys |
3. Work out the UI
Dump the tree at each step and read off stable selectors:
adb shell uiautomator dump /sdcard/ui.xml
adb shell cat /sdcard/ui.xml > ui.xml
Extract the useful attributes:
grep -oE 'resource-id="[^"]*"' ui.xml | sort -u
grep -oE 'content-desc="[^"]*"' ui.xml | sort -u
Prefer resource-id over content-desc or text — ids survive translation and
copy changes. Check a candidate marker really exists before you rely on it; a
launch marker that never matches costs the full timeout on every capture and is
easy to miss because the pipeline recovers anyway.
Test taps by hand before writing Go:
adb shell input tap 540 732
adb shell input swipe 540 1700 540 700 350
4. Determine the key mode
| The app sends | Use |
|---|---|
| a JSON wrapper around the challenge, with an Authorization header | modulardrm |
a raw binary challenge, Content-Type: application/octet-stream |
raw |
Check the license request body in appproxy_traffic.jsonl. Then set KeyMode()
in the module — never sniff it from the URL at runtime.
5. Decide which fields are required
Look at what appproxy_cap.json actually contained after a successful play:
- DASH + JSON license → usually
auth,pid,pssh,mpd - HLS + binary license → usually
license_url,mpd(the PSSH comes from the playlist afterwards)
Pass exactly those to a.Hints(...). Asking for a field the app never emits makes
every capture time out.
6. Verify
Where an app has a public catalog, capture the same channel both ways and compare — independent paths agreeing on the PSSH and key set is strong evidence both are right:
./bin/drm catalog --app myapp --channel main --keys --wvd data/device.wvd --json > a.json
./bin/drm capture --app myapp --channel main --auto-play --wvd data/device.wvd
diff <(jq -r '.keys[]' a.json | sort) <(sort outputs/myapp/latest/key.txt)
Also sanity-check that the KID you captured matches whatever you recorded in
module.yaml for that channel.
Troubleshooting
| Symptom | Likely cause |
|---|---|
tls: unknown certificate in the app |
CA not mounted into conscrypt; reinject, then force-stop the app |
timed out waiting for capture ([auth pid pssh mpd]) |
playback never started, or Hints asks for a field this app does not send |
launch: ui node not found within Ns |
launch marker does not match — dump the tree and pick a resource id |
launch: exit status 1 |
monkey raced a force-stop; usually transient, retry |
auto-play failed: ... not found |
UI changed, or the phone is on a different screen than expected |
| manifest field holds an EPG or config URL | add that host to score.deny |
CA inject skipped/failed |
transient after repeated runs; re-run proxy reinject-ca |
| Screen asleep mid-run | adb shell svc power stayon true |
Clear the phone's proxy if a run is interrupted:
./bin/drm proxy clear-proxy