From e28673e478a445d827f90525235ab5d47fd65fce Mon Sep 17 00:00:00 2001 From: n0stal6ic Date: Mon, 18 May 2026 12:47:11 -0500 Subject: [PATCH] Add files via upload --- README.md | 12 +- phrases.txt | 4 + prxtractor.py | 375 ++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 299 insertions(+), 92 deletions(-) create mode 100644 phrases.txt diff --git a/README.md b/README.md index c2dc4f9..db47561 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,15 @@ Two paths, picked per-file by inspecting the first 8 bytes: - Files starting with `Salted__` are decrypted with OpenSSL `EVP_BytesToKey` (MD5 KDF, AES-256-CBC) using a passphrase. The passphrase is either auto-detected by scanning the supplied `.so` for null-separated `pszBasePhrase` + `pszAdditionalPhrase` strings, or supplied with `--phrase`. - Anything else is treated as an RFC 3394 AES-Key-Wrap blob and unwrapped with a KEK derived from the PlayReady Porting Kit hardcoded Transient Key (`8B22...427F`) and Intermediate Key (`9CE9...E136`) via AES-CMAC KDF in counter mode. First 32 bytes of the unwrapped material are written as `zgpriv`. +## Phrase list + +Candidate passphrases live in `phrases.txt` next to the script, one per line. The script auto-loads this file and uses it for two things: + +1. Hinting candidates when the `.so` contains a known base prefix but the additional tail can't be auto-paired +2. The `--list-phrases` output + +Add new phrases as you discover them from `strings`/binwalk of new `libplayready.so` builds. Point `--phrases-file` at a different file if you keep multiple lists. + ## Usage Scan a library and decrypt both `.dat` files: @@ -241,4 +250,5 @@ python prxtractor.py --list-phrases | `so_path` | Path to `libplayready.so.0` (or any binary) to scan for passphrases. Optional if you only need to unwrap a `zgpriv_protected.dat`. | | `dat_files` | One or more `.dat` files. Format is auto-detected per file. | | `--phrase` | Override the auto-detected passphrase. | -| `--list-phrases` | Print the known passphrase candidates and exit. | +| `--phrases-file` | Path to a candidate passphrase list (default: `phrases.txt` next to the script). | +| `--list-phrases` | Print the candidates from the phrase file and exit. | \ No newline at end of file diff --git a/phrases.txt b/phrases.txt new file mode 100644 index 0000000..b7033b1 --- /dev/null +++ b/phrases.txt @@ -0,0 +1,4 @@ +AsF16eEncr4pt +AsF16eEncr4pt17mt5581 +AsF16eEncr4pt18mt5581 +AsF16eEncr4pt19mt5813 \ No newline at end of file diff --git a/prxtractor.py b/prxtractor.py index 91bc761..a54e752 100644 --- a/prxtractor.py +++ b/prxtractor.py @@ -1,91 +1,284 @@ -import re -import sys -import os -from Crypto.Cipher import AES -from hashlib import md5 - -def strings_dump(filepath, min_len=6): - 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): - data, hits = strings_dump(filepath, min_len=6) - - known_hints = ["pszBasePhrase", "pszAdditionalPhrase", "pszPhrase", "Salted__"] - print("[*] Known marker strings found:") - for h in hits: - if any(hint.lower() in h.lower() for hint in known_hints): - print(f" [hint] {h}") - - print("\n[*] Potential 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.") - - return passphrase - -def openssl_kdf(passphrase: str, salt: 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_dat(dat_path: str, passphrase: str, strip_padding: bool = False): - with open(dat_path, "rb") as f: - raw = f.read() - - if raw[:8] != b"Salted__": - print(f" [!] {dat_path} does not have OpenSSL Salted__ header — skipping") - return None - - salt = raw[8:16] - ciphertext = raw[16:] - - key, iv = openssl_kdf(passphrase, salt) - cipher = AES.new(key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(ciphertext) - - if strip_padding: - decrypted = decrypted[:-8] - print(f" [*] 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 -> {out_path} ({len(decrypted)} bytes)") - return out_path - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: prxtractor.py [bgroupcert.dat] [zgpriv.dat]") - sys.exit(1) - - so_path = sys.argv[1] - bgroupcert_path = sys.argv[2] if len(sys.argv) > 2 else None - zgpriv_path = sys.argv[3] if len(sys.argv) > 3 else None - - print(f"[*] Scanning {so_path}...\n") - passphrase = find_playready_phrases(so_path) - - if passphrase and bgroupcert_path: - print(f"\n[*] Decrypting {bgroupcert_path}...") - decrypt_dat(bgroupcert_path, passphrase, strip_padding=False) - - if passphrase and zgpriv_path: - print(f"\n[*] Decrypting {zgpriv_path}...") - decrypt_dat(zgpriv_path, passphrase, strip_padding=True) \ No newline at end of file +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