Files
revkit/tools/run_key_extract.py
T
tkgstratorandClaude Opus 4.6 a849198fd0 feat(tools): MSLクライアント実装と解析ツールを追加
- src/netflix_msl/: Python MSLクライアント (鍵交換, 暗号化/復号, マニフェスト取得)
- tools/run.py: Fridaフックランナー (@@LOG@@パース, JSONL保存, crypto表示改善)
- tools/find_appboot.js: appboot関連シンボル検索
- tools/check_tweak.js: Tweakロード状態確認
- tools/dump_rsa_key.py, verify_key.py 等の解析ユーティリティ

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 02:07:22 +00:00

571 lines
18 KiB
Python

#!/usr/bin/env python3
"""Chrome Widevine CDM Private Key Extractor (External Memory Read)
Frida のインジェクションを使わず、macOS の Mach VM API で
CDM プロセスのメモリを外部から読み取り、RSA 秘密鍵を検出する。
CDM の VerifyCdmHost_0 はプロセス内のコード改竄を検出するが、
外部からのメモリ読み取りは検出されない。
使い方:
1. Chrome で DRM コンテンツを再生開始 (再生が動作していることを確認)
2. sudo uv run python run_key_extract.py
"""
import ctypes
import ctypes.util
import json
import os
import re
import signal
import struct
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
# ─── Mach VM API ───
libc = ctypes.CDLL(ctypes.util.find_library("c"))
KERN_SUCCESS = 0
VM_REGION_BASIC_INFO_64 = 9
VM_PROT_READ = 0x01
# task_for_pid(mach_port_t target, int pid, mach_port_t *task) -> kern_return_t
libc.task_for_pid.restype = ctypes.c_int32
libc.task_for_pid.argtypes = [
ctypes.c_uint32,
ctypes.c_int32,
ctypes.POINTER(ctypes.c_uint32),
]
# mach_task_self() -> mach_port_t
libc.mach_task_self.restype = ctypes.c_uint32
libc.mach_task_self.argtypes = []
# mach_vm_read_overwrite(task, address, size, data, outsize) -> kern_return_t
libc.mach_vm_read_overwrite.restype = ctypes.c_int32
libc.mach_vm_read_overwrite.argtypes = [
ctypes.c_uint32, # task
ctypes.c_uint64, # address
ctypes.c_uint64, # size
ctypes.c_uint64, # data (pointer as uint64)
ctypes.POINTER(ctypes.c_uint64), # outsize
]
def get_task_port(pid: int) -> int:
"""Get a Mach task port for the given PID."""
task = ctypes.c_uint32(0)
kr = libc.task_for_pid(libc.mach_task_self(), pid, ctypes.byref(task))
if kr != KERN_SUCCESS:
if kr == 5: # KERN_FAILURE - usually permission denied
print(f"[!] task_for_pid failed (permission denied)")
print(f" Run with sudo: sudo uv run python run_key_extract.py")
sys.exit(1)
raise OSError(f"task_for_pid failed: kern_return={kr}")
return task.value
def read_memory(task: int, address: int, size: int) -> bytes | None:
"""Read memory from a remote process. Returns None on failure."""
buf = (ctypes.c_char * size)()
out_size = ctypes.c_uint64(0)
buf_ptr = ctypes.cast(buf, ctypes.c_void_p).value
kr = libc.mach_vm_read_overwrite(
task,
ctypes.c_uint64(address),
ctypes.c_uint64(size),
ctypes.c_uint64(buf_ptr),
ctypes.byref(out_size),
)
if kr != KERN_SUCCESS:
return None
return bytes(buf[: out_size.value])
# ─── Memory Region Enumeration ───
# mach_vm_region(task, &addr, &size, flavor, info, &infoCnt, &objName)
libc.mach_vm_region.restype = ctypes.c_int32
libc.mach_vm_region.argtypes = [
ctypes.c_uint32, # task
ctypes.POINTER(ctypes.c_uint64), # address (in/out)
ctypes.POINTER(ctypes.c_uint64), # size (out)
ctypes.c_int32, # flavor
ctypes.c_void_p, # info (out)
ctypes.POINTER(ctypes.c_uint32), # infoCnt (in/out)
ctypes.POINTER(ctypes.c_uint32), # object_name (out)
]
VM_REGION_BASIC_INFO_64 = 9
VM_REGION_BASIC_INFO_COUNT_64 = 9 # struct has 9 natural_t fields
class VMRegionBasicInfo64(ctypes.Structure):
_fields_ = [
("protection", ctypes.c_int32),
("max_protection", ctypes.c_int32),
("inheritance", ctypes.c_uint32),
("shared", ctypes.c_uint32),
("reserved", ctypes.c_uint32),
("offset", ctypes.c_uint64),
("behavior", ctypes.c_int32),
("user_wired_count", ctypes.c_uint16),
]
def enumerate_regions_mach(task: int) -> list[dict]:
"""Mach VM API でメモリリージョンを列挙する。"""
regions = []
address = ctypes.c_uint64(0)
while True:
size = ctypes.c_uint64(0)
info = VMRegionBasicInfo64()
info_count = ctypes.c_uint32(VM_REGION_BASIC_INFO_COUNT_64)
obj_name = ctypes.c_uint32(0)
kr = libc.mach_vm_region(
task,
ctypes.byref(address),
ctypes.byref(size),
VM_REGION_BASIC_INFO_64,
ctypes.byref(info),
ctypes.byref(info_count),
ctypes.byref(obj_name),
)
if kr != KERN_SUCCESS:
break
readable = bool(info.protection & VM_PROT_READ)
regions.append(
{
"start": address.value,
"end": address.value + size.value,
"size": size.value,
"readable": readable,
"line": "",
}
)
address.value += size.value
return regions
def get_memory_regions(pid: int, task: int | None = None) -> list[dict]:
"""プロセスのメモリリージョンを取得する。vmmap を試し、失敗したら Mach API。"""
# まず vmmap を試す
try:
result = subprocess.run(
["vmmap", "-w", str(pid)],
capture_output=True,
text=True,
timeout=10,
)
output = result.stdout
if output and "REGION" in output:
regions = []
pattern = re.compile(
r"^\s*([0-9a-f]+)-([0-9a-f]+)\s+\[\s*[\d.]+[KMG]?\]\s+"
r"(r|-)(w|-)(x|-)/(r|-)(w|-)(x|-)",
re.MULTILINE,
)
for m in pattern.finditer(output):
start = int(m.group(1), 16)
end = int(m.group(2), 16)
readable = m.group(3) == "r"
line_start = output.rfind("\n", 0, m.start()) + 1
line_end = output.find("\n", m.end())
line = (
output[line_start:line_end] if line_end > 0 else output[line_start:]
)
regions.append(
{
"start": start,
"end": end,
"size": end - start,
"readable": readable,
"line": line.strip(),
}
)
if regions:
print(f"[*] Got {len(regions)} regions via vmmap")
return regions
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
pass
# vmmap 失敗 → Mach VM API でフォールバック
if task is not None:
print("[*] vmmap failed, using Mach VM API...")
regions = enumerate_regions_mach(task)
print(f"[*] Got {len(regions)} regions via mach_vm_region")
return regions
return []
def find_cdm_regions(regions: list[dict]) -> list[dict]:
"""libwidevinecdm.dylib に関連するリージョンを見つける。"""
cdm_regions = []
for r in regions:
if "widevinecdm" in r["line"].lower() or "widevine" in r["line"].lower():
cdm_regions.append(r)
return cdm_regions
# ─── CDM Process Discovery ───
def find_cdm_pid() -> int | None:
"""CDM をロードしている Chrome Helper プロセスの PID を見つける。
Chrome の CDM プロセスは以下のコマンドライン引数で識別できる:
--utility-sub-type=media.mojom.CdmServiceBroker
--service-sandbox-type=cdm
"""
try:
result = subprocess.run(
["ps", "aux"],
capture_output=True,
text=True,
)
except FileNotFoundError:
return None
for line in result.stdout.splitlines():
# CDM プロセスはコマンドラインで直接識別
if "CdmServiceBroker" in line or "sandbox-type=cdm" in line:
parts = line.split()
if len(parts) < 2:
continue
try:
return int(parts[1])
except ValueError:
continue
return None
# ─── RSA Key Detection ───
def find_rsa_keys(data: bytes, base_address: int) -> list[dict]:
"""バイト列から DER エンコードされた RSA 秘密鍵を検索する。"""
keys = []
# PKCS#8 パターン: 30 82 XX XX 02 01 00 30 0D 06 09 2A 86 48 86 F7 0D 01 01 01
pkcs8_header = bytes(
[0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
)
# PKCS#1 パターン: 30 82 XX XX 02 01 00 02 82
pkcs1_marker = bytes([0x02, 0x01, 0x00, 0x02, 0x82])
for i in range(len(data) - 20):
if data[i] != 0x30 or data[i + 1] != 0x82:
continue
# DER SEQUENCE の長さを取得
seq_len = (data[i + 2] << 8) | data[i + 3]
total_len = 4 + seq_len
if total_len < 600 or total_len > 5000:
continue
if i + total_len > len(data):
continue
# version = 0 チェック
if data[i + 4] != 0x02 or data[i + 5] != 0x01 or data[i + 6] != 0x00:
continue
fmt = None
# PKCS#8 チェック
if (
i + 7 + len(pkcs8_header) <= len(data)
and data[i + 7 : i + 7 + len(pkcs8_header)] == pkcs8_header
):
fmt = "pkcs8"
# PKCS#1 チェック
elif (
i + 4 + len(pkcs1_marker) <= len(data)
and data[i + 4 : i + 4 + len(pkcs1_marker)] == pkcs1_marker
):
fmt = "pkcs1"
else:
continue
key_data = data[i : i + total_len]
# 有効な RSA 鍵か検証
try:
from cryptography.hazmat.primitives.serialization import (
load_der_private_key,
)
key_obj = load_der_private_key(key_data, password=None)
key_size = key_obj.key_size
exponent = key_obj.public_key().public_numbers().e
except Exception:
continue
keys.append(
{
"format": fmt,
"address": base_address + i,
"length": total_len,
"key_bytes": key_data,
"key_size": key_size,
"exponent": exponent,
}
)
return keys
# ─── Main ───
def main():
print("=" * 60)
print("[*] Chrome Widevine CDM Private Key Extractor")
print(f"[*] {datetime.now().isoformat()}")
print("[*] Method: External memory read (Mach VM API)")
print("=" * 60)
print()
# CDM プロセスを探す
print("[*] Searching for CDM process...")
cdm_pid = find_cdm_pid()
if not cdm_pid:
print("[!] CDM process not found.")
print("[*] Make sure Chrome is running and DRM content is playing.")
sys.exit(1)
print(f"[+] CDM process found: PID {cdm_pid}")
# Task port を取得 (vmmap より先に必要)
print("[*] Getting task port...")
task = get_task_port(cdm_pid)
print(f"[+] Task port: {task}")
# メモリリージョンを取得
print("[*] Getting memory regions...")
all_regions = get_memory_regions(cdm_pid, task=task)
cdm_regions = find_cdm_regions(all_regions)
if cdm_regions:
print(f"[+] CDM module regions:")
for r in cdm_regions:
print(f" {r['start']:#x}-{r['end']:#x} ({r['size']:,} bytes)")
else:
print("[*] CDM module regions not identified (will scan all readable regions)")
# セッションディレクトリ
session_dir = (
Path("logs") / f"keyextract_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
)
session_dir.mkdir(parents=True, exist_ok=True)
# ─── ベースラインスキャン ───
print()
print("-" * 60)
print("[Step 1] Baseline scan (recording existing keys)...")
print("-" * 60)
baseline_keys = scan_process(task, all_regions, cdm_regions, label="baseline")
baseline_fps = {k["key_bytes"] for k in baseline_keys}
print(f"[*] Baseline: {len(baseline_keys)} RSA key(s)")
for k in baseline_keys:
addr_note = ""
if any(r["start"] <= k["address"] < r["end"] for r in cdm_regions):
addr_note = " [CDM module]"
elif k["address"] >= 0x180000000:
addr_note = " [dyld shared cache]"
print(
f" {k['address']:#x} {k['format']} RSA-{k['key_size']} e={k['exponent']}{addr_note}"
)
# ─── 再生待機 & 差分スキャン ───
print()
print("-" * 60)
print("[Step 2] Differential scanning...")
print(" If DRM is already playing, new keys may appear soon.")
print(" Press ENTER to scan manually, Ctrl+C to stop.")
print("-" * 60)
running = True
def shutdown(sig, frame):
nonlocal running
running = False
print("\n[*] Stopping...")
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
new_keys_all = []
scan_count = 0
import select
last_scan = time.time()
while running:
# stdin check
try:
rlist, _, _ = select.select([sys.stdin], [], [], 0.5)
if rlist:
sys.stdin.readline()
if running:
scan_count += 1
print(f"[Scan #{scan_count}] Manual scan...")
keys = scan_process(
task, all_regions, cdm_regions, label=f"scan_{scan_count}"
)
new = [k for k in keys if k["key_bytes"] not in baseline_fps]
print(f" Found {len(keys)} total, {len(new)} new")
new_keys_all.extend(new)
for k in new:
baseline_fps.add(k["key_bytes"])
last_scan = time.time()
except (ValueError, OSError):
time.sleep(0.5)
# 自動スキャン (3秒間隔)
if running and time.time() - last_scan >= 3:
scan_count += 1
try:
keys = scan_process(
task, all_regions, cdm_regions, label=f"scan_{scan_count}"
)
except OSError:
print("[!] Process may have exited. Trying to re-discover...")
cdm_pid_new = find_cdm_pid()
if cdm_pid_new and cdm_pid_new != cdm_pid:
cdm_pid = cdm_pid_new
all_regions = get_memory_regions(cdm_pid, task=task)
cdm_regions = find_cdm_regions(all_regions)
task = get_task_port(cdm_pid)
print(f"[+] Re-attached to PID {cdm_pid}")
keys = scan_process(
task, all_regions, cdm_regions, label=f"scan_{scan_count}"
)
else:
last_scan = time.time()
continue
new = [k for k in keys if k["key_bytes"] not in baseline_fps]
if new:
print(f"[Scan #{scan_count}] {len(new)} NEW key(s) found!")
new_keys_all.extend(new)
for k in new:
baseline_fps.add(k["key_bytes"])
else:
# 進捗表示 (10回ごと)
if scan_count % 10 == 0:
print(f"[Scan #{scan_count}] No new keys yet...")
last_scan = time.time()
if new_keys_all:
break
# ─── 結果 ───
print()
print("=" * 60)
if new_keys_all:
print(f"[+] {len(new_keys_all)} NEW key(s) found after baseline!")
print("=" * 60)
for i, k in enumerate(new_keys_all):
fname = f"private_key_{i}.der"
fpath = session_dir / fname
fpath.write_bytes(k["key_bytes"])
addr_note = ""
if any(r["start"] <= k["address"] < r["end"] for r in cdm_regions):
addr_note = " [CDM module]"
print(
f" [{i}] {k['format']} RSA-{k['key_size']} e={k['exponent']}"
f" @ {k['address']:#x}{addr_note} → {fpath}"
)
# capture.jsonl にも保存
capture = session_dir / "capture.jsonl"
with open(capture, "w") as f:
for k in new_keys_all:
entry = {
"event": "cdm.privateKey",
"ts": datetime.now().isoformat(),
"format": k["format"],
"address": hex(k["address"]),
"length": k["length"],
"key_hex": k["key_bytes"].hex(),
"key_size": k["key_size"],
"exponent": k["exponent"],
"is_new": True,
}
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
print()
print(f"[*] Saved to {session_dir}/")
print("[*] Next: verify with")
print(f" uv run python verify_key.py {session_dir}/private_key_0.der")
else:
print("[-] No new keys found.")
print("=" * 60)
print("[*] The CDM device key may be obfuscated in memory.")
print("[*] Consider trying: longer playback, different content,")
print(" or hooking BoringSSL's RSA_sign within the CDM.")
print(f"\n[*] Done. {scan_count} scans performed.")
def scan_process(
task: int,
all_regions: list[dict],
cdm_regions: list[dict],
label: str,
) -> list[dict]:
"""プロセスメモリをスキャンして RSA 秘密鍵を検索する。"""
all_keys = []
chunk_size = 4 * 1024 * 1024 # 4MB chunks
scanned_regions = 0
skipped_regions = 0
read_errors = 0
# 読み取り可能なリージョンをスキャン
for region in all_regions:
if not region["readable"]:
continue
if region["size"] < 1024:
continue
scanned_regions += 1
addr = region["start"]
remaining = region["size"]
while remaining > 0:
read_size = min(remaining, chunk_size)
data = read_memory(task, addr, read_size)
if data is None:
read_errors += 1
# このチャンクをスキップして次へ
addr += read_size
remaining -= read_size
continue
keys = find_rsa_keys(data, addr)
all_keys.extend(keys)
addr += read_size
remaining -= read_size
print(
f" [{label}] {scanned_regions} regions scanned, "
f"{read_errors} read errors, {len(all_keys)} key(s) found"
)
return all_keys
if __name__ == "__main__":
main()