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
+229 -36
View File
@@ -1,39 +1,92 @@
import re import re
import sys
import os import os
from Crypto.Cipher import AES import sys
import argparse
from hashlib import md5 from hashlib import md5
from typing import List, Optional, Tuple
from Crypto.Cipher import AES
from Crypto.Hash import CMAC
def strings_dump(filepath, min_len=6):
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: with open(filepath, "rb") as f:
data = f.read() data = f.read()
pattern = rb'[\x20-\x7E]{' + str(min_len).encode() + rb',}' pattern = rb"[\x20-\x7E]{" + str(min_len).encode() + rb",}"
return data, [m.group().decode("ascii") for m in re.finditer(pattern, data)] return data, [m.group().decode("ascii") for m in re.finditer(pattern, data)]
def find_playready_phrases(filepath):
def find_playready_phrases(filepath: str, known_phrases: List[str]) -> Optional[str]:
data, hits = strings_dump(filepath, min_len=6) data, hits = strings_dump(filepath, min_len=6)
known_hints = ["pszBasePhrase", "pszAdditionalPhrase", "pszPhrase", "Salted__"] known_hints = ["pszBasePhrase", "pszAdditionalPhrase", "pszPhrase", "Salted__"]
print("[*] Known marker strings found:") print("Known marker strings found:")
found_markers = False
for h in hits: for h in hits:
if any(hint.lower() in h.lower() for hint in known_hints): if any(hint.lower() in h.lower() for hint in known_hints):
print(f" [hint] {h}") print(f" {h}")
found_markers = True
if not found_markers:
print(" None.")
print("\n[*] Potential base+additional passphrase pairs:") print("\nPotential base + additional passphrase pairs:")
passphrase = None passphrase = None
concat_pattern = rb'([\x20-\x7E]{6,24})\x00+([\x20-\x7E]{6,24})' concat_pattern = rb"([\x20-\x7E]{6,24})\x00+([\x20-\x7E]{6,24})"
for m in re.finditer(concat_pattern, data): for m in re.finditer(concat_pattern, data):
a, b = m.group(1).decode(), m.group(2).decode() 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 re.match(r"^[A-Za-z0-9]{6,24}$", a) and re.match(r"^[A-Za-z0-9]{6,24}$", b):
if not passphrase: if not passphrase:
passphrase = a + b passphrase = a + b
print(f" '{a}' + '{b}' => '{a+b}'") print(f" '{a}' + '{b}' => '{a + b}'")
if not passphrase: if not passphrase:
print(" None found.") 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 return passphrase
def openssl_kdf(passphrase: str, salt: bytes):
def openssl_kdf(passphrase: str, salt: bytes) -> Tuple[bytes, bytes]:
data = passphrase.encode() data = passphrase.encode()
d, d_i = b"", b"" d, d_i = b"", b""
while len(d) < 48: while len(d) < 48:
@@ -41,24 +94,24 @@ def openssl_kdf(passphrase: str, salt: bytes):
d += d_i d += d_i
return d[:32], d[32:48] return d[:32], d[32:48]
def decrypt_dat(dat_path: str, passphrase: str, strip_padding: bool = False):
def decrypt_salted(dat_path: str, passphrase: str, strip_padding: bool = False) -> Optional[str]:
with open(dat_path, "rb") as f: with open(dat_path, "rb") as f:
raw = f.read() raw = f.read()
if raw[:8] != b"Salted__": if raw[:8] != b"Salted__":
print(f" [!] {dat_path} does not have OpenSSL Salted__ header skipping") print(f" {dat_path} does not have an OpenSSL Salted__ header, skipping.")
return None return None
salt = raw[8:16] salt = raw[8:16]
ciphertext = raw[16:] ciphertext = raw[16:]
key, iv = openssl_kdf(passphrase, salt) key, iv = openssl_kdf(passphrase, salt)
cipher = AES.new(key, AES.MODE_CBC, iv) decrypted = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
decrypted = cipher.decrypt(ciphertext)
if strip_padding: if strip_padding:
decrypted = decrypted[:-8] decrypted = decrypted[:-8]
print(f" [*] Stripped last 8 bytes (zgpriv padding)") print(" Stripped last 8 bytes (zgpriv padding).")
pad_len = decrypted[-1] pad_len = decrypted[-1]
if 1 <= pad_len <= 16: if 1 <= pad_len <= 16:
@@ -67,25 +120,165 @@ def decrypt_dat(dat_path: str, passphrase: str, strip_padding: bool = False):
out_path = dat_path.replace(".dat", "_decrypted.bin") out_path = dat_path.replace(".dat", "_decrypted.bin")
with open(out_path, "wb") as f: with open(out_path, "wb") as f:
f.write(decrypted) f.write(decrypted)
print(f" [+] Decrypted -> {out_path} ({len(decrypted)} bytes)") print(f" Decrypted to {out_path} ({len(decrypted)} bytes)")
return out_path 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__": if __name__ == "__main__":
if len(sys.argv) < 2: raise SystemExit(main())
print("Usage: prxtractor.py <libplayready.so.0> [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)