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()