- Multi-stage Dockerfile: discover patterns from PMS binary (capstone), compile .so with zig (musl), layer onto lscr.io/linuxserver/plex - Uses LD_PRELOAD instead of patchelf (which corrupts Plex's musl loader) - Auto-discovery: broad structural patterns with string-anchored fallback (//feature) and relationship-based fallback (BITSET_REF within BS_INIT) - hook.cpp uses __has_include for generated patterns with hardcoded fallbacks - Custom wrapper.sh (no traffic_logger preload) - Vendored Freeloader source (github.com/authrequest/Freeloader, AGPL-3.0) - Removed stale plexmediaserver_crack.so binary - Supports Plex 1.43.3+ (verified against 1.43.2 and 1.43.3)
166 lines
5.1 KiB
Python
166 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Verify that all x86-64 hook signatures in Freeloader/src/hook.cpp match
|
|
the Plex Media Server binary. Exits non-zero if any signature is missing.
|
|
|
|
Usage:
|
|
python3 verify_signatures.py <path-to-Plex Media Server binary>
|
|
|
|
When a signature fails, a partial-match diagnostic is printed to help
|
|
locate the new pattern.
|
|
"""
|
|
import sys
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
|
|
# ── Signature definitions ─────────────────────────────────────────────────
|
|
# These mirror the sig_scan() calls in Freeloader/src/hook.cpp (x86-64 path).
|
|
# Each entry: (step_name, pattern_string, must_match)
|
|
|
|
SIGNATURES = [
|
|
(
|
|
"STEP1 sub_122B2F2 (preference getter)",
|
|
"55 48 89 E5 41 57 41 56 53 48 83 EC 18 48 89 F3 49 89 FE 0F B6 46 17 48 89 F1 84 C0",
|
|
True,
|
|
),
|
|
(
|
|
"STEP3 bitset reference (lea rcx + mov rdx + xchg)",
|
|
"48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08",
|
|
True,
|
|
),
|
|
(
|
|
"STEP3 bs_init (FeatureManager constructor)",
|
|
"55 48 89 E5 41 57 41 56 41 55 41 54 53 48 81 EC ? ? 00 00 49 89 FE 48 8D 9D ? ? ? ? 48 89 DF E8 ? ? ? ? 48 8B 1B 48 85 DB",
|
|
True,
|
|
),
|
|
(
|
|
"STEP4 legacy is_user_feature_set",
|
|
"55 48 89 E5 48 8B 07 48 85 C0 74 09",
|
|
False, # fallback — may not be needed if STEP3 works
|
|
),
|
|
(
|
|
"STEP4 legacy map_find",
|
|
"55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77",
|
|
False, # fallback
|
|
),
|
|
]
|
|
|
|
|
|
def parse_pattern(p):
|
|
out = []
|
|
for tok in p.split():
|
|
if tok in ("??", "?"):
|
|
out.append(None)
|
|
else:
|
|
out.append(int(tok, 16))
|
|
return out
|
|
|
|
|
|
def find_matches(data, pattern):
|
|
matches = []
|
|
plen = len(pattern)
|
|
for i in range(len(data) - plen + 1):
|
|
if all(pb is None or data[i + j] == pb for j, pb in enumerate(pattern)):
|
|
matches.append(i)
|
|
return matches
|
|
|
|
|
|
def partial_match_length(data, offset, pattern):
|
|
"""How many leading bytes of the pattern match at this offset."""
|
|
matched = 0
|
|
for j, pb in enumerate(pattern):
|
|
if offset + j >= len(data):
|
|
break
|
|
if pb is not None and data[offset + j] != pb:
|
|
break
|
|
matched += 1
|
|
return matched
|
|
|
|
|
|
def best_partial_matches(data, pattern, top_n=5):
|
|
"""Find the offsets with the longest leading match of the pattern."""
|
|
scores = []
|
|
plen = len(pattern)
|
|
for i in range(len(data) - min(plen, 8) + 1):
|
|
score = partial_match_length(data, i, pattern)
|
|
if score >= min(8, plen): # at least 8 bytes or full pattern
|
|
scores.append((score, i))
|
|
scores.sort(reverse=True)
|
|
return scores[:top_n]
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 2:
|
|
print(f"Usage: {sys.argv[0]} <path-to-Plex Media Server>", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
pms_path = sys.argv[1]
|
|
|
|
try:
|
|
with open(pms_path, "rb") as f:
|
|
data = f.read()
|
|
except Exception as e:
|
|
print(f"ERROR: cannot read {pms_path}: {e}", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
print(f"Verifying signatures against: {pms_path} ({len(data)} bytes)")
|
|
print()
|
|
|
|
all_required_ok = True
|
|
any_required_missing = False
|
|
|
|
for name, pattern_str, required in SIGNATURES:
|
|
pattern = parse_pattern(pattern_str)
|
|
matches = find_matches(data, pattern)
|
|
|
|
if matches:
|
|
status = "OK"
|
|
detail = f"{len(matches)} match(es)"
|
|
if len(matches) > 1 and required:
|
|
status = "WARN"
|
|
detail = f"{len(matches)} matches (expected 1)"
|
|
print(f" [{status}] {name}: {detail}")
|
|
for m in matches[:3]:
|
|
ctx = data[m : m + min(len(pattern) + 8, 32)].hex(" ")
|
|
print(f" 0x{m:08x}: {ctx}")
|
|
else:
|
|
if required:
|
|
print(f" [FAIL] {name}: NOT FOUND")
|
|
any_required_missing = True
|
|
all_required_ok = False
|
|
|
|
# Print partial matches to help locate the new pattern
|
|
partials = best_partial_matches(data, pattern)
|
|
if partials:
|
|
print(f" Best partial matches (leading bytes):")
|
|
for score, off in partials:
|
|
ctx = data[off : off + min(len(pattern) + 8, 32)].hex(" ")
|
|
print(
|
|
f" 0x{off:08x} ({score}/{len(pattern)} bytes): {ctx}"
|
|
)
|
|
else:
|
|
print(f" No partial matches found (>=8 leading bytes).")
|
|
else:
|
|
print(f" [SKIP] {name}: not found (optional fallback)")
|
|
|
|
print()
|
|
if all_required_ok:
|
|
print("All required signatures matched. Build can proceed.")
|
|
sys.exit(0)
|
|
else:
|
|
print(
|
|
"REQUIRED SIGNATURE(S) MISSING — the hook will not work on this PMS "
|
|
"version.",
|
|
file=sys.stderr,
|
|
)
|
|
print(
|
|
"Update the patterns in Freeloader/src/hook.cpp and rebuild.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|