Delete wvdxtractor.py

This commit is contained in:
n0stal6ic
2026-03-10 17:36:49 -05:00
committed by GitHub
parent c4616347ad
commit 9db051e6b6
-705
View File
@@ -1,705 +0,0 @@
import os
import re
import sys
import json
import mmap
import hashlib
import logging
import argparse
import struct
import subprocess
import tempfile
from typing import List, Iterable, Tuple, Optional, Dict
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
PRINT_HEXVIEW = True
EXTRACT_LEN = 228
HEXVIEW_WIDTH = 16
MAX_HITS = 0
MAX_ZERO_FRACTION = 0.25
MAX_ZERO_RUN = 16
TAIL_START = 0x90
MIN_TAIL_NONZERO_RATIO = 0.70
SCRIPT_PATH = os.path.realpath(__file__)
SCRIPT_DIR = os.path.dirname(SCRIPT_PATH)
SCRIPT_NAME = os.path.basename(SCRIPT_PATH)
AES_ECB_KEYS: List[Tuple[str, str]] = [
("214BF3C129547AF31D32A5ECB4742192", "unknown"),
("B9C956919B48E1671564F4CADB5FE63C", "unknown"),
("224420CC1EBCA39100010001846276CC", "unknown"),
("088085E0077085E08990A0E18AA0A0E1", "unknown"),
("0C6E750369A106355D7290F482205D61", "unknown"),
("0BB085E004608DE58880A0E1B95093E1", "unknown"),
("1100E63045E019B5A8A1CB49EA9625B4", "unknown"),
("F72F0FE303CC80E1BC3406E3B11095E1", "unknown"),
("203040E30100A0E3B010CCE100C094E5", "unknown"),
("13541C00B0801C00A01820006D801C00", "unknown"),
("0007FF4154534D92FC55AA0FFF0110E0", "default"),
("BC1197CA30AA0FC84F7FE62E09FD3D9F", "Hisense"),
("8981D083B3D53B3DF1AC529A70F244C0", "Vestel"),
("24490B4CC95F739CE34138478E47139E", "Vestel"),
("1234123412341234", "mb97 to mb130"),
("3503B1CDE3401EC06030C12A4311F4A5", "KTC"),
("E33AB4C45C2570B8AD15A921F752DEB6", "LG"),
("1F1E1D1C1B1A19180706050403020100", "BCM35230/early MTK5369 and LG1152 (common)"),
("7184C9C428D03C445188234D5A827196", "mtk5369 - Mediatek GP4 - HE_DTV_GP4I_AFAAATAA"),
("385A992430196A8C44F1985823C01440", "mtk5398 (a2) - Mediatek NetCast 4/4.5 - HE_LCD_NC5M_AAAAAIAA"),
("8E32E4608871ECE9B6301999D5155A07", "mtka5lr (mt5882) - Mediatek webOS 2 (2015) - HE_DTV_W15L_AFAAABAA"),
("6856A0482475A8B41728A35474810203", "new BCM35230"),
("2F2E2D2C2B2A29281716151413121110", "Saturn7/BCM3556"),
("212D3B2A5C2D3A2C4D5B234B1A321D2A", "new Saturn7/old LM1"),
("4F836AAEB4301F26172A9B0E1120EAF4", "LM1 PDP"),
("4EE662C7A2C0917F7328DE73A0836C6B", "LM1 LCD"),
("2F534ABE34801A36B7DA6B3EB1C04AD4", "m1 - MStar non-webOS - LN45*, LN53*, LN54*, LN565*, LA61*, LA643*, MA53*, PN45*, PN65*"),
("7B2CA5943D2E752CF58606228C5B2DAD", "m1a - MStar non-webOS (L15 signage) - BS_LCD_LE15_AAAAAIAM"),
("D55C6864035A8C8A2B35A6D6C4565596", "m2 - MStar SimpleSmart - HE_LCD_SS1A_AFAAAIAA"),
("ADB92D9E23035522F4708CC259B31EA2", "m2 - MStar webOS 3.0 (2016) - HE_DTV_W16R_AFAAABAA"),
("D2E6EE17639DFE2F81D3840FA0BC334A", "m2r - MStar webOS 3.5 (2017) - HE_DTV_W17R_AFAAABAA"),
("4F6DE80C0362FD562464BC2073D15567", "m3 - MStar webOS 4.0 (2018) - HE_DTV_W18R_AFAAABAA"),
("88723D91920712D0BAFE87A25E6E8EC7", "m3r - MStar webOS 4.5 (2019) - HE_DTV_W19R_AFAAATAA"),
("68A284B4953CAD15024BED2C4F852A09", "lm14 - MStar NetCast 4.5 (2014/2015) - HE_LCD_NC5U_AAADABAA"),
("19F51EE9B949C89E41AE136F48BB405C", "lm14a - MStar webOS 2 (2015) - HE_DTV_W15A_AFADABAA"),
("F8F6BD1AA24506C2759E1BE1D51BB43C", "lm14alite - MStar webOS 2 (2015) - HE_DTV_W15B_AFADABAA"),
("96F464CB29CDFF5441FD87D47D084FF8", "lm15u - MStar webOS 2 (2015) - HE_DTV_W15U_AFADABAA"),
("6FCCC4AA3389B614BABE462498D2020A", "lm18a - MStar webOS 4.0 (2018) - HE_DTV_W18A_AFADATAA"),
("806B982279521809DBAD9E2E6BF377763903565A7EB4604BAB1E1503DBFC4326", "lm21a - MStar webOS 6 (2021) - HE_DTV_W21A_AFADATAA"),
("B65119E0E6CB5DB19C69B4CC78FAC3A87C747E5AEFDE8FF58F2CD47128D9E16D", "lm21u (mt5889) - MStar webOS 6 (2021) - HE_DTV_W21U_AFADATAA"),
("FC9D81DEC206BA62614C949C43D2DA91D23E9FF3DF9674D69A444D13277BDF96", "lm21ut - MStar webOS 6 StanbyME (2022) - HE_DTV_N21D_AFAAATAA"),
("3435663331313732316538383063663538306161643131653335323334613034", "lm21an - MStar webOS 7 (2022) - HE_DTV_W22A_AFADATAA"),
("1FB2C3B789D5EA48ED16E79A0343986C691DACEC872BB07787D0F722AF5D1E2C", "lm21ann - MStar webOS 8 (2023) - HE_DTV_W23A_AFADATAA"),
("4813B5B63C998A2874EF3320684AC8D9", "lg1152 - LX GP4 - HE_DTV_GP4H_AFAAATAA"),
("14B3623488212250C7C992AACD537447", "lg115x - LX NC4 - HE_LCD_NC4H_AAADABAA"),
("12C344FDD2871C983CD0FBBC25143974", "lg1154 (h13, goldfinger) - LX webOS 1 (2014) - HE_DTV_WT1H_AFAAABAA"),
("34CC219D3AFC102433109BBC1DA44095", "m14 (m14tv) - LX webOS 1 (2014) - HE_DTV_WT1M_AFAAATAA"),
("5A167D8C342EF094800E7CFA2D10F2D0", "m14 (m14tv) - LX webOS 2 (2015) - HE_DTV_W15M_AFAAATAA"),
("13F56BE4B4A0829598DB8F74065A263B", "h15 - LX webOS 2 (2015) - HE_DTV_W15H_AFADATAA"),
("3679EF1840B7FDEBC1FBF95A0CAFCE3E", "m16 - LX webOS 3.0 (2016) - HE_DTV_W16M_AFADABAA"),
("5804DF78CB8DC6A71C05DAB0F1EDE3E1", "m16lite - LX webOS 3.0 (2016) - HE_DTV_W16N_AFADABAA"),
("1B3C76ADD3F5EE6B089DB253747A8CD4", "m16p - LX webOS 3.5 (2017) - HE_DTV_W17H_AFADABAA"),
("3C9D30DF3A95C1AA41928813292BD947", "m16plite - LX webOS 3.5 (2017) - HE_DTV_W17M_AFADATAA"),
("89E11D498392F5A521145738EF036AE5", "m16pstb - LX webOS 3.5 (2017) - HE_DTV_W17S_AFADATAA"),
("437C02F0DF99F2072D1A64EEBBD2953B", "m16pp - LX webOS 4.0 (2018) - HE_DTV_W18H_AFADABAA"),
("3471D9BFC5F4B34A8997D56932F34D94", "m16pplite - LX webOS 4.0 (2018) - HE_DTV_W18M_AFADATAA"),
("3FE1CBE11BD658BB37813E05052D5FE5", "m16p3 - LX webOS 4.5 (2019) - HE_DTV_W19H_AFADABAA"),
("A2FA48FCC1A22FD2F1944BEFA8403765EF178D4F4AB0E81AC7B5B267ACBDF14D", "m23 - LX webOS 8 (2023) - HE_DTV_W23M_AFADATAA"),
("E529BCDEDF8E49667C0FA3A81174B65E", "o18 - LX webOS 4.0 (2018) - HE_DTV_W18O_AFABABAA"),
("4ACD2CA8425BBA6C49FD03A174300239", "o18 - LX webOS 4.5 (2019) - HE_DTV_W19O_AFABABAA"),
("C9EF645424A625BBAE7521394564025EC6252658FB650D33633111BD40C76011", "o20 - LX webOS 5 (2020) - HE_DTV_W20O_AFABATAA"),
("944288798A122C6130B661BEE52DF4FE42120F60A61E312DCFC1411E300A29AE", "o20n - LX webOS 6 (2021) - HE_DTV_W21O_AFABATAA"),
("3861333237633238613136633438663239623238623037656335623433353862", "o22 - LX webOS 7 (2022) - HE_DTV_W22O_AFABATAA"),
("CF5D6DC934F18618B968382368E17BA971DEAA2ECDFC906874B327D87076E228", "o22n - LX webOS 8 (2023) - HE_DTV_W23O_AFABATAA"),
("53D6DC79418C1A2371DC9F926CD3A3A06F4E7E4396464B5F41248083C2C65637", "o22n2 - LX webOS 9 (2024) - HE_DTV_W24G_AFABATAA"),
("B7724DBBF2AEA073131E8E7D62D114E2AA02F99D17CD7350C14466624528ED79", "o24 - LX webOS 9 (2024) - HE_DTV_W24O_AFABATAA"),
("FE90B0C1BE8CC28A9738333F95AC2C58777BDE7D4E8CABABA73B24FB7D1781C7", "o22n3 - LX webOS 10 (2025) - HE_DTV_W25G_AFABATAA"),
("52A208FA24E7E70730A40999B1C22C148F4920484BC50B515D243E35D14689F1", "o24n - LX webOS 10 (2025) - HE_DTV_W25O_AFABATAA"),
("CD4171FC9C06869627A67EA7B66D739D", "o18k - LX webOS 4.5 (2019) - HE_DTV_W19K_AFADATAA"),
("0EE52A12A2EB5DE2E13999187B14913F6D3367A79B39AC35979A51E5C12A4FDF", "o208k - LX webOS 5 (2020) - HE_DTV_W20K_AFADATAA"),
("E27E6AFE44B7866D60C24ED27904ECB296CA69B4251478B5248C03851F08ECF5", "e60n - LX webOS 6 (2021) - HE_DTV_W21K_AFADATAA"),
("F2B78AEBAD6D86A17B4742B2B84B60F4", "k2l - Realtek webOS 3.0 (2016) - HE_DTV_W16K_AFADATAA"),
("C2FBBC5DDD9D366B7FD6CAEB90F86039", "k2lp - Realtek webOS 3.0 (2016) - HE_DTV_W16P_AFADABAA"),
("1C966DFA0E5AE9946AAF8D2EC06B9E18", "k3lp - Realtek webOS 3.5 (2017) - HE_DTV_W17P_AFADABAA"),
("AD17A5923B525FD21DB765A5B6822FBD", "k5lp - Realtek webOS 4.5 (2019) - HE_DTV_W19P_AFADABAA"),
("388BE4B04BD98E7C3CA45A4C6CA346DD2EB32BDCD05DC28FC4A87C9625294A5E", "k6lp - Realtek webOS 5 (2020) - HE_DTV_W20P_AFADATAA"),
("377050F9B9D91CD803ACAACCEA4046DD99B01CFBB0010451F4F87A1620C4BAEF", "k6lpfhd - Realtek webOS 5 (2020) - HE_DTV_W20L_AFAAJAAA"),
("395324AD369A529EABAC71FE1E72C25CE25594294D47303BCB2629241AFA4C98", "k6hp - Realtek webOS 5 (2020) - HE_DTV_W20H_AFADABAA"),
("74514676D68B9A72A0093CEF56D3067484E1F4D5CF7D4B4ED389BED030FA1B09", "k7lp - Realtek webOS 6 (2021) - HE_DTV_W21P_AFADATAA"),
("6A42D2485B716B25AE5C9921176588D167C25B902D4EF2903AF5C1FCC61D34C9", "k8lp - Realtek webOS 7 (2022) - HE_DTV_W22P_AFADATAA"),
("A35A57DFDD8266F7CE1AF991EC67BABF6723653ABB9A7D48A4B8AB2A2485BCFE", "k8hp - Realtek webOS 7 (2022) - HE_DTV_W22H_AFADATAA"),
("703373367638792F423F4528482B4D6251655468576D5A7134743777217A2443", "k8lp - Realtek webOS 7 hospitality (2022) - HE_IDD_H22P_AHAAATAA"),
("6251655468576D5A7133743677397A24432646294A404E635266556A586E3272", "k8hp - Realtek webOS 7 hospitality (2022) - HE_IDD_H22H_AHAAATAA"),
("3764336361633437326166373639383663353863363039316332383031626637", "k8ap - Realtek webOS 7 (2022) - HE_DTV_W22L_AFAAATAA"),
("DFFD1E4F093E305451D4F3752E63BA9A3E6A6404922D986DF36C00818F5595C1", "k8hpp - Realtek webOS 8 (2023) - HE_DTV_W23H_AFADATAA"),
("EC2C89B4AF45B5EB7EA9A83DD2387810C0815BD31BBFE1D17C809E7D68339112", "k8lpn - Realtek webOS 8 (2023) - HE_DTV_W23P_AFADATAA"),
("F322A9CA1D523C358DD2FD97D5660E25386C9C60E423632AEC9723D282BE971D", "kf23f - Realtek webOS 8 smart monitors (2023) - HE_MNT_S23Y_AAAAGLAA"),
("6252A0816884997B2FCA30662561A721A4BCC40B18CBEA5D363FA844F17D7DE9", "kid23q - Realtek webOS 8 ultrawide monitors (2023) - HE_MNT_S23Z_AAACGLAA"),
("7638792F423F4528482B4D6251655368566D597133743677397A24432646294A", "k8lpn - Realtek webOS 8 hospitality (2023) - HE_IDD_H23P_AHAAATAA"),
("6B5AD1BE81D7A1A494F58EB659431850C1B681826EE4428394D4897052691756", "k8lpn2 - Realtek webOS 9 (2024) - HE_DTV_W24P_AFADATAA"),
("FA9EBB838B7BAFBA75EFE8D5A3560374EB0699A113411CA924051B4ADB52E10D", "k24 - Realtek webOS 9 (2024) - HE_DTV_W24H_AFADATAA"),
("1A8ADAB21D9FF995677DB32BCE2E0CD559AE86840EBF4A696872076E37DFFE8F", "k24t - Realtek webOS 9 StanbyME 2 (2024) - HE_DTV_N24D_AFADATAA"),
("25DF24166745B52EAB661455BED43DE376320FAA1F7824877B938DB869308B18", "k25lp - Realtek webOS 10 (2025) - HE_DTV_W25P_AFADATAA"),
("B2E8C3E214F044B823916E48FA074E606C7C5CD5E6902B6F99BD903DAC0C792F", "k24n - Realtek webOS 10 (2025) - HE_DTV_W25H_AFADATAA"),
("B23981FD3642CDF401E7A0C2FADBDA4399B6AAF9600B802144933B4F4E5855EA", "k6lpwee - Realtek webOS 5 (2020) - HE_DTV_C20P_AFADATAA"),
("3263653764623932376235323637653637633035363066353833303235383466", "k8lpwee - Realtek webOS 7 (2022) - HE_DTV_C22P_AFADATAA"),
("46715DFBDE23C2D8CBA7EC4F36BA41AAD28E7EC00FEC51F2843F24654DB09BD3", "k8hpwee - Realtek webOS 7 (2022) - HE_DTV_C22H_AFABATAA"),
("6238323334663232396632613762316537333731333832306664666136333564", "k8apwee - Realtek webOS 7 (2022) - HE_DTV_C22L_AFAAATAA"),
]
MTK_CBC_KEYS: List[Tuple[str, str, str]] = [
("09291094092910940929109409291094", "00000000000000000000000000000000", "Mediatek Generic (Hisense, Sharp, Philips)"),
("53686172536861725348617253686172", "00000000000000000000000000000000", "SharSharSHarShar (Sharp key for Sharp external partition)"),
("5450565F5450565F5450565F5450565F", "00000000000000000000000000000000", "TPVision(Philips) 2012"),
("D378EAF81D378A801B556985789A7C31", "73079FD19183715E130858588479C652", "Philips 2012"),
("47FBF8CAD62BB95AF3AD9509E5C2175D", "63120FB321B0410F216D6DC2D8641A11", "Philips 2013"),
("55555555555555555555555555555555", "B1BFAA407F70C80C650379DFEAFAA40F", "TCL 2017"),
("1B569AA7D2E4CCE66584A7A3D8A45679", "A0E88D5D52A813260D3A34A14AA89416", "TPV_MTK2K17PLF_EU0"),
("135AFB6DE91CD56496244BC7C0E08D63", "C6A38C89F0AF5637EB6E19D35E12E257", "TPV_MTK2K14PLF_EU1"),
("7B17F7764818AE2C897BA69D428D0CB3", "672041CCB9FCD4272B11E57AB6047163", "TPV_MTK2K16PLF_EU0"),
]
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger(__name__)
@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 > 0:
log.info(f" WVD files made : {self.wvd_generated}")
log.info("─────────────────────────────────────────")
def _check_crypto() -> bool:
try:
from Crypto.Cipher import AES as _AES
return True
except ImportError:
return False
HAS_CRYPTO = _check_crypto()
def load_keys_from_file(path: str) -> Tuple[List[Tuple[str,str]], List[Tuple[str,str,str]]]:
ecb: List[Tuple[str,str]] = []
cbc: List[Tuple[str,str,str]] = []
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split('#', 1)
key_part = parts[0].strip()
comment = parts[1].strip() if len(parts) > 1 else 'external'
if key_part.startswith("b'") or key_part.startswith('b"'):
continue
if ',' in key_part:
k, iv = key_part.split(',', 1)
k = k.strip().replace(' ','')
iv = iv.strip().replace(' ','')
try:
bytes.fromhex(k)
bytes.fromhex(iv)
if len(k) >= 32:
cbc.append((k.upper(), iv.upper(), comment))
except ValueError:
pass
else:
k = key_part.replace(' ','')
try:
bytes.fromhex(k)
if len(k) >= 32:
ecb.append((k.upper(), comment))
except ValueError:
pass
except Exception as e:
log.warning(f" Could not load key file {path}: {e}")
return ecb, cbc
def _aes_ecb_decrypt(data: bytes, key: bytes) -> Optional[bytes]:
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
padded = pad(data, 16)
cipher = AES.new(key, AES.MODE_ECB)
return cipher.decrypt(padded)
except Exception:
return None
def _aes_cbc_decrypt(data: bytes, key: bytes, iv: bytes) -> Optional[bytes]:
try:
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
padded = pad(data, 16)
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
return cipher.decrypt(padded)
except Exception:
return None
def _extract_payload(dec: bytes) -> Optional[bytes]:
for offset in (64, 96):
candidate = dec[offset:]
if b"CHAI" in candidate or b"kbox" in candidate:
if b"CHAI" in candidate:
return candidate
else:
return candidate[:128]
return dec[64:]
def try_decrypt(
blob: bytes,
manual_key_hex: Optional[str],
extra_ecb: List[Tuple[str,str]],
extra_cbc: List[Tuple[str,str,str]],
) -> Tuple[Optional[bytes], Optional[str], Optional[str]]:
if not HAS_CRYPTO:
return None, None, None
ecb_candidates: List[Tuple[str,str]] = []
cbc_candidates: List[Tuple[str,str,str]] = []
if manual_key_hex:
k = manual_key_hex.replace(' ','').upper()
ecb_candidates.append((k, 'manual'))
ecb_candidates.extend(extra_ecb)
ecb_candidates.extend(AES_ECB_KEYS)
cbc_candidates.extend(extra_cbc)
cbc_candidates.extend(MTK_CBC_KEYS)
for key_hex, desc in ecb_candidates:
try:
key = bytes.fromhex(key_hex)
except ValueError:
continue
dec = _aes_ecb_decrypt(blob, key)
if dec and b"INNER_MSTAR" in dec:
payload = _extract_payload(dec)
return payload, key_hex, f"AES-ECB | {desc}"
for key_hex, iv_hex, desc in cbc_candidates:
try:
key = bytes.fromhex(key_hex)
iv = bytes.fromhex(iv_hex)
except ValueError:
continue
dec = _aes_cbc_decrypt(blob, key, iv)
if dec and b"INNER_MSTAR" in dec:
payload = _extract_payload(dec)
return payload, key_hex, f"AES-CBC | {desc}"
return None, None, None
def _check_pywidevine() -> bool:
try:
import importlib
importlib.import_module('pywidevine')
return True
except ImportError:
return False
HAS_PYWIDEVINE = _check_pywidevine()
def try_generate_wvd(payload: bytes, out_stem: str, output_dir: str) -> Optional[str]:
if not HAS_PYWIDEVINE:
return None
try:
import tempfile
tmpdir = tempfile.mkdtemp()
payload_path = os.path.join(tmpdir, "payload.bin")
with open(payload_path, "wb") as f:
f.write(payload)
wvd_path = os.path.join(output_dir, f"{out_stem}.wvd")
result = subprocess.run(
[sys.executable, "-m", "pywidevine", "create-device",
"--client-id", payload_path,
"--type", "ANDROID",
"--security-level", "1",
"-o", wvd_path],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and os.path.exists(wvd_path):
return wvd_path
try:
from pywidevine.device import Device, DeviceTypes
from pywidevine.cdm import Cdm
device = Device.loads(payload)
device.dump(wvd_path)
if os.path.exists(wvd_path):
return wvd_path
except Exception:
pass
except Exception as e:
log.debug(f" WVD generation error: {e}")
return None
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 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 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)
hex_part_padded = hex_part.ljust(width * 3 - 1)
ascii_part = "".join(chr(x) if is_printable_ascii(x) else "." for x in chunk)
lines.append(f"{base_offset + i:08X} {hex_part_padded} |{ascii_part}|")
return "\n".join(lines)
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:
continue
if 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 = 0
cur = 0
for x in b:
if x == 0:
cur += 1
if cur > best:
best = cur
else:
cur = 0
return best
def zero_fraction(b: bytes) -> float:
if not b:
return 1.0
return sum(1 for x in b if x == 0) / len(b)
def nonzero_ratio(b: bytes) -> float:
if not b:
return 0.0
return sum(1 for x in b if x != 0) / len(b)
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%} > threshold {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} > threshold {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%} < threshold {MIN_TAIL_NONZERO_RATIO:.2%}")
return False
return True
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"
_KEYBOX_OUTPUT_RE = re.compile(r'_Keybox(_\d+)?\.bin$', re.IGNORECASE)
def is_skipped_file(path: str) -> bool:
base = os.path.basename(path)
if base == SCRIPT_NAME:
return True
if _KEYBOX_OUTPUT_RE.search(base):
return True
return False
def out_name_for_file(prefix: str, hit_idx: int, output_dir: str) -> str:
name = f"{prefix}_Keybox.bin" if hit_idx == 1 else f"{prefix}_Keybox_{hit_idx}.bin"
return os.path.join(output_dir, name)
def out_stem_for_hit(prefix: str, hit_idx: int) -> str:
return f"{prefix}_Keybox" if hit_idx == 1 else f"{prefix}_Keybox_{hit_idx}"
def write_manifest(entries: list, output_dir: str):
manifest_path = os.path.join(output_dir, "manifest.json")
with open(manifest_path, "w") as f:
json.dump(entries, f, indent=2)
log.info(f"\nManifest written -> {manifest_path}")
def extract_from_file(
path: str,
prefix: bytes,
postfixes: List[bytes],
output_dir: str,
stats: ScanStats,
manifest_entries: list,
verbose_filters: bool = False,
no_decrypt: bool = False,
manual_key_hex: Optional[str] = None,
extra_ecb: Optional[List[Tuple[str,str]]] = None,
extra_cbc: Optional[List[Tuple[str,str,str]]] = None,
no_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
per_file_prefix = safe_prefix_from_filename(path)
saved = 0
seen = set()
window = best_postfix_window(postfixes)
_extra_ecb = extra_ecb or []
_extra_cbc = extra_cbc or []
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
stem = out_stem_for_hit(per_file_prefix, saved)
out_name = os.path.join(output_dir, stem + ".bin")
with open(out_name, "wb") as out:
out.write(block)
log.info(f"[{os.path.basename(path)} | Hit #{saved}] -> {out_name} prefix@0x{ppos:X} postfix@0x{qpos:X}")
if PRINT_HEXVIEW:
log.info(hexview(block, base_offset=ppos, width=HEXVIEW_WIDTH))
log.info("")
manifest_entry: Dict = {
"source_file": path,
"offset_hex": hex(ppos),
"sha256": hashlib.sha256(block).hexdigest(),
"keybox_file": out_name,
"decrypted": False,
"decrypt_key": None,
"decrypt_method": None,
"payload_file": None,
"wvd_file": None,
}
if not no_decrypt:
if not HAS_CRYPTO:
log.warning(" [!] pycryptodome not installed.")
else:
log.info(f" [~] Attempting decryption ({len(AES_ECB_KEYS)} ECB + {len(MTK_CBC_KEYS)} CBC keys)...")
payload, used_key, used_method = try_decrypt(block, manual_key_hex, _extra_ecb, _extra_cbc)
if payload is not None:
stats.decrypted += 1
payload_path = os.path.join(output_dir, stem + "_payload.bin")
with open(payload_path, "wb") as pf:
pf.write(payload)
log.info(f" [+] Decrypted -> {payload_path}")
log.info(f" [+] Key used : {used_key}")
log.info(f" [+] Method : {used_method}")
manifest_entry["decrypted"] = True
manifest_entry["decrypt_key"] = used_key
manifest_entry["decrypt_method"] = used_method
manifest_entry["payload_file"] = payload_path
if not no_wvd:
if not HAS_PYWIDEVINE:
log.info(" [i] pywidevine not installed.")
log.info(" Install with: pip install pywidevine")
else:
wvd_path = try_generate_wvd(payload, stem, output_dir)
if wvd_path:
stats.wvd_generated += 1
manifest_entry["wvd_file"] = wvd_path
log.info(f" [+] WVD -> {wvd_path}")
else:
log.info(" [!] WVD generation failed.")
else:
stats.decrypt_failed += 1
log.info(" [-] No matching decryption key found in database.")
log.info(" Use --key <hex> to provide a key manually.")
manifest_entries.append(manifest_entry)
log.info("")
if MAX_HITS and saved >= MAX_HITS:
break
finally:
mm.close()
return saved
def main() -> int:
parser = argparse.ArgumentParser(
prog="MStarToWVD",
description="Extract MStar/MediaTek Widevine L1 keyboxes from raw eMMC dumps and firmware images, decrypt them, and generates .wvd files."
)
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=".", help="Directory to save all output files.")
parser.add_argument("--no-hexview", action="store_true", help="Remove hex dump output.")
parser.add_argument("--verbose-filters", action="store_true", help="Log candidate block rejection reasons.")
parser.add_argument("--quiet", "-q", action="store_true", help="Remove all output except errors.")
parser.add_argument("--no-decrypt", action="store_true", help="Skip decryption.")
parser.add_argument("--key", metavar="HEX", help="Manual AES key in hex.")
parser.add_argument("--keys-file", metavar="FILE", help="Path to an external key database file.")
parser.add_argument("--no-wvd", action="store_true", help="Skip WVD generation.")
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
extra_ecb: List[Tuple[str,str]] = []
extra_cbc: List[Tuple[str,str,str]] = []
if args.keys_file:
if not os.path.isfile(args.keys_file):
log.error(f"Key file not found: {args.keys_file}")
return 2
extra_ecb, extra_cbc = load_keys_from_file(args.keys_file)
log.info(f"Loaded {len(extra_ecb)} ECB + {len(extra_cbc)} CBC keys from {args.keys_file}")
os.makedirs(args.output_dir, exist_ok=True)
stats = ScanStats()
manifest_entries: list = []
if not args.quiet and not args.no_decrypt:
if not HAS_CRYPTO:
log.info("[!] pycryptodome not found! Decryption disabled. Install: pip install pycryptodome")
if not HAS_PYWIDEVINE and not args.no_wvd:
log.info("[i] pywidevine not found! WVD generation disabled. Install: pip install pywidevine")
log.info("")
kwargs = dict(
output_dir=args.output_dir,
stats=stats,
manifest_entries=manifest_entries,
verbose_filters=args.verbose_filters,
no_decrypt=args.no_decrypt,
manual_key_hex=args.key,
extra_ecb=extra_ecb,
extra_cbc=extra_cbc,
no_wvd=args.no_wvd,
)
if args.all:
total = 0
for name in sorted(os.listdir(SCRIPT_DIR)):
path = os.path.join(SCRIPT_DIR, name)
if not os.path.isfile(path):
continue
if is_skipped_file(path):
continue
total += extract_from_file(path, prefix, postfixes, **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 path in args.files:
if not os.path.isfile(path):
log.error(f"File not found: {path}")
continue
total += extract_from_file(path, prefix, postfixes, **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())