mirror of
https://github.com/qtmleap/revkit.git
synced 2026-09-28 14:01:50 +02:00
- 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>
571 lines
18 KiB
Python
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()
|