Null-DRM-Official/docs/capture.md
404errordeveloper 2fa8f2435f Initial commit: Null DRM Official
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.
2026-10-06 00:25:35 +02:00

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