From 9bebc9d50c14a71b04deb159dd4035f75add017d Mon Sep 17 00:00:00 2001 From: n0stal6ic Date: Mon, 18 May 2026 12:46:50 -0500 Subject: [PATCH] Delete prxtractor.py --- prxtractor.py | 284 -------------------------------------------------- 1 file changed, 284 deletions(-) delete mode 100644 prxtractor.py diff --git a/prxtractor.py b/prxtractor.py deleted file mode 100644 index a54e752..0000000 --- a/prxtractor.py +++ /dev/null @@ -1,284 +0,0 @@ -import re -import os -import sys -import argparse -from hashlib import md5 -from typing import List, Optional, Tuple -from Crypto.Cipher import AES -from Crypto.Hash import CMAC - - -SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__)) -DEFAULT_PHRASES_FILE = os.path.join(SCRIPT_DIR, "phrases.txt") - -REPLAYREADY_TK_HEX = "8B222FFD1E76195659CF2703898C427F" -REPLAYREADY_IK_HEX = "9CE93432C7D74016BA684763F801E136" - - -def load_phrases(path: str) -> List[str]: - if not os.path.isfile(path): - return [] - phrases: List[str] = [] - with open(path, "r", encoding="utf-8", errors="ignore") as f: - for line in f: - line = line.split("#", 1)[0].strip() - if line: - phrases.append(line) - return phrases - - -def base_phrases_from(phrases: List[str]) -> List[str]: - if not phrases: - return [] - bases = set() - for a in phrases: - for b in phrases: - if a != b and b.startswith(a): - bases.add(a) - break - if not bases: - bases.add(min(phrases, key=len)) - return sorted(bases) - - -def strings_dump(filepath: str, min_len: int = 6) -> Tuple[bytes, List[str]]: - with open(filepath, "rb") as f: - data = f.read() - pattern = rb"[\x20-\x7E]{" + str(min_len).encode() + rb",}" - return data, [m.group().decode("ascii") for m in re.finditer(pattern, data)] - - -def find_playready_phrases(filepath: str, known_phrases: List[str]) -> Optional[str]: - data, hits = strings_dump(filepath, min_len=6) - - known_hints = ["pszBasePhrase", "pszAdditionalPhrase", "pszPhrase", "Salted__"] - print("Known marker strings found:") - found_markers = False - for h in hits: - if any(hint.lower() in h.lower() for hint in known_hints): - print(f" {h}") - found_markers = True - if not found_markers: - print(" None.") - - print("\nPotential base + additional passphrase pairs:") - passphrase = None - concat_pattern = rb"([\x20-\x7E]{6,24})\x00+([\x20-\x7E]{6,24})" - for m in re.finditer(concat_pattern, data): - a, b = m.group(1).decode(), m.group(2).decode() - if re.match(r"^[A-Za-z0-9]{6,24}$", a) and re.match(r"^[A-Za-z0-9]{6,24}$", b): - if not passphrase: - passphrase = a + b - print(f" '{a}' + '{b}' => '{a + b}'") - if not passphrase: - print(" None found.") - - if not passphrase and known_phrases: - for base in base_phrases_from(known_phrases): - if base.encode() in data: - print(f"\nFound a known base phrase '{base}' in the binary.") - print("The additional phrase may be model-specific. Try the candidates") - print("below with --phrase, or supply your own.") - for p in known_phrases: - print(f" {p}") - break - - return passphrase - - -def openssl_kdf(passphrase: str, salt: bytes) -> Tuple[bytes, bytes]: - data = passphrase.encode() - d, d_i = b"", b"" - while len(d) < 48: - d_i = md5(d_i + data + salt).digest() - d += d_i - return d[:32], d[32:48] - - -def decrypt_salted(dat_path: str, passphrase: str, strip_padding: bool = False) -> Optional[str]: - with open(dat_path, "rb") as f: - raw = f.read() - - if raw[:8] != b"Salted__": - print(f" {dat_path} does not have an OpenSSL Salted__ header, skipping.") - return None - - salt = raw[8:16] - ciphertext = raw[16:] - - key, iv = openssl_kdf(passphrase, salt) - decrypted = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext) - - if strip_padding: - decrypted = decrypted[:-8] - print(" Stripped last 8 bytes (zgpriv padding).") - - pad_len = decrypted[-1] - if 1 <= pad_len <= 16: - decrypted = decrypted[:-pad_len] - - out_path = dat_path.replace(".dat", "_decrypted.bin") - with open(out_path, "wb") as f: - f.write(decrypted) - print(f" Decrypted to {out_path} ({len(decrypted)} bytes)") - return out_path - - -def aes_key_unwrap(kek: bytes, wrapped: bytes) -> bytes: - if len(wrapped) < 24 or len(wrapped) % 8 != 0: - raise ValueError("wrapped data must be at least 24 bytes and a multiple of 8") - cipher = AES.new(kek, AES.MODE_ECB) - n = len(wrapped) // 8 - 1 - A = wrapped[:8] - R = [wrapped[8 + i * 8 : 8 + (i + 1) * 8] for i in range(n)] - for j in range(5, -1, -1): - for i in range(n, 0, -1): - t = (n * j) + i - A_xor = bytes(a ^ b for a, b in zip(A, t.to_bytes(8, "big"))) - block = cipher.decrypt(A_xor + R[i - 1]) - A = block[:8] - R[i - 1] = block[8:] - if A != b"\xA6" * 8: - raise ValueError("AIV mismatch (wrong KEK or corrupted input)") - return b"".join(R) - - -def derive_replayready_kek() -> bytes: - cmac_data = ( - b"\x01" - + bytes.fromhex(REPLAYREADY_IK_HEX) - + b"\x00" - + b"\x00" * 16 - + b"\x00\x80" - ) - cmac = CMAC.new(bytes.fromhex(REPLAYREADY_TK_HEX), ciphermod=AES) - cmac.update(cmac_data) - return cmac.digest() - - -def decrypt_zgpriv_protected(path: str) -> Optional[str]: - with open(path, "rb") as f: - wrapped = f.read() - - if len(wrapped) < 24: - print(f" {path} is too short to be a wrapped key ({len(wrapped)} bytes).") - return None - - try: - kek = derive_replayready_kek() - unwrapped = aes_key_unwrap(kek, wrapped) - except Exception as e: - print(f" AES key unwrap failed: {e}") - print(" This file may use a different KEK than the PlayReady Porting Kit default.") - return None - - zgpriv = unwrapped[:32] - base, _ = os.path.splitext(path) - out_path = base.replace("_protected", "") + "_decrypted.bin" - with open(out_path, "wb") as f: - f.write(zgpriv) - print(f" Unwrapped zgpriv saved to {out_path} ({len(zgpriv)} bytes)") - return out_path - - -def looks_like_salted(path: str) -> bool: - try: - with open(path, "rb") as f: - return f.read(8) == b"Salted__" - except OSError: - return False - - -def main() -> int: - parser = argparse.ArgumentParser( - prog="prxtractor", - description=( - "Extract PlayReady passphrases from libplayready.so and decrypt the " - "associated bgroupcert/zgpriv files. Also handles zgpriv_protected.dat " - "wrapped with the PlayReady Porting Kit default Transient/Intermediate keys." - ), - ) - parser.add_argument( - "so_path", - nargs="?", - help="Path to libplayready.so.0 (or any binary). " - "Optional if you only want to unwrap a zgpriv_protected.dat.", - ) - parser.add_argument( - "dat_files", - nargs="*", - help="One or more .dat files to decrypt. Detected automatically: " - "files starting with 'Salted__' use the passphrase path, others " - "treated as PR-PK-wrapped zgpriv_protected.dat.", - ) - parser.add_argument( - "--phrase", - default=None, - help="Override the auto-detected passphrase. Used when the binary is " - "stripped or uses a non-standard layout. See --list-phrases for " - "known candidates.", - ) - parser.add_argument( - "--phrases-file", - default=DEFAULT_PHRASES_FILE, - help=f"Path to a text file of candidate passphrases, one per line. " - f"Defaults to phrases.txt next to the script.", - ) - parser.add_argument( - "--list-phrases", - action="store_true", - help="Print the candidate passphrases from --phrases-file and exit.", - ) - args = parser.parse_args() - - known_phrases = load_phrases(args.phrases_file) - - if args.list_phrases: - if not known_phrases: - print(f"No phrases loaded (file not found: {args.phrases_file}).") - return 1 - print(f"Candidate passphrases from {args.phrases_file}:") - for p in known_phrases: - print(f" {p}") - return 0 - - if not args.so_path and not args.dat_files: - parser.print_help() - return 2 - - passphrase: Optional[str] = args.phrase - if args.so_path: - if not os.path.isfile(args.so_path): - print(f"File not found: {args.so_path}") - return 2 - print(f"Scanning {args.so_path}...\n") - detected = find_playready_phrases(args.so_path, known_phrases) - if not passphrase: - passphrase = detected - elif detected and detected != passphrase: - print(f"\nOverriding detected passphrase '{detected}' with '{passphrase}'.") - - if passphrase: - print(f"\nUsing passphrase: {passphrase}") - - for dat in args.dat_files: - if not os.path.isfile(dat): - print(f"\nFile not found: {dat}") - continue - print(f"\nProcessing {dat}...") - base = os.path.basename(dat).lower() - if looks_like_salted(dat): - if not passphrase: - print(" No passphrase available, cannot decrypt Salted__ file.") - print(" Supply one with --phrase, or pass libplayready.so.0 first.") - continue - strip = "zgpriv" in base - decrypt_salted(dat, passphrase, strip_padding=strip) - else: - decrypt_zgpriv_protected(dat) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file