mirror of
https://github.com/n0stal6ic/MStarToKeybox.git
synced 2026-07-15 18:00:04 +02:00
Add files via upload
This commit is contained in:
+959
@@ -0,0 +1,959 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zlib
|
||||
import json
|
||||
import mmap
|
||||
import struct
|
||||
import hashlib
|
||||
import logging
|
||||
import argparse
|
||||
import tempfile
|
||||
from datetime import date
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
PREFIX_HEX = "4D 53 54 41 52 5F 53 45 43 55 52 45 5F 53 54 4F 52 45 5F 46 49 4C 45 5F 4D 41 47 49 43 5F 49 44"
|
||||
POSTFIX_HEX_LIST = ["00 00"]
|
||||
REQUIRE_POSTFIX_AT_EXTRACT_END = True
|
||||
EXTRACT_LEN = 228
|
||||
HEXVIEW_WIDTH = 16
|
||||
MAX_HITS = 0
|
||||
MAX_ZERO_FRACTION = 0.25
|
||||
MAX_ZERO_RUN = 16
|
||||
TAIL_START = 144
|
||||
MIN_TAIL_NONZERO_RATIO = 0.7
|
||||
WV_KEYBOX_LEN = 128
|
||||
WV_DEVICE_ID_OFF = 0
|
||||
WV_DEVICE_ID_LEN = 32
|
||||
WV_DEVICE_KEY_OFF = 32
|
||||
WV_DEVICE_KEY_LEN = 16
|
||||
WV_KEY_DATA_OFF = 48
|
||||
WV_KEY_DATA_LEN = 72
|
||||
WV_MAGIC_OFF = 120
|
||||
WV_CRC_OFF = 124
|
||||
WV_MAGIC = b"kbox"
|
||||
SCRIPT_PATH = os.path.realpath(__file__)
|
||||
SCRIPT_DIR = os.path.dirname(SCRIPT_PATH)
|
||||
SCRIPT_NAME = os.path.basename(SCRIPT_PATH)
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _has_module(name: str) -> bool:
|
||||
import importlib
|
||||
|
||||
try:
|
||||
importlib.import_module(name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
HAS_CRYPTO = _has_module("Crypto")
|
||||
HAS_REQUESTS = _has_module("requests")
|
||||
HAS_PYWIDEVINE = _has_module("pywidevine")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanStats:
|
||||
files_scanned: int = 0
|
||||
candidates_found: int = 0
|
||||
filter_rejected: int = 0
|
||||
duplicates_skipped: int = 0
|
||||
saved: int = 0
|
||||
decrypted: int = 0
|
||||
decrypt_failed: int = 0
|
||||
wvd_generated: int = 0
|
||||
|
||||
def report(self):
|
||||
log.info("\n─────────────────────────────────────────")
|
||||
log.info(" Complete")
|
||||
log.info("─────────────────────────────────────────")
|
||||
log.info(f" Files scanned : {self.files_scanned}")
|
||||
log.info(f" Candidates found : {self.candidates_found}")
|
||||
log.info(f" Filter rejected : {self.filter_rejected}")
|
||||
log.info(f" Duplicates skipped : {self.duplicates_skipped}")
|
||||
log.info(f" Keyboxes saved : {self.saved}")
|
||||
log.info(f" Decrypted : {self.decrypted}")
|
||||
log.info(f" Decrypt failed : {self.decrypt_failed}")
|
||||
if self.wvd_generated:
|
||||
log.info(f" WVD files made : {self.wvd_generated}")
|
||||
log.info("─────────────────────────────────────────")
|
||||
|
||||
|
||||
def clean_hex(s: str) -> bytes:
|
||||
s = s.replace("0x", "").replace("0X", "")
|
||||
s = "".join(s.split())
|
||||
if not s:
|
||||
return b""
|
||||
if len(s) % 2 != 0:
|
||||
raise ValueError("Hex string must have an even number of digits.")
|
||||
return bytes.fromhex(s)
|
||||
|
||||
|
||||
def is_printable_ascii(b: int) -> bool:
|
||||
return 32 <= b <= 126
|
||||
|
||||
|
||||
def hexview(data: bytes, base_offset: int = 0, width: int = 16) -> str:
|
||||
lines = []
|
||||
for i in range(0, len(data), width):
|
||||
chunk = data[i : i + width]
|
||||
hex_part = " ".join((f"{x:02X}" for x in chunk))
|
||||
ascii_part = "".join((chr(x) if is_printable_ascii(x) else "." for x in chunk))
|
||||
lines.append(
|
||||
f"{base_offset + i:08X} {hex_part.ljust(width * 3 - 1)} |{ascii_part}|"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_HEX_KEY_RE = re.compile(
|
||||
"(?i)(?<![0-9a-f])(?:[0-9a-f]{64}|[0-9a-f]{48}|[0-9a-f]{32})(?![0-9a-f])"
|
||||
)
|
||||
|
||||
|
||||
def _parse_keys_from_text(text: str) -> List[str]:
|
||||
keys: List[str] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
line = line.split("#", 1)[0]
|
||||
for m in _HEX_KEY_RE.findall(line):
|
||||
try:
|
||||
kb = bytes.fromhex(m)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(kb) in (16, 24, 32):
|
||||
keys.append(m.upper())
|
||||
return keys
|
||||
|
||||
|
||||
def _parse_cbc_keys_from_text(text: str) -> List[Tuple[str, str]]:
|
||||
pairs: List[Tuple[str, str]] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
line = line.split("#", 1)[0].strip()
|
||||
if "," not in line:
|
||||
continue
|
||||
parts = line.split(",", 1)
|
||||
k = parts[0].strip().replace(" ", "")
|
||||
iv = parts[1].strip().replace(" ", "")
|
||||
try:
|
||||
kb = bytes.fromhex(k)
|
||||
ivb = bytes.fromhex(iv)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(kb) in (16, 24, 32) and len(ivb) == 16:
|
||||
pairs.append((k.upper(), iv.upper()))
|
||||
return pairs
|
||||
|
||||
|
||||
def _download_text(url: str, timeout: int = 20) -> str:
|
||||
import requests
|
||||
|
||||
r = requests.get(url, timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.text
|
||||
|
||||
|
||||
def _cache_path(output_dir: str) -> str:
|
||||
return os.path.join(output_dir, "keys.txt")
|
||||
|
||||
|
||||
def _cache_is_fresh(cache_path: str) -> bool:
|
||||
if not os.path.exists(cache_path):
|
||||
return False
|
||||
try:
|
||||
mtime = os.path.getmtime(cache_path)
|
||||
return date.fromtimestamp(mtime) == date.today()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _load_cache(cache_path: str) -> List[str]:
|
||||
keys: List[str] = []
|
||||
if not os.path.exists(cache_path):
|
||||
return keys
|
||||
with open(cache_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
keys = _parse_keys_from_text(f.read())
|
||||
return keys
|
||||
|
||||
|
||||
def _save_cache(cache_path: str, keys: List[str], sources: List[str]) -> None:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(cache_path)) or ".", exist_ok=True)
|
||||
with open(cache_path, "w", encoding="utf-8") as f:
|
||||
f.write("# kbxdecoder, cached AES keys\n")
|
||||
f.write(f"# Updated: {date.today().isoformat()}\n")
|
||||
f.write("# Sources:\n")
|
||||
for s in sources:
|
||||
f.write(f"# {s}\n")
|
||||
f.write("\n")
|
||||
for k in keys:
|
||||
f.write(k + "\n")
|
||||
log.info(f" [i] Key cache updated -> {cache_path} ({len(keys)} keys)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyDatabase:
|
||||
ecb: List[str] = field(default_factory=list)
|
||||
cbc: List[Tuple[str, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_key_database(
|
||||
key_args: List[str], output_dir: str, force_update: bool, only_custom: bool
|
||||
) -> KeyDatabase:
|
||||
db = KeyDatabase()
|
||||
url_sources: List[str] = []
|
||||
file_sources: List[str] = []
|
||||
manual_keys: List[str] = []
|
||||
for arg in key_args:
|
||||
arg = arg.strip()
|
||||
no_spaces = arg.replace(" ", "")
|
||||
if re.fullmatch("[0-9A-Fa-f]{32}|[0-9A-Fa-f]{48}|[0-9A-Fa-f]{64}", no_spaces):
|
||||
manual_keys.append(no_spaces.upper())
|
||||
elif arg.lower().startswith("http://") or arg.lower().startswith("https://"):
|
||||
url_sources.append(arg)
|
||||
elif os.path.isfile(arg):
|
||||
file_sources.append(arg)
|
||||
else:
|
||||
log.warning(
|
||||
f" [!] --key argument not recognised as hex, URL, or file: {arg!r}"
|
||||
)
|
||||
seen_ecb = set()
|
||||
seen_cbc = set()
|
||||
|
||||
def _add_ecb(k: str):
|
||||
if k not in seen_ecb:
|
||||
seen_ecb.add(k)
|
||||
db.ecb.append(k)
|
||||
|
||||
def _add_cbc(k: str, iv: str):
|
||||
pair = (k, iv)
|
||||
if pair not in seen_cbc:
|
||||
seen_cbc.add(pair)
|
||||
db.cbc.append(pair)
|
||||
|
||||
for k in manual_keys:
|
||||
_add_ecb(k)
|
||||
if only_custom:
|
||||
return db
|
||||
for fpath in file_sources:
|
||||
try:
|
||||
with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
text = f.read()
|
||||
for k in _parse_keys_from_text(text):
|
||||
_add_ecb(k)
|
||||
for k, iv in _parse_cbc_keys_from_text(text):
|
||||
_add_cbc(k, iv)
|
||||
log.info(f" [i] Loaded keys from file: {fpath}")
|
||||
except Exception as e:
|
||||
log.warning(f" [!] Could not read key file {fpath}: {e}")
|
||||
if not url_sources:
|
||||
return db
|
||||
if not HAS_REQUESTS:
|
||||
log.warning(" [!] 'requests' not installed, cannot download key URLs.")
|
||||
log.warning(" Install with: pip install requests")
|
||||
return db
|
||||
cache_file = _cache_path(output_dir)
|
||||
if not force_update and _cache_is_fresh(cache_file):
|
||||
log.info(f" [i] Using cached keys: {cache_file}")
|
||||
for k in _load_cache(cache_file):
|
||||
_add_ecb(k)
|
||||
return db
|
||||
downloaded: List[str] = []
|
||||
for url in url_sources:
|
||||
try:
|
||||
text = _download_text(url)
|
||||
new_keys = _parse_keys_from_text(text)
|
||||
new_cbc = _parse_cbc_keys_from_text(text)
|
||||
for k in new_keys:
|
||||
if k not in seen_ecb:
|
||||
downloaded.append(k)
|
||||
_add_ecb(k)
|
||||
for k, iv in new_cbc:
|
||||
_add_cbc(k, iv)
|
||||
log.info(
|
||||
f" [i] Downloaded {len(new_keys)} ECB + {len(new_cbc)} CBC keys from {url}"
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f" [!] Failed to download {url}: {e}")
|
||||
_save_cache(cache_file, downloaded, url_sources)
|
||||
return db
|
||||
|
||||
|
||||
def _pad_to_block(data: bytes) -> bytes:
|
||||
from Crypto.Util.Padding import pad
|
||||
|
||||
if len(data) % 16 == 0:
|
||||
return data
|
||||
return pad(data, 16)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecryptResult:
|
||||
key_hex: str
|
||||
mode_name: str
|
||||
iv_used: Optional[bytes]
|
||||
plaintext: bytes
|
||||
|
||||
|
||||
def _try_key(blob: bytes, key_hex: str) -> List[DecryptResult]:
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util import Counter
|
||||
|
||||
try:
|
||||
key = bytes.fromhex(key_hex)
|
||||
except ValueError:
|
||||
return []
|
||||
results: List[DecryptResult] = []
|
||||
MAGIC = b"INNER_MSTAR"
|
||||
|
||||
def ok(dec: bytes) -> bool:
|
||||
return MAGIC in dec
|
||||
|
||||
def add(mode: str, iv: Optional[bytes], dec: bytes):
|
||||
results.append(DecryptResult(key_hex, mode, iv, dec))
|
||||
|
||||
try:
|
||||
dec = AES.new(key, AES.MODE_ECB).decrypt(_pad_to_block(blob))
|
||||
if ok(dec):
|
||||
add("ECB", None, dec)
|
||||
except Exception:
|
||||
pass
|
||||
iv_zero = b"\x00" * 16
|
||||
iv_prefix = blob[:16] if len(blob) >= 16 else None
|
||||
ct_after = blob[16:] if len(blob) > 16 else blob
|
||||
ivs = [("IV_ZERO", iv_zero, blob)]
|
||||
if iv_prefix is not None:
|
||||
ivs.append(("IV_PREFIX16", iv_prefix, ct_after))
|
||||
for iv_label, iv, ct in ivs:
|
||||
try:
|
||||
dec = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(_pad_to_block(ct))
|
||||
if ok(dec):
|
||||
add(f"CBC({iv_label})", iv, dec)
|
||||
except Exception:
|
||||
pass
|
||||
for iv_label, iv, ct in ivs:
|
||||
try:
|
||||
dec = AES.new(key, AES.MODE_CFB, iv=iv, segment_size=128).decrypt(ct)
|
||||
if ok(dec):
|
||||
add(f"CFB({iv_label})", iv, dec)
|
||||
except Exception:
|
||||
pass
|
||||
for iv_label, iv, ct in ivs:
|
||||
try:
|
||||
dec = AES.new(key, AES.MODE_OFB, iv=iv).decrypt(ct)
|
||||
if ok(dec):
|
||||
add(f"OFB({iv_label})", iv, dec)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ctr = Counter.new(128, initial_value=0)
|
||||
dec = AES.new(key, AES.MODE_CTR, counter=ctr).decrypt(blob)
|
||||
if ok(dec):
|
||||
add("CTR(counter=0)", None, dec)
|
||||
except Exception:
|
||||
pass
|
||||
if len(blob) >= 16:
|
||||
try:
|
||||
init_val = int.from_bytes(blob[:16], "big")
|
||||
ctr = Counter.new(128, initial_value=init_val)
|
||||
dec = AES.new(key, AES.MODE_CTR, counter=ctr).decrypt(blob[16:])
|
||||
if ok(dec):
|
||||
add("CTR(counter=prefix16)", blob[:16], dec)
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def _try_cbc_pair(blob: bytes, key_hex: str, iv_hex: str) -> List[DecryptResult]:
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
MAGIC = b"INNER_MSTAR"
|
||||
try:
|
||||
key = bytes.fromhex(key_hex)
|
||||
iv = bytes.fromhex(iv_hex)
|
||||
dec = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(_pad_to_block(blob))
|
||||
if MAGIC in dec:
|
||||
return [
|
||||
DecryptResult(key_hex, f"CBC(explicit_iv={iv_hex[:8]}...)", iv, dec)
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def decrypt_blob(
|
||||
blob: bytes, db: KeyDatabase, stop_on_first: bool = True
|
||||
) -> List[DecryptResult]:
|
||||
if not HAS_CRYPTO:
|
||||
return []
|
||||
all_results: List[DecryptResult] = []
|
||||
for key_hex in db.ecb:
|
||||
res = _try_key(blob, key_hex)
|
||||
all_results.extend(res)
|
||||
if stop_on_first and all_results:
|
||||
return all_results
|
||||
for key_hex, iv_hex in db.cbc:
|
||||
res = _try_cbc_pair(blob, key_hex, iv_hex)
|
||||
all_results.extend(res)
|
||||
if stop_on_first and all_results:
|
||||
return all_results
|
||||
return all_results
|
||||
|
||||
|
||||
def extract_payload(dec: bytes) -> bytes:
|
||||
for offset in (64, 96):
|
||||
candidate = dec[offset:]
|
||||
if b"CHAI" in candidate:
|
||||
return candidate
|
||||
if b"kbox" in candidate:
|
||||
idx = candidate.find(b"kbox")
|
||||
start = max(0, idx - WV_MAGIC_OFF)
|
||||
return candidate[start : start + WV_KEYBOX_LEN]
|
||||
return dec[64:]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WVKeybox:
|
||||
device_id: bytes
|
||||
device_key: bytes
|
||||
key_data: bytes
|
||||
crc_valid: bool
|
||||
raw: bytes
|
||||
|
||||
|
||||
def _crc32_widevine(data: bytes) -> int:
|
||||
return zlib.crc32(data[:WV_CRC_OFF]) & 4294967295
|
||||
|
||||
|
||||
def parse_wv_keybox(payload: bytes) -> Optional[WVKeybox]:
|
||||
idx = payload.find(WV_MAGIC)
|
||||
if idx == -1:
|
||||
return None
|
||||
start = idx - WV_MAGIC_OFF
|
||||
if start < 0 or start + WV_KEYBOX_LEN > len(payload):
|
||||
return None
|
||||
raw = payload[start : start + WV_KEYBOX_LEN]
|
||||
device_id = raw[WV_DEVICE_ID_OFF : WV_DEVICE_ID_OFF + WV_DEVICE_ID_LEN]
|
||||
device_key = raw[WV_DEVICE_KEY_OFF : WV_DEVICE_KEY_OFF + WV_DEVICE_KEY_LEN]
|
||||
key_data = raw[WV_KEY_DATA_OFF : WV_KEY_DATA_OFF + WV_KEY_DATA_LEN]
|
||||
stored_crc = struct.unpack_from("<I", raw, WV_CRC_OFF)[0]
|
||||
computed_crc = _crc32_widevine(raw)
|
||||
crc_valid = stored_crc == computed_crc
|
||||
return WVKeybox(
|
||||
device_id=device_id,
|
||||
device_key=device_key,
|
||||
key_data=key_data,
|
||||
crc_valid=crc_valid,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
|
||||
def device_id_slug(wvkb: WVKeybox) -> str:
|
||||
raw_id = wvkb.device_id.rstrip(b"\x00")
|
||||
try:
|
||||
s = raw_id.decode("ascii")
|
||||
s = re.sub("[^A-Za-z0-9_.-]", "_", s).strip("_")
|
||||
if s:
|
||||
return s[:24]
|
||||
except Exception:
|
||||
pass
|
||||
return wvkb.device_id[:8].hex().upper()
|
||||
|
||||
|
||||
def _make_wvd(private_key_pem: bytes, client_id_blob: bytes, out_path: str) -> bool:
|
||||
if not HAS_PYWIDEVINE:
|
||||
return False
|
||||
try:
|
||||
from pywidevine.device import Device, DeviceTypes
|
||||
from pywidevine.license_protocol_pb2 import ClientIdentification
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
client_id = ClientIdentification()
|
||||
client_id.ParseFromString(client_id_blob)
|
||||
rsa_key = RSA.import_key(private_key_pem)
|
||||
device = Device(
|
||||
type_=DeviceTypes.ANDROID,
|
||||
security_level=1,
|
||||
flags=None,
|
||||
private_key=rsa_key,
|
||||
client_id=client_id,
|
||||
)
|
||||
device.dump(out_path)
|
||||
return os.path.exists(out_path)
|
||||
except Exception as e:
|
||||
log.debug(f" WVD generation error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def safe_prefix_from_filename(path: str) -> str:
|
||||
base = os.path.basename(path)
|
||||
root, _ = os.path.splitext(base)
|
||||
safe = []
|
||||
for ch in root:
|
||||
if ch.isalnum() or ch in "._-":
|
||||
safe.append(ch)
|
||||
else:
|
||||
safe.append("_")
|
||||
s = "".join(safe).strip("._-")
|
||||
return s or "file"
|
||||
|
||||
|
||||
_SKIP_RE = re.compile(
|
||||
"(_raw|_decrypted|_payload|_l1)(\\.(bin|wvd|prd))?$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def is_skipped_file(path: str) -> bool:
|
||||
base = os.path.basename(path)
|
||||
if base == SCRIPT_NAME:
|
||||
return True
|
||||
if _SKIP_RE.search(os.path.splitext(base)[0]):
|
||||
return True
|
||||
if base == "keys.txt":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_stem(
|
||||
source_prefix: str,
|
||||
hit_idx: int,
|
||||
device_id_str: Optional[str],
|
||||
key_desc_slug: Optional[str],
|
||||
confirmed_l1: bool,
|
||||
) -> str:
|
||||
parts = [source_prefix, str(hit_idx)]
|
||||
if device_id_str:
|
||||
parts.append(device_id_str)
|
||||
elif key_desc_slug:
|
||||
slug = re.sub("[^A-Za-z0-9_.-]", "_", key_desc_slug)[:32].strip("_")
|
||||
if slug:
|
||||
parts.append(slug)
|
||||
if confirmed_l1:
|
||||
parts.append("l1")
|
||||
return "_".join(parts)
|
||||
|
||||
|
||||
PRINT_HEXVIEW = True
|
||||
|
||||
|
||||
def iter_all(mm: mmap.mmap, needle: bytes, start: int = 0) -> Iterable[int]:
|
||||
i = start
|
||||
while True:
|
||||
pos = mm.find(needle, i)
|
||||
if pos == -1:
|
||||
return
|
||||
yield pos
|
||||
i = pos + 1
|
||||
|
||||
|
||||
def best_postfix_window(postfixes: List[bytes]) -> int:
|
||||
return max((len(p) for p in postfixes if p), default=0)
|
||||
|
||||
|
||||
def find_postfix_after_extract(
|
||||
mm: mmap.mmap, postfixes: List[bytes], prefix_pos: int, file_size: int, window: int
|
||||
) -> Tuple[int, bytes]:
|
||||
start = prefix_pos + EXTRACT_LEN
|
||||
if start >= file_size:
|
||||
return (-1, b"")
|
||||
end = min(file_size, start + window)
|
||||
for pf in postfixes:
|
||||
if not pf or start + len(pf) > file_size:
|
||||
continue
|
||||
pos = mm.find(pf, start, end)
|
||||
if pos != -1:
|
||||
return (pos, pf)
|
||||
return (-1, b"")
|
||||
|
||||
|
||||
def max_zero_run(b: bytes) -> int:
|
||||
best = cur = 0
|
||||
for x in b:
|
||||
cur = cur + 1 if x == 0 else 0
|
||||
best = max(best, cur)
|
||||
return best
|
||||
|
||||
|
||||
def zero_fraction(b: bytes) -> float:
|
||||
return sum((1 for x in b if x == 0)) / len(b) if b else 1.0
|
||||
|
||||
|
||||
def nonzero_ratio(b: bytes) -> float:
|
||||
return sum((1 for x in b if x != 0)) / len(b) if b else 0.0
|
||||
|
||||
|
||||
def passes_filters(block: bytes, verbose: bool = False) -> bool:
|
||||
zf = zero_fraction(block)
|
||||
if zf > MAX_ZERO_FRACTION:
|
||||
if verbose:
|
||||
log.debug(f" REJECT zero_fraction={zf:.2%} > {MAX_ZERO_FRACTION:.2%}")
|
||||
return False
|
||||
zr = max_zero_run(block)
|
||||
if zr > MAX_ZERO_RUN:
|
||||
if verbose:
|
||||
log.debug(f" REJECT max_zero_run={zr} > {MAX_ZERO_RUN}")
|
||||
return False
|
||||
tail = block[TAIL_START:] if TAIL_START < len(block) else b""
|
||||
if tail:
|
||||
nr = nonzero_ratio(tail)
|
||||
if nr < MIN_TAIL_NONZERO_RATIO:
|
||||
if verbose:
|
||||
log.debug(
|
||||
f" REJECT tail_nonzero_ratio={nr:.2%} < {MIN_TAIL_NONZERO_RATIO:.2%}"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def extract_from_file(
|
||||
path: str,
|
||||
prefix: bytes,
|
||||
postfixes: List[bytes],
|
||||
output_dir: str,
|
||||
stats: ScanStats,
|
||||
manifest_entries: list,
|
||||
db: KeyDatabase,
|
||||
verbose_filters: bool = False,
|
||||
no_decrypt: bool = False,
|
||||
stop_on_first_key: bool = True,
|
||||
extract_wvd: bool = False,
|
||||
) -> int:
|
||||
if not os.path.isfile(path):
|
||||
return 0
|
||||
file_size = os.path.getsize(path)
|
||||
if file_size <= 0:
|
||||
return 0
|
||||
stats.files_scanned += 1
|
||||
src_prefix = safe_prefix_from_filename(path)
|
||||
saved = 0
|
||||
seen = set()
|
||||
window = best_postfix_window(postfixes)
|
||||
with open(path, "rb") as f:
|
||||
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
try:
|
||||
for ppos in iter_all(mm, prefix, 0):
|
||||
if ppos + EXTRACT_LEN > file_size:
|
||||
continue
|
||||
qpos, _ = find_postfix_after_extract(
|
||||
mm, postfixes, ppos, file_size, window
|
||||
)
|
||||
if qpos == -1:
|
||||
continue
|
||||
if REQUIRE_POSTFIX_AT_EXTRACT_END and qpos != ppos + EXTRACT_LEN:
|
||||
continue
|
||||
stats.candidates_found += 1
|
||||
block = bytes(mm[ppos : ppos + EXTRACT_LEN])
|
||||
if not passes_filters(block, verbose=verbose_filters):
|
||||
stats.filter_rejected += 1
|
||||
continue
|
||||
h = hashlib.sha256(block).digest()
|
||||
if h in seen:
|
||||
stats.duplicates_skipped += 1
|
||||
continue
|
||||
seen.add(h)
|
||||
saved += 1
|
||||
stats.saved += 1
|
||||
raw_stem = f"{src_prefix}_{saved}_raw"
|
||||
raw_path = os.path.join(output_dir, raw_stem + ".bin")
|
||||
with open(raw_path, "wb") as out:
|
||||
out.write(block)
|
||||
log.info(f"[{os.path.basename(path)} | Hit #{saved}]")
|
||||
log.info(f" Raw keybox -> {raw_path}")
|
||||
log.info(f" Offset : 0x{ppos:X} (postfix @ 0x{qpos:X})")
|
||||
if PRINT_HEXVIEW:
|
||||
log.info(hexview(block, base_offset=ppos, width=HEXVIEW_WIDTH))
|
||||
log.info("")
|
||||
entry: Dict = {
|
||||
"source_file": path,
|
||||
"offset_hex": hex(ppos),
|
||||
"sha256": hashlib.sha256(block).hexdigest(),
|
||||
"raw_file": raw_path,
|
||||
"decrypted": False,
|
||||
"decrypt_key": None,
|
||||
"decrypt_mode": None,
|
||||
"payload_file": None,
|
||||
"wvkb_device_id": None,
|
||||
"wvkb_crc_valid": None,
|
||||
"wvd_file": None,
|
||||
}
|
||||
if not no_decrypt:
|
||||
if not HAS_CRYPTO:
|
||||
log.warning(
|
||||
" [!] pycryptodome not installed, skipping decryption."
|
||||
)
|
||||
log.warning(" Install with: pip install pycryptodome")
|
||||
elif not (db.ecb or db.cbc):
|
||||
log.warning(
|
||||
" [!] No keys provided, use --key to supply keys."
|
||||
)
|
||||
else:
|
||||
ecb_count = len(db.ecb)
|
||||
cbc_count = len(db.cbc)
|
||||
log.info(
|
||||
f" [~] Trying {ecb_count} ECB + {cbc_count} CBC key(s) across 5 AES modes..."
|
||||
)
|
||||
results = decrypt_blob(
|
||||
block, db, stop_on_first=stop_on_first_key
|
||||
)
|
||||
if not results:
|
||||
stats.decrypt_failed += 1
|
||||
log.info(" [-] No matching decryption key found.")
|
||||
log.info(" Supply keys with --key <hex|file|url>")
|
||||
else:
|
||||
for r_idx, r in enumerate(results):
|
||||
stats.decrypted += 1
|
||||
payload = extract_payload(r.plaintext)
|
||||
wvkb = parse_wv_keybox(payload)
|
||||
dev_id_str = device_id_slug(wvkb) if wvkb else None
|
||||
key_slug = re.sub("\\s+", "_", r.mode_name)[:20]
|
||||
final_stem = build_stem(
|
||||
source_prefix=src_prefix,
|
||||
hit_idx=saved,
|
||||
device_id_str=dev_id_str,
|
||||
key_desc_slug=key_slug,
|
||||
confirmed_l1=True,
|
||||
)
|
||||
if len(results) > 1:
|
||||
final_stem += f"_match{r_idx + 1}"
|
||||
dec_path = os.path.join(
|
||||
output_dir, final_stem + "_decrypted.bin"
|
||||
)
|
||||
with open(dec_path, "wb") as pf:
|
||||
pf.write(payload)
|
||||
iv_info = (
|
||||
f", iv={r.iv_used.hex().upper()}"
|
||||
if r.iv_used
|
||||
else ""
|
||||
)
|
||||
log.info(f" [+] Decrypted -> {dec_path}")
|
||||
log.info(f" [+] Key : {r.key_hex}")
|
||||
log.info(f" [+] Mode : {r.mode_name}{iv_info}")
|
||||
entry["decrypted"] = True
|
||||
entry["decrypt_key"] = r.key_hex
|
||||
entry["decrypt_mode"] = r.mode_name
|
||||
entry["payload_file"] = dec_path
|
||||
if wvkb:
|
||||
crc_status = "OK" if wvkb.crc_valid else "MISMATCH"
|
||||
log.info(
|
||||
f" [+] Device ID : {dev_id_str} (CRC: {crc_status})"
|
||||
)
|
||||
entry["wvkb_device_id"] = dev_id_str
|
||||
entry["wvkb_crc_valid"] = wvkb.crc_valid
|
||||
wvkb_path = os.path.join(
|
||||
output_dir, final_stem + "_wvkeybox.bin"
|
||||
)
|
||||
with open(wvkb_path, "wb") as wf:
|
||||
wf.write(wvkb.raw)
|
||||
log.info(f" [+] WV Keybox -> {wvkb_path}")
|
||||
if extract_wvd:
|
||||
if not HAS_PYWIDEVINE:
|
||||
log.info(
|
||||
" [i] pywidevine not installed, cannot generate WVD."
|
||||
)
|
||||
log.info(
|
||||
" Install with: pip install pywidevine"
|
||||
)
|
||||
else:
|
||||
client_id_path = os.path.join(
|
||||
SCRIPT_DIR, "client_id.bin"
|
||||
)
|
||||
priv_key_path = os.path.join(
|
||||
SCRIPT_DIR, "private_key.pem"
|
||||
)
|
||||
if os.path.exists(
|
||||
client_id_path
|
||||
) and os.path.exists(priv_key_path):
|
||||
wvd_path = os.path.join(
|
||||
output_dir, final_stem + ".wvd"
|
||||
)
|
||||
with open(client_id_path, "rb") as ci:
|
||||
client_id_blob = ci.read()
|
||||
with open(priv_key_path, "rb") as pk:
|
||||
private_key_pem = pk.read()
|
||||
if _make_wvd(
|
||||
private_key_pem,
|
||||
client_id_blob,
|
||||
wvd_path,
|
||||
):
|
||||
stats.wvd_generated += 1
|
||||
entry["wvd_file"] = wvd_path
|
||||
log.info(
|
||||
f" [+] WVD -> {wvd_path}"
|
||||
)
|
||||
else:
|
||||
log.info(" [!] WVD generation failed.")
|
||||
else:
|
||||
log.info(
|
||||
" [i] WVD generation requires provisioned client_id.bin"
|
||||
)
|
||||
log.info(
|
||||
" + private_key.pem in the same directory as this script."
|
||||
)
|
||||
log.info(
|
||||
" The raw keybox has been saved, use it to provision"
|
||||
)
|
||||
log.info(
|
||||
" the device and obtain these files first."
|
||||
)
|
||||
manifest_entries.append(entry)
|
||||
log.info("")
|
||||
if MAX_HITS and saved >= MAX_HITS:
|
||||
break
|
||||
finally:
|
||||
mm.close()
|
||||
return saved
|
||||
|
||||
|
||||
def write_manifest(entries: list, output_dir: str):
|
||||
path = os.path.join(output_dir, "manifest.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(entries, f, indent=2)
|
||||
log.info(f"Manifest written -> {path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="kbxdecoder",
|
||||
description="Extract and decode MStar/MediaTek Widevine keyboxes from eMMC dumps and firmware images. Decryption from ECB, CBC, CFB, OFB, and CTR modes using supplied keys (hex string, local file, or URL).",
|
||||
)
|
||||
parser.add_argument("files", nargs="*", help="One or more binary files to scan.")
|
||||
parser.add_argument(
|
||||
"--all", action="store_true", help="Scan all files in the script's directory."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
"-o",
|
||||
default=SCRIPT_DIR,
|
||||
help="Directory to save all output files and key cache. (default: script directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="HEX|FILE|URL",
|
||||
help="AES key source. Accepts any of: a 32/48/64-char hex string, a path to a local key file, or a URL to download a key list from. Repeat to combine multiple sources. Lines formatted as KEY,IV are treated as CBC pairs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only-custom",
|
||||
action="store_true",
|
||||
help="Only use keys supplied via --key. Skip URL downloads and cache.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force-update-keys",
|
||||
action="store_true",
|
||||
help="Force re-download of URL key sources even if cache is fresh.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-decrypt",
|
||||
action="store_true",
|
||||
help="Skip decryption entirely. Only extract raw keybox blobs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all-matches",
|
||||
action="store_true",
|
||||
help="Save output for every key/mode match, not just the first.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extract-wvd",
|
||||
action="store_true",
|
||||
help="Attempt .wvd file generation. Requires pywidevine and provisioned client_id.bin + private_key.pem in the script directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-hexview", action="store_true", help="Suppress hex dump output."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose-filters",
|
||||
action="store_true",
|
||||
help="Log the reason each candidate block is rejected.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet", "-q", action="store_true", help="Suppress all output except errors."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.quiet:
|
||||
logging.getLogger().setLevel(logging.ERROR)
|
||||
elif args.verbose_filters:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
global PRINT_HEXVIEW
|
||||
if args.no_hexview:
|
||||
PRINT_HEXVIEW = False
|
||||
try:
|
||||
prefix = clean_hex(PREFIX_HEX)
|
||||
postfixes = [clean_hex(x) for x in POSTFIX_HEX_LIST]
|
||||
except (ValueError, Exception) as e:
|
||||
log.error(f"Failed to parse hex constants: {e}")
|
||||
return 2
|
||||
postfixes = [p for p in postfixes if p]
|
||||
if not prefix or not postfixes or EXTRACT_LEN <= 0:
|
||||
log.error("Error: prefix, postfix, or EXTRACT_LEN is missing.")
|
||||
return 2
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
if not args.quiet and (not args.no_decrypt):
|
||||
if not HAS_CRYPTO:
|
||||
log.info("[!] pycryptodome not found, decryption disabled.")
|
||||
log.info(" Install with: pip install pycryptodome")
|
||||
if args.extract_wvd and (not HAS_PYWIDEVINE):
|
||||
log.info("[i] pywidevine not found, WVD generation disabled.")
|
||||
log.info(" Install with: pip install pywidevine")
|
||||
log.info("")
|
||||
db = KeyDatabase()
|
||||
if not args.no_decrypt and args.key:
|
||||
log.info("[*] Resolving key sources...")
|
||||
db = build_key_database(
|
||||
key_args=args.key,
|
||||
output_dir=args.output_dir,
|
||||
force_update=args.force_update_keys,
|
||||
only_custom=args.only_custom,
|
||||
)
|
||||
log.info(f" ECB candidates : {len(db.ecb)}")
|
||||
log.info(f" CBC pairs : {len(db.cbc)}")
|
||||
log.info("")
|
||||
elif not args.no_decrypt and (not args.key):
|
||||
log.info("[!] No keys supplied. Use --key <hex|file|url> to enable decryption.")
|
||||
log.info("")
|
||||
stats = ScanStats()
|
||||
manifest_entries = []
|
||||
scan_kwargs = dict(
|
||||
output_dir=args.output_dir,
|
||||
stats=stats,
|
||||
manifest_entries=manifest_entries,
|
||||
db=db,
|
||||
verbose_filters=args.verbose_filters,
|
||||
no_decrypt=args.no_decrypt,
|
||||
stop_on_first_key=not args.all_matches,
|
||||
extract_wvd=args.extract_wvd,
|
||||
)
|
||||
if args.all:
|
||||
total = 0
|
||||
for name in sorted(os.listdir(SCRIPT_DIR)):
|
||||
fpath = os.path.join(SCRIPT_DIR, name)
|
||||
if not os.path.isfile(fpath) or is_skipped_file(fpath):
|
||||
continue
|
||||
total += extract_from_file(fpath, prefix, postfixes, **scan_kwargs)
|
||||
stats.report()
|
||||
if manifest_entries:
|
||||
write_manifest(manifest_entries, args.output_dir)
|
||||
return 0 if total else 1
|
||||
if args.files:
|
||||
total = 0
|
||||
for fpath in args.files:
|
||||
if not os.path.isfile(fpath):
|
||||
log.error(f"File not found: {fpath}")
|
||||
continue
|
||||
total += extract_from_file(fpath, prefix, postfixes, **scan_kwargs)
|
||||
stats.report()
|
||||
if manifest_entries:
|
||||
write_manifest(manifest_entries, args.output_dir)
|
||||
return 0 if total else 1
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user