Files

303 lines
11 KiB
Python
Raw Permalink Normal View History

2026-08-08 12:35:28 +02:00
#!/usr/bin/env python3
"""
Hook Chrome's child CDM process to capture the Widevine RSA private key.
Chrome's EME uses its own bundled CDM running in sandboxed_process0 (or
privileged_process0 on some builds). We use Frida spawn-gating to intercept
every child Chrome spawns, enumerate its crypto exports, install RSA hooks in
the right process, then navigate Chrome to drm_test.html which auto-triggers
a Widevine challenge.
Prerequisite: Chrome --no-sandbox flag should be set so Frida can inject into
sandboxed processes. Run once before starting this script:
adb shell "echo 'chrome --no-sandbox --disable-gpu-watchdog' > /data/local/tmp/chrome-command-line"
Usage:
python3 chrome_cdm.py
"""
import frida, time, sys, subprocess, os, threading
from pathlib import Path
OUT = Path(__file__).parent / "L3"
OUT.mkdir(exist_ok=True)
ADB = os.path.expanduser("~/Android/Sdk/platform-tools/adb")
# Per-process state
proc_exports = {} # pid → list of export dicts
proc_sessions = {} # pid → frida session
proc_scripts = {} # pid → frida script
captured = [False]
DIAG_JS = r"""
(function() {
send({type:'modules', pid: Process.id,
names: Process.enumerateModules().map(function(m){ return m.name; })});
Process.enumerateModules().forEach(function(mod) {
if (mod.name.toLowerCase().indexOf('widevin') === -1 &&
mod.name.toLowerCase().indexOf('crypto') === -1 &&
mod.name.toLowerCase().indexOf('ssl') === -1) return;
try {
mod.enumerateExports().forEach(function(e) {
var n = e.name.toLowerCase();
if (n.indexOf('rsa') !== -1 || n.indexOf('sign') !== -1 ||
n.indexOf('private') !== -1) {
send({type:'export', mod: mod.name, name: e.name,
addr: e.address.toString()});
}
});
} catch(ex) {}
});
send({type:'diag_done', pid: Process.id});
})();
"""
def make_hook_js(exports):
addrs = {e['name']: e['addr'] for e in exports}
i2d_addr = addrs.get('i2d_RSAPrivateKey', '0')
rtk_addr = addrs.get('RSA_private_key_to_bytes', '0')
sign_a = addrs.get('RSA_sign', '0')
raw_a = addrs.get('RSA_sign_raw', '0')
priv_a = addrs.get('RSA_private_encrypt', '0')
from_a = addrs.get('RSA_private_key_from_bytes', '0')
return f"""
'use strict';
var alreadySent = false;
var i2dAddr = '{i2d_addr}';
var rtkAddr = '{rtk_addr}';
var signA = '{sign_a}';
var rawA = '{raw_a}';
var privA = '{priv_a}';
var fromA = '{from_a}';
// Also scan for RSA key in memory (Chrome's BoringSSL is bundled, not exported)
function scanForRSAKey(label) {{
if (alreadySent) return;
var pattern = '30 82 ?? ?? 02 01 00 02 82';
var found = 0;
Process.enumerateRanges({{protection:'rw-', coalesce:true}}).forEach(function(r) {{
if (r.size < 200) return;
try {{
Memory.scan(r.base, r.size, pattern, {{
onMatch: function(addr) {{
if (alreadySent) return 'stop';
var innerLen = (addr.add(2).readU8() << 8) | addr.add(3).readU8();
var total = 4 + innerLen;
if (total < 200 || total > 5000) return;
var v = addr.add(4).readByteArray(3);
var va = new Uint8Array(v);
if (va[0] !== 0x02 || va[1] !== 0x01 || va[2] !== 0x00) return;
found++;
alreadySent = true;
send({{type:'private_key', src: label + '@' + addr,
data: Array.from(new Uint8Array(addr.readByteArray(total)))}});
return 'stop';
}},
onError: function() {{}},
onComplete: function() {{}}
}});
}} catch(e) {{}}
}});
send({{type:'log', msg:'Scan(' + label + '): ' + found + ' RSA key candidates'}});
}}
var dumpKey;
if (rtkAddr !== '0') {{
var rtk = new NativeFunction(ptr(rtkAddr), 'int', ['pointer','pointer','pointer']);
dumpKey = function(rsa, label) {{
if (alreadySent || !rsa || rsa.isNull()) return;
var pBuf = Memory.alloc(8), pLen = Memory.alloc(8);
pBuf.writePointer(ptr('0')); pLen.writeU64(0);
if (rtk(pBuf, pLen, rsa) !== 1) return;
var buf = pBuf.readPointer(), len = pLen.readU64().toNumber();
if (len < 100 || buf.isNull()) return;
alreadySent = true;
send({{type:'private_key', src: label,
data: Array.from(new Uint8Array(buf.readByteArray(len)))}});
}};
}} else if (i2dAddr !== '0') {{
var i2d = new NativeFunction(ptr(i2dAddr), 'int', ['pointer','pointer']);
dumpKey = function(rsa, label) {{
if (alreadySent || !rsa || rsa.isNull()) return;
var pp = Memory.alloc(8); pp.writePointer(ptr('0'));
var len = i2d(rsa, pp); if (len <= 0) return;
var buf = pp.readPointer(); if (buf.isNull()) return;
alreadySent = true;
send({{type:'private_key', src: label,
data: Array.from(new Uint8Array(buf.readByteArray(len)))}});
}};
}} else {{
dumpKey = function(rsa, label) {{
send({{type:'log', msg: label + ' fired — no serializer, trying memory scan'}});
scanForRSAKey(label + '_scan');
}};
}}
if (signA !== '0') {{
Interceptor.attach(ptr(signA), {{
onEnter: function(a) {{ this.rsa = a[5]; }},
onLeave: function(r) {{ if (r.toInt32()===1) dumpKey(this.rsa,'RSA_sign'); }}
}}); send({{type:'log', msg:'Hooked RSA_sign'}});
}}
if (rawA !== '0') {{
Interceptor.attach(ptr(rawA), {{
onEnter: function(a) {{ this.rsa = a[0]; }},
onLeave: function(r) {{ if (r.toInt32()===1) dumpKey(this.rsa,'RSA_sign_raw'); }}
}}); send({{type:'log', msg:'Hooked RSA_sign_raw'}});
}}
if (privA !== '0') {{
Interceptor.attach(ptr(privA), {{
onEnter: function(a) {{ this.rsa = a[3]; }},
onLeave: function(r) {{ if (r.toInt32()>0) dumpKey(this.rsa,'RSA_private_encrypt'); }}
}}); send({{type:'log', msg:'Hooked RSA_private_encrypt'}});
}}
if (fromA !== '0') {{
Interceptor.attach(ptr(fromA), {{
onEnter: function(a) {{ this.inPtr = a[0]; }},
onLeave: function(r) {{
if (!r.isNull()) {{
send({{type:'log', msg:'RSA_private_key_from_bytes → RSA*=' + r}});
dumpKey(r, 'RSA_private_key_from_bytes');
}}
}}
}}); send({{type:'log', msg:'Hooked RSA_private_key_from_bytes'}});
}}
send({{type:'hooks_ready'}});
"""
def save_key(raw, src):
captured[0] = True
out = OUT / 'private_key.der'
out.write_bytes(raw)
print(f'\n[+] Private key saved → {out} ({len(raw)} bytes)')
print(f' Source: {src}')
print(f' Header: {raw[:16].hex()}')
def navigate_chrome():
url = 'http://localhost:8765/drm_test.html'
subprocess.run([ADB, 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW',
'-d', url, 'com.android.chrome'], capture_output=True)
print(f' Navigated Chrome to {url}')
def make_handler(pid):
def on_message(msg, data):
if captured[0]:
return
if msg['type'] == 'send':
p = msg['payload']
t = p.get('type')
if t == 'modules':
interesting = [n for n in p['names']
if any(k in n.lower() for k in
['widevin','crypto','ssl','boringssl','monochrome'])]
if interesting:
print(f' [PID {pid}] Interesting modules: {interesting}')
elif t == 'export':
proc_exports.setdefault(pid, []).append(p)
print(f' [PID {pid}] Export: {p["mod"]}!{p["name"]}')
elif t == 'diag_done':
exps = proc_exports.get(pid, [])
print(f' [PID {pid}] Diagnostic done. {len(exps)} relevant exports.')
if exps:
install_hooks(pid)
else:
print(f' [PID {pid}] No exports found — skipping hooks for this process.')
elif t == 'hooks_ready':
print(f' [PID {pid}] Hooks ready! Navigating Chrome...')
time.sleep(1) # let Chrome stabilize before navigating
navigate_chrome()
elif t == 'log':
print(f' [PID {pid}] {p["msg"]}')
elif t == 'private_key' and not captured[0]:
raw = bytes(p['data'])
if len(raw) > 100:
save_key(raw, p.get('src', '?'))
elif msg['type'] == 'error':
print(f' [PID {pid}] JS Error: {msg["description"]}')
return on_message
def install_hooks(pid):
session = proc_sessions.get(pid)
if not session:
return
exps = proc_exports.get(pid, [])
print(f' [PID {pid}] Installing hooks based on {len(exps)} exports...')
js = make_hook_js(exps)
script = session.create_script(js)
script.on('message', make_handler(pid))
script.load()
proc_scripts[pid] = script
def on_child_added(child):
pid = child.pid
ident = child.identifier or ''
print(f'[*] Child spawned: PID={pid} id={ident}')
# Skip GPU/browser processes by name — they cause instability when hooked at startup
# Focus on sandboxed (renderer/CDM) and privileged (utility/CDM) processes
# With --no-sandbox, sandboxed_process0 can be injected into
skip_keywords = [] # hook everything now that --no-sandbox is set
if any(k in ident for k in skip_keywords):
print(f' Skipping {ident}')
dev.resume(pid)
return
try:
session = dev.attach(pid)
proc_sessions[pid] = session
except Exception as e:
print(f' Could not attach to PID {pid}: {e}')
dev.resume(pid)
return
proc_exports[pid] = []
# Delay injection slightly so the process can initialize its native libraries
def inject_delayed():
time.sleep(0.5)
try:
script = session.create_script(DIAG_JS)
script.on('message', make_handler(pid))
script.load()
proc_scripts[pid] = script
except Exception as e:
print(f' Injection failed for PID {pid}: {e}')
threading.Thread(target=inject_delayed, daemon=True).start()
dev.resume(pid)
# ── Main ──────────────────────────────────────────────────────────────────────
print('[*] Setting up adb reverse so localhost:8765 reaches the HTTP server...')
subprocess.run([ADB, 'reverse', 'tcp:8765', 'tcp:8765'], capture_output=True)
dev = frida.get_usb_device()
print('[*] Force-stopping Chrome...')
subprocess.run([ADB, 'shell', 'am', 'force-stop', 'com.android.chrome'], capture_output=True)
time.sleep(1)
print('[*] Enabling spawn gating...')
dev.enable_spawn_gating()
dev.on('spawn-added', on_child_added)
print('[*] Spawning com.android.chrome...')
try:
pid = dev.spawn('com.android.chrome')
print(f'[*] Chrome PID={pid}')
proc_sessions[pid] = dev.attach(pid)
dev.resume(pid)
print('[*] Chrome started. Waiting for child processes...\n')
except Exception as e:
print(f'[!] Spawn failed: {e}')
sys.exit(1)
print('[*] Monitoring (Ctrl+C to quit)...\n')
try:
while not captured[0]:
time.sleep(1)
print('\n[+] Key captured! Done.')
except KeyboardInterrupt:
print('\n[!] Stopped.')