Replace patchelf crack with Freeloader LD_PRELOAD approach

- 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)
This commit is contained in:
benjamin committed 2026-08-19 22:33:42 +02:00
1 parent 4399a8288d
commit 72f4661bdc
72 files changed
+77927 -17

No files matched your search

+507
View File
@@ -0,0 +1,507 @@
#!/usr/bin/env python3
"""
Auto-discover hook signatures from the Plex Media Server binary.
For each hook target, the script tries these strategies in order:
1. Broad structural pattern (opcodes with displacements/immediates wildcarded)
2. String-anchored discovery: find a key string, find the LEA referencing it,
backtrack to the function prologue, auto-generate a pattern by disassembling
the prologue and wildcarding all displacement/immediate operands via capstone.
3. Relationship-based: search within another discovered function's body.
If all strategies fail, the build fails with diagnostics.
Usage:
python3 discover_patterns.py <PMS binary> -o patterns_generated.h
"""
import sys
import os
import struct
import argparse
try:
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
except ImportError:
print("ERROR: capstone not installed. Run: pip install capstone", file=sys.stderr)
sys.exit(2)
# Capstone operand type constants
CS_OP_REG = 1
CS_OP_IMM = 2
CS_OP_MEM = 3
# Capstone x86 register IDs
X86_REG_RIP = 41
# ── ELF parsing ────────────────────────────────────────────────────────────
def parse_elf_segments(data):
if data[:4] != b'\x7fELF':
raise ValueError("Not an ELF file")
e_phoff = struct.unpack('<Q', data[32:40])[0]
e_phentsize = struct.unpack('<H', data[54:56])[0]
e_phnum = struct.unpack('<H', data[56:58])[0]
segs = []
for i in range(e_phnum):
off = e_phoff + i * e_phentsize
if struct.unpack('<I', data[off:off+4])[0] != 1:
continue
segs.append((
struct.unpack('<Q', data[off+8:off+16])[0], # p_offset
struct.unpack('<Q', data[off+16:off+24])[0], # p_vaddr
struct.unpack('<Q', data[off+32:off+40])[0], # p_filesz
))
return segs
def file_to_vaddr(segments, file_off):
for p_offset, p_vaddr, p_filesz in segments:
if p_offset <= file_off < p_offset + p_filesz:
return file_off - p_offset + p_vaddr
return file_off
def vaddr_to_file(segments, vaddr):
for p_offset, p_vaddr, p_filesz in segments:
if p_vaddr <= vaddr < p_vaddr + p_filesz:
return vaddr - p_vaddr + p_offset
return vaddr
# ── Byte pattern matching ──────────────────────────────────────────────────
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 pattern_to_str(pat):
return ' '.join(f'{b:02X}' if b is not None else '?' for b in pat)
# ── Instruction analysis (capstone) ────────────────────────────────────────
LEGACY_PREFIXES = {0x26, 0x2e, 0x36, 0x3e, 0x64, 0x65, 0x66, 0x67, 0xf0, 0xf2, 0xf3}
def find_modrm_pos(raw, size):
"""Find the ModRM byte position in an x86-64 instruction's raw bytes."""
pos = 0
while pos < size and raw[pos] in LEGACY_PREFIXES:
pos += 1
if pos < size and 0x40 <= raw[pos] <= 0x4f:
pos += 1 # REX
if pos < size and raw[pos] == 0xc5:
pos += 2 # VEX 2-byte
elif pos < size and raw[pos] == 0xc4:
pos += 3 # VEX 3-byte
if pos < size:
if raw[pos] == 0x0f:
pos += 1
if pos < size and raw[pos] in (0x38, 0x3a):
pos += 1
pos += 1 # opcode byte
return pos if pos < size else -1
def wildcard_instruction(insn):
"""Return a list of (byte|None) for an instruction, wildcarding all
displacement and immediate operands. None means wildcard."""
raw = list(insn.bytes)
size = insn.size
wildcard = [False] * size
has_rip_mem = False
has_nonrip_mem_disp = False
mem_disp = 0
has_imm = False
for op in insn.operands:
if op.type == CS_OP_IMM:
has_imm = True
elif op.type == CS_OP_MEM:
if op.mem.base == X86_REG_RIP:
has_rip_mem = True
elif op.mem.disp != 0 and op.mem.base != 0:
has_nonrip_mem_disp = True
mem_disp = op.mem.disp
# Determine immediate total size and how many LOW bytes to wildcard.
# imm_total: full immediate width. imm_wc: how many low bytes to
# wildcard (high bytes kept for specificity, e.g. 0x00 for small frames).
imm_total = 0
imm_wc = 0
if has_imm:
mnem = insn.mnemonic
if mnem in ('call', 'jmp') or mnem.startswith('j'):
imm_total = 4 if size >= 5 else 1
imm_wc = imm_total
elif mnem in ('sub', 'add', 'cmp'):
if size == 4:
imm_total = 1; imm_wc = 1
else:
imm_total = 4; imm_wc = 2 # wildcard low 2, keep high 2 zeros
elif mnem == 'mov':
if size >= 10:
imm_total = 8; imm_wc = 8
elif size >= 7:
imm_total = 4; imm_wc = 4
else:
imm_total = 1; imm_wc = 1
elif mnem == 'push':
imm_total = 1 if size == 2 else 4; imm_wc = imm_total
elif mnem == 'test':
imm_total = 1 if size <= 4 else 4; imm_wc = imm_total
else:
imm_total = min(4, size - 1); imm_wc = imm_total
# Determine displacement size
disp_size = 0
if has_rip_mem:
disp_size = 4
elif has_nonrip_mem_disp:
modrm_pos = find_modrm_pos(raw, size)
if modrm_pos >= 0:
mod_field = (raw[modrm_pos] >> 6) & 3
if mod_field == 1:
disp_size = 1
elif mod_field == 2:
disp_size = 4
if disp_size == 0:
disp_size = 1 if -128 <= mem_disp <= 127 else 4
# Wildcard displacement bytes (immediately before the immediate field)
if disp_size > 0:
disp_start = size - imm_total - disp_size
for i in range(max(0, disp_start), min(size, disp_start + disp_size)):
wildcard[i] = True
# Wildcard the LOW imm_wc bytes of the immediate (keep high bytes)
if imm_total > 0:
imm_start = size - imm_total
for i in range(max(0, imm_start), min(size, imm_start + imm_wc)):
wildcard[i] = True
return [(raw[i] if not wildcard[i] else None) for i in range(size)]
def auto_wildcard_pattern(data, segments, func_file_off, length):
"""Disassemble a function and generate a byte pattern with all
displacement/immediate operands auto-wildcarded."""
md = Cs(CS_ARCH_X86, CS_MODE_64)
md.detail = True
vaddr = file_to_vaddr(segments, func_file_off)
code = data[func_file_off:func_file_off + length + 32]
pattern = []
bytes_consumed = 0
for insn in md.disasm(code, vaddr):
if bytes_consumed >= length:
break
wc_bytes = wildcard_instruction(insn)
for b in wc_bytes:
if bytes_consumed >= length:
break
pattern.append(b)
bytes_consumed += 1
while len(pattern) < length:
pattern.append(None)
return pattern[:length]
# ── Function discovery ─────────────────────────────────────────────────────
def find_string_in_binary(data, search_string):
"""Find all occurrences of a null-terminated string in the binary."""
needle = search_string.encode() + b'\x00'
results = []
start = 0
while True:
idx = data.find(needle, start)
if idx == -1:
break
results.append(idx)
start = idx + 1
return results
def find_lea_refs_to_string(data, segments, string_file_off):
"""Find all LEA reg,[rip+disp32] instructions that reference a string."""
results = []
for p_offset, p_vaddr, p_filesz in segments:
end = min(p_offset + p_filesz, len(data) - 7)
for i in range(p_offset, end):
if data[i] in (0x48, 0x4c) and data[i+1] == 0x8D:
modrm = data[i+2]
if (modrm & 0xC7) == 0x05:
disp = struct.unpack('<i', data[i+3:i+7])[0]
insn_vaddr = file_to_vaddr(segments, i)
target_vaddr = insn_vaddr + 7 + disp
target_file = vaddr_to_file(segments, target_vaddr)
if target_file == string_file_off:
reg = (modrm >> 3) & 7
if data[i] == 0x4c:
reg += 8
results.append((i, reg))
return results
def find_func_start_backwards(data, file_off, max_scan=16384):
"""Scan backwards for a function prologue (55 48 89 E5)."""
for j in range(file_off, max(0, file_off - max_scan), -1):
if data[j:j+4] == b'\x55\x48\x89\xe5':
return j
return None
def find_func_end(data, segments, func_file_off, max_insns=5000):
"""Find the end of a function by scanning for ret/int3 after the prologue."""
md = Cs(CS_ARCH_X86, CS_MODE_64)
vaddr = file_to_vaddr(segments, func_file_off)
code = data[func_file_off:func_file_off + 16384]
count = 0
for insn in md.disasm(code, vaddr):
count += 1
if count > max_insns:
break
if insn.mnemonic in ('ret', 'repret', 'ud2'):
return func_file_off + insn.address - vaddr + insn.size
return func_file_off + 8192
def string_anchored_discovery(data, segments, search_string, pattern_length,
expected_func_start_prefix=None):
"""Discover a function by finding a string reference, then backtracking
to the function prologue. Auto-generates a pattern with wildcarded
displacement/immediate operands."""
string_locations = find_string_in_binary(data, search_string)
if not string_locations:
return None, f"string '{search_string}' not found in binary"
for string_off in string_locations:
lea_refs = find_lea_refs_to_string(data, segments, string_off)
for lea_off, reg in lea_refs:
func_start = find_func_start_backwards(data, lea_off)
if not func_start:
continue
if expected_func_start_prefix:
prefix = data[func_start:func_start + len(expected_func_start_prefix)]
if prefix != expected_func_start_prefix:
continue
pattern = auto_wildcard_pattern(data, segments, func_start, pattern_length)
matches = find_matches(data, pattern)
if matches:
return pattern, f"found via string '{search_string}' -> LEA at 0x{lea_off:08x} -> func at 0x{func_start:08x} ({len(matches)} match(es))"
return None, f"string '{search_string}' found but no enclosing function prologue"
def relationship_based_discovery(data, segments, ref_func_off, search_pattern_str,
search_range=8192):
"""Search within a function's body for a structural pattern."""
pat = parse_pattern(search_pattern_str)
end = min(len(data), ref_func_off + search_range)
matches = []
for i in range(ref_func_off, end - len(pat) + 1):
if all(pb is None or data[i+j] == pb for j, pb in enumerate(pat)):
matches.append(i)
if matches:
return pat, f"found {len(matches)} match(es) within function body"
return None, "pattern not found within function body"
# ── Hook target definitions ────────────────────────────────────────────────
TARGETS = [
{
"name": "PREF_GETTER",
"broad_pattern": "55 48 89 E5 41 57 41 56 53 48 83 EC ? 48 89 F3 49 89 FE 0F B6 46 17 48 89 F1 84 C0",
"string_anchor": None,
"relates_to": None,
"expected_matches": (1, 3),
"required": True,
"length": 26,
},
{
"name": "BITSET_REF",
"broad_pattern": "48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08",
"string_anchor": None,
"relates_to": ("BS_INIT", "48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08"),
"expected_matches": (1, 4),
"required": True,
"length": 18,
},
{
"name": "BS_INIT",
"broad_pattern": "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",
"string_anchor": "//feature",
"string_prefix": b'\x55\x48\x89\xe5\x41\x57\x41\x56\x41\x55\x41\x54\x53',
"relates_to": None,
"expected_matches": (1, 1),
"required": True,
"length": 44,
},
{
"name": "LEGACY_USF",
"broad_pattern": "55 48 89 E5 48 8B 07 48 85 C0 74 09",
"string_anchor": None,
"relates_to": None,
"expected_matches": (1, 5),
"required": False,
"length": 12,
},
{
"name": "LEGACY_MF",
"broad_pattern": "55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77",
"string_anchor": None,
"relates_to": None,
"expected_matches": (1, 3),
"required": False,
"length": 18,
},
]
def generate_header(patterns, output_path):
with open(output_path, 'w') as f:
f.write("#pragma once\n")
f.write("// Auto-generated by discover_patterns.py — DO NOT EDIT.\n")
f.write("// Hook signatures discovered from the PMS binary at build time.\n\n")
for name, pattern_str, match_count, method in patterns:
f.write(f'// {name}: {match_count} match(es) — {method}\n')
f.write(f'static const char* PATTERN_{name} = "{pattern_str}";\n\n')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('pms_path')
parser.add_argument('-o', '--output', default='patterns_generated.h')
args = parser.parse_args()
with open(args.pms_path, 'rb') as f:
data = f.read()
segments = parse_elf_segments(data)
print(f"Loaded {args.pms_path} ({len(data)} bytes, {len(segments)} LOAD segments)")
results = []
discovered_func_offsets = {}
all_ok = True
# Pass 1: discover targets via broad pattern or string-anchored (independent)
# Pass 2: discover relationship-dependent targets using results from pass 1
pending = []
for target in TARGETS:
name = target['name']
broad = parse_pattern(target['broad_pattern'])
lo, hi = target['expected_matches']
# Strategy 1: broad structural pattern
matches = find_matches(data, broad)
if lo <= len(matches) <= hi:
print(f" [OK] {name}: broad pattern ({len(matches)} matches)")
for m in matches[:3]:
print(f" 0x{m:08x}: {data[m:m+min(len(broad)+8,32)].hex(' ')}")
results.append((name, pattern_to_str(broad), len(matches), f"broad pattern ({len(matches)} matches)"))
discovered_func_offsets[name] = matches[0] if matches else None
continue
# Strategy 2: string-anchored discovery
if target.get('string_anchor'):
pattern, detail = string_anchored_discovery(
data, segments, target['string_anchor'],
target['length'], target.get('string_prefix'))
if pattern:
matches = find_matches(data, pattern)
if lo <= len(matches) <= hi:
print(f" [OK] {name}: string-anchored ({len(matches)} matches)")
print(f" {detail}")
for m in matches[:3]:
print(f" 0x{m:08x}: {data[m:m+min(len(pattern)+8,32)].hex(' ')}")
results.append((name, pattern_to_str(pattern), len(matches), f"string-anchored: {detail}"))
discovered_func_offsets[name] = matches[0] if matches else None
continue
# Defer relationship-based to pass 2
if target.get('relates_to'):
pending.append(target)
elif target['required']:
print(f" [FAIL] {name}: all strategies failed")
print(f" Broad pattern: {pattern_to_str(broad)}")
if target.get('string_anchor'):
print(f" String anchor: '{target['string_anchor']}'")
all_ok = False
results.append((name, pattern_to_str(broad), 0, "FAILED"))
else:
print(f" [SKIP] {name}: not found (optional)")
results.append((name, pattern_to_str(broad), 0, "skipped (optional)"))
# Pass 2: relationship-based discovery (depends on pass 1 results)
for target in pending:
name = target['name']
broad = parse_pattern(target['broad_pattern'])
lo, hi = target['expected_matches']
ref_name, rel_pattern = target['relates_to']
ref_off = discovered_func_offsets.get(ref_name)
if ref_off:
# Search within the referenced function's body
func_end = find_func_end(data, segments, ref_off)
pattern, detail = relationship_based_discovery(
data, segments, ref_off, rel_pattern, search_range=func_end - ref_off + 256)
if pattern:
matches = find_matches(data, pattern)
if lo <= len(matches) <= hi:
print(f" [OK] {name}: relationship ({ref_name}) ({len(matches)} matches)")
print(f" {detail}")
for m in matches[:3]:
print(f" 0x{m:08x}: {data[m:m+min(len(pattern)+8,32)].hex(' ')}")
results.append((name, pattern_to_str(pattern), len(matches), f"relationship ({ref_name}): {detail}"))
discovered_func_offsets[name] = matches[0] if matches else None
continue
# Relationship failed — try broad as last resort
matches = find_matches(data, broad)
if lo <= len(matches) <= hi:
print(f" [OK] {name}: broad pattern ({len(matches)} matches) [fallback]")
results.append((name, pattern_to_str(broad), len(matches), f"broad pattern fallback ({len(matches)} matches)"))
discovered_func_offsets[name] = matches[0] if matches else None
continue
if target['required']:
print(f" [FAIL] {name}: all strategies failed (broad + string + relationship)")
all_ok = False
results.append((name, pattern_to_str(broad), 0, "FAILED"))
else:
print(f" [SKIP] {name}: not found (optional)")
results.append((name, pattern_to_str(broad), 0, "skipped (optional)"))
if all_ok:
generate_header(results, args.output)
print(f"\nAll required patterns discovered. Header written to {args.output}")
sys.exit(0)
else:
print("\nFAILED: one or more required patterns not found.", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
+165
View File
@@ -0,0 +1,165 @@
#!/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()