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 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())