Add files via upload

This commit is contained in:
n0stal6ic
2026-05-18 12:47:11 -05:00
committed by GitHub
parent 3abd1afec6
commit e28673e478
3 changed files with 299 additions and 92 deletions
+11 -1
View File
@@ -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`. - 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`. - 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 ## Usage
Scan a library and decrypt both `.dat` files: 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`. | | `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. | | `dat_files` | One or more `.dat` files. Format is auto-detected per file. |
| `--phrase` | Override the auto-detected passphrase. | | `--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. |
+4
View File
@@ -0,0 +1,4 @@
AsF16eEncr4pt
AsF16eEncr4pt17mt5581
AsF16eEncr4pt18mt5581
AsF16eEncr4pt19mt5813
+284 -91
View File
@@ -1,91 +1,284 @@
import re import re
import sys import os
import os import sys
from Crypto.Cipher import AES import argparse
from hashlib import md5 from hashlib import md5
from typing import List, Optional, Tuple
def strings_dump(filepath, min_len=6): from Crypto.Cipher import AES
with open(filepath, "rb") as f: from Crypto.Hash import CMAC
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)] SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
DEFAULT_PHRASES_FILE = os.path.join(SCRIPT_DIR, "phrases.txt")
def find_playready_phrases(filepath):
data, hits = strings_dump(filepath, min_len=6) REPLAYREADY_TK_HEX = "8B222FFD1E76195659CF2703898C427F"
REPLAYREADY_IK_HEX = "9CE93432C7D74016BA684763F801E136"
known_hints = ["pszBasePhrase", "pszAdditionalPhrase", "pszPhrase", "Salted__"]
print("[*] Known marker strings found:")
for h in hits: def load_phrases(path: str) -> List[str]:
if any(hint.lower() in h.lower() for hint in known_hints): if not os.path.isfile(path):
print(f" [hint] {h}") return []
phrases: List[str] = []
print("\n[*] Potential base+additional passphrase pairs:") with open(path, "r", encoding="utf-8", errors="ignore") as f:
passphrase = None for line in f:
concat_pattern = rb'([\x20-\x7E]{6,24})\x00+([\x20-\x7E]{6,24})' line = line.split("#", 1)[0].strip()
for m in re.finditer(concat_pattern, data): if line:
a, b = m.group(1).decode(), m.group(2).decode() phrases.append(line)
if re.match(r'^[A-Za-z0-9]{6,24}$', a) and re.match(r'^[A-Za-z0-9]{6,24}$', b): return phrases
if not passphrase:
passphrase = a + b
print(f" '{a}' + '{b}' => '{a+b}'") def base_phrases_from(phrases: List[str]) -> List[str]:
if not passphrase: if not phrases:
print(" None found.") return []
bases = set()
return passphrase for a in phrases:
for b in phrases:
def openssl_kdf(passphrase: str, salt: bytes): if a != b and b.startswith(a):
data = passphrase.encode() bases.add(a)
d, d_i = b"", b"" break
while len(d) < 48: if not bases:
d_i = md5(d_i + data + salt).digest() bases.add(min(phrases, key=len))
d += d_i return sorted(bases)
return d[:32], d[32:48]
def decrypt_dat(dat_path: str, passphrase: str, strip_padding: bool = False): def strings_dump(filepath: str, min_len: int = 6) -> Tuple[bytes, List[str]]:
with open(dat_path, "rb") as f: with open(filepath, "rb") as f:
raw = f.read() data = f.read()
pattern = rb"[\x20-\x7E]{" + str(min_len).encode() + rb",}"
if raw[:8] != b"Salted__": return data, [m.group().decode("ascii") for m in re.finditer(pattern, data)]
print(f" [!] {dat_path} does not have OpenSSL Salted__ header — skipping")
return None
def find_playready_phrases(filepath: str, known_phrases: List[str]) -> Optional[str]:
salt = raw[8:16] data, hits = strings_dump(filepath, min_len=6)
ciphertext = raw[16:]
known_hints = ["pszBasePhrase", "pszAdditionalPhrase", "pszPhrase", "Salted__"]
key, iv = openssl_kdf(passphrase, salt) print("Known marker strings found:")
cipher = AES.new(key, AES.MODE_CBC, iv) found_markers = False
decrypted = cipher.decrypt(ciphertext) for h in hits:
if any(hint.lower() in h.lower() for hint in known_hints):
if strip_padding: print(f" {h}")
decrypted = decrypted[:-8] found_markers = True
print(f" [*] Stripped last 8 bytes (zgpriv padding)") if not found_markers:
print(" None.")
pad_len = decrypted[-1]
if 1 <= pad_len <= 16: print("\nPotential base + additional passphrase pairs:")
decrypted = decrypted[:-pad_len] passphrase = None
concat_pattern = rb"([\x20-\x7E]{6,24})\x00+([\x20-\x7E]{6,24})"
out_path = dat_path.replace(".dat", "_decrypted.bin") for m in re.finditer(concat_pattern, data):
with open(out_path, "wb") as f: a, b = m.group(1).decode(), m.group(2).decode()
f.write(decrypted) if re.match(r"^[A-Za-z0-9]{6,24}$", a) and re.match(r"^[A-Za-z0-9]{6,24}$", b):
print(f" [+] Decrypted -> {out_path} ({len(decrypted)} bytes)") if not passphrase:
return out_path passphrase = a + b
print(f" '{a}' + '{b}' => '{a + b}'")
if __name__ == "__main__": if not passphrase:
if len(sys.argv) < 2: print(" None found.")
print("Usage: prxtractor.py <libplayready.so.0> [bgroupcert.dat] [zgpriv.dat]")
sys.exit(1) if not passphrase and known_phrases:
for base in base_phrases_from(known_phrases):
so_path = sys.argv[1] if base.encode() in data:
bgroupcert_path = sys.argv[2] if len(sys.argv) > 2 else None print(f"\nFound a known base phrase '{base}' in the binary.")
zgpriv_path = sys.argv[3] if len(sys.argv) > 3 else None print("The additional phrase may be model-specific. Try the candidates")
print("below with --phrase, or supply your own.")
print(f"[*] Scanning {so_path}...\n") for p in known_phrases:
passphrase = find_playready_phrases(so_path) print(f" {p}")
break
if passphrase and bgroupcert_path:
print(f"\n[*] Decrypting {bgroupcert_path}...") return passphrase
decrypt_dat(bgroupcert_path, passphrase, strip_padding=False)
if passphrase and zgpriv_path: def openssl_kdf(passphrase: str, salt: bytes) -> Tuple[bytes, bytes]:
print(f"\n[*] Decrypting {zgpriv_path}...") data = passphrase.encode()
decrypt_dat(zgpriv_path, passphrase, strip_padding=True) 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())