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

+3
View File
@@ -0,0 +1,3 @@
Freeloader/.git
Freeloader/.gitignore
.git
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+81 -17
View File
@@ -1,21 +1,85 @@
FROM lscr.io/linuxserver/plex:latest # syntax=docker/dockerfile:1.7
#
# Multi-stage build with automatic signature discovery:
# 1. base — PMS base image (source of the binary to analyze)
# 2. discover — auto-discover hook patterns from the PMS binary
# 3. builder — cross-compile the .so with zig (musl) + generated patterns
# 4. runtime — layer onto lscr.io/linuxserver/plex with LD_PRELOAD wrapper
#
# Based on https://github.com/authrequest/Freeloader (AGPL-3.0-or-later).
# Install patchelf ARG PLEX_BASE_IMAGE=lscr.io/linuxserver/plex:latest
RUN apt-get update && \
apt-get install -y patchelf && \
rm -rf /var/lib/apt/lists/*
# COPY plexmediaserver_crack.so /config/plexmediaserver_crack.so # ── Stage 1: base (PMS image, used as source for discovery) ──────────────
# RUN chmod 644 /config/plexmediaserver_crack.so FROM ${PLEX_BASE_IMAGE} AS base
# Download the Plex crack # ── Stage 2: discover hook patterns from the PMS binary ─────────────────
RUN wget -O /tmp/plexmediaserver_crack.so \ # Auto-discovers byte patterns by analyzing the PMS binary. If a pattern
https://gitgud.io/yuv420p10le/plexmediaserver_crack/-/raw/master/binaries/plexmediaserver_crack.so && \ # can't be found, the build fails here — before compiling or shipping.
cp /tmp/plexmediaserver_crack.so /config/plexmediaserver_crack.so && \ FROM debian:bookworm-slim AS discover
chmod 644 /config/plexmediaserver_crack.so RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip \
&& pip3 install --break-system-packages capstone \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/discover_patterns.py /tmp/discover_patterns.py
COPY --from=base /usr/lib/plexmediaserver/ /tmp/plex/
RUN python3 /tmp/discover_patterns.py "/tmp/plex/Plex Media Server" \
-o /tmp/patterns_generated.h \
&& cat /tmp/patterns_generated.h
# Apply the crack using patchelf # ── Stage 3: build the musl .so ───────────────────────────────────────────
RUN PLEX_DIR=/usr/lib/plexmediaserver && \ FROM debian:bookworm-slim AS builder
ln -sf /config/plexmediaserver_crack.so $PLEX_DIR/lib/plexmediaserver_crack.so && \
patchelf --remove-needed plexmediaserver_crack.so $PLEX_DIR/lib/libsoci_core.so || true && \ ARG ZIG_VERSION=0.13.0
patchelf --add-needed plexmediaserver_crack.so $PLEX_DIR/lib/libsoci_core.so ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN mkdir -p /src/toolchain \
&& curl -fsSL \
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
-o /tmp/zig.tar.xz \
&& tar -C /src/toolchain --strip-components=1 -xf /tmp/zig.tar.xz \
&& rm /tmp/zig.tar.xz
ENV PATH="/src/toolchain:${PATH}"
COPY Freeloader/build.sh ./
COPY Freeloader/src ./src
COPY Freeloader/third_party ./third_party
COPY --from=discover /tmp/patterns_generated.h ./src/patterns_generated.h
RUN bash build.sh
# ── Stage 4: runtime -- patch lscr.io/linuxserver/plex ────────────────────
FROM ${PLEX_BASE_IMAGE} AS runtime
ARG PLEX_BASE_IMAGE
ARG PATCH_VERSION=dev
LABEL org.opencontainers.image.title="plexmediaserver-crack (linuxserver)" \
org.opencontainers.image.source="https://github.com/authrequest/Freeloader" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
plex_patch.base="${PLEX_BASE_IMAGE}" \
plex_patch.version="${PATCH_VERSION}"
RUN set -eux; \
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
PMS_LIB="/usr/lib/plexmediaserver/lib"; \
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
[ -x "${PMS}" ] || { echo "patcher: missing ${PMS} in ${PLEX_BASE_IMAGE}"; exit 1; }; \
[ -d "${PMS_LIB}" ] || { echo "patcher: missing ${PMS_LIB}/ in ${PLEX_BASE_IMAGE}"; exit 1; }; \
[ -f "${RUN_SCRIPT}" ] || { echo "patcher: missing ${RUN_SCRIPT} in ${PLEX_BASE_IMAGE}"; exit 1; }
COPY --from=builder /src/build/plexmediaserver_crack.so \
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
RUN chmod 0644 /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
COPY wrapper.sh /usr/lib/plexmediaserver/plex-crack-wrapper.sh
RUN chmod 0755 /usr/lib/plexmediaserver/plex-crack-wrapper.sh
RUN set -eux; \
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
cp "${RUN_SCRIPT}" "${RUN_SCRIPT}.orig"; \
printf '#!/usr/bin/with-contenv bash\nexec s6-setuidgid abc /usr/lib/plexmediaserver/plex-crack-wrapper.sh\n' \
> "${RUN_SCRIPT}"; \
chmod 0755 "${RUN_SCRIPT}"
+76
View File
@@ -0,0 +1,76 @@
# .dockerignore — keep the Docker build context small.
#
# Both docker/Dockerfile.* only need: build.sh, src/, third_party/.
# Everything else is excluded so the daemon doesn't ship big/unrelated
# files into the buildkit context.
# VCS / secrets
.git
.gitattributes
.mcp.json
.env
.env.*
# Build outputs and toolchain
build/
Build/
build-*/
dist/
toolchain/
compile_commands.json
# Compiled artifacts
*.o
*.obj
*.lo
*.a
*.so
*.so.*
*.dll
*.dylib
*.exe
*.out
# Plex binaries / IDA DBs
libsoci_core.so
Plex_Media_Server
*.i64
*.idb
*.id0
*.id1
*.id2
*.nam
*.til
# Subprojects not part of the build
windows/
plex_relay/
experimental/
scripts/
docs/
# Docs and meta
AGENTS.md
README.md
LICENSE
.gitattributes
# Python cruft
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
.cache/
*.tmp
*.log
*.bak
*.swp
+9
View File
@@ -0,0 +1,9 @@
# Linux-targeted project: keep everything LF in the repo and on checkout.
# The /bin/sh + python launchers MUST stay LF or they break on the Plex host.
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.cpp text eol=lf
*.hpp text eol=lf
*.c text eol=lf
*.h text eol=lf
+87
View File
@@ -0,0 +1,87 @@
# ─── Secrets / local machine config (NEVER COMMIT) ───────────────────────
# .mcp.json here held a plaintext SSH password — keep all local creds out.
.mcp.json
.env
.env.*
*.pem
*.key
*_rsa
*_rsa.pub
id_ed25519*
id_rsa*
# ─── Copyrighted Plex binaries (do NOT redistribute) ─────────────────────
/Plex_Media_Server
/Plex Media Server
pms.bin
libsoci_core.so
# ─── IDA Pro databases (large; derived from the copyrighted binary) ──────
*.i64
*.idb
*.id0
*.id1
*.id2
*.nam
*.til
# ─── Compiled output (from github/gitignore: C++) ────────────────────────
*.o
*.obj
*.lo
*.slo
*.gch
*.pch
*.d
*.a
*.la
*.lai
*.lib
*.so
*.so.*
*.dylib
*.dll
*.out
*.app
*.exe
# ─── Build dirs / generated ───────────────────────────────────────────────
build/
Build/
build-*/
dist/
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
install_manifest.txt
compile_commands.json
# ─── Toolchain auto-downloaded by build.sh ────────────────────────────────
toolchain/
# ─── Python (plex_relay / scripts/plex-tailnet) ───────────────────────────
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
.tox/
# ─── Temp / logs ──────────────────────────────────────────────────────────
*.tmp
*.log
*.bak
*.swp
*~
.cache/
# ─── OS / editor cruft ────────────────────────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
.idea/
.vscode/
+144
View File
@@ -0,0 +1,144 @@
# AGENTS.md - Plex_Patch
## Project overview
Reverse-engineering notes and a runtime patch for **Plex Media Server** on
**Linux x86-64** (and **ARM64/aarch64**). It hooks Plex's
`FeatureManager` and forces every feature bit on, unlocking gated features.
Educational / RE use on software you run yourself; it ships no Plex code and
bypasses no account/server authentication.
## Target & runtime reality (important)
- **Target:** the main `Plex Media Server` executable (PIE). The feature machinery
moved here from `libsoci_core.so` on post-2024/08/13 builds; `libsoci_core.so`
is no longer the target.
- **Plex runs against its OWN bundled musl libc + libgcompat**
(`/usr/lib/plexmediaserver/lib/`), NOT the host glibc. This drives the build and
injection choices below.
- **ARM64:** confirmed working on aarch64-linux-musl. The ARM64 binary has the
same musl constraint; zig cross-compilation targets `aarch64-linux-musl`.
## Architecture
### x86-64
- **Language:** C++20, plus the vendored Zydis C amalgamation for instruction
decoding.
- **Module discovery:** `dl_iterate_phdr` used in early versions; current code
uses `/proc/self/maps` (the constructor runs before the main PIE is loaded by
musl, so `dl_iterate_phdr` returns 0 callbacks).
- **Hook:** `sig_scan()` finds target functions by byte-pattern signature;
`create_hook()` installs a 14-byte `jmp [rip+0x06]` trampoline (Zydis decodes
the prologue so relocated bytes stay valid).
- **Feature unlock effect:** after Plex applies its MyPlex feature list, the
hook forces all 14 `g_feature_bitset_slots` qwords on (`std::bitset<896>`),
so every feature (including Plex Pass, code 92, slot 11) reads as enabled.
- **Webhook unlock:** an additional hook targets `sub_122B2F2` (the generic
preference getter at `0x122B2F2`). When the key is `"WebHooksEnabled"`, it
returns `true` regardless of the actual persisted value, enabling Plex's
built-in webhook dispatch (play/pause/stop/rate events). This is a separate
mechanism from the feature bitset — `WebHooksEnabled` is a plain boolean
preference, not a feature bit. Both dispatch functions (`sub_125A6D4` @
`0x125A6D4` and `sub_125B766` @ `0x125B766`) check only this preference
with no secondary feature-bit gate.
### ARM64 / aarch64
- **Language:** C++20. Zydis is NOT used on ARM64 (fixed 4-byte instructions,
no length decoding needed). `Zydis.h` is conditionally excluded via
`#ifndef __aarch64__`.
- **Module discovery:** same `/proc/self/maps` parser (`get_dottext_info()`)
works identically on ARM64.
- **Hook:** `create_hook_arm64()` installs a 16-byte
`LDR X17, [PC, #8]; BR X17; <8-byte target>` trampoline. No Zydis needed.
The trampoline copies 4 original instructions (16 bytes) and appends the
same LDR+BR jump-back sequence.
- **Feature unlock effect:** same bitset-force approach — hook the
FeatureManager constructor via ARM64 signature, then force all 14 uint64_t
slots to `UINT64_MAX`.
- **Webhook unlock:** currently targets preference init functions (SSO-check
prologue patterns like `ldrb w?, [x?, #0x17]`). The ARM64 callbacks are
generic feature-return-true for now; WebHooksEnabled-specific key matching
(like the x86-64 sub_122B2F2 hook) is pending identification of the exact
ARM64 preference getter function.
- **Analysis tooling:** `get_arm64_sigs.py` uses Capstone to extract ARM64
function signatures with ADRP page counting; `check_webhooks.py` dumps
ADRP+ADD string loads inside candidate functions; `disasm_final.py` /
`disasm_key_areas.py` are earlier (broken) Capstone analysis scripts.
### ARM64 function signatures (from get_arm64_sigs.py)
| VAddr | Signature | Notes |
|-------|-----------|-------|
| `0x10dd904` | `FD 7B BA A9 FC 6F 01 A9 FA 67 02 A9 F8 5F 03 A9 F6 57 04 A9 F4 4F 05 A9 FD 03 00 91 FF 43 09 D1` | Preference init function — 6 stp pairs (save 12 regs), sub sp,#0x250. The function at this address loads "WebHooksEnabled" string via ADRP+ADD at 0x10e0eac. |
| `0x658120` | `FD 7B BD A9 F5 0B 00 F9 F4 4F 02 A9 FD 03 00 91 F3 03 01 AA F4 03 00 AA 61 00 80 52 E0 03 13 AA` | FeatureManager class function — saves 2 regs, calls with x0/x1, loads FeatureManager strings from page 0x1B7000. |
| `0x658070` | `FD 7B 03 A9 F4 4F 04 A9 FD C3 00 91` | FeatureManager constructor-like — stp x29,x30,[sp,#-0x30]! ; stp x20,x19,[sp,#0x10] ; mov x29,sp. Used as catch-all FeatureManager hook target. |
| `0xeba0b4` | `FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39 09 04 40 F9 F3 03 00 AA 0A 1D 00 13 5F 01 00 71` | Feature-check function — stp x29,x30,[sp,#-0x10]! ; str x19,[sp,#8] ; mov x29,sp ; ldrb w8,[x0,#0x17] (SSO check). 3 ADRP refs to page 0x1BD000 (hasPlexPass strings). |
| `0x5e4188` | `FD 7B 01 A9 FD 43 00 91 A8 65 00 B0 08 85 42 F9 49 66 00 F0 ...` | hasPlexPass checking function — 3 ADRP refs to page 0x1BD000. |
| `0x5e42c0` | `FD 7B BE A9 F4 4F 01 A9 FD 03 00 91 74 66 00 90 88 82 46 39 ...` | hasPlexPass checking function — 3 ADRP refs to page 0x1BD000. |
| `0x5e4368` | `FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 73 66 00 90 68 A2 47 39 ...` | hasPlexPass checking function — 3 ADRP refs to page 0x1BD000. |
### ARM64 binary layout (from `_Plex Media Server`, text section at file offset 0x5d35bc)
| Page | Contains | Notable Strings |
|------|----------|-----------------|
| `0x1B7000` | FeatureManager strings, preference init strings | `"FeatureManager"`, `"FeatureManager: Using cached data"` (@ 0x1B72AB), `"hasPlexPass"` (@ 0x1B749B) |
| `0x1BD000` | Feature checking strings | `"playing"`, `"paused"`, `"buffering"`, `"media_css_min_assets_cache_ms"` |
| `0x363000` | Webhook/web-related strings | `"WebHooksEnabled"` (@ 0x363871) |
### ARM64 text section
- Text section: vaddr=0x5e35bc, file_offset=0x5d35bc, size=0xc05964 (~12MB)
- Built for aarch64 Linux (little-endian), PIE position-independent
- All functions use ARM64 standard prologue: `stp x29, x30, [sp, #-N]!`
- String references via ADRP+ADD pairs (PC-relative page + 12-bit offset)
- No fixed function addresses when PIE-loaded; all discovery via sig_scan
## Build & inject (details in README.md / docs/BUILD.md)
- **Build with musl** via `zig` (`-target x86_64-linux-musl`): `bash build.sh`.
For ARM64: `bash build.sh --arm64`.
A glibc build cannot relocate glibc-only symbols (`__isoc23_strtol`,
`arc4random`, `*_chk`, `_dl_find_object`) in Plex's musl runtime → exit 127.
- **Inject with `LD_PRELOAD`** via `scripts/plex-crack-wrapper.sh` + a systemd
## Key files
- `src/hook.cpp` / `src/hook.hpp` — hook engine, feature logic, feature-UUID catalog
- `src/main.cpp` — library constructor (`unsetenv("LD_PRELOAD")` then `hook()`)
- `build.sh` — musl build via zig (auto-downloaded) with an ABI sanity gate
- `scripts/plex-crack-wrapper.sh` — `LD_PRELOAD` launcher scoped to the PMS process
- `scripts/readbitset.py` — live feature-bitset verifier
- `third_party/zydis/` — vendored Zydis disassembler (MIT)
- `get_arm64_sigs.py` — Capstone-based ARM64 function signature extractor with ADRP page counting
- `check_webhooks.py` — dump ADRP+ADD string loads within candidate ARM64 functions
- `experimental/debug_hook.c` — standalone alternate hook (legacy `is_feature_available` signature)
RE artifacts (the `Plex Media Server` binary, `libsoci_core.so`, and `*.i64` IDA
databases) are git-ignored and not redistributed.
## Signature patterns
Hex bytes with `?` wildcards; spaces ignored (`?` = one-byte wildcard). Patterns
are version-specific — re-verify after PMS updates.
### x86-64 signatures
| Target | Address | Signature | Notes |
|--------|---------|-----------|-------|
| `bitset_init` (modern path constructor) | dynamic | `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` | `FeatureManager_apply_feature_list_xml` — post-2024/08/13 |
| `sub_122B2F2` (preference getter) | `0x122B2F2` | `48 89 F3 4C 89 F7 0F B6 46 17 48 89 F1 84 C0` | `mov rbx, rsi; mov r14, rdi; movzx eax,[rsi+0x17]` — std::string SSO check prologue |
| `is_user_feature_set` (legacy) | dynamic | `55 48 89 E5 48 8B 07 48 85 C0 74 09` | Pre-2024/08/13 fallback |
| `is_feature_available` (legacy) | dynamic | `E8 ? ? ? ? 86 43` (call rel32 + `test al, byte ptr [rbx+3]`) | Rel32 followed, pre-2024/08/13 fallback |
| `map_find` (legacy) | dynamic | `55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77` | Pre-2024/08/13 fallback |
### ARM64 signatures (built with `bash build.sh --arm64`)
| Target | Signature | Notes |
|--------|-----------|-------|
| `FeatureManager_init` (bitset constructor) | `FD 7B 03 A9 F4 4F 04 A9 FD C3 00 91` | stp x29,x30,[sp,#-0x30]! ; stp x20,x19,[sp,#0x10] ; mov x29,sp. Catches FeatureManager init to force bits on. |
| `FeatureManager_class_method` (alternative) | `FD 7B BD A9 F5 0B 00 F9 F4 4F 02 A9 FD 03 00 91 F3 03 01 AA F4 03 00 AA 61 00 80 52 E0 03 13 AA` | Saves 1 reg pair + str ; handles x0/x1 args. |
| `feature_check_sso` (feature checker) | `FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39` | stp x29,x30,[sp,#-0x10]! ; str x19,[sp,#8] ; mov x29,sp ; ldrb w8,[x0,#0x17]. Hooked to always return true. |
| `pref_init` (preference init) | `FD 7B BA A9 FC 6F 01 A9 FA 67 02 A9 F8 5F 03 A9 F6 57 04 A9 F4 4F 05 A9 FD 03 00 91 FF 43 09 D1` | 6 stp pairs, sub sp,#0x250. Loads "WebHooksEnabled" string. WebHooksEnabled-specific hook pending. |
| `pref_getter_sso` (generic getter, placeholder) | `FD 7B ?? A9 ?? ?? ?? A9 FD 03 00 91 ?? 5C 40 39 ??` | Broad pattern: prologue + SSO check on any x-reg. May match the preference getter. |
+365
View File
@@ -0,0 +1,365 @@
# FEEDBACK.md — load this at the start of every session
> **Read this file at the start of every session on this project.**
> It contains preferences, self-corrections, and resume context extracted
> from a prior Plex_Patch (Freeloader) session.
>
> **Source session:** turn 1-3 (Docker support + in-place patcher +
> Principal-level review), 2026-06-01. Reviewed by the user.
>
> **If the file gets long, trim it.** Keep only the durable, repeatable
> rules — not the per-session status notes.
---
## 1 · User preferences (extracted from how they actually work)
- **Hands-off, trusting style.** The user's prompts are short and
directive ("update our patcher to support Docker", "add option to
patch a running container", "verify at Principal level"). They
expect me to figure out the *how* once they give the *what*.
- **One-shot prompts, not iterative.** Each user turn is a complete
scope expansion. They do not iterate on micro-decisions.
- **Trusts my decisions.** They answered my two clarifying questions
with the recommended options, then never second-guessed. Don't
re-ask what I can decide.
- **Values quality over speed.** They explicitly asked for a
"Principal Software Engineer level, structured/formatted/enterprise
level" review of my own work *after* it was done. Match that
quality bar from the start.
- **Wants resumability.** They asked "What did we do so far?" mid-
session. They value the ability to context-switch.
- **Wants the safety valve.** "Continue if you have next steps, or
stop and ask for clarification if you are unsure how to proceed."
This is a good model — finish the work but stop when genuinely
blocked.
- **Wants me to do the prep.** "Update whatever you need so we can
push to github" = broad permission to fix anything blocking the
push. Do the audit, do the fixes, then commit and push.
- **Likes tabular structured output.** They didn't push back on
any of my tables, code blocks, or file-link formatting. Keep
using `| col | col |` tables and `file:///` links for file refs.
## 2 · Commit style for this repo (Freeloader / Plex_Patch)
Style observed in `git log` — match it:
```
Add top-level Windows patching doc index
Add Windows x64 godmode DLL + injector
Add relay reimplementation + remote-access tooling; label Remote Watch Pass
Add GNU AGPL-3.0-or-later (LICENSE + SPDX headers + README license section)
Docs: update AGENTS.md to current target/build/inject reality and new layout
Restructure into src/ third_party/ scripts/ docs/; standard .gitignore; drop stale duplicates and orphaned CM...
```
Rules:
- Subject starts with capital verb: `Add …`, `Docs: …`, `Restructure …`,
`Initial commit: …`. No `feat:` / `fix:` / `chore:` conventional-commits
prefixes.
- Subject ≤ 72 chars, concise.
- Body (when present) explains *what* + *why* and any non-obvious
decisions. Multiple paragraphs separated by blank lines are fine.
- One atomic commit per feature, not per file.
## 3 · Things I should do differently next time (self-corrections)
These are the actual mistakes I made this session:
1. **Don't claim behavior I haven't verified.** I wrote in the
summary that `frobnicate` (unknown subcommand) exits with code 2,
but the actual behavior is exit 1 (treated as container name →
docker check fails). Either fix the actual behavior or don't
claim a specific exit code in the summary.
2. **Don't fire background agents I won't engage with.** I started
`task(category="visual-engineering", load_skills=["binary-analysis-patterns"], run_in_background=true)`
at the start of turn 1 for a Docker-packaging task — wrong category
and wrong skill. The agent was never used. Either use the
background agent or don't start it. Background agents are
expensive; do not start speculatively.
3. **Don't re-read files I just wrote.** I read
`docker/plex-docker-patch.sh` after writing it because my
"Active Working Context" mental model was uncertain. Trust the
context I have. Re-read only if I've genuinely lost track (e.g.,
after a compaction or long turn gap).
4. **Keep summaries concise and resume-actionable.** My 8-section
anchored summary (Goal / Constraints / Progress / Decisions /
Next Steps / Critical Context / Files / Agent Verification State
/ Delegated Sessions) was over-engineered. The user wanted
"what did we do so far", not a meta-analysis of my own process.
Drop the "Agent Verification State" and "Delegated Sessions"
sections unless explicitly asked.
5. **Don't include "Active Working Context" sections in summaries.**
They are a mental model, not the actual file content. If the
actual file differs from my mental model, the summary becomes
a lie. Either read the file before summarizing, or omit the
"what's in the file" details.
6. **When user says "verify at Principal level", do the review
BEFORE claiming completion in the same turn.** I finished the
implementation, then ran the review in a later turn. Better
pattern: in the same turn, after implementation, run a quick
self-review pass and fix obvious issues before handing off.
Saves a turn.
7. **When in-place rewrites are large (e.g., 337 → 552 lines),
state the scope at the top of the summary.** "Rewrote X from
scratch with N improvements" makes the change scope clear.
8. **When the user says "update whatever you need so we can push
to github", do the pre-push audit as a checklist:**
- No secrets in any new file (`git grep -nE 'password|api[_-]?key|token|secret|bearer'`)
- No active git hooks (`.git/hooks/` all `.sample`?)
- `.gitignore` covers all sensitive patterns?
- Git identity set?
- `core.fileMode` and `core.autocrlf` consistent with repo?
- All changes intentional (`git status` matches plan)?
Then commit + push. Don't ask for re-confirmation.
9. **Use `bash -n` + smoke tests in PARALLEL, not sequentially.**
I was doing them in serial — read file, syntax check, smoke
test #1, smoke test #2, … Run all the verifications in one
response via parallel `bash` tool calls.
10. **PowerShell on Windows — common gotchas to remember:**
- `head` is not a valid command. Use `Get-Content -TotalCount N`
or `Select-Object -First N`. Or just don't truncate in the
bash command.
- `$?` is a **boolean** (success/failure), not an exit code.
Use `$LASTEXITCODE` for the numeric exit code.
- `ls -la` is not valid. Use `Get-ChildItem -LiteralPath X`.
- Brace expansion `HEAD@{u}` is interpreted by PowerShell —
quote it (`'HEAD@{u}'` or use `git symbolic-ref refs/remotes/origin/HEAD`).
- `bash -n C:\path\file` fails on Windows paths. Use a
relative path with the `workdir` parameter.
## 4 · Patterns that worked (keep doing these)
- **Arg-parser with single `case` loop, mutual-exclusion checks
at the end.** Cleaner than scattered checks per-flag.
- **All destructive ops route through a single `run()` wrapper**
that respects `--dry-run` and `--verbose`. Single place to see
side effects. The pattern from `plex-docker-patch.sh` is good —
reuse it for future scripts.
- **Container shell commands use `docker exec sh -c '...' _ "${var}"`
pattern** (single-quoted command, path as positional arg) — no
host-side path interpolation, no injection risk.
- **Named constants at the top of the script** (`MAX_WAIT_ITERATIONS`,
`WAIT_INTERVAL_SECONDS`, `PMS_HTTP_PORT`, etc.) — easier to tune,
easier to read.
- **Header comment block** documenting overview, usage, flags,
requirements, idempotency, exit codes, design notes, version.
This is the "enterprise README inline" pattern.
- **Idempotency statement in the header.** "install: safe to
re-run. The .orig is preserved across re-installs, the .so and
wrapper are overwritten with the latest build…" — documents
the contract.
- **Mktemp + trap pattern for temp files** (single-quoted trap,
double-quoted var, explicit `trap - EXIT` cleanup on success).
- **Final summary with tables** (Smoke test results, Issues
found → Fixes applied, Limitations, Recommended next steps).
The user read and engaged with this format.
- **Pre-existing failures explicitly noted.** "Done. Note: N
pre-existing errors unrelated to my changes." — distinguishes
my work from prior state.
## 5 · Project context (resume faster next time)
- **Project:** Plex_Patch (fork: **Freeloader**).
- **Remote:** `https://github.com/authrequest/Freeloader.git`.
- **Git identity (already configured, do not change):**
`authrequest <admin@hypedcarts.com>`.
- **`core.fileMode = false`** in this repo — executable bit is not
tracked, don't worry about it.
- **`core.autocrlf = true`** — Windows line endings are normal.
- **Target:** Plex Media Server on Linux x86_64.
- **Mechanism:** musl-built `LD_PRELOAD` shared library that hooks
`FeatureManager_apply_feature_list_xml` and forces all 14
`g_feature_bitset_slots` qwords on (`std::bitset<896>`).
- **Build:** zig 0.13.0 cross-compile to `x86_64-linux-musl`.
`bash build.sh` from the project root. Build artifact:
`build/plexmediaserver_crack.so`.
- **Injection (native):** `LD_PRELOAD` via `scripts/plex-crack-wrapper.sh`
+ systemd drop-in. **Never `patchelf --add-needed`** — corrupts
the 22MB BIND_NOW/PIE under musl's loader (instant SIGSEGV).
- **Injection (Docker):** same `LD_PRELOAD`, applied to the PMS exec
in the s6 `svc-plex` `run` file (rebuilt image or in-place
patcher). The .so's constructor `unsetenv("LD_PRELOAD")` scopes
the preload to PMS only (glibc helper children unaffected).
- **Languages:** C++20, bash, Python (plex_relay / plex-tailnet).
- **Vendored:** Zydis disassembler in `third_party/zydis/` (MIT).
- **Layout:**
- `src/` — `main.cpp` (constructor), `hook.cpp` / `hook.hpp`
(signature scan, Zydis-disassembled trampoline)
- `build.sh` — musl build + ABI sanity gate
- `scripts/` — `plex-crack-wrapper.sh` (native systemd),
`readbitset.py` (live verifier), `plex-tailnet/`
- `docker/` — `Dockerfile.plexinc`, `Dockerfile.linuxserver`,
`wrapper.sh`, `plex-docker-patch.sh`, `README.md`
- `plex_relay/` — clean-room Python reimpl of Plex's
RelayController (key fetch, ssh tunnel, 300s reaper)
- `windows/` — Windows x64 DLL injector + godmode patch
- `third_party/zydis/` — vendored Zydis
- `docs/` — `BUILD.md`, `DOCKER.md`, `WINDOWS.md`
- `AGENTS.md` — architecture / RE notes
- `experimental/debug_hook.c` — legacy alternate hook
- `LICENSE` — AGPL-3.0-or-later
- **Git-ignored:** `Plex Media Server` binary, `libsoci_core.so`,
`*.i64` / `*.idb` IDA DBs, `build/`, `toolchain/`, `.mcp.json`,
`.env*`, `*.pem`, `*.key`, `id_*` SSH keys.
- **Docker images patched (turn 1):**
- `plexinc/pms-docker` — official
- `lscr.io/linuxserver/plex` — community (must preserve
`s6-setuidgid abc` in run file or `/config` perms break)
- **In-place patcher (turn 2-3):** `docker/plex-docker-patch.sh`
v1.0.0 with install/uninstall/status subcommands + full flag
surface. See the file's header for the design notes.
- **Limitations:** x86_64 only (no arm64 PMS Docker image today).
LSIO requires preserving `s6-setuidgid abc`. Plex bundles its
own musl libc + libgcompat — glibc `.so` cannot be loaded.
## 6 · Templates (re-use these)
### Pre-push audit checklist
```
[ ] git status — all changes intentional, no unexpected files
[ ] git diff --stat — sizes look right
[ ] git grep -nE 'password|api[_-]?key|token|secret|bearer' -- <paths>
[ ] .git/hooks/ — all *.sample, no active hooks
[ ] git config --get user.{name,email} — set
[ ] git remote -v — right remote
[ ] core.fileMode / core.autocrlf — match repo
[ ] bash -n <shell scripts> — passes
[ ] commit message — matches repo style (capital verb, no conventional prefix)
```
### Bash script header template (from plex-docker-patch.sh)
```bash
#!/usr/bin/env bash
# <path>
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# <one-line purpose>
#
# ── Overview ──────────────────────────────────────────────────────
# <how it works>
#
# ── Usage ─────────────────────────────────────────────────────────
# <script> [flags] <subcommand> [args]
#
# Subcommands:
# <subcmd> [args] <purpose>
#
# Flags:
# --flag <purpose>
#
# ── Requirements ──────────────────────────────────────────────────
# - <req 1>
#
# ── Idempotency ──────────────────────────────────────────────────
# <what's safe to re-run, what's not>
#
# ── Exit codes ───────────────────────────────────────────────────
# 0 success
# 1 runtime error
# 2 usage error
#
# ── Design notes ─────────────────────────────────────────────────
# - <key design decision 1>
# - <key design decision 2>
#
# ── Version ──────────────────────────────────────────────────────
SCRIPT_VERSION='X.Y.Z'
set -euo pipefail
[[ -n "${DEBUG:-}" ]] && set -x
```
### Arg-parser template
```bash
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
subcmd1|subcmd2)
if [ -n "${SUBCOMMAND}" ]; then
die "subcommand already specified: ${SUBCOMMAND}"
fi
SUBCOMMAND="$1"; shift
;;
help|-h|--help) print_usage; exit 0 ;;
--flag) [ "$#" -ge 2 ] || die "--flag requires an argument"
VAR="$2"; shift 2 ;;
--flag=*) VAR="${1#--flag=}"; shift ;;
--bool-flag) BOOL=true; shift ;;
--) shift; break ;;
-*) die "unknown flag: $1 (try --help)" ;;
*)
if [ -z "${POSITIONAL}" ]; then
POSITIONAL="$1"
else
die "unexpected positional: $1"
fi
shift
;;
esac
done
# Defaults.
SUBCOMMAND="${SUBCOMMAND:-default}"
POSITIONAL="${POSITIONAL:-default-positional}"
# Mutual-exclusion.
if [ "${FLAG_A}" = "true" ] && [ "${FLAG_B}" = "true" ]; then
die "--flag-a and --flag-b are mutually exclusive"
fi
}
```
### `run()` wrapper template (for --dry-run / --verbose)
```bash
run() {
if [ "${DRY_RUN}" = "true" ]; then
local arg
printf '[dry-run]'
for arg in "$@"; do printf ' %s' "${arg}"; done
printf '\n' >&2
else
[ "${VERBOSE}" = "true" ] && printf '+ %s\n' "$*" >&2
"$@"
fi
}
```
---
## 7 · Anti-patterns I should remember to avoid
- **String interpolation in `docker exec sh -c "..."` patterns.**
Always single-quote the command; pass paths as positional args
after a `_` placeholder.
- **`ps -ef | grep X | grep -v grep` for PID lookup.** Can match
the scanning command itself. Use `/proc/*/comm` scan instead.
- **`docker ps -a | grep -qx NAME` for container existence.** Use
`docker inspect NAME` (canonical, race-free).
- **`mktemp -t prefix` for portability.** BSD vs GNU semantics
differ. Use plain `mktemp` (no template).
- **Unquoted `trap 'rm -f $var' EXIT`.** Empty `$var` → `rm -f`
runs in CWD. Always single-quote the trap, double-quote the var.
- **Heredoc-over-`docker exec` for writing files.** Quoting hell,
injection risk. Build the file locally with `printf`, then
`docker cp`.
- **Bash arrays in `for X in ${arr}` (unquoted).** Always
`for X in "${arr[@]}"`.
- **Deleting failing tests to "pass".** Detect the real bug.
- **`as any` / `@ts-ignore` / empty `catch {}`.** Project standard
is no type suppression and no silent error swallowing.
- **Amending a commit that was rejected by hooks.** Always create
a new commit; never `--amend` a failed commit.
---
**Last updated:** 2026-06-01 (turn 1-3 review session on Plex_Patch / Freeloader).
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+145
View File
@@ -0,0 +1,145 @@
# Plex_Patch
Reverse-engineering notes and tooling for **Plex Media Server** on **Linux
x86-64** — covering both *feature unlocking* and *remote access*.
> ⚠️ **Disclaimer** — For educational and reverse-engineering purposes, on
> software you legally run yourself. Nothing here bypasses account or server
> authentication, and **no Plex code** is included or redistributed. If you rely
> on Plex, buy a Plex Pass — it funds the developers. Use at your own risk; no
> warranty.
## What's here
| # | Component | Path | Summary |
|---|-----------|------|---------|
| 1 | **Feature-unlock patch** | `src/`, `build.sh` | `LD_PRELOAD` shared library: forces every `FeatureManager` bit on **and** adds webhook CRUD via socket interception |
| 2 | **Relay RE + model** | `plex_relay/` | Reverse-engineered, runnable reimplementation of Plex's `RelayController` |
| 3 | **Remote access (no patch)** | `scripts/plex-tailnet/` | Reach your server over Tailscale/Headscale instead of Plex Relay |
| 4 | **Docker support** | `docker/`, [`docs/DOCKER.md`](docs/DOCKER.md) | Patched `plexinc/pms-docker` / `lscr.io/linuxserver/plex` images (multi-stage build) **and** in-place patcher for a running container (`plex-docker-patch.sh`) |
Each subsystem has its own README; this page is the map.
---
## 1 · Feature-unlock patch
Plex's feature gates read a single in-memory table, `g_feature_bitset_slots`
(14 × `uint64`), populated from the MyPlex feature list. A feature with internal
code `C` is "available" iff `slots[C >> 3] & (1 << (C & 7))`. The patch (`src/`)
is a small shared library whose constructor finds
`FeatureManager_apply_feature_list_xml`, installs a trampoline, and forces all 14
slots to `0xFF…FF` after Plex applies its feature list — so every feature
(including Plex Pass, code 92) reads as enabled.
Two non-obvious requirements make or break this on a real install:
1. **Build against musl, not glibc.** Plex bundles its own musl libc + libgcompat
(`/usr/lib/plexmediaserver/lib/`). A glibc-built `.so` fails to relocate
glibc-only symbols and Plex exits 127. The build uses `zig` to target
`x86_64-linux-musl`.
2. **Inject with `LD_PRELOAD`, never `patchelf`.** `patchelf --add-needed`
corrupts the PIE under musl's loader (instant SIGSEGV). A tiny launcher sets
`LD_PRELOAD` only for the Plex `exec`, and the library `unsetenv`s it so
Plex's glibc helper children are unaffected.
```bash
bash build.sh # -> build/plexmediaserver_crack.so (musl); prints install steps
```
**Webhook socket interceptor.** The same `.so` also hooks POSIX socket functions
(`read`, `recvfrom`, `sendmsg`) to intercept `/api/v2/user/webhooks` HTTP requests
made by the Plex Web client to the local server. Instead of returning PMS's native
404 (the endpoint only exists on plex.tv, not locally), the hook serves a complete
webhook CRUD API backed by a JSON file at `/var/lib/plexmediaserver/webhooks.json`:
- `GET /api/v2/user/webhooks` — list all webhooks
- `POST /api/v2/user/webhooks` — add webhook(s) from `urls[]=` form body
- `PUT /api/v2/user/webhooks/:id` — update a webhook
- `DELETE /api/v2/user/webhooks/:id` — delete a webhook
- `OPTIONS` — CORS preflight
After every mutating operation, the hook calls into Plex's in-process
`WebhookManager` to refresh the dispatch vector, so changes take effect without
a server restart. The webhook file path can be overridden with the
`PLEX_WEBHOOKS_FILE` environment variable. The Plex Web bundle also needs a
one-time static patch so its JavaScript talks to `window.location.origin`
instead of the Plex cloud API — see [`AGENTS.md`](AGENTS.md) for details.
Full build / install / uninstall guide: **[`docs/BUILD.md`](docs/BUILD.md)**.
## 2 · Plex Relay — `plex_relay/`
A study of how Plex makes a server reachable when no direct connection exists: it
opens a **reverse SSH tunnel to a Plex-operated relay host**. `plex_relay/` is a
clean-room, dependency-free Python reimplementation of the `RelayController`
translation unit (key fetch + 24h cache, `relayHostKey.txt` pinning, the ssh
tunnel, the 300s reaper), with a typed error model, injected I/O seams, and a
full test suite. See **[`plex_relay/README.md`](plex_relay/README.md)**.
## 3 · Remote access without patching — `scripts/plex-tailnet/`
The pragmatic alternative to both Plex Relay and patching: put the server and its
viewers on a **Tailscale/Headscale mesh VPN** and let Plex publish the tailnet
address. Includes an idempotent setup script (security questionnaire, firewall
lockdown, health check), an optional self-hosted Headscale installer, and a
shared shell library. See **[`scripts/plex-tailnet/README.md`](scripts/plex-tailnet/README.md)**.
## 4 · Docker support — `docker/`
Same `LD_PRELOAD`-on-the-PMS-exec patch, packaged for the two popular Plex
container images. Two flows are supported:
- **Rebuild a patched image** — multi-stage Dockerfiles (`Dockerfile.plexinc`,
`Dockerfile.linuxserver`) build the musl `.so` with `zig`, layer it onto
the upstream image, and replace the s6 `svc-plex` `run` file. Best for
repeat deploys and CI/CD.
- **Patch a running container in place** — `plex-docker-patch.sh` modifies
the live container's filesystem (`.so`, wrapper, s6 `run` file) and
restarts it. No image rebuild, original image untouched, fully
revertible. Best for one-off patching of a container you don't want
to touch.
The wrapper sets `LD_PRELOAD` *last* and the `.so`'s constructor `unsetenv`s
it, so glibc helper children (Tuner, Script Host, transcoders) are unaffected.
See **[`docker/README.md`](docker/README.md)** and the full guide
**[`docs/DOCKER.md`](docs/DOCKER.md)**.
---
## Repository layout
| Path | What |
|------|------|
| `src/hook.cpp` · `hook.hpp` | hooking engine: `dl_iterate_phdr` discovery, signature scan, trampoline, feature logic, feature-UUID catalog |
| `src/main.cpp` | library constructor (`unsetenv` + `hook()`) |
| `src/webhook_handler.cpp` · `webhook_handler.hpp` | socket-level HTTP interceptor: hooks `read`/`recvfrom`/`sendmsg` to intercept `/api/v2/user/webhooks` and serve local CRUD from a JSON file |
| `build.sh` | musl build via `zig` (auto-downloaded) with an ABI sanity gate |
| `scripts/plex-crack-wrapper.sh` | systemd `ExecStart` launcher scoping `LD_PRELOAD` to the Plex process |
| `scripts/readbitset.py` | verifier: dumps the live feature bitset from a running PMS |
| `scripts/plex-tailnet/` | Tailscale/Headscale remote-access setup (see its README) |
| `plex_relay/` | Python reimplementation of Plex's `RelayController` (see its README) |
| `windows/` | Windows x64 DLL injector + godmode patch (see its README) |
| `docker/` | patched `plexinc/pms-docker` + `lscr.io/linuxserver/plex` images + in-place patcher for running containers (see its README) |
| `third_party/zydis/` | vendored [Zydis](https://github.com/zyantific/zydis) disassembler (MIT) |
| `docs/BUILD.md` | native Linux build / install / uninstall guide |
| `docs/DOCKER.md` | Docker build / run / verify / uninstall / troubleshooting guide |
| `docs/WINDOWS.md` | top-level Windows x64 patching/build index |
| `experimental/debug_hook.c` | standalone alternate hook (legacy signature) |
| `AGENTS.md` | architecture / RE notes |
## Not in this repo (by design)
The copyrighted Plex binaries (`Plex Media Server`, `libsoci_core.so`), the IDA
Pro databases (`*.i64`, `*.id0`, …), the auto-downloaded `toolchain/`, and any
local machine config (`.mcp.json`, keys, `.env`) are intentionally
**git-ignored** — they are large, sensitive, or not ours to distribute. Point
your own analysis tools at your own Plex install.
## License
[GNU AGPL-3.0-or-later](LICENSE) © the Plex_Patch authors. Each source file
carries an `SPDX-License-Identifier: AGPL-3.0-or-later` tag.
The vendored Zydis disassembler in `third_party/zydis/` is **MIT**-licensed (see
`third_party/zydis/README.md`); its terms are preserved and unaffected.
+182
View File
@@ -0,0 +1,182 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Build plexmediaserver_crack.so for Plex Media Server on Linux.
#
# IMPORTANT: Plex ships and runs against its OWN bundled musl libc + libgcompat
# (see /usr/lib/plexmediaserver/lib/{libc.so,ld-musl-x86_64.so.1,libgcompat.so.0}).
# A glibc-built .so will NOT load into Plex -- the dynamic loader fails to
# relocate glibc-only symbols (__isoc23_strtol, arc4random, *_chk, _dl_find_object)
# and Plex exits 127. We therefore cross-compile against musl with zig, which
# bundles musl for clean cross-compilation.
#
# IMPORTANT: The .so must NOT statically link libc++ or libc++abi. Plex's
# runtime uses GCC's libstdc++ for C++ exception handling. If our .so defines
# __cxa_throw/__cxa_begin_catch/__gxx_personality_v0 (from libc++abi), they
# override libstdc++'s versions via LD_PRELOAD, breaking boost::filesystem
# exception handling and crashing Plex. We use -nostdlib++ and strip all C++
# runtime usage from the source to avoid this entirely.
#
# Injection is done with LD_PRELOAD (NOT patchelf): patchelf rewrites the 22MB
# BIND_NOW/PIE binary's program headers in a way musl's loader cannot tolerate,
# which corrupts the executable (instant SIGSEGV on start). See install notes
# printed at the end.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
# Parse command-line arguments.
ARM64=false
while [[ $# -gt 0 ]]; do
case "$1" in
-a|--arm64) ARM64=true; shift ;;
-h|--help)
echo "Usage: $0 [-a|--arm64]"
echo " -a, --arm64 Build for aarch64-linux-musl (ARM64, e.g. UDM Pro)"
echo " (default: x86_64-linux-musl)"
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
ZIG_VERSION="0.13.0"
if [ "$ARM64" = true ]; then
TARGET="aarch64-linux-musl"
OUT="build/plexmediaserver_crack_arm64.so"
echo "=== Building for ARM64 (aarch64-linux-musl) ==="
else
TARGET="x86_64-linux-musl"
OUT="build/plexmediaserver_crack.so"
echo "=== Building for x86_64 (x86_64-linux-musl) ==="
fi
echo "=== Plex Media Server Crack - Linux (musl) Build ==="
# Resolve a zig toolchain: $ZIG override, then PATH, then a local download.
if [ -n "${ZIG:-}" ] && [ -x "${ZIG}" ]; then
:
elif command -v zig &> /dev/null; then
ZIG="$(command -v zig)"
else
ZIG_DIR="toolchain/zig-linux-x86_64-${ZIG_VERSION}"
if [ ! -x "${ZIG_DIR}/zig" ]; then
echo "zig not found; downloading ${ZIG_VERSION}..."
mkdir -p toolchain
curl -fL --connect-timeout 20 \
-o "toolchain/zig.tar.xz" \
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz"
tar -C toolchain -xf "toolchain/zig.tar.xz"
fi
ZIG="${ZIG_DIR}/zig"
fi
echo "Using zig: ${ZIG} ($("${ZIG}" version))"
# Required sources.
REQUIRED_FILES="src/hook.cpp src/hook.hpp src/main.cpp src/webhook_handler.hpp src/webhook_handler.cpp src/traffic_logger.hpp src/traffic_logger.cpp"
if [ "$ARM64" = false ]; then
REQUIRED_FILES="$REQUIRED_FILES third_party/zydis/Zydis.c third_party/zydis/Zydis.h"
fi
for f in $REQUIRED_FILES; do
[ -f "$f" ] || { echo "ERROR: missing $f"; exit 1; }
done
# ARM64 doesn't use Zydis (fixed 4-byte instructions, no decoding needed).
if [ "$ARM64" = true ]; then
CFLAGS=(-target "${TARGET}" -O2 -fPIC -I src)
CXXFLAGS=(-target "${TARGET}" -std=c++20 -O2 -fPIC -fno-exceptions -fno-rtti -nostdlib++ -I src)
else
CFLAGS=(-target "${TARGET}" -O2 -fPIC -I src -I third_party/zydis)
CXXFLAGS=(-target "${TARGET}" -std=c++20 -O2 -fPIC -fno-exceptions -fno-rtti -nostdlib++ -I src -I third_party/zydis)
fi
mkdir -p build
if [ "$ARM64" = false ]; then
echo "=== compiling Zydis.c (C) ==="
"${ZIG}" cc "${CFLAGS[@]}" -c third_party/zydis/Zydis.c -o build/Zydis.o
fi
echo "=== compiling hook.cpp (C++) ==="
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/hook.cpp -o build/hook.o
echo "=== compiling main.cpp (C++) ==="
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/main.cpp -o build/main.o
echo "=== compiling webhook_handler.cpp (C++) ==="
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/webhook_handler.cpp -o build/webhook_handler.o
echo "=== linking ${OUT} ==="
if [ "$ARM64" = true ]; then
"${ZIG}" c++ -target "${TARGET}" -nostdlib++ -shared -o "${OUT}" build/main.o build/hook.o build/webhook_handler.o
else
"${ZIG}" c++ -target "${TARGET}" -nostdlib++ -shared -o "${OUT}" build/main.o build/hook.o build/webhook_handler.o build/Zydis.o
fi
rm -f build/Zydis.o build/hook.o build/main.o build/webhook_handler.o
# ── Traffic logger (socket-hooking LD_PRELOAD library) ──────────────────────
TRF_OUT="build/plexmediaserver_traffic_logger.so"
echo "=== compiling traffic_logger.cpp (C++) ==="
"${ZIG}" c++ "${CXXFLAGS[@]}" -c src/traffic_logger.cpp -o build/traffic_logger.o
echo "=== linking ${TRF_OUT} ==="
"${ZIG}" c++ -target "${TARGET}" -nostdlib++ -shared -o "${TRF_OUT}" build/traffic_logger.o
rm -f build/traffic_logger.o
echo "=== $("${ZIG}" size "${TRF_OUT}" 2>/dev/null || stat -c %s "${TRF_OUT}") ==="
for lib in "${OUT}" "${TRF_OUT}"; do
if [ -f "${lib}" ]; then
echo ""
echo "=== ABI sanity check: ${lib} ==="
if readelf --dyn-syms "${lib}" | grep -E "UND .*(__isoc23_|_chk$|arc4random|_dl_find_object)" ; then
echo "ERROR: glibc-only symbols present in ${lib} -- this will not load into Plex."
exit 1
fi
echo "OK: only musl libc symbols are referenced."
echo "NEEDED: $(readelf -d "${lib}" | awk '/NEEDED/{print $5}' | tr -d '[]' | tr '\n' ' ')"
echo "=== $(stat -c %s "${lib}") bytes ==="
fi
done
if [ "$ARM64" = true ]; then
cat <<'EOF'
Install (ARM64 / UDM Pro -- via SSH):
1. Copy the artifacts to the UDM Pro:
scp build/plexmediaserver_crack_arm64.so \
root@udmpro:/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
scp scripts/plex-crack-wrapper.sh \
root@udmpro:/usr/local/bin/plex-crack-wrapper.sh
2. SSH into UDM Pro and set permissions:
ssh root@udmpro
chmod 755 /usr/local/bin/plex-crack-wrapper.sh
mkdir -p /etc/systemd/system/plexmediaserver.service.d
printf '[Service]\nExecStart=\nExecStart=/usr/local/bin/plex-crack-wrapper.sh\n' \
> /etc/systemd/system/plexmediaserver.service.d/override.conf
systemctl daemon-reload
systemctl restart plexmediaserver
EOF
else
cat <<'EOF'
Install (proper method -- LD_PRELOAD, no patchelf):
1. Copy the artifacts to the Plex host:
build/plexmediaserver_crack.so -> /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
scripts/plex-crack-wrapper.sh -> /usr/local/bin/ (chmod 755)
2. Add a systemd drop-in that swaps ExecStart for the wrapper (the wrapper sets
LD_PRELOAD *after* /bin/sh starts, so only the musl Plex process is preloaded
and glibc helper children are unaffected):
mkdir -p /etc/systemd/system/plexmediaserver.service.d
printf '[Service]\nExecStart=\nExecStart=/usr/local/bin/plex-crack-wrapper.sh\n' \
> /etc/systemd/system/plexmediaserver.service.d/override.conf
3. Apply:
systemctl daemon-reload
systemctl restart plexmediaserver
To uninstall: remove the drop-in (and rm the .so), then daemon-reload + restart.
Do NOT use `patchelf --add-needed` on the Plex binary -- it corrupts it under
musl's loader. If a previous attempt did, restore with:
apt-get install --reinstall plexmediaserver # or dpkg -i the matching .deb
EOF
fi
+116
View File
@@ -0,0 +1,116 @@
# syntax=docker/dockerfile:1.7
#
# Patch lscr.io/linuxserver/plex with the feature-unlock shared library.
#
# docker build -f docker/Dockerfile.linuxserver -t plex-crack:lsio .
# docker run -d --name plex --network=host \
# -e PUID=$(id -u) -e PGID=$(id -g) \
# -v /srv/plex/config:/config -v /srv/plex/data:/data \
# plex-crack:lsio
#
# Two stages (mirrors Dockerfile.plexinc):
# 1. builder -- zig cross-compile plexmediaserver_crack.so (musl)
# 2. runtime -- layer onto lscr.io/linuxserver/plex + override the s6 plex
# service so PMS is exec'd (as user 'abc' via s6-setuidgid,
# preserving LSIO's permission model) with LD_PRELOAD.
#
# Why s6-setuidgid is preserved: LSIO's run file drops to the 'abc' user
# (uid 911) before exec'ing the PMS binary. If we don't keep that, PMS
# would run as root, which (a) breaks LSIO's permission model, (b) prevents
# writes to /config and /data which are chowned to abc on first start,
# (c) makes the container harder to use (root-owned media library).
# ── Build args (global -- visible to every FROM) ──────────────────────────
ARG PLEX_BASE_IMAGE=lscr.io/linuxserver/plex:latest
# ── Stage 1: build the musl .so (identical to plexinc) ────────────────────
FROM debian:bookworm-slim AS builder
ARG ZIG_VERSION=0.13.0
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
RUN mkdir -p /src/toolchain \
&& curl -fsSL \
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
-o /tmp/zig.tar.xz \
&& tar -C /src/toolchain --strip-components=1 -xf /tmp/zig.tar.xz \
&& rm /tmp/zig.tar.xz
ENV PATH="/src/toolchain:${PATH}"
COPY build.sh ./
COPY src ./src
COPY third_party ./third_party
RUN bash build.sh
# ── Stage 2: runtime -- patch lscr.io/linuxserver/plex ────────────────────
FROM ${PLEX_BASE_IMAGE} AS runtime
ARG PLEX_BASE_IMAGE
ARG PATCH_VERSION=dev
LABEL org.opencontainers.image.title="plexmediaserver-crack (linuxserver)" \
org.opencontainers.image.source="https://github.com/authrequest/Freeloader" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
plex_patch.base="${PLEX_BASE_IMAGE}" \
plex_patch.version="${PATCH_VERSION}"
# Sanity: refuse to build on an unfamiliar upstream layout.
RUN set -eux; \
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
PMS_LIB="/usr/lib/plexmediaserver/lib"; \
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
[ -x "${PMS}" ] || { echo "patcher: missing ${PMS} in ${PLEX_BASE_IMAGE}"; exit 1; }; \
[ -d "${PMS_LIB}" ] || { echo "patcher: missing ${PMS_LIB}/ in ${PLEX_BASE_IMAGE}"; exit 1; }; \
[ -f "${RUN_SCRIPT}" ] || { echo "patcher: missing ${RUN_SCRIPT} in ${PLEX_BASE_IMAGE}"; exit 1; }
COPY --from=builder /src/build/plexmediaserver_crack.so \
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
COPY --from=builder /src/build/plexmediaserver_traffic_logger.so \
/usr/lib/plexmediaserver/lib/plexmediaserver_traffic_logger.so
COPY docker/wrapper.sh /usr/lib/plexmediaserver/plex-crack-wrapper.sh
RUN chmod 0755 /usr/lib/plexmediaserver/plex-crack-wrapper.sh
# Override the s6 plex service run file. We preserve LSIO's s6-setuidgid
# abc so PMS still runs as user 'abc' (uid 911) -- otherwise /config and
# /data would be created as root and break the LSIO permission model.
RUN set -eux; \
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
cp "${RUN_SCRIPT}" "${RUN_SCRIPT}.orig"; \
printf '#!/usr/bin/with-contenv bash\nexec s6-setuidgid abc /usr/lib/plexmediaserver/plex-crack-wrapper.sh\n' \
> "${RUN_SCRIPT}"; \
chmod 0755 "${RUN_SCRIPT}"
# ── Patch the PMS binary in-place to disable transcode session limits ──────
# Patches 6 conditional-jump instructions so the limit-checking code always
# takes the normal (no-error) path. See AGENTS.md for full RE notes.
RUN set -eux; \
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
\
# Read N bytes at a file offset as hex string (no separator). \
rd() { dd if="${PMS}" bs=1 skip="$1" count="$2" 2>/dev/null | od -A n -t x1 | tr -d ' \n'; }; \
\
# sanity: verify expected bytes at each patch site (guards against version drift) \
echo "=== verifying patch-site bytes ==="; \
[ "$(rd 18624607 2)" = "7e0c" ] || { echo "sanity FAIL at 0x11C305F"; exit 1; }; \
[ "$(rd 18624781 2)" = "7e08" ] || { echo "sanity FAIL at 0x11C310D"; exit 1; }; \
[ "$(rd 18624793 2)" = "7e08" ] || { echo "sanity FAIL at 0x11C3119"; exit 1; }; \
[ "$(rd 18624674 6)" = "0f8fc2f6ffff" ] || { echo "sanity FAIL at 0x11C30A2"; exit 1; }; \
[ "$(rd 18624682 6)" = "0f8f1ef7ffff" ] || { echo "sanity FAIL at 0x11C30AA"; exit 1; }; \
[ "$(rd 18624630 6)" = "0f8f9f000000" ] || { echo "sanity FAIL at 0x11C3076"; exit 1; }; \
echo "sanity: expected bytes match, applying patches"; \
\
# apply patches \
# jle -> jmp (2-byte: \353=0xEB \14=0x0C \10=0x08) \
printf '\353\014' | dd of="${PMS}" bs=1 seek=18624607 conv=notrunc 2>/dev/null; \
printf '\353\010' | dd of="${PMS}" bs=1 seek=18624781 conv=notrunc 2>/dev/null; \
printf '\353\010' | dd of="${PMS}" bs=1 seek=18624793 conv=notrunc 2>/dev/null; \
# jg -> NOP (6-byte: \220=0x90) \
printf '\220\220\220\220\220\220' | dd of="${PMS}" bs=1 seek=18624674 conv=notrunc 2>/dev/null; \
printf '\220\220\220\220\220\220' | dd of="${PMS}" bs=1 seek=18624682 conv=notrunc 2>/dev/null; \
printf '\220\220\220\220\220\220' | dd of="${PMS}" bs=1 seek=18624630 conv=notrunc 2>/dev/null; \
\
echo "binary patch applied: transcode session limits disabled"
+91
View File
@@ -0,0 +1,91 @@
# syntax=docker/dockerfile:1.7
#
# Patch plexinc/pms-docker with the feature-unlock shared library.
#
# docker build -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
# docker run -d --name plex --network=host \
# -v /srv/plex/config:/config -v /srv/plex/data:/data \
# plex-crack:plexinc
#
# Two stages:
# 1. builder -- zig 0.13.0 cross-compile plexmediaserver_crack.so (musl)
# 2. runtime -- layer it onto plexinc/pms-docker + override the s6 plex
# service so PMS is exec'd with LD_PRELOAD=...crack.so.
# The .so's constructor (src/main.cpp) calls unsetenv, so
# PMS's glibc helper children (Tuner, Script Host) are
# unaffected.
#
# Patch invariants are enforced at build time (RUN sanity): the upstream
# layout must match what the wrapper assumes. If plexinc/pms-docker
# restructures, the build fails here rather than the container failing
# mysteriously at runtime.
# ── Build args (global -- visible to every FROM) ──────────────────────────
ARG PLEX_BASE_IMAGE=plexinc/pms-docker:latest
# ── Stage 1: build the musl .so ────────────────────────────────────────────
FROM debian:bookworm-slim AS builder
ARG ZIG_VERSION=0.13.0
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Toolchain layer (cached across source-only changes).
RUN mkdir -p /src/toolchain \
&& curl -fsSL \
"https://ziglang.org/download/${ZIG_VERSION}/zig-linux-x86_64-${ZIG_VERSION}.tar.xz" \
-o /tmp/zig.tar.xz \
&& tar -C /src/toolchain --strip-components=1 -xf /tmp/zig.tar.xz \
&& rm /tmp/zig.tar.xz
ENV PATH="/src/toolchain:${PATH}"
# Build sources. The .dockerignore at the repo root whitelists these.
COPY build.sh ./
COPY src ./src
COPY third_party ./third_party
RUN bash build.sh
# build.sh writes /src/build/plexmediaserver_crack.so (musl).
# ── Stage 2: runtime -- patch plexinc/pms-docker ──────────────────────────
FROM ${PLEX_BASE_IMAGE} AS runtime
ARG PLEX_BASE_IMAGE
ARG PATCH_VERSION=dev
LABEL org.opencontainers.image.title="plexmediaserver-crack (plexinc)" \
org.opencontainers.image.source="https://github.com/authrequest/Freeloader" \
org.opencontainers.image.licenses="AGPL-3.0-or-later" \
plex_patch.base="${PLEX_BASE_IMAGE}" \
plex_patch.version="${PATCH_VERSION}"
# Sanity: refuse to build on an unfamiliar upstream layout.
RUN set -eux; \
PMS="/usr/lib/plexmediaserver/Plex Media Server"; \
PMS_LIB="/usr/lib/plexmediaserver/lib"; \
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
[ -x "${PMS}" ] || { echo "patcher: missing ${PMS} in ${PLEX_BASE_IMAGE}"; exit 1; }; \
[ -d "${PMS_LIB}" ] || { echo "patcher: missing ${PMS_LIB}/ in ${PLEX_BASE_IMAGE}"; exit 1; }; \
[ -f "${RUN_SCRIPT}" ] || { echo "patcher: missing ${RUN_SCRIPT} in ${PLEX_BASE_IMAGE}"; exit 1; }
# Drop the .so and the in-container launcher.
COPY --from=builder /src/build/plexmediaserver_crack.so \
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
COPY --from=builder /src/build/plexmediaserver_traffic_logger.so \
/usr/lib/plexmediaserver/lib/plexmediaserver_traffic_logger.so
COPY docker/wrapper.sh /usr/lib/plexmediaserver/plex-crack-wrapper.sh
RUN chmod 0755 /usr/lib/plexmediaserver/plex-crack-wrapper.sh
# Override the s6 plex service run file. The original is kept as .orig for
# forensics / downgrade (rebuild against the unpatched image to revert).
# plexinc's upstream service already runs as the 'plex' user, so we do not
# add s6-setuidgid here -- LSIO is the image that needs it (see its Dockerfile).
RUN set -eux; \
RUN_SCRIPT="/etc/s6-overlay/s6-rc.d/svc-plex/run"; \
cp "${RUN_SCRIPT}" "${RUN_SCRIPT}.orig"; \
printf '#!/usr/bin/with-contenv bash\nexec /usr/lib/plexmediaserver/plex-crack-wrapper.sh\n' \
> "${RUN_SCRIPT}"; \
chmod 0755 "${RUN_SCRIPT}"
+113
View File
@@ -0,0 +1,113 @@
# Docker support for plexmediaserver_crack
Patches Plex Media Server running in Docker — both the official
[`plexinc/pms-docker`](https://hub.docker.com/r/plexinc/pms-docker) image
and the community [`lscr.io/linuxserver/plex`](https://hub.docker.com/r/linuxserver/plex)
image — using the same `LD_PRELOAD`-on-the-PMS-exec mechanism as the native
systemd install. See the top-level [README](../README.md) and the full guide
[`docs/DOCKER.md`](../docs/DOCKER.md) for the why and the troubleshooting.
## Build
From the project root:
```bash
# Official image (plexinc/pms-docker)
docker build -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
# Community image (lscr.io/linuxserver/plex)
docker build -f docker/Dockerfile.linuxserver -t plex-crack:lsio .
```
The first build downloads `zig 0.13.0` and the chosen Plex base image.
Subsequent builds reuse cached layers until `src/`, `third_party/`, or
`build.sh` change. Pin the base with `--build-arg PLEX_BASE_IMAGE=...` if
you need reproducibility across PMS updates.
## Run
```bash
# plexinc (no PUID/PGID; the image runs PMS as the upstream 'plex' user)
docker run -d --name plex --network=host \
-v /srv/plex/config:/config \
-v /srv/plex/data:/data \
plex-crack:plexinc
# linuxserver (honor PUID/PGID so /config and /data chown correctly on first start)
docker run -d --name plex --network=host \
-e PUID=$(id -u) -e PGID=$(id -g) \
-e TZ=America/Los_Angeles \
-v /srv/plex/config:/config \
-v /srv/plex/data:/data \
plex-crack:lsio
```
Then:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:32400/identity # -> 200
```
## Patch in place (no rebuild)
If you already have a Plex container running and don't want to rebuild
the image or recreate the container, `plex-docker-patch.sh` applies the
same patch to a live container — no `docker build` needed, original
image untouched, original container + its volumes preserved. Revertible
via `uninstall` (a `.orig` copy of the s6 `run` file is kept).
```bash
# Default container name: "plex"
./docker/plex-docker-patch.sh install
# Custom container name
./docker/plex-docker-patch.sh install my-plex
# Revert (restores the s6 run file from its .orig)
./docker/plex-docker-patch.sh uninstall my-plex
# Status
./docker/plex-docker-patch.sh status my-plex
```
The script auto-detects the base image (plexinc vs LSIO) by reading the
s6 `run` file content inside the container, so the same `.so` and
`wrapper.sh` are used in both cases. Zig must be available on the host
(the script invokes `build.sh`); a pre-existing
`build/plexmediaserver_crack.so` is reused.
### When to use which
- **Dockerfile build** (the `docker build` flow above) — best for
repeat deploys, multi-host, CI/CD, immutable images. You commit a
patched image and ship it.
- **`plex-docker-patch.sh`** — best for one-off patching of a running
container you don't want to touch. Modifies the live container's
filesystem; fully revertible via `uninstall`.
## What's where
| File | Purpose |
|------|---------|
| `Dockerfile.plexinc` | Multi-stage build → patched `plexinc/pms-docker` |
| `Dockerfile.linuxserver` | Multi-stage build → patched `lscr.io/linuxserver/plex` |
| `wrapper.sh` | In-container launcher (env → `LD_PRELOAD` last → `exec` PMS) |
| `plex-docker-patch.sh` | In-place patcher for a running container (`install` / `uninstall` / `status`) |
For docker-compose, signature drift, verification with
`scripts/readbitset.py`, uninstall, and troubleshooting, see
[`../docs/DOCKER.md`](../docs/DOCKER.md).
### Quick troubleshooting (in-place patcher)
| Symptom | Likely cause | First check |
|---|---|---|
| `install` says `ERROR: docker not on PATH` | docker CLI not installed or user not in `docker` group | `docker version` (must run as you) |
| `install` says `could not find s6 svc-plex run file` | upstream image changed its s6 layout | `docker exec <name> ls -la /etc/s6-overlay/s6-rc.d/svc-plex/ /etc/services.d/plex/` — open an issue with output |
| `install` succeeds but `.so is NOT in /proc/$PID/maps` | PMS exited 127 (loader failure) | `docker logs <name> \| tail -50` — usually a glibc `.so` got in (rebuild with `build.sh`) |
| `status` shows `PATCH IS NOT ACTIVE` after `install` | run file wasn't rewritten (e.g., readonly layer) or container wasn't restarted | `docker exec <name> cat /etc/s6-overlay/s6-rc.d/svc-plex/run` — should print `exec .../plex-crack-wrapper.sh` |
| `uninstall` says `no run.orig found` | `.orig` was deleted, or the run file was never backed up (e.g., you ran an older version of the script) | restore manually: `docker cp <upstream-image>:/etc/s6-overlay/s6-rc.d/svc-plex/run <name>:/etc/s6-overlay/s6-rc.d/svc-plex/run` |
For deeper diagnostics (PMS exit 127, glibc vs musl ABI, LSIO `/config`
ownership, signature drift on PMS updates), see
[`../docs/DOCKER.md`](../docs/DOCKER.md#troubleshooting).
+551
View File
@@ -0,0 +1,551 @@
#!/usr/bin/env bash
# docker/plex-docker-patch.sh
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# Patch a running Plex Media Server container in place.
#
# ── Overview ──────────────────────────────────────────────────────────────
# Same LD_PRELOAD mechanism as the Dockerfile-based approach: the s6
# `svc-plex` `run` file is rewritten to exec the in-container wrapper,
# which sets LD_PRELOAD last and execs the PMS binary. The .so's
# constructor (src/main.cpp) calls unsetenv, so glibc helper children
# (Tuner, Script Host, transcoders) are unaffected.
#
# No `docker build`, no container recreate, original image untouched.
# Revertible via `uninstall` (a `.orig` copy of the s6 `run` file is kept).
#
# ── Usage ──────────────────────────────────────────────────────────────────
# plex-docker-patch.sh [flags] <subcommand> [container-name]
#
# Subcommands:
# install [name] Apply the patch in place (default if omitted)
# uninstall [name] Restore the s6 run file from its .orig
# status [name] Report whether the patch is active
# help Show usage
#
# Flags (can appear before or after the subcommand):
# --name <name> Container name (alternative to positional)
# --no-build Use existing build/plexmediaserver_crack.so; do not invoke build.sh
# --force-rebuild Delete build/plexmediaserver_crack.so and rebuild from scratch
# --dry-run Print the actions that would be taken without executing them
# --verbose, -v Trace every docker/build command to stderr before execution
# --quiet, -q Suppress non-essential output (only the final report)
# --version Print the script version and exit
#
# Container name defaults to "plex". Both plexinc/pms-docker and
# lscr.io/linuxserver/plex are auto-detected by reading the s6 run file:
# LSIO uses `s6-setuidgid abc`; plexinc does not.
#
# The .so is built locally via the project's build.sh, so a zig-capable
# toolchain is required on the host (or an existing build/plexmediaserver_crack.so
# is reused). See docs/DOCKER.md for the full guide.
#
# ── Requirements ──────────────────────────────────────────────────────────
# - docker on PATH and accessible to the current user
# - bash 4+ (or bash 3.2+ on macOS; arrays + $'...' are used)
# - curl + xz-utils (for build.sh) if no prebuilt .so
#
# ── Idempotency ────────────────────────────────────────────────────────────
# install: safe to re-run. The .orig is preserved across re-installs, the
# .so and wrapper are overwritten with the latest build, the
# run file is rewritten, the container is restarted.
# uninstall: safe to re-run. The run file is re-copied from .orig each time.
# status: read-only, always safe.
#
# ── Exit codes ─────────────────────────────────────────────────────────────
# 0 success
# 1 runtime error (docker missing, container not found, install failed)
# 2 usage error (unknown subcommand or flag)
#
# ── Design notes ──────────────────────────────────────────────────────────
# - `set -euo pipefail` for fail-fast. All transient failures must surface
# as non-zero exits so callers can detect them.
# - `docker exec sh -c '...' _ "${var}"` is the only safe pattern for
# passing host-side paths into the container's shell: single-quote the
# command so the host bash does NOT interpolate, then pass the path as
# a positional arg. Without this, a path containing `;` or `$()` would
# be a command-injection vector. (Currently safe because all paths come
# from a hardcoded candidate list, but the safe pattern is enforced
# throughout for future-proofing.)
# - PMS PID lookup scans /proc/*/comm (not ps -ef | grep), which avoids
# the "Plex" pattern matching the scanning command itself, and works
# regardless of which process-listing tools the container provides.
# - All destructive operations route through `run`, which respects
# --dry-run and --verbose. This is the single place to look to see what
# side effects the script has.
# - Logging: stdout is for human-readable status and the final report;
# stderr is for warnings, errors, --verbose traces, and --dry-run plans.
# This makes the script CI-friendly (pipe stdout, capture stderr).
# - `DEBUG=1` env var enables `set -x` for full command tracing.
#
# ── Version ────────────────────────────────────────────────────────────────
SCRIPT_VERSION='1.0.0'
set -euo pipefail
[[ -n "${DEBUG:-}" ]] && set -x
# ── Paths / constants ─────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# In-container paths (must match what the Dockerfiles copy to).
SO_PATH_INSIDE="/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
WRAPPER_PATH_INSIDE="/usr/lib/plexmediaserver/plex-crack-wrapper.sh"
PMS_COMM_NAME="Plex Media Server" # prctl(PR_SET_NAME) sets this
# s6-overlay v3 / v2 candidate paths, in preference order. If the upstream
# image's s6 layout changes, this is the single place to update.
S6_RUN_CANDIDATES=(
"/etc/s6-overlay/s6-rc.d/svc-plex/run"
"/etc/services.d/plex/run"
)
# Operational defaults.
DEFAULT_CONTAINER="plex"
MAX_WAIT_ITERATIONS=30
WAIT_INTERVAL_SECONDS=2
PMS_HTTP_PORT=32400
# ── Arg-parser state ──────────────────────────────────────────────────────
SUBCOMMAND=""
CONTAINER_NAME=""
DRY_RUN=false
VERBOSE=false
QUIET=false
SKIP_BUILD=false
FORCE_REBUILD=false
# ── Logging ───────────────────────────────────────────────────────────────
# stdout -> human-readable status, success messages, final report
# stderr -> warnings, errors, --verbose traces, --dry-run plans
log() { [ "${QUIET}" != "true" ] && printf '%s\n' "$*"; }
warn() { printf 'WARNING: %s\n' "$*" >&2; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
trace() { [ "${VERBOSE}" = "true" ] && printf '+ %s\n' "$*" >&2 || true; }
# ── Command runner: respects --dry-run and --verbose ──────────────────────
# All destructive operations go through this wrapper. In dry-run mode
# the command is echoed to stderr and skipped. In verbose mode the
# command is echoed to stderr before execution. Stdout is captured by
# the caller as usual.
run() {
if [ "${DRY_RUN}" = "true" ]; then
printf '[dry-run]'
local arg
for arg in "$@"; do
printf ' %s' "${arg}"
done
printf '\n' >&2
else
trace "$*"
"$@"
fi
}
# ── Usage ─────────────────────────────────────────────────────────────────
print_usage() {
cat <<EOF
Usage: $(basename "$0") [flags] <subcommand> [container-name]
Subcommands:
install [name] Apply the patch in place (default)
uninstall [name] Revert the s6 run file from its .orig
status [name] Report whether the patch is active
help Show this message
Flags (can appear before or after the subcommand):
--name <name> Container name (alternative to positional)
--no-build Use existing build/plexmediaserver_crack.so; do not invoke build.sh
--force-rebuild Delete build/plexmediaserver_crack.so and rebuild from scratch
--dry-run Print the actions that would be taken without executing them
--verbose, -v Trace every docker/build command to stderr
--quiet, -q Suppress non-essential output (only the final report)
--version Print the script version and exit
Container name defaults to "${DEFAULT_CONTAINER}".
Examples:
$(basename "$0") install # default container
$(basename "$0") install my-plex # custom name
$(basename "$0") --name my-plex install # flag form
$(basename "$0") install --dry-run # preview only
$(basename "$0") --verbose status my-plex # trace every docker call
$(basename "$0") uninstall my-plex
The script auto-detects the base image (plexinc vs linuxserver) by
reading the s6 svc-plex run file inside the container. The same .so and
docker/wrapper.sh are used in both cases; only the run file content
differs (LSIO preserves s6-setuidgid abc).
EOF
}
# ── Arg parsing ───────────────────────────────────────────────────────────
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
install|uninstall|status)
if [ -n "${SUBCOMMAND}" ]; then
die "subcommand already specified: ${SUBCOMMAND}"
fi
SUBCOMMAND="$1"
shift
;;
help|-h|--help) print_usage; exit 0 ;;
--name) [ "$#" -ge 2 ] || die "--name requires an argument"
CONTAINER_NAME="$2"; shift 2 ;;
--name=*) CONTAINER_NAME="${1#--name=}"; shift ;;
--no-build) SKIP_BUILD=true; shift ;;
--force-rebuild) FORCE_REBUILD=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--verbose|-v) VERBOSE=true; shift ;;
--quiet|-q) QUIET=true; shift ;;
--version) printf '%s\n' "${SCRIPT_VERSION}"; exit 0 ;;
--) shift; break ;;
-*) die "unknown flag: $1 (try --help)" ;;
*)
if [ -z "${CONTAINER_NAME}" ]; then
CONTAINER_NAME="$1"
else
die "unexpected positional argument: $1"
fi
shift
;;
esac
done
SUBCOMMAND="${SUBCOMMAND:-install}"
CONTAINER_NAME="${CONTAINER_NAME:-${DEFAULT_CONTAINER}}"
# Mutual-exclusion checks.
if [ "${SKIP_BUILD}" = "true" ] && [ "${FORCE_REBUILD}" = "true" ]; then
die "--no-build and --force-rebuild are mutually exclusive"
fi
if [ "${QUIET}" = "true" ] && [ "${VERBOSE}" = "true" ]; then
die "--quiet and --verbose are mutually exclusive"
fi
# Make state available to subcommand functions.
export SUBCOMMAND CONTAINER_NAME DRY_RUN VERBOSE QUIET SKIP_BUILD FORCE_REBUILD
}
# ── Pre-flight checks ────────────────────────────────────────────────────
require_docker() {
command -v docker >/dev/null 2>&1 || die "docker not on PATH"
}
require_container_exists() {
require_docker
if ! docker inspect "${CONTAINER_NAME}" >/dev/null 2>&1; then
die "container '${CONTAINER_NAME}' not found. Start one with: docker run -d --name ${CONTAINER_NAME} ..."
fi
}
require_container_running() {
require_container_exists
local state
state="$(docker inspect --format '{{.State.Running}}' "${CONTAINER_NAME}" 2>/dev/null || echo unknown)"
if [ "${state}" != "true" ]; then
die "container '${CONTAINER_NAME}' is not running (state: ${state}). Start it with: docker start ${CONTAINER_NAME}"
fi
}
# ── Detection ─────────────────────────────────────────────────────────────
# Echoes the s6 svc-plex run file path on stdout, or returns 1.
# Two candidates are tried in preference order: s6-overlay v3 path, then
# the legacy v2 path.
detect_run_script() {
local p
for p in "${S6_RUN_CANDIDATES[@]}"; do
if run docker exec -u root "${CONTAINER_NAME}" test -f "${p}" 2>/dev/null; then
printf '%s\n' "${p}"
return 0
fi
done
return 1
}
# Echoes one of: plexinc, linuxserver, unknown
# Detection: LSIO's run file content includes `s6-setuidgid abc`; plexinc's
# does not. If neither marker is found, return `unknown` (the install
# flow will refuse to proceed).
detect_base_image() {
local run_script="$1"
local content
content="$(run docker exec -u root "${CONTAINER_NAME}" cat "${run_script}" 2>/dev/null || true)"
if printf '%s' "${content}" | grep -q 's6-setuidgid abc'; then
printf 'linuxserver\n'
elif printf '%s' "${content}" | grep -qE 'Plex Media Server|start\.sh|with-contenv'; then
printf 'plexinc\n'
else
printf 'unknown\n'
fi
}
# Echoes the PMS PID on stdout, or empty.
# Strategy: scan /proc/*/comm for the PMS comm name (set via
# prctl(PR_SET_NAME)). This avoids the "ps -ef | grep | grep -v grep"
# pattern, which has a tendency to match its own command line, and works
# regardless of which process-listing tools are in the container.
find_pms_pid() {
run docker exec "${CONTAINER_NAME}" sh -c '
for d in /proc/[0-9]*; do
[ -r "$d/comm" ] || continue
if [ "$(cat "$d/comm" 2>/dev/null)" = "$1" ]; then
basename "$d"
exit 0
fi
done
exit 1
' _ "${PMS_COMM_NAME}" 2>/dev/null || true
}
# ── Build ─────────────────────────────────────────────────────────────────
build_so() {
local so_path="${PROJECT_ROOT}/build/plexmediaserver_crack.so"
if [ "${FORCE_REBUILD}" = "true" ] && [ -f "${so_path}" ]; then
log "[*] --force-rebuild: removing existing .so"
run rm -f "${so_path}"
fi
if [ "${SKIP_BUILD}" = "true" ]; then
if [ ! -f "${so_path}" ]; then
die "--no-build specified but ${so_path} does not exist. Build it first with: bash build.sh"
fi
log "[*] --no-build: reusing existing .so (build.sh not invoked)"
return 0
fi
if [ -f "${so_path}" ]; then
log "[*] Reusing existing .so (rm it or pass --force-rebuild to rebuild)"
return 0
fi
log "[*] Building .so via build.sh (this may take 1-2 min on first run)..."
( cd "${PROJECT_ROOT}" && run bash build.sh )
}
# ── Filesystem ops ────────────────────────────────────────────────────────
# Build the new s6 run file content locally and docker cp it in. We do
# this locally (rather than heredoc-over-docker-exec) to avoid quoting
# hell and command-injection risk.
write_new_run_file() {
local base="$1" out="$2"
{
printf '#!/usr/bin/with-contenv bash\n'
if [ "${base}" = "linuxserver" ]; then
printf 'exec s6-setuidgid abc %s\n' "${WRAPPER_PATH_INSIDE}"
else
printf 'exec %s\n' "${WRAPPER_PATH_INSIDE}"
fi
} > "${out}"
chmod 0755 "${out}"
}
# Back up the s6 run file to .orig. Idempotent: if .orig exists, leave it.
# Uses the safe `sh -c '...' _ "${path}"` pattern: single-quoted command
# + positional arg, so the host bash never interpolates the path.
backup_run_file() {
local run_script="$1"
if run docker exec -u root "${CONTAINER_NAME}" test -f "${run_script}.orig" 2>/dev/null; then
log "[*] ${run_script}.orig already present, leaving it"
return 0
fi
run docker exec -u root "${CONTAINER_NAME}" \
sh -c 'cp "$1" "$1".orig' _ "${run_script}"
log "[*] Backed up ${run_script} -> ${run_script}.orig"
}
# Copy the .so and wrapper into the container and chmod the wrapper.
copy_artifacts() {
local so_host="${PROJECT_ROOT}/build/plexmediaserver_crack.so"
local wrapper_host="${SCRIPT_DIR}/wrapper.sh"
if [ ! -f "${so_host}" ]; then
die "${so_host} does not exist. Build it first with: bash build.sh"
fi
if [ ! -f "${wrapper_host}" ]; then
die "${wrapper_host} does not exist. Re-clone the project or restore docker/wrapper.sh."
fi
log "[*] Copying .so and wrapper into the container..."
run docker cp "${so_host}" "${CONTAINER_NAME}:${SO_PATH_INSIDE}"
run docker cp "${wrapper_host}" "${CONTAINER_NAME}:${WRAPPER_PATH_INSIDE}"
run docker exec -u root "${CONTAINER_NAME}" chmod 0755 "${WRAPPER_PATH_INSIDE}"
}
# ── Verification ───────────────────────────────────────────────────────────
# Polls PMS /identity until it returns 200 or the timeout expires.
wait_for_pms_ready() {
log "[*] Waiting for PMS /identity (up to $((MAX_WAIT_ITERATIONS * WAIT_INTERVAL_SECONDS))s)..."
local i code
for i in $(seq 1 "${MAX_WAIT_ITERATIONS}"); do
code="$(curl -fsS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PMS_HTTP_PORT}/identity" 2>/dev/null || echo 000)"
if [ "${code}" = "200" ]; then
log "[*] PMS is up (HTTP 200)."
return 0
fi
sleep "${WAIT_INTERVAL_SECONDS}"
done
return 1
}
# Confirms the .so is mapped into the PMS process.
verify_so_mapped() {
local pms_pid="$1"
if [ -z "${pms_pid}" ]; then
warn "could not find PMS pid inside container"
return 1
fi
if run docker exec "${CONTAINER_NAME}" grep -F plexmediaserver_crack.so "/proc/${pms_pid}/maps" >/dev/null 2>&1; then
log "[*] OK: plexmediaserver_crack.so is mapped into PMS (pid ${pms_pid})"
return 0
fi
warn "PMS running (pid ${pms_pid}) but .so is NOT in /proc/${pms_pid}/maps"
warn " This usually means PMS exited 127 (loader failure). Check: docker logs ${CONTAINER_NAME} | tail -50"
return 1
}
# ── Subcommands ──────────────────────────────────────────────────────────
do_install() {
require_container_running
log "[*] Container: ${CONTAINER_NAME}"
local run_script
if ! run_script="$(detect_run_script)"; then
die "could not find s6 svc-plex run file in container. Tried: ${S6_RUN_CANDIDATES[*]}"
fi
log "[*] s6 run file: ${run_script}"
local base
base="$(detect_base_image "${run_script}")"
case "${base}" in
linuxserver) log "[*] Detected base: lscr.io/linuxserver/plex (s6-setuidgid abc will be preserved)" ;;
plexinc) log "[*] Detected base: plexinc/pms-docker" ;;
*)
die "could not detect base image from s6 run file content. Please file an issue with the file contents."
;;
esac
build_so
copy_artifacts
backup_run_file "${run_script}"
# Write the new run file locally and copy it in. The mktemp + trap
# pattern ensures we never leak the temp file, even on error paths.
local tmp_run
tmp_run="$(mktemp)"
trap 'rm -f "${tmp_run}"' EXIT
write_new_run_file "${base}" "${tmp_run}"
run docker cp "${tmp_run}" "${CONTAINER_NAME}:${run_script}"
rm -f "${tmp_run}"
trap - EXIT
log "[*] Restarting ${CONTAINER_NAME} (s6 will exec the new run file)..."
run docker restart "${CONTAINER_NAME}" >/dev/null
if ! wait_for_pms_ready; then
warn "PMS did not return 200 within $((MAX_WAIT_ITERATIONS * WAIT_INTERVAL_SECONDS))s. Check: docker logs ${CONTAINER_NAME} | tail -50"
fi
local pms_pid
pms_pid="$(find_pms_pid)"
verify_so_mapped "${pms_pid}"
cat <<EOF
Patch applied. For full verification (all 14 feature bits ON):
docker cp ${PROJECT_ROOT}/scripts/readbitset.py ${CONTAINER_NAME}:/tmp/readbitset.py
docker exec -u root ${CONTAINER_NAME} python3 /tmp/readbitset.py ${pms_pid:-<PMS_PID>}
To revert: $(basename "$0") uninstall ${CONTAINER_NAME}
To re-check: $(basename "$0") status ${CONTAINER_NAME}
EOF
}
do_uninstall() {
require_container_running
local run_script
if ! run_script="$(detect_run_script)"; then
die "could not find s6 svc-plex run file. Tried: ${S6_RUN_CANDIDATES[*]}"
fi
if ! run docker exec -u root "${CONTAINER_NAME}" test -f "${run_script}.orig" 2>/dev/null; then
die "no ${run_script}.orig found -- the patch may not be installed, or the .orig was deleted. Manual restore: docker cp <upstream-image>:${run_script} ${CONTAINER_NAME}:${run_script}"
fi
log "[*] Restoring ${run_script} from .orig..."
run docker exec -u root "${CONTAINER_NAME}" \
sh -c 'cp "$1" "$2"' _ "${run_script}.orig" "${run_script}"
run docker exec -u root "${CONTAINER_NAME}" chmod 0755 "${run_script}"
log "[*] Restarting ${CONTAINER_NAME}..."
run docker restart "${CONTAINER_NAME}" >/dev/null
cat <<EOF
Patch removed. PMS is back to upstream behavior. Optional cleanup:
docker exec -u root ${CONTAINER_NAME} rm -f ${SO_PATH_INSIDE} ${WRAPPER_PATH_INSIDE}
docker exec -u root ${CONTAINER_NAME} rm -f ${run_script}.orig
EOF
}
do_status() {
require_container_exists
local run_script
if ! run_script="$(detect_run_script)"; then
warn "s6 svc-plex run file not found (tried: ${S6_RUN_CANDIDATES[*]})"
return 1
fi
local run_content has_orig has_so has_wrapper pms_pid
run_content="$(run docker exec -u root "${CONTAINER_NAME}" cat "${run_script}" 2>/dev/null || true)"
if run docker exec -u root "${CONTAINER_NAME}" test -f "${run_script}.orig" 2>/dev/null; then
has_orig="yes"
else
has_orig="no"
fi
if run docker exec -u root "${CONTAINER_NAME}" test -f "${SO_PATH_INSIDE}" 2>/dev/null; then
has_so="yes"
else
has_so="no"
fi
if run docker exec -u root "${CONTAINER_NAME}" test -x "${WRAPPER_PATH_INSIDE}" 2>/dev/null; then
has_wrapper="yes"
else
has_wrapper="no"
fi
pms_pid="$(find_pms_pid)"
log "Container: ${CONTAINER_NAME}"
log " s6 run file: ${run_script}"
log " .orig present: ${has_orig}"
log " .so present: ${has_so} (${SO_PATH_INSIDE})"
log " wrapper: ${has_wrapper} (${WRAPPER_PATH_INSIDE})"
log " PMS pid: ${pms_pid:-<not running>}"
log " run file content:"
printf '%s\n' "${run_content}" | sed 's/^/ /'
log ""
if printf '%s' "${run_content}" | grep -q 'plex-crack-wrapper.sh'; then
log "[*] PATCH IS ACTIVE (s6 run file points to the wrapper)."
if [ -n "${pms_pid}" ] && run docker exec "${CONTAINER_NAME}" grep -F plexmediaserver_crack.so "/proc/${pms_pid}/maps" >/dev/null 2>&1; then
log "[*] .so is mapped into PMS (pid ${pms_pid})."
else
warn "PMS running (pid ${pms_pid:-?}) but .so is NOT in its maps; check docker logs."
fi
else
log "[*] PATCH IS NOT ACTIVE (s6 run file does not point to the wrapper)."
fi
}
# ── Main ──────────────────────────────────────────────────────────────────
parse_args "$@"
case "${SUBCOMMAND}" in
install) do_install ;;
uninstall) do_uninstall ;;
status) do_status ;;
esac
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# In-container launcher for Plex Media Server. Used by the patched
# plexinc/pms-docker and lscr.io/linuxserver/plex images; both Dockerfiles
# replace the upstream s6 service run file so it execs this script.
#
# Mirrors scripts/plex-crack-wrapper.sh from the native systemd install, with
# one key invariant:
#
# LD_PRELOAD is exported *last*, immediately before `exec`, so the glibc
# shell helpers spawned for the PLEX_MEDIA_SERVER_INFO_* assignments
# (grep/awk/uname/tr) are NOT preloaded. The .so's constructor
# (src/main.cpp) calls unsetenv("LD_PRELOAD") when it loads into the
# (musl) PMS process, so PMS's glibc helper children (Tuner Service,
# Script Host, transcoders) are unaffected as well.
#
# `exec` is mandatory so s6-supervise sees PMS as the supervised process
# (no fork). "$@" preserves any args the upstream invocation might add.
set -eu
# 1. Platform strings PMS echoes in /identity. Mirrors upstream start.sh.
export PLEX_MEDIA_SERVER_INFO_VENDOR="$(grep ^NAME= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
export PLEX_MEDIA_SERVER_INFO_MODEL="$(uname -m)"
export PLEX_MEDIA_SERVER_INFO_PLATFORM_VERSION="$(grep ^VERSION= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
# 2. Set LD_PRELOAD only now. The .so paths match where the Dockerfiles
# copy them. Do NOT change this without updating both Dockerfiles.
PRELOADS=""
PRELOADS="${PRELOADS}:/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
PRELOADS="${PRELOADS}:/usr/lib/plexmediaserver/lib/plexmediaserver_traffic_logger.so"
export LD_PRELOAD="${PRELOADS#:}"
# 3. Hand off to PMS.
exec "/usr/lib/plexmediaserver/Plex Media Server" "$@"
+107
View File
@@ -0,0 +1,107 @@
# Building & installing plexmediaserver_crack (Linux)
## How this works (read this first)
Plex Media Server ships and runs against its **own bundled musl libc + libgcompat**
(`/usr/lib/plexmediaserver/lib/{libc.so,ld-musl-x86_64.so.1,libgcompat.so.0}`),
**not** the host's glibc. Two consequences drive everything below:
- **Build target must be musl.** A glibc-built `.so` fails to load into Plex: the
loader can't relocate glibc-only symbols (`__isoc23_strtol`, `arc4random`,
`*_chk`, `_dl_find_object`) and PMS exits 127. We cross-compile with **zig**
(`-target x86_64-linux-musl`), which bundles musl + libc++ and statically links
the C++ runtime, leaving only musl libc references that Plex's `libc.so` satisfies.
- **Inject with `LD_PRELOAD`, never `patchelf`.** `patchelf --add-needed` rewrites
the 22 MB BIND_NOW/PIE binary's program headers in a way musl's loader cannot
tolerate, which **corrupts the executable** (instant SIGSEGV on start). `LD_PRELOAD`
touches nothing on disk and is trivially reversible.
The crack hooks `FeatureManager_apply_feature_list_xml` and forces all 14 entries
of `g_feature_bitset_slots` on (the "Godmode" approach), enabling every feature
including Plex Pass (feature code 92, slot 11).
## Prerequisites
A Linux environment (native or WSL). `build.sh` will download a pinned `zig`
toolchain on first run, so you only need `curl` + `tar`/`xz` available (or `zig`
already on `PATH`, or pass `ZIG=/path/to/zig`). No cmake/g++/glibc toolchain needed.
## Build
```bash
bash build.sh
```
Output: `build/plexmediaserver_crack.so` (a musl shared object). The script runs an ABI
sanity check and refuses to emit a `.so` that references any glibc-only symbol.
It prints the install steps below on success.
## Install (native systemd installs)
The launcher `plex-crack-wrapper.sh` sets `LD_PRELOAD` **after** `/bin/sh` is
already running and just before it `exec`s Plex, so only the (musl) Plex process
is preloaded. The crack's constructor also calls `unsetenv("LD_PRELOAD")`, so the
glibc `/bin/sh` helper children Plex spawns (Tuner Service, Script Host) are
unaffected.
```bash
# 1. Place the artifacts on the Plex host:
install -o plex -g plex -m644 build/plexmediaserver_crack.so /usr/lib/plexmediaserver/lib/
install -m755 scripts/plex-crack-wrapper.sh /usr/local/bin/
# 2. Drop-in that swaps ExecStart for the wrapper:
mkdir -p /etc/systemd/system/plexmediaserver.service.d
printf '[Service]\nExecStart=\nExecStart=/usr/local/bin/plex-crack-wrapper.sh\n' \
> /etc/systemd/system/plexmediaserver.service.d/override.conf
# 3. Apply:
systemctl daemon-reload
systemctl restart plexmediaserver
```
### Verify
```bash
systemctl is-active plexmediaserver # -> active
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:32400/identity # -> 200
# Confirm the .so is mapped into the process:
PID=$(systemctl show -p MainPID --value plexmediaserver)
grep -F plexmediaserver_crack.so /proc/$PID/maps
```
`scripts/readbitset.py <pid>` (root) dumps the live feature bitset; all 14 slots should
read `0xffffffffffffffff` when the hook is active.
## Uninstall
```bash
rm -f /etc/systemd/system/plexmediaserver.service.d/override.conf
rm -f /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so /usr/local/bin/plex-crack-wrapper.sh
systemctl daemon-reload && systemctl restart plexmediaserver
```
### Recovering a binary already corrupted by patchelf
If a prior attempt ran `patchelf` against the Plex binary and PMS now SIGSEGVs on
start, restore the pristine executable:
```bash
apt-get install --reinstall -y plexmediaserver
# If the repo isn't configured, fetch the exact installed version and dpkg -i it:
V=$(dpkg-query -W -f='${Version}' plexmediaserver)
curl -fL -o /tmp/pms.deb "https://downloads.plex.tv/plex-media-server-new/$V/debian/plexmediaserver_${V}_amd64.deb"
dpkg -i /tmp/pms.deb
systemctl reset-failed plexmediaserver && systemctl restart plexmediaserver
```
## Notes
- x86-64 Linux only.
- "Godmode": enables ALL features regardless of GUID. The full feature UUID
catalog is in `src/hook.cpp` (`kFeatureGuidCatalog`) for reference.
- Signature patterns in `hook()` are version-specific; you may need to re-verify
them after a PMS update.
- For Docker (`plexinc/pms-docker`, `lscr.io/linuxserver/plex`), see
[`DOCKER.md`](DOCKER.md) — same `LD_PRELOAD`-on-the-PMS-exec principle,
but the s6 service plumbing is different per image.
- For intro/credit detection: Settings -> Library -> Marker source -> "local detection only".
+351
View File
@@ -0,0 +1,351 @@
# Building & running the Plex patcher in Docker (Linux x86-64)
The same `LD_PRELOAD`-on-the-PMS-exec trick the native systemd install uses
([`BUILD.md`](BUILD.md)) translates to Docker: build a patched Plex image,
run it as you would the upstream image, and the (musl) PMS process is
preloaded with the crack without affecting the (glibc) s6 init, helper
children, or transcoders.
## How this works (read this first)
Three constraints are identical to the native install:
1. **Build target is musl** — Plex bundles and runs against its own musl
libc. A glibc `.so` won't load. We cross-compile with `zig` in a
multi-stage build (`debian:bookworm-slim` builder, Plex base image
runtime).
2. **Inject with `LD_PRELOAD`** — `patchelf` corrupts the PIE under musl's
loader. The patch is at-rest: drop a `.so` next to PMS in the image and
have the s6 service exec PMS through a thin wrapper that exports
`LD_PRELOAD` only at exec time.
3. **Scope `LD_PRELOAD` to the PMS exec** — the wrapper sets `LD_PRELOAD`
*after* any glibc child work and only for the final `exec`. The `.so`'s
constructor (see [`../src/main.cpp`](../src/main.cpp)) calls
`unsetenv("LD_PRELOAD")`, so PMS's glibc helper children (Tuner, Script
Host, transcoders) are unaffected.
The only difference between the two Dockerfiles is how the s6 service for
Plex is replaced — the `.so` and `wrapper.sh` are identical. In both cases
the upstream run file is backed up as `run.orig` for forensics.
## Prerequisites
- Docker Engine 20.10+ with BuildKit enabled (`DOCKER_BUILDKIT=1` or
BuildKit as the default builder in recent Docker). Both Dockerfiles use
`# syntax=docker/dockerfile:1.7`.
- An x86_64 Linux host (or any host with `docker buildx` configured for
`linux/amd64`).
- A place to keep PMS state on the host. We recommend `/srv/plex/config`
and `/srv/plex/data`; the Dockerfiles don't bake any of this in.
## Build
From the project root:
```bash
# Official (plexinc/pms-docker)
docker build -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
# Community (lscr.io/linuxserver/plex)
docker build -f docker/Dockerfile.linuxserver -t plex-crack:lsio .
```
The first build downloads `zig 0.13.0` and the chosen Plex base image.
Subsequent builds reuse cached layers until `src/`, `third_party/`, or
`build.sh` change.
### Pinning the base image
Both Dockerfiles accept a build-arg to pin the upstream Plex image:
```bash
docker build -f docker/Dockerfile.plexinc \
--build-arg PLEX_BASE_IMAGE=plexinc/pms-docker:1.42.1.1007-7e0e6c83c \
-t plex-crack:plexinc-1.42 .
```
The sanity `RUN` in stage 2 verifies the upstream layout before the patch
layers are added — if plexinc or LSIO moves the PMS binary, the `lib/`
directory, or the s6 service file, the build fails *here* with a clear
message rather than the container failing mysteriously at runtime.
You can also stamp the patched image with a version:
```bash
docker build -f docker/Dockerfile.plexinc \
--build-arg PATCH_VERSION=v1.2.3 \
-t plex-crack:plexinc-v1.2.3 .
```
`PATCH_VERSION` ends up in the `plex_patch.version` OCI label and is
visible via `docker inspect`.
## Run
### docker run
```bash
# Official (plexinc/pms-docker). No special env; plex runs as the upstream
# 'plex' user.
docker run -d --name plex --network=host \
-v /srv/plex/config:/config \
-v /srv/plex/data:/data \
plex-crack:plexinc
# Community (linuxserver). Honor the LSIO PUID/PGID convention so /config
# and /data are chowned correctly on first start.
docker run -d --name plex --network=host \
-e PUID=$(id -u) -e PGID=$(id -g) \
-e TZ=America/Los_Angeles \
-v /srv/plex/config:/config \
-v /srv/plex/data:/data \
plex-crack:lsio
```
`--network=host` is what Plex expects for direct LAN access; bridge
networking works too if you publish `32400/tcp` (and `3005/tcp`, `8324/tcp`,
`32469/udp` for Bonjour/avahi, etc.) — see the Plex docs.
### docker compose
```yaml
services:
plex:
image: plex-crack:lsio
container_name: plex
network_mode: host
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
volumes:
- /srv/plex/config:/config
- /srv/plex/data:/data
restart: unless-stopped
```
For `plex-crack:plexinc`, drop the `PUID`/`PGID` lines.
## Verify
```bash
# 1. PMS is up.
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:32400/identity # -> 200
# 2. The .so is mapped into the PMS process. s6 runs PMS as a child, so
# find it first.
docker exec plex ps -ef | grep 'Plex Media Server' | grep -v grep
# Take the PID (first column) -- call it $PMS_PID.
docker exec plex grep -F plexmediaserver_crack.so /proc/$PMS_PID/maps
# -> should list the .so with r-xp perms.
# 3. Dump the live feature bitset. python3 must be available inside the
# container (the upstream images include it; if not, `docker exec -u root
# plex apk add python3` on alpine or `apt-get install -y python3` on
# debian/ubuntu).
docker cp scripts/readbitset.py plex:/tmp/readbitset.py
docker exec -u root plex python3 /tmp/readbitset.py $PMS_PID
# -> all 14 slots should read 0xffffffffffffffff.
```
Step 3 is the strongest check: if any slot is `0x0…0`, the hook didn't
fire and the signature patterns in `src/hook.cpp` need to be re-verified
against the new PMS binary.
## Alternative: patch a running container in place
The `docker build` flow above builds a new patched image and starts a
new container. If you already have a Plex container running — and you
want to apply the same patch **without** rebuilding, recreating, or
pulling a new image — `docker/plex-docker-patch.sh` does it in place.
The container's filesystem is modified directly; the upstream image is
left untouched. A `.orig` copy of the s6 `run` file is kept for
`uninstall`.
```bash
# Apply (default container name: "plex").
./docker/plex-docker-patch.sh install
# Or with a custom container name.
./docker/plex-docker-patch.sh install media-plex
# Verify.
./docker/plex-docker-patch.sh status media-plex
# Revert (restores s6 run file from .orig).
./docker/plex-docker-patch.sh uninstall media-plex
```
Requirements on the host: `docker` on `PATH`, `bash 4+`, and either
`zig` available (so `build.sh` can cross-compile) or an existing
`build/plexmediaserver_crack.so` (the script reuses it if present).
### What it does, step by step
1. Verifies the container exists and is running.
2. Detects the s6 service `run` file path
(`/etc/s6-overlay/s6-rc.d/svc-plex/run` for v3,
`/etc/services.d/plex/run` for v2) and reads it to auto-detect the
base image (plexinc vs linuxserver — the latter uses
`s6-setuidgid abc`).
3. Invokes `build.sh` (skipped if `build/plexmediaserver_crack.so` is
already present) to produce the musl `.so`.
4. `docker cp`s the `.so` and `wrapper.sh` into the container.
5. Backs up the s6 `run` file to `.orig` (preserved across re-installs).
6. Writes a new `run` file that execs the wrapper (with or without
`s6-setuidgid abc`, depending on the base image).
7. `docker restart`s the container.
8. Waits for `http://127.0.0.1:32400/identity` to return 200.
9. Confirms `plexmediaserver_crack.so` is mapped into the PMS process
(`/proc/$PID/maps`).
10. Prints the `readbitset.py` command for full feature-bitset
verification.
### When to use which
- **Dockerfile build** — best for repeat deploys, multi-host, CI/CD,
immutable images. You commit a patched image and ship it; running
instances are disposable.
- **In-place patch** — best for one-off patching of an existing
container you don't want to touch (complex `docker run` invocation,
custom network, volume layout, or a single-node homelab). Modifies
the live container's filesystem; fully revertible via `uninstall`.
Both use the same `docker/wrapper.sh` and the same
`LD_PRELOAD`-on-the-PMS-exec mechanism — only the injection plumbing
differs.
### Limitations
- x86_64 Linux hosts only (the `.so` is `x86_64-linux-musl`).
- The host must be able to `docker exec -u root` into the container.
On rootless Docker setups this should still work since the
container's `root` is mapped to the host's user.
- If the upstream image's s6 layout changes (plexinc or LSIO move the
service), the script's auto-detection will fail with a clear error.
The Dockerfile flow would fail its sanity `RUN` at build time with
the same kind of error.
- The patch is **per-container**, not per-image. Re-creating the
container (e.g., `docker rm` + `docker run` of the upstream image)
reverts the patch; you have to re-run `plex-docker-patch.sh install`.
Use the Dockerfile flow for image-baked persistence.
## Uninstall
Two ways to remove the patch, depending on which flow you used.
### Dockerfile-built image
The patch lives entirely in the image — there is no host state to undo
beyond the host's PMS data volumes, which the patch never touches.
```bash
# Stop and remove the container.
docker rm -f plex
# Remove the image.
docker rmi plex-crack:plexinc # or plex-crack:lsio
```
To revert to the unmodified upstream image:
```bash
docker pull plexinc/pms-docker:latest
docker run -d --name plex --network=host \
-v /srv/plex/config:/config \
-v /srv/plex/data:/data \
plexinc/pms-docker:latest
```
The original s6 service `run` file is preserved in the patched image as
`/etc/s6-overlay/s6-rc.d/svc-plex/run.orig` — it can be recovered by
rebuilding the patched image without the patch layer (just use the
upstream image directly).
### In-place patch
```bash
# Restore the s6 run file from its .orig and restart the container.
./docker/plex-docker-patch.sh uninstall plex
# Optional cleanup of the .so, wrapper, and .orig backup:
docker exec -u root plex rm -f \
/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so \
/usr/lib/plexmediaserver/plex-crack-wrapper.sh \
/etc/s6-overlay/s6-rc.d/svc-plex/run.orig
```
To verify the patch is gone:
```bash
./docker/plex-docker-patch.sh status plex
# -> "PATCH IS NOT ACTIVE (s6 run file does not point to the wrapper)."
```
## Troubleshooting
### `error while loading shared libraries: ...musl...` on PMS start
A glibc `.so` accidentally got into the image. The build's ABI sanity gate
(`grep -E "UND .*(__isoc23_|_chk$|arc4random|_dl_find_object)"` in
`build.sh`) should have caught this — if you bypassed `build.sh` and copied
in a pre-built artifact, rebuild via the Dockerfile so the gate runs.
### `patchelf: ...` warnings during build
We don't use `patchelf`. If you see this, you're running a non-canonical
build flow — see [`BUILD.md`](BUILD.md) for why `patchelf` corrupts the
PIE under musl's loader.
### `Plex Media Server` exits 127 immediately
The s6 service exec'd PMS but PMS can't find a library. Usually this is
either the `.so` ABI mismatch (rebuild) or the wrapper is in the wrong
location. Check:
```bash
docker exec plex ls -l /usr/lib/plexmediaserver/lib/plexmediaserver_crack.so
docker exec plex ls -l /usr/lib/plexmediaserver/plex-crack-wrapper.sh
docker exec plex cat /etc/s6-overlay/s6-rc.d/svc-plex/run
```
The last command should print the wrapper path (with or without
`s6-setuidgid abc` depending on which image you built).
### Feature bitset is not all 1s after `readbitset.py`
The hook didn't fire. Likely cause: PMS has a version the signature
patterns in `src/hook.cpp` don't match. Re-verify the signature patterns
against the new PMS binary; the project's RE notes are in
[`../AGENTS.md`](../AGENTS.md). After fixing the patterns, rebuild with
`--no-cache` so the builder stage re-runs:
```bash
docker build --no-cache -f docker/Dockerfile.plexinc -t plex-crack:plexinc .
```
### Container restarts in a loop, s6 logs `script /etc/s6-overlay/s6-rc.d/svc-plex/run exited 1`
The wrapper is missing or not executable, or the `.so` failed its ABI
gate. Check `docker logs plex` and the in-container paths listed above.
### LSIO: /config or /data ends up owned by root
PUID/PGID weren't set, or were set to 0. LSIO's perms init runs once and
chowns to the configured UID/GID; if PMS is later started as root (which
it would be if the s6-setuidgid was dropped from the run file), the
subsequent writes will be root-owned. The Dockerfile preserves
`s6-setuidgid abc` exactly so this shouldn't happen with a clean build —
rebuild without modifying the `cat > "${RUN_SCRIPT}"` block.
## Notes
- x86_64 Linux only — the build emits `x86_64-linux-musl` and the base
images are amd64. There is no arm64/v3 PMS Docker image today.
- Both Dockerfiles emit OCI labels: `plex_patch.base` (the upstream image
reference) and `plex_patch.version` (a build-time stamp, default `dev`).
- For intro/credit detection: Settings → Library → Marker source → "local
detection only".
- The patch has no effect on PMS's network behavior, media transcoding,
or library scanning — it only forces the in-memory feature bitset on
after PMS applies its MyPlex feature list.
+66
View File
@@ -0,0 +1,66 @@
# Windows patching guide
Top-level index for the Windows x64 Plex Media Server work in this repo.
## What exists
The Windows implementation lives in [`../windows/`](../windows/) and contains:
- `plex_patch.dll` — injected payload that forces the feature bitset on
- `plex_inject.exe` — injector that attaches to or launches `Plex Media Server.exe`
- a small engine split into:
- `pe_image.h` — PE parsing, section bounds, RVA resolution, version guard
- `sig_scan.h` — byte-pattern scanning helpers
- `trampoline.h` — Zydis-based x64 inline hook engine
- `feature_patch.h` — bitset forcing, populator hook, guard thread
## Current target
Pinned to:
- **Plex Media Server** `1.43.2.10687-563d026ea`
- **Platform**: Windows x64
The patch uses a **version guard** and **bounds-checked RVA resolution** before
it touches the target process.
## Build
From `windows/`:
```bat
build.bat
```
This produces:
- `build\plex_patch.dll`
- `build\plex_inject.exe`
The build reuses the repo's vendored Zydis and Zig toolchain conventions.
## Run
Attach to a running PMS:
```bat
build\plex_inject.exe
```
Or launch PMS through the injector:
```bat
build\plex_inject.exe --launch "C:\Program Files\Plex\Plex Media Server\Plex Media Server.exe"
```
## Where to read next
- Detailed Windows architecture and usage: [`../windows/README.md`](../windows/README.md)
- Linux build/install flow: [`BUILD.md`](BUILD.md)
- Repo overview: [`../README.md`](../README.md)
## Notes
- `windows/build/` artifacts are intentionally git-ignored.
- The original incompatible `.i64` was left untouched; the working Windows IDB
was rebuilt in a writable analysis directory during RE.
+82
View File
@@ -0,0 +1,82 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
static uint64_t hook_fn(uintptr_t user, const char** feature)
{
return 1;
}
static int get_dottext_info(uintptr_t* start_out, uintptr_t* end_out)
{
FILE* file = fopen("/proc/self/maps", "r");
if (!file) return 0;
char line[1024];
while (fgets(line, sizeof(line), file))
{
if (strstr(line, "Plex Media Server") && strstr(line, "r-xp"))
{
char* endptr;
*start_out = strtoull(line, &endptr, 16);
if (*endptr != '-') continue;
*end_out = strtoull(endptr + 1, NULL, 16);
fclose(file);
return 1;
}
}
fclose(file);
return 0;
}
static uintptr_t sig_scan(uintptr_t start, uintptr_t end, const uint8_t* pattern, const uint8_t* mask, int pat_len)
{
for (uintptr_t i = start; i <= end - (uintptr_t)pat_len; i++)
{
int match = 1;
for (int x = 0; x < pat_len; x++)
{
if (!mask[x]) continue;
if (*(uint8_t*)(i + x) != pattern[x]) { match = 0; break; }
}
if (match) return i;
}
return 0;
}
static void do_hook(uintptr_t target)
{
if (!target) return;
uint8_t shellcode[] = {
0x48, 0xB8,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x50,
0xC3
};
*(uint64_t*)(&shellcode[2]) = (uint64_t)&hook_fn;
long page_size = 4096;
uintptr_t page_start = target & ~(page_size - 1);
mprotect((void*)page_start, page_size, PROT_READ | PROT_WRITE | PROT_EXEC);
memcpy((void*)target, shellcode, sizeof(shellcode));
mprotect((void*)page_start, page_size, PROT_READ | PROT_EXEC);
}
void __attribute__((constructor)) init_so(void)
{
uintptr_t start, end;
if (!get_dottext_info(&start, &end)) return;
uint8_t pat[] = { 0xE8, 0x00, 0x00, 0x00, 0x00, 0x86, 0x43 };
uint8_t mask[] = { 1, 0, 0, 0, 0, 1, 1 };
uintptr_t found = sig_scan(start, end, pat, mask, 7);
if (found) {
int32_t rel = *(int32_t*)(found + 1);
uintptr_t call_target = found + 5 + rel;
do_hook(call_target);
}
}
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
build/
dist/
.venv/
+178
View File
@@ -0,0 +1,178 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
# plex_relay
A reverse-engineered, runnable reimplementation of the **Plex Media Server**
`RelayController` — the component that makes a server reachable remotely by
opening a **reverse SSH tunnel out to a Plex-operated relay host**.
Reconstructed from the `Plex Media Server` **1.43.2.10687** binary (Linux
x86-64). Ships **no Plex code**, embeds no keys, and authenticates to nothing on
its own. It is a behavioural model for interoperability research on
infrastructure **you operate yourself**.
---
## What Plex Relay does (reversed)
```
plex.tv ──"startRelay"(host,port)──▶ ServerEventManager_handle_pubsub_event (0xF5BE3C)
│ gate: signed_in && published && relay_enabled
▼
RelayController_connect (0x12307F2)
│ 1 dedup an already-active tunnel for host
│ 2 refresh relay host key (≤24h cache)
│ 3 pin [host]:443 in relayHostKey.txt
│ 4 spawn the ssh reverse tunnel
│ 5 arm the 300s inactive-connection reaper
▼
ssh -p <port> -N -R 0:127.0.0.1:<PMS port>
-o UserKnownHostsFile=<datadir>/relayHostKey.txt
-o LogLevel=VERBOSE -o PreferredAuthentications=password
-o PubkeyAuthentication=no -l <myplex-id> -F /dev/null <relay-host>
password = MyPlex token, delivered via the PLEXTOKEN env var + SSH_ASKPASS
```
The relay binds an ephemeral remote port (`-R 0:…`) and forwards inbound
remote-client traffic back down the tunnel to the local PMS service. The relay
host's SSH key is **pinned**: PMS downloads it at most once per day from
`https://downloads.plex.tv/relay/relay_v1.pub` and writes a per-endpoint
known_hosts line. `relayHostKey.txt` doubles as PMS's cache and the file handed
to `ssh` (the `#` lines are valid known_hosts comments).
---
## Architecture
High cohesion (one reason to change per module) and low coupling (dependencies
point inward to abstractions, never outward to I/O):
```
cli composition root / argument parsing
└─ controller orchestration; depends ONLY on the two protocols below
├─ store HostKeyTrust ── composes ↓↓
│ ├─ keys RelayKeyProvider (HTTPS fetch + TTL cache)
│ └─ cache HostKeyCache (relayHostKey.txt, atomic, 0o600)
└─ tunnel TunnelFactory ── build_ssh_argv (pure) + SSH_ASKPASS + child process
models immutable domain values + parsing (no I/O, thread-safe)
config immutable, validated configuration
errors one rooted exception hierarchy
```
**Dependency inversion.** `RelayController` names what it needs as `Protocol`s —
`HostKeyTrust` (store) and `TunnelFactory`/`Tunnel` (tunnel) — and is handed
concrete adapters by `RelayController.from_config`, the single composition root.
Every external concern (HTTP, filesystem, subprocess, clock) sits behind an
injected seam, so the orchestration is unit-tested with fakes and **no network,
disk, or process is touched** in the suite.
| Module | Responsibility | Depends on |
| --- | --- | --- |
| `errors` | exception taxonomy | — |
| `models` | `HostKey`, `RelayKey`, `parse_relay_pub`, endpoint formatting | `errors` |
| `config` | frozen, validated `RelayConfig` (token redacted from `repr`) | `errors` |
| `keys` | fetch `relay_v1.pub` (HTTPS-only, byte-capped, timed) + TTL cache | `errors`, `models` |
| `cache` | load/save `relayHostKey.txt` atomically at `0o600` | `errors`, `models` |
| `store` | `HostKeyManager`: compose key + cache, pin endpoints | `keys`, `cache`, `models` |
| `tunnel` | `build_ssh_argv` (pure) + askpass + `SubprocessTunnel` | `config`, `errors` |
| `controller` | connect / reap / stop lifecycle, thread-safety | the protocols above |
| `cli` | wire adapters, parse args | everything |
### Binary → code map
| Binary symbol | Address | Code |
| --- | --- | --- |
| `RelayController` ctor (loads cache) | `0x122FDDA` | `HostKeyCache.load` + `HostKeyManager.__init__` |
| `RelayController_connect` | `0x12307F2` | `RelayController.connect` + `store` + `tunnel` |
| stopRelay | `0x123068C` | `RelayController.stop` |
| inactive-connection reaper (300s) | `0x12320EE` | `RelayController.reap_once` / `_reaper_tick` |
| `relayHostKey.txt` path | `0x1231FEE` | `RelayConfig.cache_path` |
| `startRelay` PubSub dispatch | `0xF5BE3C` | `RelayController.start_relay` |
---
## Error model
All failures derive from `RelayError`, so callers catch the subsystem broadly or
a specific mode. Adapter exceptions (`urllib`, `OSError`, `subprocess`) are
caught at the boundary and re-raised as domain errors — they never leak.
- `ConfigError` — invalid configuration (raised eagerly in `RelayConfig`).
- `RelayKeyError` — relay key fetch/parse (non-HTTPS, oversize, transport, bad format).
- `HostKeyCacheError` — unreadable/malformed cache (load auto-rebuilds, as PMS does).
- `TunnelError` — `ssh` could not be launched.
**Contract:** `connect()` *raises* on failure (library callers decide).
`start_relay()` — the plex.tv event entry — is *resilient*: it logs and returns
`False`, mirroring PMS so a bad event can't kill an event loop.
---
## Security
- **Credential never on the command line.** The token is passed to `ssh` only
via the `PLEXTOKEN` env var, read by a generated `SSH_ASKPASS` helper. The
helper is mode `0o700` and removed on stop *and* via a `weakref.finalize`, so a
crash can't leak it. `RelayConfig` excludes the token from `repr`.
- **Fetch hardening.** The relay-key URL is HTTPS-only by default (configurable
URLs are an SSRF surface), the response is byte-capped, and the request is
time-limited.
- **Trust-store integrity.** `relayHostKey.txt` is written atomically at `0o600`;
it pins the host key `ssh` verifies, so it must not be world-writable.
- **No shell.** Processes are spawned from an argv list, never a shell string.
---
## Performance & scale
- **At most one key fetch per TTL**, behind a lock, on a **monotonic** clock
(immune to wall-clock jumps).
- **Disk writes only on change** — re-pinning an unchanged endpoint is a no-op.
- **O(1) liveness** via `Popen.poll()`; the reaper is a single background
`threading.Timer` that sweeps O(n) connections every 300s and reschedules
itself only while connections remain (idle controllers spawn no timers).
- **Immutable domain + value objects** are freely shareable across threads;
mutable state lives behind one `RLock`.
---
## Install & test
```bash
cd plex_relay
python -m pip install -e ".[test]"
python -m pytest -q # 51 tests + 1 POSIX-only; no network, no ssh
```
## Use
```bash
export PLEX_RELAY_TOKEN=... # keep the secret off the cmdline
plex-relay show --host RELAY_HOST --user MY_ID # dry run: resolve key + print argv
plex-relay connect --host RELAY_HOST --user MY_ID --local-port 32400
```
```python
from plex_relay import RelayConfig, RelayController
with RelayController.from_config(RelayConfig(token="…", ssh_user="my-id")) as ctrl:
ctrl.start_relay("relay.example.net", 443) # gated + resilient, like PMS
```
For tests or custom transports, bypass the composition root and inject your own
collaborators: `RelayController(config, hostkeys=…, tunnels=…)`.
---
## Fidelity & limitations
- **Faithful:** ssh argv (order + flags), the `relayHostKey.txt` format, the 24h
key cache, per-endpoint pinning, the dedup / 300s reaper lifecycle, and the
`startRelay` gating.
- **Adapted, with intent:** the known_hosts endpoint is keyed by the actual ssh
port (the binary hardcodes `:443`); the local forward target is configurable
(the binary reads the PMS port from its own config); TTLs use a monotonic clock.
- **POSIX only:** password delivery uses `SSH_ASKPASS`, which Windows OpenSSH
does not honour.
- **Not a turnkey relay swap:** this is only the *server→relay* leg. Plex brokers
both ends, so pointing it at your own relay also needs a relay `sshd` you
control plus client-discovery redirection (see the parent project's notes).
+5
View File
@@ -0,0 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "src"))
+39
View File
@@ -0,0 +1,39 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "plex-relay"
version = "0.2.0"
description = "Reverse-engineered reimplementation of the Plex Media Server RelayController (educational / RE use)."
readme = "README.md"
requires-python = ">=3.10"
license = { text = "AGPL-3.0-or-later" }
authors = [{ name = "Plex_Patch RE notes" }]
dependencies = [] # stdlib only
[project.optional-dependencies]
test = ["pytest>=7"]
[project.scripts]
plex-relay = "plex_relay.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
plex_relay = ["py.typed"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.10"
warn_unused_ignores = true
warn_redundant_casts = true
disallow_untyped_defs = true
[tool.ruff]
line-length = 100
target-version = "py310"
@@ -0,0 +1,46 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""plex_relay -- a reverse-engineered reimplementation of the Plex Media Server
``RelayController`` (Linux x86-64, build 1.43.2.10687).
Layering (high cohesion, dependency inversion top-to-bottom):
cli composition root / argument parsing
controller orchestration; depends only on the protocols below
store HostKeyTrust: composes the key provider + the disk cache
keys relay key acquisition (HTTPS fetch + TTL cache)
cache relayHostKey.txt persistence (atomic, 0o600)
tunnel ssh argv (pure) + SSH_ASKPASS + child-process tunnel
models immutable domain values + parsing (no I/O)
config immutable, validated configuration
errors one rooted exception hierarchy
Binary provenance of the key symbols is documented in each module and in
``README.md``. Ships no Plex code; authenticates to nothing on its own.
"""
from .config import RelayConfig
from .controller import RelayController
from .errors import (
ConfigError,
HostKeyCacheError,
RelayError,
RelayKeyError,
TunnelError,
)
from .models import HostKey, RelayKey, known_hosts_endpoint, parse_relay_pub
from .tunnel import build_ssh_argv
__all__ = [
"RelayConfig",
"RelayController",
"HostKey",
"RelayKey",
"known_hosts_endpoint",
"parse_relay_pub",
"build_ssh_argv",
"RelayError",
"ConfigError",
"RelayKeyError",
"HostKeyCacheError",
"TunnelError",
]
__version__ = "0.2.0"
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""On-disk known_hosts cache (``relayHostKey.txt``) -- file I/O only.
This module owns the file format and the filesystem; it knows nothing about
HTTP, TTLs, or ssh. The file doubles as PMS's cache and the OpenSSH
``UserKnownHostsFile`` (``#`` lines are valid known_hosts comments).
Writes are atomic (write-temp-then-rename) and the file is mode ``0o600`` --
it pins the keys ssh will trust, so it must not be world-writable.
"""
from __future__ import annotations
import logging
import os
import stat
import tempfile
from pathlib import Path
from typing import Iterable, Mapping
from .errors import HostKeyCacheError
from .models import HostKey
log = logging.getLogger("plex_relay.cache")
def parse_known_hosts(lines: Iterable[str]) -> dict[str, HostKey]:
"""Parse ``# <endpoint>`` + ``<host> <keytype> <keydata>`` line pairs.
:raises HostKeyCacheError: on any structural violation (mirrors the binary,
which discards and rebuilds a malformed file).
"""
items = [ln.rstrip("\n") for ln in lines if ln.strip()]
entries: dict[str, HostKey] = {}
i = 0
while i < len(items):
comment = items[i]
if not comment.startswith("#"):
raise HostKeyCacheError(f"expected '# <endpoint>' marker, got {comment!r}")
if i + 1 >= len(items):
raise HostKeyCacheError("comment marker without a following data line")
tokens = items[i + 1].split()
if len(tokens) != 3:
raise HostKeyCacheError(f"data line part count incorrect: {items[i + 1]!r}")
endpoint = comment[1:].strip() or tokens[0]
host, keytype, keydata = tokens
entries[endpoint] = HostKey(host, keytype, keydata)
i += 2
return entries
class HostKeyCache:
"""Load/store relay host keys from a single known_hosts-format file."""
def __init__(self, path: Path) -> None:
self._path = Path(path)
@property
def path(self) -> Path:
return self._path
def load(self) -> dict[str, HostKey]:
"""Return cached entries; a malformed file is dropped and rebuilt empty."""
if not self._path.exists():
return {}
try:
text = self._path.read_text("utf-8", "replace")
except OSError as exc:
raise HostKeyCacheError(f"cannot read {self._path}: {exc}") from exc
try:
entries = parse_known_hosts(text.splitlines())
except HostKeyCacheError as exc:
log.warning("host key file malformed, rebuilding: %s", exc)
self.save({})
return {}
log.info("read %d cached host key entries", len(entries))
return entries
def save(self, entries: Mapping[str, HostKey]) -> None:
"""Atomically write ``entries`` to the cache file with mode 0o600."""
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
blocks = "".join(entries[k].cache_block() for k in sorted(entries))
fd, tmp_name = tempfile.mkstemp(
dir=self._path.parent, prefix=self._path.name + ".", suffix=".tmp"
)
tmp = Path(tmp_name)
try:
os.write(fd, blocks.encode("utf-8"))
finally:
os.close(fd)
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR) # 0o600
os.replace(tmp, self._path)
except OSError as exc:
raise HostKeyCacheError(f"cannot write {self._path}: {exc}") from exc
__all__ = ["HostKeyCache", "parse_known_hosts"]
+126
View File
@@ -0,0 +1,126 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Command-line composition root.
plex-relay show --host H [--port 443] ... # resolve key + print ssh argv (no spawn)
plex-relay connect --host H [--port 443] ... # establish and hold the tunnel
The token is read from ``$PLEX_RELAY_TOKEN`` by default so it never appears in
the process list; ``--token`` overrides. ``show`` is a safe dry run.
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import threading
from pathlib import Path
from .config import DEFAULT_LOCAL_HOST, DEFAULT_LOCAL_PORT, DEFAULT_RELAY_KEY_URL, RelayConfig
from .errors import RelayError
from .keys import HttpsRelayKeyFetcher, RelayKeyProvider
from .models import known_hosts_endpoint
from .tunnel import build_ssh_argv
log = logging.getLogger("plex_relay.cli")
def _add_common(p: argparse.ArgumentParser) -> None:
p.add_argument("--host", required=True, help="relay host to dial")
p.add_argument("--port", type=int, default=443, help="relay SSH port (default 443)")
p.add_argument("--user", required=True, help="relay SSH login (PMS: MyPlex identity)")
p.add_argument("--token", default=os.environ.get("PLEX_RELAY_TOKEN", ""),
help="relay password (default: $PLEX_RELAY_TOKEN)")
p.add_argument("--local-host", default=DEFAULT_LOCAL_HOST)
p.add_argument("--local-port", type=int, default=DEFAULT_LOCAL_PORT)
p.add_argument("--data-dir", type=Path, default=Path.home() / ".plex_relay")
p.add_argument("--key-url", default=DEFAULT_RELAY_KEY_URL)
p.add_argument("--insecure-key-url", action="store_true",
help="permit a non-HTTPS relay key URL (e.g. file:// for testing)")
p.add_argument("--ssh", default="ssh", help="ssh binary")
def _config(args: argparse.Namespace) -> RelayConfig:
return RelayConfig(
token=args.token or "dry-run",
ssh_user=args.user,
local_host=args.local_host,
local_port=args.local_port,
relay_key_url=args.key_url,
data_dir=args.data_dir,
ssh_binary=args.ssh,
allow_insecure_key_url=args.insecure_key_url,
)
def _cmd_show(args: argparse.Namespace) -> int:
cfg = _config(args)
provider = RelayKeyProvider(
cfg.relay_key_url,
cfg.key_ttl_seconds,
fetcher=HttpsRelayKeyFetcher(
timeout=cfg.connect_timeout_seconds, allow_insecure=cfg.allow_insecure_key_url
),
)
endpoint = known_hosts_endpoint(args.host, args.port)
try:
key = provider.get()
print(f"relay key : {key.keytype} {key.keydata[:24]}... (from {cfg.relay_key_url})")
print(f"known_hosts : {endpoint} {key.keytype} {key.keydata[:24]}...")
except RelayError as exc:
print(f"relay key : <unavailable: {exc}>")
argv = build_ssh_argv(cfg, args.host, args.port, cfg.cache_path)
print("ssh argv :\n " + " ".join(argv))
print("env : PLEXTOKEN=*** SSH_ASKPASS=<helper> SSH_ASKPASS_REQUIRE=force")
return 0
def _cmd_connect(args: argparse.Namespace) -> int:
if not args.token:
print("error: no token (set $PLEX_RELAY_TOKEN or --token)", file=sys.stderr)
return 2
from .controller import RelayController
cfg = _config(args)
stop = threading.Event()
with RelayController.from_config(cfg) as ctrl:
if not ctrl.start_relay(args.host, args.port):
print("error: relay did not start (gating, already active, or failure)", file=sys.stderr)
return 1
print(f"relay up: {args.host}:{args.port} -> {cfg.local_host}:{cfg.local_port} (Ctrl-C to stop)")
signal.signal(signal.SIGINT, lambda *_: stop.set())
signal.signal(signal.SIGTERM, lambda *_: stop.set())
while not stop.is_set():
stop.wait(2.0)
if not ctrl.active_hosts:
print("relay connection ended", file=sys.stderr)
return 1
print("relay stopped")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="plex-relay", description=__doc__)
parser.add_argument("-v", "--verbose", action="store_true")
sub = parser.add_subparsers(dest="cmd", required=True)
for name, func, help_ in (("show", _cmd_show, "resolve key + print ssh argv (no spawn)"),
("connect", _cmd_connect, "establish and hold the relay tunnel")):
sp = sub.add_parser(name, help=help_)
_add_common(sp)
sp.set_defaults(func=func)
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s %(name)s: %(message)s",
)
try:
return int(args.func(args))
except RelayError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,85 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Immutable, validated runtime configuration.
Frozen so it can be shared freely across threads and never mutated behind a
collaborator's back. The relay credential is excluded from ``repr`` so it cannot
leak into logs or tracebacks.
Defaults mirror constants observed in ``RelayController_connect`` (``0x12307F2``):
relay key URL, the 24h key TTL (``86400000000`` us) and the 300s reaper
(``300000000`` us). The reverse-forward target (``0:127.0.0.1:%d``) is the PMS
service port, which the binary reads from its own config -- hence configurable.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from .errors import ConfigError
DEFAULT_RELAY_KEY_URL = "https://downloads.plex.tv/relay/relay_v1.pub"
DEFAULT_KEY_TTL_SECONDS = 86_400.0
DEFAULT_REAP_INTERVAL_SECONDS = 300.0
DEFAULT_LOCAL_HOST = "127.0.0.1"
DEFAULT_LOCAL_PORT = 32400
CACHE_FILENAME = "relayHostKey.txt"
def _default_data_dir() -> Path:
return Path.home() / ".plex_relay"
@dataclass(frozen=True, slots=True)
class RelayConfig:
"""Everything :class:`~plex_relay.controller.RelayController` needs to run."""
token: str = field(repr=False) # SSH password (PLEXTOKEN); never logged
ssh_user: str
local_host: str = DEFAULT_LOCAL_HOST
local_port: int = DEFAULT_LOCAL_PORT
relay_key_url: str = DEFAULT_RELAY_KEY_URL
key_ttl_seconds: float = DEFAULT_KEY_TTL_SECONDS
reap_interval_seconds: float = DEFAULT_REAP_INTERVAL_SECONDS
data_dir: Path = field(default_factory=_default_data_dir)
ssh_binary: str = "ssh"
ssh_interface: str = "tailscale0"
connect_timeout_seconds: float = 15.0
stop_timeout_seconds: float = 5.0
allow_insecure_key_url: bool = False
# startRelay gating, mirroring ServerEventManager_handle_pubsub_event:
# signin_state == 4 && PublishServerOnPlexOnlineKey && RelayEnabled
signed_in: bool = True
published: bool = True
relay_enabled: bool = True
def __post_init__(self) -> None:
# Frozen dataclass: normalise/validate via object.__setattr__.
object.__setattr__(self, "data_dir", Path(self.data_dir))
if not self.token:
raise ConfigError("token is required (relay SSH password)")
if not self.ssh_user:
raise ConfigError("ssh_user is required (relay SSH login)")
if not 0 < self.local_port < 65536:
raise ConfigError(f"local_port out of range: {self.local_port}")
for name in ("key_ttl_seconds", "reap_interval_seconds",
"connect_timeout_seconds", "stop_timeout_seconds"):
if getattr(self, name) <= 0:
raise ConfigError(f"{name} must be positive")
@property
def cache_path(self) -> Path:
"""``<data_dir>/relayHostKey.txt`` (the binary's ``sub_1231FEE``)."""
return self.data_dir / CACHE_FILENAME
def gating_ok(self) -> bool:
"""True iff all three startRelay preconditions hold."""
return self.signed_in and self.published and self.relay_enabled
__all__ = ["RelayConfig", "DEFAULT_RELAY_KEY_URL", "DEFAULT_LOCAL_HOST",
"DEFAULT_LOCAL_PORT", "CACHE_FILENAME"]
@@ -0,0 +1,162 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Relay controller -- the orchestration layer.
Depends only on abstractions (:class:`HostKeyTrust`, :class:`TunnelFactory`),
so the network, disk, and process concerns are all injected and substitutable.
:meth:`RelayController.from_config` is the composition root that wires the
default adapters together.
Concurrency: a single ``RLock`` (the binary's ``recursive_mutex``) guards the
connection table and the reaper, which runs on a background ``threading.Timer``
and reschedules itself only while connections remain.
Error contract: :meth:`connect` raises :class:`RelayError` on failure (callers
decide). :meth:`start_relay` -- the plex.tv event entry point -- is resilient:
it logs and returns ``False`` rather than letting an exception escape an event
loop, matching PMS's ``ServerEventManager`` behaviour.
"""
from __future__ import annotations
import logging
import threading
from .cache import HostKeyCache
from .config import RelayConfig
from .errors import RelayError
from .keys import RelayKeyProvider
from .store import HostKeyManager, HostKeyTrust
from .tunnel import SubprocessTunnelFactory, Tunnel, TunnelFactory
log = logging.getLogger("plex_relay.controller")
class RelayController:
"""Manages relay tunnels for one server: connect, reap, stop."""
def __init__(
self,
config: RelayConfig,
hostkeys: HostKeyTrust,
tunnels: TunnelFactory,
) -> None:
self._config = config
self._hostkeys = hostkeys
self._tunnels = tunnels
self._lock = threading.RLock()
self._connections: dict[str, Tunnel] = {}
self._reaper: threading.Timer | None = None
self._closed = False
@classmethod
def from_config(cls, config: RelayConfig) -> "RelayController":
"""Composition root: wire the default network/disk/process adapters."""
provider = RelayKeyProvider(
config.relay_key_url,
config.key_ttl_seconds,
fetcher=_default_fetcher(config),
)
manager = HostKeyManager(provider, HostKeyCache(config.cache_path))
return cls(config, manager, SubprocessTunnelFactory(config))
# -- entry points ------------------------------------------------------
def start_relay(self, host: str, port: int = 443) -> bool:
"""plex.tv ``startRelay`` handler: gated and resilient."""
if not self._config.gating_ok():
log.info(
"startRelay ignored (signed_in=%s published=%s relay_enabled=%s)",
self._config.signed_in, self._config.published, self._config.relay_enabled,
)
return False
try:
return self.connect(host, port)
except RelayError as exc:
log.error("relay to %s failed: %s", host, exc)
return False
def connect(self, host: str, port: int = 443) -> bool:
"""Establish a tunnel to ``host``. Returns False if already active.
:raises RelayError: if the key cannot be obtained or ssh cannot launch.
"""
with self._lock:
if self._closed:
raise RelayError("controller is closed")
existing = self._connections.get(host)
if existing is not None and existing.is_alive():
log.info("already have an active relay connection to %s", host)
return False
self._hostkeys.ensure_trusted(host, port)
tunnel = self._tunnels(host, port, self._hostkeys.known_hosts_path)
tunnel.start()
self._connections[host] = tunnel
self._arm_reaper()
return True
def stop(self) -> None:
"""Cancel the reaper and tear down every tunnel. Idempotent."""
with self._lock:
self._closed = True
self._cancel_reaper()
connections, self._connections = self._connections, {}
for tunnel in connections.values():
tunnel.stop(self._config.stop_timeout_seconds)
# -- reaper ------------------------------------------------------------
def reap_once(self) -> list[str]:
"""Drop finished tunnels; return the hosts removed."""
with self._lock:
dead = [h for h, t in self._connections.items() if not t.is_alive()]
stopped = [self._connections.pop(h) for h in dead]
for tunnel in stopped:
log.info("cleaning up inactive relay connection to %s", tunnel.host)
tunnel.stop(self._config.stop_timeout_seconds)
return dead
def _arm_reaper(self) -> None:
if self._reaper is None and self._connections and not self._closed:
self._schedule_reaper()
def _schedule_reaper(self) -> None:
timer = threading.Timer(self._config.reap_interval_seconds, self._reaper_tick)
timer.daemon = True
self._reaper = timer
timer.start()
def _reaper_tick(self) -> None:
self.reap_once()
with self._lock:
self._reaper = None
if self._connections and not self._closed:
self._schedule_reaper()
def _cancel_reaper(self) -> None:
if self._reaper is not None:
self._reaper.cancel()
self._reaper = None
# -- introspection -----------------------------------------------------
@property
def active_hosts(self) -> list[str]:
with self._lock:
return sorted(h for h, t in self._connections.items() if t.is_alive())
def __enter__(self) -> "RelayController":
return self
def __exit__(self, *exc: object) -> None:
self.stop()
def _default_fetcher(config: RelayConfig):
from .keys import HttpsRelayKeyFetcher
return HttpsRelayKeyFetcher(
timeout=config.connect_timeout_seconds,
allow_insecure=config.allow_insecure_key_url,
)
__all__ = ["RelayController"]
@@ -0,0 +1,38 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Exception hierarchy for :mod:`plex_relay`.
A single rooted hierarchy lets callers catch the whole subsystem
(``except RelayError``) or a specific failure mode, and keeps adapter-specific
exceptions (``urllib``, ``OSError``, ``subprocess``) from leaking across module
boundaries.
"""
from __future__ import annotations
class RelayError(Exception):
"""Base class for every error raised by this package."""
class ConfigError(RelayError):
"""Invalid configuration (bad port, empty credential, ...)."""
class RelayKeyError(RelayError):
"""The relay host key could not be fetched or parsed."""
class HostKeyCacheError(RelayError):
"""The on-disk known_hosts cache is unreadable or malformed."""
class TunnelError(RelayError):
"""The ssh relay tunnel could not be launched."""
__all__ = [
"RelayError",
"ConfigError",
"RelayKeyError",
"HostKeyCacheError",
"TunnelError",
]
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Relay host-key acquisition: fetch ``relay_v1.pub`` and cache it with a TTL.
Separated from on-disk caching (:mod:`plex_relay.cache`) so the network policy
(HTTPS-only, byte-capped, time-limited) and the freshness policy (TTL) live in
one cohesive place and can be swapped wholesale in tests via the injected
``fetcher``/``clock`` seams.
"""
from __future__ import annotations
import logging
import threading
import time
import urllib.parse
import urllib.request
from typing import Callable, Protocol
from .errors import RelayKeyError
from .models import RelayKey, parse_relay_pub
log = logging.getLogger("plex_relay.keys")
#: Fetches the raw body of a relay-key URL. The seam that tests stub.
Fetcher = Callable[[str], str]
#: Monotonic time source (seconds). Monotonic so TTLs survive wall-clock jumps.
Clock = Callable[[], float]
_MAX_KEY_BYTES = 64 * 1024 # a host key is a few hundred bytes; cap to bound I/O
class _Opener(Protocol):
def __call__(self, url: str, timeout: float): ... # pragma: no cover
class HttpsRelayKeyFetcher:
"""Default fetcher: HTTPS-only, time-limited, response-size-capped.
Hardened against the obvious abuse of a configurable URL: non-HTTPS schemes
are refused unless explicitly allowed, the read is bounded, and every
transport failure is normalised to :class:`RelayKeyError`.
"""
def __init__(
self,
*,
timeout: float = 15.0,
max_bytes: int = _MAX_KEY_BYTES,
allow_insecure: bool = False,
opener: _Opener = urllib.request.urlopen,
) -> None:
self._timeout = timeout
self._max_bytes = max_bytes
self._allow_insecure = allow_insecure
self._opener = opener
def __call__(self, url: str) -> str:
scheme = urllib.parse.urlsplit(url).scheme.lower()
if scheme != "https" and not (self._allow_insecure and scheme in ("http", "file")):
raise RelayKeyError(f"refusing non-HTTPS relay key URL: {url!r}")
try:
with self._opener(url, timeout=self._timeout) as resp:
data = resp.read(self._max_bytes + 1)
except (OSError, ValueError) as exc:
raise RelayKeyError(f"failed to fetch relay key from {url!r}: {exc}") from exc
if len(data) > self._max_bytes:
raise RelayKeyError(f"relay key response exceeds {self._max_bytes} bytes")
return data.decode("utf-8", "replace")
class RelayKeyProvider:
"""Provides the relay :class:`RelayKey`, refreshing past a TTL.
Thread-safe: concurrent ``get()`` calls serialise on a lock so the key is
fetched at most once per TTL window even under contention.
"""
def __init__(
self,
url: str,
ttl_seconds: float,
fetcher: Fetcher | None = None,
clock: Clock = time.monotonic,
) -> None:
self._url = url
self._ttl = ttl_seconds
self._fetch = fetcher or HttpsRelayKeyFetcher()
self._clock = clock
self._lock = threading.Lock()
self._key: RelayKey | None = None
self._fetched_at = 0.0
def get(self, *, force: bool = False) -> RelayKey:
"""Return the relay key, fetching only when stale or ``force``."""
with self._lock:
now = self._clock()
if not force and self._key is not None and (now - self._fetched_at) < self._ttl:
log.debug("relay key reused (age %.0fs)", now - self._fetched_at)
return self._key
key = parse_relay_pub(self._fetch(self._url))
self._key, self._fetched_at = key, now
log.info("relay key refreshed from %s", self._url)
return key
__all__ = ["Fetcher", "Clock", "HttpsRelayKeyFetcher", "RelayKeyProvider"]
@@ -0,0 +1,110 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Pure domain model: value objects and parsing, no I/O.
Everything here is deterministic and side-effect free, so it is trivially
testable and safe to share across threads (all types are immutable).
Provenance: in ``Plex Media Server`` 1.43.2.10687, ``RelayController_connect``
(``0x12307F2``) downloads ``relay_v1.pub``, splits it into exactly three
whitespace tokens (rejecting otherwise -- the "part count incorrect" log), and
writes a per-endpoint OpenSSH known_hosts line ``[host]:443 <keytype> <keydata>``
into ``relayHostKey.txt``.
"""
from __future__ import annotations
from dataclasses import dataclass
from .errors import RelayKeyError
# Key types OpenSSH recognises. Used to disambiguate a public-key line
# ("keytype keydata comment") from a known_hosts line ("host keytype keydata").
_KEY_TYPES: frozenset[str] = frozenset(
{
"ssh-rsa",
"ssh-dss",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519-cert-v01@openssh.com",
}
)
def known_hosts_endpoint(host: str, port: int = 443) -> str:
"""Return the OpenSSH known_hosts host field for ``host:port``.
OpenSSH uses bracket notation for any non-default port. The binary hardcodes
``[host]:443``; keying by the actual port keeps the entry valid for relays on
other ports while remaining identical at 443.
"""
host = host.strip()
if not host:
raise RelayKeyError("empty relay host")
return host if port == 22 else f"[{host}]:{port}"
@dataclass(frozen=True, slots=True)
class RelayKey:
"""An SSH host key: an algorithm and its base64-encoded blob."""
keytype: str
keydata: str
@dataclass(frozen=True, slots=True)
class HostKey:
"""A single OpenSSH known_hosts entry for a relay endpoint."""
endpoint: str
keytype: str
keydata: str
@classmethod
def for_endpoint(cls, endpoint: str, key: RelayKey) -> "HostKey":
return cls(endpoint=endpoint, keytype=key.keytype, keydata=key.keydata)
@property
def key(self) -> RelayKey:
return RelayKey(self.keytype, self.keydata)
def known_hosts_line(self) -> str:
"""The line OpenSSH consumes: ``<endpoint> <keytype> <keydata>``."""
return f"{self.endpoint} {self.keytype} {self.keydata}"
def cache_block(self) -> str:
"""PMS ``relayHostKey.txt`` representation: ``# <endpoint>`` + data line."""
return f"# {self.endpoint}\n{self.known_hosts_line()}\n"
def parse_relay_pub(body: str) -> RelayKey:
"""Parse a ``relay_v1.pub`` payload into a :class:`RelayKey`.
Accepts both the OpenSSH public-key form (``keytype keydata comment``) and
the known_hosts form (``host keytype keydata``), disambiguated by which token
is a recognised key type.
:raises RelayKeyError: if no usable key line is present (mirrors the binary's
"part count incorrect" rejection).
"""
for raw in body.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
tokens = line.split()
if len(tokens) < 3:
raise RelayKeyError(
f"relay key: part count incorrect ({len(tokens)} tokens)"
)
if tokens[0] in _KEY_TYPES: # keytype keydata comment
return RelayKey(tokens[0], tokens[1])
if tokens[1] in _KEY_TYPES: # host keytype keydata
return RelayKey(tokens[1], tokens[2])
raise RelayKeyError("relay key: no recognised key type in payload")
raise RelayKeyError("relay key: payload contained no key line")
__all__ = ["RelayKey", "HostKey", "known_hosts_endpoint", "parse_relay_pub"]
Whitespace-only changes.
@@ -0,0 +1,65 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Host-key trust management -- composes the key provider and the disk cache.
This is the seam the controller depends on (the :class:`HostKeyTrust` protocol):
"make sure ssh will trust this relay endpoint, and tell me which file to hand
it." It owns no I/O of its own; it wires together :class:`RelayKeyProvider`
(network + TTL) and :class:`HostKeyCache` (disk), keeping each collaborator
single-purpose and independently testable.
"""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import Protocol
from .cache import HostKeyCache
from .keys import RelayKeyProvider
from .models import HostKey, known_hosts_endpoint
log = logging.getLogger("plex_relay.store")
class HostKeyTrust(Protocol):
"""What the controller requires to make a relay endpoint trusted by ssh."""
def ensure_trusted(self, host: str, port: int = 443, *, force: bool = False) -> HostKey: ...
@property
def known_hosts_path(self) -> Path: ...
class HostKeyManager:
"""Default :class:`HostKeyTrust`: refresh key, pin endpoint, persist on change."""
def __init__(self, provider: RelayKeyProvider, cache: HostKeyCache) -> None:
self._provider = provider
self._cache = cache
self._lock = threading.Lock()
self._entries: dict[str, HostKey] = cache.load()
@property
def known_hosts_path(self) -> Path:
return self._cache.path
@property
def entries(self) -> dict[str, HostKey]:
with self._lock:
return dict(self._entries)
def ensure_trusted(self, host: str, port: int = 443, *, force: bool = False) -> HostKey:
"""Ensure ``relayHostKey.txt`` trusts ``host:port``; persist iff changed."""
key = self._provider.get(force=force)
endpoint = known_hosts_endpoint(host, port)
entry = HostKey.for_endpoint(endpoint, key)
with self._lock:
if self._entries.get(endpoint) == entry:
return entry
self._entries[endpoint] = entry
self._cache.save(self._entries)
log.info("pinned relay host key for %s", endpoint)
return entry
__all__ = ["HostKeyTrust", "HostKeyManager"]
@@ -0,0 +1,207 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""The relay tunnel adapter: build the ssh argv and run it as a child process.
``build_ssh_argv`` is a pure function (the byte-for-byte reproduction of the
argv ``RelayController_connect`` assembles), kept separate from process control
so it is testable without spawning anything.
Security: the relay credential is delivered to ssh via the ``PLEXTOKEN``
environment variable read by a generated ``SSH_ASKPASS`` helper -- it never
appears on a command line or in the argv list. The helper is mode ``0o700`` and
is removed on stop *and* via a finaliser, so a crash cannot leak it.
"""
from __future__ import annotations
import logging
import os
import stat
import subprocess
import tempfile
import weakref
from pathlib import Path
from typing import Callable, Mapping, Protocol
from .config import RelayConfig
from .errors import TunnelError
log = logging.getLogger("plex_relay.tunnel")
_ASKPASS_SCRIPT = "#!/bin/sh\nprintf '%s' \"$PLEXTOKEN\"\n"
class ProcessHandle(Protocol):
"""The slice of ``subprocess.Popen`` the tunnel relies on."""
def poll(self) -> int | None: ...
def terminate(self) -> None: ...
def kill(self) -> None: ...
def wait(self, timeout: float | None = ...) -> int: ...
#: Launches a child process from an argv + environment. The seam tests stub.
Spawner = Callable[[list[str], Mapping[str, str]], ProcessHandle]
def build_ssh_argv(
config: RelayConfig, host: str, port: int, known_hosts_path: Path
) -> list[str]:
"""Assemble the ssh reverse-tunnel argv, exactly as the binary does.
The known_hosts path uses POSIX separators (identical on Linux; OpenSSH
accepts forward slashes everywhere).
"""
return [
config.ssh_binary,
"-p", str(port),
"-N",
"-R", f"0:{config.local_host}:{config.local_port}",
"-o", f"UserKnownHostsFile={Path(known_hosts_path).as_posix()}",
"-o", "LogLevel=VERBOSE",
"-o", "PreferredAuthentications=password",
"-o", "PubkeyAuthentication=no",
"-l", config.ssh_user,
"-F", "/dev/null",
host,
]
class _Askpass:
"""A short-lived, self-cleaning SSH_ASKPASS helper script."""
def __init__(self, token: str) -> None:
fd, name = tempfile.mkstemp(prefix="plex_relay_askpass_", suffix=".sh")
try:
os.write(fd, _ASKPASS_SCRIPT.encode("ascii"))
finally:
os.close(fd)
self.path = Path(name)
self.path.chmod(stat.S_IRWXU) # 0o700
self._token = token
self._finalizer = weakref.finalize(self, _unlink, self.path)
def env(self, base: Mapping[str, str]) -> dict[str, str]:
env = dict(base)
env.update(
PLEXTOKEN=self._token,
SSH_ASKPASS=str(self.path),
SSH_ASKPASS_REQUIRE="force",
DISPLAY=base.get("DISPLAY", ":0"),
)
return env
def cleanup(self) -> None:
self._finalizer()
def _unlink(path: Path) -> None:
try:
path.unlink()
except OSError:
pass
def _default_spawner(argv: list[str], env: Mapping[str, str]) -> ProcessHandle:
# Detach from the controlling tty so ssh uses SSH_ASKPASS for the password.
return subprocess.Popen( # noqa: S603 - argv is fully built; no shell
argv,
env=dict(env),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
start_new_session=True,
)
class Tunnel(Protocol):
"""Lifecycle of a single relay tunnel."""
@property
def host(self) -> str: ...
def start(self) -> None: ...
def is_alive(self) -> bool: ...
def stop(self, timeout: float | None = ...) -> None: ...
class TunnelFactory(Protocol):
def __call__(self, host: str, port: int, known_hosts_path: Path) -> Tunnel: ...
class SubprocessTunnel:
"""A relay tunnel backed by a child ``ssh`` process."""
def __init__(
self,
config: RelayConfig,
host: str,
port: int,
known_hosts_path: Path,
spawner: Spawner,
) -> None:
self._config = config
self._host = host
self._port = port
self._known_hosts = Path(known_hosts_path)
self._spawn = spawner
self._proc: ProcessHandle | None = None
self._askpass: _Askpass | None = None
@property
def host(self) -> str:
return self._host
@property
def argv(self) -> list[str]:
return build_ssh_argv(self._config, self._host, self._port, self._known_hosts)
def start(self) -> None:
if self.is_alive():
return
askpass = _Askpass(self._config.token)
try:
log.info("starting relay tunnel to %s:%d", self._host, self._port)
self._proc = self._spawn(self.argv, askpass.env(os.environ))
except OSError as exc:
askpass.cleanup()
raise TunnelError(f"failed to launch ssh for {self._host}: {exc}") from exc
self._askpass = askpass
def is_alive(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def stop(self, timeout: float | None = 5.0) -> None:
proc, self._proc = self._proc, None
if proc is not None:
log.info("stopping relay tunnel to %s", self._host)
try:
proc.terminate()
proc.wait(timeout=timeout)
except Exception: # noqa: BLE001 - escalate to kill on any wait failure
try:
proc.kill()
except OSError:
pass
if self._askpass is not None:
self._askpass.cleanup()
self._askpass = None
class SubprocessTunnelFactory:
"""Default :class:`TunnelFactory` producing :class:`SubprocessTunnel`."""
def __init__(self, config: RelayConfig, spawner: Spawner | None = None) -> None:
self._config = config
self._spawner = spawner or _default_spawner
def __call__(self, host: str, port: int, known_hosts_path: Path) -> SubprocessTunnel:
return SubprocessTunnel(self._config, host, port, known_hosts_path, self._spawner)
__all__ = [
"ProcessHandle",
"Spawner",
"Tunnel",
"TunnelFactory",
"SubprocessTunnel",
"SubprocessTunnelFactory",
"build_ssh_argv",
]
+59
View File
@@ -0,0 +1,59 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import os
import stat
import pytest
from plex_relay.cache import HostKeyCache, parse_known_hosts
from plex_relay.errors import HostKeyCacheError
from plex_relay.models import HostKey
def _entries():
return {
"[1.2.3.4]:443": HostKey("[1.2.3.4]:443", "ssh-ed25519", "AAAAfirst"),
"[5.6.7.8]:443": HostKey("[5.6.7.8]:443", "ssh-ed25519", "AAAAsecond"),
}
def test_save_load_roundtrip(tmp_path):
cache = HostKeyCache(tmp_path / "relayHostKey.txt")
cache.save(_entries())
loaded = HostKeyCache(tmp_path / "relayHostKey.txt").load()
assert loaded == _entries()
def test_save_is_sorted_and_blocked(tmp_path):
p = tmp_path / "relayHostKey.txt"
HostKeyCache(p).save(_entries())
text = p.read_text()
assert text.index("[1.2.3.4]") < text.index("[5.6.7.8]")
assert "# [1.2.3.4]:443\n[1.2.3.4]:443 ssh-ed25519 AAAAfirst\n" in text
@pytest.mark.skipif(os.name != "posix", reason="POSIX file modes only")
def test_save_is_0600(tmp_path):
p = tmp_path / "relayHostKey.txt"
HostKeyCache(p).save(_entries())
assert stat.S_IMODE(os.stat(p).st_mode) == 0o600
def test_missing_file_loads_empty(tmp_path):
assert HostKeyCache(tmp_path / "nope.txt").load() == {}
def test_malformed_file_is_rebuilt_empty(tmp_path):
p = tmp_path / "relayHostKey.txt"
p.write_text("not a comment\ngarbage line\n")
assert HostKeyCache(p).load() == {}
assert p.read_text() == ""
@pytest.mark.parametrize("lines", [
["data without marker"],
["# marker only"],
["# marker", "too many tokens here now"],
])
def test_parse_rejects_malformed(lines):
with pytest.raises(HostKeyCacheError):
parse_known_hosts(lines)
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import dataclasses
import pytest
from plex_relay.config import RelayConfig
from plex_relay.errors import ConfigError
def test_valid_config_and_cache_path(tmp_path):
cfg = RelayConfig(token="t", ssh_user="u", data_dir=tmp_path)
assert cfg.cache_path == tmp_path / "relayHostKey.txt"
assert cfg.gating_ok() is True
@pytest.mark.parametrize("kwargs", [
{"token": "", "ssh_user": "u"},
{"token": "t", "ssh_user": ""},
{"token": "t", "ssh_user": "u", "local_port": 0},
{"token": "t", "ssh_user": "u", "local_port": 70000},
{"token": "t", "ssh_user": "u", "key_ttl_seconds": 0},
{"token": "t", "ssh_user": "u", "reap_interval_seconds": -1},
])
def test_invalid_config_raises(kwargs):
with pytest.raises(ConfigError):
RelayConfig(**kwargs)
def test_gating_requires_all_three():
base = dict(token="t", ssh_user="u")
assert RelayConfig(**base, relay_enabled=False).gating_ok() is False
assert RelayConfig(**base, published=False).gating_ok() is False
assert RelayConfig(**base, signed_in=False).gating_ok() is False
def test_token_is_not_in_repr():
cfg = RelayConfig(token="SUPERSECRET", ssh_user="u")
assert "SUPERSECRET" not in repr(cfg)
def test_config_is_frozen():
cfg = RelayConfig(token="t", ssh_user="u")
with pytest.raises(dataclasses.FrozenInstanceError):
cfg.local_port = 1 # type: ignore[misc]
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from pathlib import Path
import pytest
from plex_relay.config import RelayConfig
from plex_relay.controller import RelayController
from plex_relay.errors import RelayError, TunnelError
from plex_relay.models import HostKey, RelayKey, known_hosts_endpoint
class FakeTunnel:
def __init__(self, host, fail=False):
self._host = host
self._fail = fail
self.alive = False
self.stopped = False
@property
def host(self):
return self._host
def start(self):
if self._fail:
raise TunnelError("spawn failed")
self.alive = True
def is_alive(self):
return self.alive
def stop(self, timeout=None):
self.stopped = True
self.alive = False
class FakeFactory:
def __init__(self):
self.fail = False
self.created = []
def __call__(self, host, port, known_hosts_path):
t = FakeTunnel(host, fail=self.fail)
self.created.append(t)
return t
class FakeTrust:
def __init__(self):
self.calls = []
def ensure_trusted(self, host, port=443, *, force=False):
self.calls.append((host, port))
return HostKey.for_endpoint(known_hosts_endpoint(host, port), RelayKey("ssh-ed25519", "k"))
@property
def known_hosts_path(self):
return Path("/k")
def build(**cfgkw):
cfg = RelayConfig(token="t", ssh_user="u", reap_interval_seconds=999.0, **cfgkw)
trust, factory = FakeTrust(), FakeFactory()
return RelayController(cfg, trust, factory), trust, factory
def test_connect_tracks_and_trusts():
ctrl, trust, factory = build()
try:
assert ctrl.connect("relay.example", 443) is True
assert ctrl.active_hosts == ["relay.example"]
assert trust.calls == [("relay.example", 443)]
assert len(factory.created) == 1
finally:
ctrl.stop()
def test_connect_dedup_when_alive():
ctrl, _, factory = build()
try:
assert ctrl.connect("h") is True
assert ctrl.connect("h") is False
assert len(factory.created) == 1
finally:
ctrl.stop()
def test_reconnect_after_death():
ctrl, _, factory = build()
try:
ctrl.connect("h")
factory.created[0].alive = False # tunnel died
assert ctrl.connect("h") is True
assert len(factory.created) == 2
finally:
ctrl.stop()
def test_connect_raises_on_tunnel_failure():
ctrl, _, factory = build()
factory.fail = True
with pytest.raises(RelayError):
ctrl.connect("h")
assert ctrl.active_hosts == []
ctrl.stop()
def test_start_relay_blocked_by_gating():
for kw in ({"relay_enabled": False}, {"published": False}, {"signed_in": False}):
ctrl, trust, _ = build(**kw)
assert ctrl.start_relay("h") is False
assert trust.calls == []
ctrl.stop()
def test_start_relay_is_resilient_to_failure():
ctrl, _, factory = build()
factory.fail = True
assert ctrl.start_relay("h") is False # logs + swallows, does not raise
ctrl.stop()
def test_start_relay_ok():
ctrl, _, _ = build()
try:
assert ctrl.start_relay("h", 443) is True
assert ctrl.active_hosts == ["h"]
finally:
ctrl.stop()
def test_reaper_removes_dead():
ctrl, _, factory = build()
try:
ctrl.connect("a")
ctrl.connect("b")
factory.created[0].alive = False
assert ctrl.reap_once() == ["a"]
assert ctrl.active_hosts == ["b"]
assert factory.created[0].stopped is True
finally:
ctrl.stop()
def test_stop_terminates_all_and_closes():
ctrl, _, factory = build()
ctrl.connect("a")
ctrl.connect("b")
ctrl.stop()
assert ctrl.active_hosts == []
assert all(t.stopped for t in factory.created)
with pytest.raises(RelayError):
ctrl.connect("c")
def test_from_config_builds_controller(tmp_path):
cfg = RelayConfig(token="t", ssh_user="u", data_dir=tmp_path)
assert isinstance(RelayController.from_config(cfg), RelayController)
+70
View File
@@ -0,0 +1,70 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import pytest
from plex_relay.errors import RelayKeyError
from plex_relay.keys import HttpsRelayKeyFetcher, RelayKeyProvider
from plex_relay.models import RelayKey
PUB = "ssh-ed25519 AAAAkeydata comment"
KEY = RelayKey("ssh-ed25519", "AAAAkeydata")
def test_provider_respects_ttl_and_force():
calls = []
now = [1000.0]
provider = RelayKeyProvider(
"https://x/relay_v1.pub", 86_400.0,
fetcher=lambda url: (calls.append(url), PUB)[1],
clock=lambda: now[0],
)
assert provider.get() == KEY
now[0] += 3600 # within TTL -> reuse
provider.get()
assert len(calls) == 1
now[0] += 86_400 # past TTL -> refetch
provider.get()
assert len(calls) == 2
provider.get(force=True) # force -> refetch
assert len(calls) == 3
# --- HttpsRelayKeyFetcher ---------------------------------------------------
class _FakeResp:
def __init__(self, data: bytes):
self._data = data
def read(self, n: int = -1) -> bytes:
return self._data[:n] if n >= 0 else self._data
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def test_fetcher_rejects_non_https():
f = HttpsRelayKeyFetcher(opener=lambda *a, **k: _FakeResp(b""))
with pytest.raises(RelayKeyError):
f("http://insecure/relay_v1.pub")
def test_fetcher_allows_insecure_when_opted_in():
f = HttpsRelayKeyFetcher(allow_insecure=True, opener=lambda *a, **k: _FakeResp(PUB.encode()))
assert f("file:///tmp/relay_v1.pub") == PUB
def test_fetcher_caps_response_size():
big = b"x" * 100
f = HttpsRelayKeyFetcher(max_bytes=10, opener=lambda *a, **k: _FakeResp(big))
with pytest.raises(RelayKeyError, match="exceeds"):
f("https://x/relay_v1.pub")
def test_fetcher_wraps_transport_errors():
def boom(*a, **k):
raise OSError("connection refused")
f = HttpsRelayKeyFetcher(opener=boom)
with pytest.raises(RelayKeyError, match="failed to fetch"):
f("https://x/relay_v1.pub")
@@ -0,0 +1,45 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import pytest
from plex_relay.errors import RelayKeyError
from plex_relay.models import HostKey, RelayKey, known_hosts_endpoint, parse_relay_pub
PUB = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc relay@plex" # keytype keydata comment
KH = "* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc" # host keytype keydata
EXPECT = RelayKey("ssh-ed25519", "AAAAC3NzaC1lZDI1NTE5AAAAIabc")
def test_parse_pubkey_form():
assert parse_relay_pub(PUB) == EXPECT
def test_parse_known_hosts_form():
assert parse_relay_pub(KH) == EXPECT
def test_parse_skips_comments_and_blanks():
assert parse_relay_pub(f"# header\n\n{PUB}\n") == EXPECT
@pytest.mark.parametrize("bad", ["ssh-ed25519 onlytwo", "aaa bbb ccc", "", "# only comment\n"])
def test_parse_rejects_bad_payloads(bad):
with pytest.raises(RelayKeyError):
parse_relay_pub(bad)
def test_endpoint_bracket_notation():
assert known_hosts_endpoint("1.2.3.4", 443) == "[1.2.3.4]:443"
assert known_hosts_endpoint("relay.example", 2222) == "[relay.example]:2222"
assert known_hosts_endpoint("relay.example", 22) == "relay.example"
def test_endpoint_rejects_empty_host():
with pytest.raises(RelayKeyError):
known_hosts_endpoint(" ", 443)
def test_hostkey_serialization():
hk = HostKey.for_endpoint("[1.2.3.4]:443", EXPECT)
assert hk.known_hosts_line() == "[1.2.3.4]:443 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabc"
assert hk.cache_block() == f"# [1.2.3.4]:443\n{hk.known_hosts_line()}\n"
assert hk.key == EXPECT
+61
View File
@@ -0,0 +1,61 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from pathlib import Path
from plex_relay.keys import RelayKeyProvider
from plex_relay.store import HostKeyManager
class FakeCache:
def __init__(self):
self.entries = {}
self.saves = 0
@property
def path(self) -> Path:
return Path("/tmp/relayHostKey.txt")
def load(self):
return dict(self.entries)
def save(self, entries):
self.saves += 1
self.entries = dict(entries)
def _provider(text_box):
return RelayKeyProvider("https://x/relay_v1.pub", 86_400.0,
fetcher=lambda u: text_box[0], clock=lambda: 0.0)
def test_ensure_trusted_pins_and_persists():
cache = FakeCache()
mgr = HostKeyManager(_provider(["ssh-ed25519 AAAAfirst c"]), cache)
entry = mgr.ensure_trusted("1.2.3.4", 443)
assert entry.known_hosts_line() == "[1.2.3.4]:443 ssh-ed25519 AAAAfirst"
assert cache.saves == 1
assert "[1.2.3.4]:443" in cache.entries
def test_ensure_trusted_is_idempotent():
cache = FakeCache()
mgr = HostKeyManager(_provider(["ssh-ed25519 AAAAfirst c"]), cache)
mgr.ensure_trusted("1.2.3.4", 443)
mgr.ensure_trusted("1.2.3.4", 443) # unchanged -> no extra write
assert cache.saves == 1
def test_ensure_trusted_rewrites_on_key_change():
cache = FakeCache()
box = ["ssh-ed25519 AAAAfirst c"]
mgr = HostKeyManager(_provider(box), cache)
mgr.ensure_trusted("h", 443)
box[0] = "ssh-ed25519 AAAAsecond c"
entry = mgr.ensure_trusted("h", 443, force=True)
assert entry.keydata == "AAAAsecond"
assert cache.saves == 2
def test_known_hosts_path_is_cache_path():
cache = FakeCache()
mgr = HostKeyManager(_provider(["ssh-ed25519 k c"]), cache)
assert mgr.known_hosts_path == cache.path
@@ -0,0 +1,91 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from pathlib import Path
import pytest
from plex_relay.config import RelayConfig
from plex_relay.errors import TunnelError
from plex_relay.tunnel import SubprocessTunnel, SubprocessTunnelFactory, build_ssh_argv
def cfg(**kw):
base = dict(token="secret", ssh_user="machineid", local_host="127.0.0.1", local_port=32400)
base.update(kw)
return RelayConfig(**base)
def test_argv_matches_binary_layout():
argv = build_ssh_argv(cfg(), "relay.example", 443, Path("/data/relayHostKey.txt"))
assert argv == [
"ssh", "-p", "443", "-N", "-R", "0:127.0.0.1:32400",
"-o", "UserKnownHostsFile=/data/relayHostKey.txt",
"-o", "LogLevel=VERBOSE",
"-o", "PreferredAuthentications=password",
"-o", "PubkeyAuthentication=no",
"-l", "machineid", "-F", "/dev/null", "relay.example",
]
def test_argv_honours_port_and_target():
argv = build_ssh_argv(cfg(local_host="10.0.0.5", local_port=32500), "h", 2222, Path("/k"))
assert "0:10.0.0.5:32500" in argv
assert argv[argv.index("-p") + 1] == "2222"
class FakeProc:
def __init__(self):
self.alive = True
self.terminated = False
def poll(self):
return None if self.alive else 0
def terminate(self):
self.terminated = True
self.alive = False
def kill(self):
self.alive = False
def wait(self, timeout=None):
self.alive = False
return 0
def test_tunnel_start_sets_secret_env_and_cleans_askpass():
captured = {}
def spawner(argv, env):
captured["argv"] = argv
captured["env"] = dict(env)
return FakeProc()
t = SubprocessTunnel(cfg(), "relay.example", 443, Path("/k"), spawner)
t.start()
assert t.is_alive()
assert captured["env"]["PLEXTOKEN"] == "secret"
assert "SSH_ASKPASS" in captured["env"]
askpass = Path(captured["env"]["SSH_ASKPASS"])
assert askpass.exists()
assert "secret" not in captured["argv"] # never on the command line
t.stop()
assert not t.is_alive()
assert not askpass.exists() # helper removed on stop
def test_tunnel_start_wraps_spawn_failure():
def boom(argv, env):
raise OSError("ssh not found")
t = SubprocessTunnel(cfg(), "h", 443, Path("/k"), boom)
with pytest.raises(TunnelError):
t.start()
assert not t.is_alive()
def test_factory_builds_tunnel():
factory = SubprocessTunnelFactory(cfg(), spawner=lambda a, e: FakeProc())
t = factory("h", 443, Path("/k"))
assert t.host == "h"
t.start()
assert t.is_alive()
t.stop()
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# Launcher for plexmediaserver that LD_PRELOADs the (musl-built) crack into the
# Plex Media Server process only. LD_PRELOAD must NOT be set via the unit's
# Environment= because ExecStart is run by /bin/sh (glibc) first, and a musl .so
# cannot load into a glibc process. Setting it here, after this shell is already
# running, means only the final `exec` of the (musl) Plex binary is preloaded.
#
# The three PLEX_MEDIA_SERVER_INFO_* exports mirror the stock unit's ExecStart.
export PLEX_MEDIA_SERVER_INFO_VENDOR="$(grep ^NAME= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
export PLEX_MEDIA_SERVER_INFO_MODEL="$(uname -m)"
export PLEX_MEDIA_SERVER_INFO_PLATFORM_VERSION="$(grep ^VERSION= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
export LD_PRELOAD="/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
exec "/usr/lib/plexmediaserver/Plex Media Server"
+172
View File
@@ -0,0 +1,172 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
# Plex over Tailscale / Headscale (no code patching)
Make a local Plex server reachable by remote users through a **mesh VPN** instead
of Plex Relay, router port-forwarding, or a binary patch. Every device that joins
your tailnet reaches the Plex host's private tailnet IP directly; WireGuard
encrypts the transport end-to-end.
```
Remote client (Tailscale) ─────WireGuard────▶ Plex host (Tailscale) 100.x.y.z:32400
│ ▲
└── learns the server from plex.tv, which now publishes
the custom URL https://100.x.y.z:32400
```
---
## Layout & design
```
plex-tailnet/
├── plex-tailscale-setup.sh # run on the Plex host: VPN + Plex config + healthcheck
├── headscale-server-setup.sh # run on a VPS (optional): self-hosted control plane
├── lib/
│ ├── common.sh # shared shell helpers (sourced, never executed)
│ └── plex_prefs.py # Preferences.xml read/merge (XML lives here, not in bash)
└── README.md
```
Principles applied:
- **Low coupling / high cohesion.** Generic concerns (colour logging, dry-run
execution, prompts, root/command guards, secret redaction) live once in
`lib/common.sh`; each script keeps only its own orchestration. All XML editing
is isolated in `lib/plex_prefs.py` — cohesive, reviewable, and testable on its
own (`plex_prefs.py merge|get`), so the shell never hand-parses XML.
- **Fail fast, located.** `set -euo pipefail` plus an `ERR` trap that reports the
failing line; tolerated failures are explicitly guarded (`|| true` / `|| warn`).
- **Idempotent & reversible.** Re-runs merge (never duplicate) settings; every
`Preferences.xml` change is preceded by a timestamped backup and ownership is
restored afterwards. `--dry-run` previews every action and changes nothing.
- **Secret hygiene.** Auth keys are redacted in logs **and** never routed through
the dry-run echo. The `headscale` pre-auth key is printed once, labelled as a
secret.
| Component | Runs on | Responsibility |
| --- | --- | --- |
| `plex-tailscale-setup.sh` | Plex host (Linux/systemd) | install/join Tailscale, security questionnaire, edit `Preferences.xml`, firewall, healthcheck |
| `headscale-server-setup.sh` | public VPS *(optional)* | install + configure Headscale, create user, mint pre-auth key |
| `lib/common.sh` | sourced | logging, `run` (dry-run), prompts, guards, redaction |
| `lib/plex_prefs.py` | invoked | merge/read `Preferences.xml` attributes |
---
## Quick start
### A) Tailscale's control plane (simplest; free for up to 3 users)
```bash
sudo ./plex-tailscale-setup.sh # interactive login (prints a URL)
sudo ./plex-tailscale-setup.sh --authkey tskey-auth-xxxxx # unattended
```
Each remote user installs Tailscale (https://tailscale.com/download), joins the
same tailnet, opens Plex — done.
### B) Your own Headscale (no user limits, full control)
On a public VPS with a DNS name and ports 80/443 open:
```bash
sudo ./headscale-server-setup.sh --domain hs.example.com --user plex # prints a key
```
On the Plex host and every client:
```bash
sudo tailscale up --login-server https://hs.example.com --authkey <preauth-key>
# Plex host can do VPN + Plex config in one go:
sudo ./plex-tailscale-setup.sh --login-server https://hs.example.com --authkey <preauth-key>
```
---
## What the Plex script changes
`Preferences.xml` is edited **while Plex is stopped** (Plex overwrites it on
shutdown), via `lib/plex_prefs.py`, after a backup, with ownership restored:
| Attribute | Change | Why |
| --- | --- | --- |
| `customConnections` | append `https://<tailscale-ip>:32400` | plex.tv publishes the tailnet address for discovery |
| `LanNetworksBandwidth` | append `100.64.0.0/10`, `fd7a:115c:a1e0::/48` | treat the tailnet as **LAN**: full quality, no remote throttle |
| `secureConnections` | your choice (default Preferred) | clean connect over the already-encrypted tunnel |
| `RelayEnabled` | `0` (if you disable Relay) | stop bouncing through Plex's relay once on the tailnet |
### Security questionnaire (interactive)
On a terminal the script asks three questions (each has a safe default; pass the
flag — or `--yes` — to skip the prompt):
| Prompt | Flag(s) | Default | Effect |
| --- | --- | --- | --- |
| Secure connections mode | `--secure required\|preferred\|disabled\|keep` | preferred | `secureConnections` |
| Disable Plex Relay? | `--disable-relay` / `--keep-relay` | disable | `RelayEnabled=0` |
| Firewall lockdown of `32400/tcp` | `--firewall none\|tailnet\|lan` | none | see below |
**Firewall lockdown** restricts Plex's port to the VPN (`tailnet`) or VPN + RFC1918
LAN (`lan`). It only ever touches `32400/tcp` (SSH stays open), acts only on an
**already-active** ufw/firewalld (never enables a firewall — that risks an SSH
lockout), and otherwise prints an equivalent `nftables` snippet.
### Health check
Runs after install, and standalone with `sudo ./plex-tailscale-setup.sh
--healthcheck` (**no changes, no root**). PASS/WARN/FAIL for: Tailscale backend +
tailnet IP, the Plex service, Plex's local API, Plex reachable at its tailnet IP,
the `customConnections` / LAN-networks / Relay values Plex actually persisted, and
the firewall posture.
Other flags: `--prefs PATH` (quote it), `--service`, `--port`, `--url-scheme`,
`--ts-iface`, `--skip-tailscale`, `--skip-plex`, `--dry-run`.
**Prerequisites:** Plex installed, **claimed**, owner signed in; Linux + systemd;
`python3` + `python3-defusedxml` + `curl`; run as root (except `--healthcheck`).
---
## Letting other people in
1. They install Tailscale and join your tailnet/Headscale (a per-user reusable
pre-auth key from `headscale preauthkeys create` is the easy path).
2. In Plex, **Settings → Users & Sharing**, share the libraries with their Plex
account.
3. They sign into Plex; the server shows up over the tailnet.
### Lock guests to the Plex port with ACLs (recommended)
Headscale (`/etc/headscale/acl.hujson`, referenced by `policy.path`):
```hujson
{
"groups": { "group:plexusers": ["alice@", "bob@"] },
"hosts": { "plexserver": "100.64.0.5/32" },
"acls": [
{ "action": "accept", "src": ["group:plexusers"], "dst": ["plexserver:32400"] }
]
}
```
Tailscale's admin console (Access Controls) uses the equivalent `acls`/`tagOwners`.
---
## Caveats
- **2026 Plex Pass enforcement.** Reports indicate Plex now requires Plex Pass /
Remote Watch Pass on the **server account** for *remote* streaming even over
Tailscale. `LanNetworksBandwidth` makes Plex treat the tailnet as local (which
historically sidestepped the cap and the entitlement gate); if your build still
gates, the lever is on the server account, not the client. VPN connectivity
works regardless.
- **TLS / certificates.** Capable clients reach `https://<ip>:32400` via Plex's
auto-generated `plex.direct` hostname (valid cert). For a strict client, use
`--secure preferred` (default) or `--url-scheme http`; WireGuard already
encrypts the wire.
- **Headscale TLS.** `--no-tls` listens on `127.0.0.1:8080` for a reverse proxy;
otherwise built-in Let's Encrypt needs ports 80 + 443 reachable.
- **POSIX/systemd only.** Targets Debian/Ubuntu-family Plex hosts.
Interoperability/remote-access tooling for infrastructure you operate yourself.
It ships no Plex code and bypasses no account authentication.
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# headscale-server-setup.sh -- OPTIONAL self-hosted coordination server.
#
# Use instead of Tailscale's control plane when you want no account limits and
# full control over who may join. Run on a PUBLIC Debian 12+/Ubuntu 22.04+ VPS
# with a DNS name pointing at it. It:
# 1. installs the official Headscale .deb (latest release, or --version)
# 2. points server_url at https://<domain> and enables built-in Let's Encrypt
# TLS (unless --no-tls, for running behind your own reverse proxy)
# 3. starts the systemd service
# 4. creates a user and mints a reusable pre-auth key
#
# The Plex host and every client then join with:
# sudo tailscale up --login-server https://<domain> --authkey <preauthkey>
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh" || { echo "missing ${SCRIPT_DIR}/lib/common.sh" >&2; exit 1; }
enable_error_trap
readonly CFG="/etc/headscale/config.yaml"
DOMAIN=""
USER_NAME="plex"
VERSION="" # auto-detect latest if empty
EXPIRY="720h" # preauth key lifetime (30 days)
USE_TLS=1
readonly LISTEN_PLAIN="127.0.0.1:8080"
PREAUTH_KEY=""
usage() {
cat <<EOF
Usage: sudo $0 --domain hs.example.com [options]
--domain NAME Public DNS name for this Headscale server (required).
--user NAME Headscale user to create (default: $USER_NAME).
--version VER Headscale version (default: latest GitHub release).
--expiration DUR Pre-auth key lifetime, Go duration (default: $EXPIRY).
--no-tls Listen on $LISTEN_PLAIN for a reverse proxy (no built-in TLS).
--dry-run Print actions without changing anything.
-h, --help This help.
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="$2"; shift 2;;
--user) USER_NAME="$2"; shift 2;;
--version) VERSION="$2"; shift 2;;
--expiration) EXPIRY="$2"; shift 2;;
--no-tls) USE_TLS=0; shift;;
--dry-run) DRY_RUN=1; shift;;
-h|--help) usage; exit 0;;
*) die "unknown option: $1 (see --help)";;
esac
done
require_root
[[ -n "$DOMAIN" ]] || die "--domain is required"
[[ "$DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || die "--domain looks invalid: $DOMAIN"
[[ "$EXPIRY" =~ ^[0-9]+[smhd]$ ]] || die "--expiration must be a Go duration like 720h, got: $EXPIRY"
[[ -n "$USER_NAME" ]] || die "--user must not be empty"
need_cmd curl
need_cmd dpkg
}
detect_version() {
[[ -n "$VERSION" ]] && { printf '%s' "$VERSION"; return; }
local tag
tag="$(curl -fsSL https://api.github.com/repos/juanfont/headscale/releases/latest \
| sed -n 's/.*"tag_name":[[:space:]]*"v\{0,1\}\([^"]*\)".*/\1/p' | head -n1)"
[[ -n "$tag" ]] || die "could not detect latest Headscale version; pass --version X.Y.Z"
printf '%s' "$tag"
}
install_headscale() {
if have_cmd headscale; then
ok "headscale already installed ($(headscale version 2>/dev/null | head -n1))"
return
fi
local ver arch url tmp
ver="$(detect_version)"
arch="$(dpkg --print-architecture)"
url="https://github.com/juanfont/headscale/releases/download/v${ver}/headscale_${ver}_linux_${arch}.deb"
tmp="$(mktemp --suffix=.deb)"
log "downloading Headscale v${ver} (${arch})"
run curl -fsSL -o "$tmp" "$url"
log "installing package"
run apt-get install -y "$tmp"
run rm -f "$tmp"
}
# set or append a top-level scalar key in the YAML config (other keys untouched)
set_yaml() {
local key="$1" val="$2"
if grep -qE "^[[:space:]]*${key}:" "$CFG"; then
run sed -i -E "s|^([[:space:]]*)${key}:.*|\1${key}: ${val}|" "$CFG"
elif [[ $DRY_RUN -eq 1 ]]; then
echo " + append ${key}: ${val} >> $CFG"
else
printf '%s: %s\n' "$key" "$val" >> "$CFG"
fi
}
configure_headscale() {
[[ -f "$CFG" ]] || die "expected config at $CFG (did the package install correctly?)"
run cp -a "$CFG" "${CFG}.bak.$(date +%Y%m%d%H%M%S)"
set_yaml server_url "https://${DOMAIN}"
if [[ $USE_TLS -eq 1 ]]; then
set_yaml listen_addr "0.0.0.0:443"
set_yaml tls_letsencrypt_hostname "${DOMAIN}"
set_yaml tls_letsencrypt_challenge_type "HTTP-01"
set_yaml tls_letsencrypt_listen ":http"
warn "built-in TLS: ports 80 (ACME challenge) and 443 must be reachable."
else
set_yaml listen_addr "$LISTEN_PLAIN"
warn "--no-tls: terminate TLS at a reverse proxy in front of $LISTEN_PLAIN."
fi
ok "configured $CFG (server_url=https://${DOMAIN})"
}
start_headscale() {
run systemctl enable --now headscale
if [[ $DRY_RUN -eq 0 ]]; then
sleep 2
systemctl is-active --quiet headscale \
&& ok "headscale is running" \
|| warn "headscale not active; check 'journalctl -u headscale -e'"
fi
}
provision_user() {
if [[ $DRY_RUN -eq 1 ]]; then
echo " + headscale users create $USER_NAME"
echo " + headscale preauthkeys create --user $USER_NAME --reusable --expiration $EXPIRY"
return
fi
if ! headscale users list 2>/dev/null | grep -qw "$USER_NAME"; then
log "creating user '$USER_NAME'"
headscale users create "$USER_NAME" || warn "users create failed (may already exist)"
else
ok "user '$USER_NAME' already exists"
fi
log "minting reusable pre-auth key (valid $EXPIRY)"
# Newer headscale wants the user id; older accepts the name. Try name, then id.
PREAUTH_KEY="$(headscale preauthkeys create --user "$USER_NAME" --reusable --expiration "$EXPIRY" 2>/dev/null | tail -n1 || true)"
if [[ -z "$PREAUTH_KEY" || "$PREAUTH_KEY" == *" "* ]]; then
local uid
uid="$(headscale users list 2>/dev/null | awk -v u="$USER_NAME" '$0 ~ u {print $1; exit}')"
[[ -n "$uid" ]] && PREAUTH_KEY="$(headscale preauthkeys create --user "$uid" --reusable --expiration "$EXPIRY" 2>/dev/null | tail -n1 || true)"
fi
if [[ -n "$PREAUTH_KEY" ]]; then
ok "pre-auth key (treat as a secret): $PREAUTH_KEY"
else
warn "could not auto-mint a key; run: headscale preauthkeys create --user $USER_NAME --reusable --expiration $EXPIRY"
fi
}
main() {
parse_args "$@"
install_headscale
configure_headscale
start_headscale
provision_user
cat <<EOF
$(ok "Headscale ready at https://${DOMAIN}")
Join the Plex server and every client with:
sudo tailscale up --login-server https://${DOMAIN} --authkey ${PREAUTH_KEY:-<preauth-key>}
On the Plex host, do VPN + Plex config in one step:
sudo ./plex-tailscale-setup.sh --login-server https://${DOMAIN} --authkey ${PREAUTH_KEY:-<preauth-key>}
Manage access:
headscale users list
headscale nodes list
headscale preauthkeys create --user ${USER_NAME} --reusable --expiration ${EXPIRY}
EOF
}
main "$@"
@@ -0,0 +1,76 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# shellcheck shell=bash
#
# Shared helpers for the plex-tailnet scripts. SOURCE this file; do not run it.
# Keeping the generic concerns (logging, dry-run execution, prompts, guards,
# secret redaction) here removes duplication between the setup scripts and keeps
# each script focused on its own orchestration.
# --- colour-aware logging (colours only on a TTY) ---------------------------
_c() { [[ -t 1 ]] && printf '%s' "$1" || true; }
log() { printf '%s[*]%s %s\n' "$(_c $'\033[1;34m')" "$(_c $'\033[0m')" "$*"; }
ok() { printf '%s[+]%s %s\n' "$(_c $'\033[1;32m')" "$(_c $'\033[0m')" "$*"; }
warn() { printf '%s[!]%s %s\n' "$(_c $'\033[1;33m')" "$(_c $'\033[0m')" "$*" >&2; }
die() { printf '%s[x]%s %s\n' "$(_c $'\033[1;31m')" "$(_c $'\033[0m')" "$*" >&2; exit 1; }
# --- command execution that honours DRY_RUN ---------------------------------
: "${DRY_RUN:=0}"
run() {
if [[ $DRY_RUN -eq 1 ]]; then
printf ' +'; printf ' %q' "$@"; echo
else
"$@"
fi
}
# --- fail fast with a located diagnostic ------------------------------------
# Usage: enable_error_trap (after sourcing). Tolerated failures must be
# guarded with `|| true` / `|| warn ...` as usual.
__err_trap() { warn "aborted (exit $1) near line $2"; exit "$1"; }
enable_error_trap() { trap '__err_trap "$?" "$LINENO"' ERR; }
# --- guards / predicates ----------------------------------------------------
require_root() { [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "must run as root (use sudo)"; }
need_cmd() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"; }
have_cmd() { command -v "$1" >/dev/null 2>&1; }
is_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )); }
# --- interactive prompts (read the controlling terminal directly) -----------
ask_yes_no() { # question [default Y|N] -> 0 = yes, 1 = no
local q="$1" def="${2:-Y}" ans prompt
[[ "$def" == "Y" ]] && prompt="[Y/n]" || prompt="[y/N]"
read -r -p "$(printf '%s[?]%s %s %s ' "$(_c $'\033[1;36m')" "$(_c $'\033[0m')" "$q" "$prompt")" ans </dev/tty || ans=""
ans="${ans:-$def}"
[[ "$ans" =~ ^[Yy] ]]
}
ask_choice() { # question default opt... -> echoes the chosen value (prompt on stderr)
local q="$1" def="$2"; shift 2
local opts=("$@") i ans o
{
printf '%s[?]%s %s\n' "$(_c $'\033[1;36m')" "$(_c $'\033[0m')" "$q"
for i in "${!opts[@]}"; do
printf ' %d) %s%s\n' "$((i + 1))" "${opts[$i]}" "$([[ ${opts[$i]} == "$def" ]] && echo ' (default)')"
done
printf ' choice [%s]: ' "$def"
} >&2
read -r ans </dev/tty || ans=""
[[ -z "$ans" ]] && { printf '%s' "$def"; return; }
if [[ "$ans" =~ ^[0-9]+$ ]] && (( ans >= 1 && ans <= ${#opts[@]} )); then
printf '%s' "${opts[$((ans - 1))]}"; return
fi
for o in "${opts[@]}"; do [[ "$ans" == "$o" ]] && { printf '%s' "$o"; return; }; done
printf '%s' "$def"
}
# --- secret redaction for logging -------------------------------------------
# redact_after FLAG ARG... -> echoes ARGs with the value following FLAG masked.
redact_after() {
local flag="$1"; shift
local out=() mask=0 a
for a in "$@"; do
if [[ $mask -eq 1 ]]; then out+=("***"); mask=0
else out+=("$a"); [[ "$a" == "$flag" ]] && mask=1; fi
done
printf '%s' "${out[*]}"
}
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Read and edit Plex ``Preferences.xml`` attributes.
Used by ``plex-tailscale-setup.sh``. The XML logic lives here -- not in a bash
heredoc -- so it is cohesive, reviewable, and independently testable. The shell
owns the lifecycle (stop Plex, back up, restore ownership, restart); this owns
the document.
plex_prefs.py merge PREFS [--custom-url URL] [--lan CIDR[,CIDR...]]
[--secure 0|1|2] [--relay 0|1]
plex_prefs.py get PREFS ATTR
``merge`` is additive and idempotent: list attributes gain only missing values;
scalar attributes are set only when a value is supplied. Unrelated attributes
(tokens, machine identity, ...) are preserved.
"""
from __future__ import annotations
import argparse
import sys
from collections.abc import Callable
from typing import Protocol, cast
try:
import defusedxml.ElementTree as ET
except ModuleNotFoundError:
sys.exit("plex_prefs: missing dependency: install python3-defusedxml")
class _PrefsElement(Protocol):
tag: str
def get(self, key: str, default: str = "") -> str: ...
def set(self, key: str, value: str) -> None: ...
class _PrefsTree(Protocol):
def getroot(self) -> _PrefsElement: ...
def write(self, file_or_filename: str, encoding: str, xml_declaration: bool) -> None: ...
def _load(path: str) -> tuple[_PrefsTree, _PrefsElement]:
try:
tree = cast(_PrefsTree, cast(object, ET.parse(path)))
except (OSError, ET.ParseError) as exc:
sys.exit(f"plex_prefs: cannot read {path}: {exc}")
root = tree.getroot()
if root.tag != "Preferences":
sys.exit(f"plex_prefs: unexpected root <{root.tag}>; refusing to edit {path}")
return tree, root
def _merge_csv(root: _PrefsElement, attr: str, additions: list[str]) -> None:
items = [x for x in (s.strip() for s in root.get(attr, "").split(",")) if x]
for value in additions:
if value and value not in items:
items.append(value)
root.set(attr, ",".join(items))
def cmd_merge(args: argparse.Namespace) -> int:
prefs = cast(str, args.prefs)
custom_url = cast(str, args.custom_url)
lan = cast(str, args.lan)
secure = cast(str, args.secure)
relay = cast(str, args.relay)
tree, root = _load(prefs)
if custom_url:
_merge_csv(root, "customConnections", [custom_url])
if lan:
_merge_csv(root, "LanNetworksBandwidth", [c for c in lan.split(",") if c])
if secure in ("0", "1", "2"):
root.set("secureConnections", secure)
if relay in ("0", "1"):
root.set("RelayEnabled", relay)
tree.write(prefs, encoding="utf-8", xml_declaration=True)
return 0
def cmd_get(args: argparse.Namespace) -> int:
prefs = cast(str, args.prefs)
attr = cast(str, args.attr)
_, root = _load(prefs)
print(root.get(attr, ""))
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="plex_prefs", description=__doc__)
sub = parser.add_subparsers(dest="cmd", required=True)
m = sub.add_parser("merge", help="merge tailnet settings into Preferences.xml")
_ = m.add_argument("prefs")
_ = m.add_argument("--custom-url", default="")
_ = m.add_argument("--lan", default="")
_ = m.add_argument("--secure", default="", help="0=Required 1=Preferred 2=Disabled")
_ = m.add_argument("--relay", default="", help="0=disable 1=enable Plex Relay")
m.set_defaults(func=cmd_merge)
g = sub.add_parser("get", help="print one Preferences.xml attribute")
_ = g.add_argument("prefs")
_ = g.add_argument("attr")
g.set_defaults(func=cmd_get)
args = parser.parse_args(argv)
func = cast(Callable[[argparse.Namespace], int], args.func)
return func(args)
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,374 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# plex-tailscale-setup.sh -- run on the LINUX host that runs Plex Media Server.
#
# Makes a local Plex server reachable by remote users over a Tailscale /
# Headscale mesh VPN: no router port-forwarding, no Plex Relay, no patching.
#
# 1. install/join Tailscale (Tailscale's control plane, or your Headscale)
# 2. ask a few security questions (skippable with flags or --yes)
# 3. edit Preferences.xml safely (Plex stopped, backed up, ownership restored)
# 4. optionally lock the firewall to the tailnet
# 5. restart Plex and run a health check (also available as `--healthcheck`)
#
# Target: Debian/Ubuntu-family with systemd. Requires: tailscale (auto-installed),
# python3, python3-defusedxml, curl. Run as root (except --healthcheck).
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh" || { echo "missing ${SCRIPT_DIR}/lib/common.sh" >&2; exit 1; }
enable_error_trap
readonly PREFS_PY="${SCRIPT_DIR}/lib/plex_prefs.py"
# ---- defaults --------------------------------------------------------------
readonly PREFS_DEFAULT='/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Preferences.xml'
PREFS="${PLEX_PREFS:-$PREFS_DEFAULT}"
SERVICE="plexmediaserver"
PLEX_PORT="32400"
URL_SCHEME="https"
SECURE="" # ask | required|preferred|disabled|keep
RELAY="" # ask | disable|keep
FIREWALL="" # ask | none|tailnet|lan
LOGIN_SERVER=""
AUTHKEY=""
TS_HOSTNAME=""
TS_IFACE="tailscale0"
readonly TAILNET_V4="100.64.0.0/10"
readonly TAILNET_V6="fd7a:115c:a1e0::/48"
SKIP_TAILSCALE=0
SKIP_PLEX=0
ASSUME_YES=0
HEALTHCHECK_ONLY=0
INTERACTIVE=0
usage() {
cat <<EOF
Usage: sudo $0 [options]
Connectivity:
--login-server URL Use a self-hosted Headscale control server.
--authkey KEY Pre-auth/auth key (unattended join; never logged).
--hostname NAME Tailnet hostname for this node.
--ts-iface NAME Tailscale interface (default: $TS_IFACE).
Plex:
--prefs PATH Preferences.xml path (quote it -- it has spaces).
--service NAME systemd unit name (default: $SERVICE).
--port N Plex port (default: $PLEX_PORT).
--url-scheme S https|http for the published URL (default: https).
Security (prompted interactively unless set here or with --yes):
--secure MODE required|preferred|disabled|keep (default: preferred).
--disable-relay Set RelayEnabled=0 (recommended on a tailnet).
--keep-relay Leave Plex Relay untouched.
--firewall MODE none|tailnet|lan (default: none).
Control:
--healthcheck Run health checks only and exit (no changes, no root).
--skip-tailscale Do not touch Tailscale.
--skip-plex Do not touch Plex config.
-y, --yes Non-interactive: accept defaults.
--dry-run Print actions without changing anything.
-h, --help This help.
EOF
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--login-server) LOGIN_SERVER="$2"; shift 2;;
--authkey) AUTHKEY="$2"; shift 2;;
--hostname) TS_HOSTNAME="$2"; shift 2;;
--ts-iface) TS_IFACE="$2"; shift 2;;
--prefs) PREFS="$2"; shift 2;;
--service) SERVICE="$2"; shift 2;;
--port) PLEX_PORT="$2"; shift 2;;
--url-scheme) URL_SCHEME="$2"; shift 2;;
--secure) SECURE="$2"; shift 2;;
--disable-relay) RELAY="disable"; shift;;
--keep-relay) RELAY="keep"; shift;;
--firewall) FIREWALL="$2"; shift 2;;
--healthcheck) HEALTHCHECK_ONLY=1; shift;;
--skip-tailscale) SKIP_TAILSCALE=1; shift;;
--skip-plex) SKIP_PLEX=1; shift;;
-y|--yes|--non-interactive) ASSUME_YES=1; shift;;
--dry-run) DRY_RUN=1; shift;;
-h|--help) usage; exit 0;;
*) die "unknown option: $1 (see --help)";;
esac
done
case "$URL_SCHEME" in http|https) ;; *) die "--url-scheme must be http or https";; esac
is_port "$PLEX_PORT" || die "--port must be 1-65535, got: $PLEX_PORT"
[[ $ASSUME_YES -eq 0 && -t 0 ]] && INTERACTIVE=1 || INTERACTIVE=0
}
# ---- security questionnaire ------------------------------------------------
resolve_security_options() {
if [[ -z "$SECURE" ]]; then
[[ $INTERACTIVE -eq 1 ]] \
&& SECURE="$(ask_choice 'Secure connections between clients and server:' preferred required preferred disabled keep)" \
|| SECURE="preferred"
fi
if [[ -z "$RELAY" ]]; then
if [[ $INTERACTIVE -eq 1 ]]; then
ask_yes_no 'Disable Plex Relay (recommended -- you reach the server via the tailnet)?' Y && RELAY=disable || RELAY=keep
else RELAY=disable; fi
fi
if [[ -z "$FIREWALL" ]]; then
[[ $INTERACTIVE -eq 1 ]] \
&& FIREWALL="$(ask_choice "Lock down Plex ${PLEX_PORT}/tcp? (tailnet=VPN only, lan=VPN+home LAN, none=leave)" none tailnet lan none)" \
|| FIREWALL="none"
fi
case "$SECURE" in required|preferred|disabled|keep) ;; *) die "--secure must be required|preferred|disabled|keep";; esac
case "$RELAY" in disable|keep) ;; *) die "relay choice must be disable|keep";; esac
case "$FIREWALL" in none|tailnet|lan) ;; *) die "--firewall must be none|tailnet|lan";; esac
log "security: secureConnections=$SECURE, relay=$RELAY, firewall=$FIREWALL"
}
# ---- tailscale -------------------------------------------------------------
install_tailscale() {
if have_cmd tailscale; then
ok "tailscale already installed ($(tailscale version 2>/dev/null | head -n1))"
else
need_cmd curl
log "installing Tailscale via official script"
if [[ $DRY_RUN -eq 1 ]]; then echo " + curl -fsSL https://tailscale.com/install.sh | sh"
else curl -fsSL https://tailscale.com/install.sh | sh; fi
fi
run systemctl enable --now tailscaled
}
join_tailnet() {
local args=(up --reset)
[[ -n "$LOGIN_SERVER" ]] && args+=(--login-server "$LOGIN_SERVER")
[[ -n "$AUTHKEY" ]] && args+=(--authkey "$AUTHKEY")
[[ -n "$TS_HOSTNAME" ]] && args+=(--hostname "$TS_HOSTNAME")
log "bringing up tailscale: tailscale $(redact_after --authkey "${args[@]}")"
[[ -z "$AUTHKEY" ]] && warn "no --authkey: 'tailscale up' prints a login URL; open it to authenticate."
# Do not route the auth key through run(): its dry-run echo would print the
# secret. The redacted command was already logged above.
[[ $DRY_RUN -eq 1 ]] && return 0
tailscale "${args[@]}"
}
tailnet_ip_soft() { tailscale ip -4 2>/dev/null | head -n1 || true; }
# ---- plex ------------------------------------------------------------------
secure_value() {
case "$1" in required) echo 0;; preferred) echo 1;; disabled) echo 2;; *) echo "";; esac
}
get_attr() { python3 "$PREFS_PY" get "$PREFS" "$1" 2>/dev/null || true; }
configure_plex() {
local ts_ip="$1"
[[ -f "$PREFS" ]] || die "Preferences.xml not found at: $PREFS (pass --prefs; quote the path)"
[[ -f "$PREFS_PY" ]] || die "missing helper: $PREFS_PY"
need_cmd python3
local url="${URL_SCHEME}://${ts_ip}:${PLEX_PORT}"
local owner mode sv relay_val
owner="$(stat -c '%U:%G' "$PREFS")"
mode="$(stat -c '%a' "$PREFS")"
sv="$(secure_value "$SECURE")"
[[ "$RELAY" == "disable" ]] && relay_val="0" || relay_val=""
log "stopping $SERVICE (Plex rewrites Preferences.xml on exit; edit while stopped)"
run systemctl stop "$SERVICE" || warn "could not stop $SERVICE; continuing"
local bak; bak="${PREFS}.bak.$(date +%Y%m%d%H%M%S)"
run cp -a "$PREFS" "$bak"
ok "backup written: $bak"
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] merge customConnections += $url"
log "[dry-run] merge LanNetworksBandwidth += $TAILNET_V4,$TAILNET_V6"
[[ -n "$sv" ]] && log "[dry-run] set secureConnections = $sv ($SECURE)"
[[ -n "$relay_val" ]] && log "[dry-run] set RelayEnabled = 0 (disable relay)"
else
python3 "$PREFS_PY" merge "$PREFS" \
--custom-url "$url" --lan "${TAILNET_V4},${TAILNET_V6}" \
--secure "$sv" --relay "$relay_val"
ok "Preferences.xml updated"
fi
run chown "$owner" "$PREFS"
run chmod "$mode" "$PREFS"
log "starting $SERVICE"
run systemctl start "$SERVICE"
if [[ $DRY_RUN -eq 0 ]]; then
log "waiting for Plex to answer locally..."
local i
for i in $(seq 1 20); do
curl -fsS "http://127.0.0.1:${PLEX_PORT}/identity" >/dev/null 2>&1 && { ok "Plex is up locally"; return 0; }
sleep 1
done
warn "Plex did not answer on :${PLEX_PORT} within 20s; check 'systemctl status $SERVICE'"
fi
}
# ---- firewall (only ever touches ${PLEX_PORT}/tcp; SSH stays open) ----------
configure_firewall() {
local mode="$1"
[[ "$mode" == "none" ]] && { log "firewall: left unchanged"; return 0; }
if have_cmd ufw && ufw status 2>/dev/null | grep -qi '^Status: active'; then
log "firewall: ufw active -- restricting ${PLEX_PORT}/tcp"
run ufw allow in on "$TS_IFACE" to any port "$PLEX_PORT" proto tcp || true
if [[ "$mode" == "lan" ]]; then
local n
for n in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do
run ufw allow from "$n" to any port "$PLEX_PORT" proto tcp || true
done
fi
run ufw deny "$PLEX_PORT"/tcp || true
ok "ufw: ${PLEX_PORT}/tcp limited to tailnet$([[ "$mode" == lan ]] && echo ' + private LAN')"
return 0
fi
if have_cmd firewall-cmd && firewall-cmd --state 2>/dev/null | grep -qi running; then
log "firewall: firewalld running -- restricting ${PLEX_PORT}/tcp"
run firewall-cmd --permanent --zone=trusted --change-interface="$TS_IFACE" || true
run firewall-cmd --permanent --remove-port="$PLEX_PORT"/tcp || true
if [[ "$mode" == "lan" ]]; then
local n
for n in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do
run firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=$n port port=$PLEX_PORT protocol=tcp accept" || true
done
fi
run firewall-cmd --reload || true
ok "firewalld: $TS_IFACE trusted; ${PLEX_PORT}/tcp not exposed publicly"
return 0
fi
warn "no ACTIVE managed firewall (ufw/firewalld) found; not touching firewall (avoiding lockout)."
warn "Manual nftables equivalent (only filters ${PLEX_PORT}/tcp, safe for SSH):"
cat >&2 <<EOF
nft add table inet plexlock
nft 'add chain inet plexlock input { type filter hook input priority -10 ; }'
nft add rule inet plexlock input iifname "lo" accept
nft add rule inet plexlock input iifname "$TS_IFACE" tcp dport ${PLEX_PORT} accept
$( [[ "$mode" == lan ]] && echo " nft add rule inet plexlock input ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } tcp dport ${PLEX_PORT} accept" )
nft add rule inet plexlock input tcp dport ${PLEX_PORT} drop
EOF
}
# ---- health check ----------------------------------------------------------
HC_PASS=0; HC_WARN=0; HC_FAIL=0
hc() { # label status detail
local label="$1" status="$2" detail="${3:-}" sym col
case "$status" in
PASS) sym="+"; col=$'\033[1;32m'; HC_PASS=$((HC_PASS + 1));;
WARN) sym="!"; col=$'\033[1;33m'; HC_WARN=$((HC_WARN + 1));;
FAIL) sym="x"; col=$'\033[1;31m'; HC_FAIL=$((HC_FAIL + 1));;
esac
printf ' %s[%s]%s %-26s %s\n' "$(_c "$col")" "$sym" "$(_c $'\033[0m')" "$label" "$detail"
}
healthcheck() {
local ts_ip="${1:-}"
HC_PASS=0; HC_WARN=0; HC_FAIL=0
printf '\n%sHealth check%s\n' "$(_c $'\033[1m')" "$(_c $'\033[0m')"
if have_cmd tailscale; then
tailscale status >/dev/null 2>&1 && hc "Tailscale backend" PASS "running" \
|| hc "Tailscale backend" FAIL "down / logged out (run 'tailscale up')"
local ip; ip="$(tailnet_ip_soft)"
[[ -n "$ip" ]] && hc "Tailnet IPv4" PASS "$ip" || hc "Tailnet IPv4" FAIL "no address assigned"
[[ -z "$ts_ip" || "$ts_ip" == "<"* ]] && ts_ip="$ip"
else
hc "Tailscale" FAIL "not installed"
fi
systemctl is-active --quiet "$SERVICE" 2>/dev/null \
&& hc "Plex service" PASS "$SERVICE active" \
|| hc "Plex service" WARN "$SERVICE not active (or no systemd)"
curl -fsS --max-time 8 "http://127.0.0.1:${PLEX_PORT}/identity" >/dev/null 2>&1 \
&& hc "Plex local API" PASS "127.0.0.1:${PLEX_PORT}" \
|| hc "Plex local API" FAIL "no response on :${PLEX_PORT}"
if [[ -n "$ts_ip" && "$ts_ip" != "<"* ]]; then
curl -fsSk --max-time 8 "http://${ts_ip}:${PLEX_PORT}/identity" >/dev/null 2>&1 \
&& hc "Plex via tailnet IP" PASS "${ts_ip}:${PLEX_PORT}" \
|| hc "Plex via tailnet IP" WARN "unreachable at ${ts_ip}:${PLEX_PORT} (firewall/not joined?)"
fi
if [[ -f "$PREFS" ]] && have_cmd python3; then
local cc lan rly
cc="$(get_attr customConnections)"; lan="$(get_attr LanNetworksBandwidth)"; rly="$(get_attr RelayEnabled)"
[[ "$cc" == *":${PLEX_PORT}"* ]] && hc "customConnections" PASS "$cc" || hc "customConnections" WARN "no tailnet URL (${cc:-empty})"
[[ "$lan" == *"100.64.0.0/10"* ]] && hc "LAN networks" PASS "tailnet treated as LAN" || hc "LAN networks" WARN "tailnet range missing (${lan:-empty})"
[[ "$rly" == "0" ]] && hc "Plex Relay" PASS "disabled" || hc "Plex Relay" WARN "enabled (RelayEnabled=${rly:-unset})"
else
hc "Preferences.xml" WARN "not readable at $PREFS"
fi
if have_cmd ufw && ufw status 2>/dev/null | grep -qi '^Status: active'; then
ufw status 2>/dev/null | grep -q "$PLEX_PORT" \
&& hc "Firewall (ufw)" PASS "${PLEX_PORT}/tcp rules present" \
|| hc "Firewall (ufw)" WARN "${PLEX_PORT}/tcp open on all interfaces"
elif have_cmd firewall-cmd && firewall-cmd --state 2>/dev/null | grep -qi running; then
hc "Firewall (firewalld)" PASS "running"
else
hc "Firewall" WARN "no managed firewall active"
fi
printf '\n %s%d passed%s, %s%d warnings%s, %s%d failed%s\n' \
"$(_c $'\033[1;32m')" "$HC_PASS" "$(_c $'\033[0m')" \
"$(_c $'\033[1;33m')" "$HC_WARN" "$(_c $'\033[0m')" \
"$(_c $'\033[1;31m')" "$HC_FAIL" "$(_c $'\033[0m')"
[[ $HC_FAIL -eq 0 ]]
}
# ---- main ------------------------------------------------------------------
main() {
parse_args "$@"
if [[ $HEALTHCHECK_ONLY -eq 1 ]]; then
if healthcheck "$(tailnet_ip_soft)"; then exit 0; else exit 1; fi
fi
require_root
resolve_security_options
local ts_ip="<tailscale-ip>"
if [[ $SKIP_TAILSCALE -eq 0 ]]; then
install_tailscale
join_tailnet
ts_ip="$(tailnet_ip_soft)"
[[ -n "$ts_ip" ]] || die "could not read tailscale IPv4 (authenticated? 'tailscale status')"
ok "this node's tailnet IPv4: $ts_ip"
else
ts_ip="$(tailnet_ip_soft)"; ts_ip="${ts_ip:-<tailscale-ip>}"
warn "--skip-tailscale: using existing tailnet IP $ts_ip"
fi
[[ $SKIP_PLEX -eq 0 ]] && configure_plex "$ts_ip" || warn "--skip-plex: not modifying Plex"
configure_firewall "$FIREWALL"
[[ $DRY_RUN -eq 0 ]] && healthcheck "$ts_ip" || true
cat <<EOF
$(ok "Server setup complete.")
Published Plex connection : ${URL_SCHEME}://${ts_ip}:${PLEX_PORT}
Tailnet treated as LAN : ${TAILNET_V4}, ${TAILNET_V6}
Security : secureConnections=${SECURE}, relay=${RELAY}, firewall=${FIREWALL}
For each remote user:
1. Install Tailscale: https://tailscale.com/download
$( [[ -n "$LOGIN_SERVER" ]] && echo " 2. Join your Headscale: sudo tailscale up --login-server $LOGIN_SERVER --authkey <their-preauthkey>" \
|| echo " 2. Sign in to the SAME tailnet, or invite them to it." )
3. Open Plex, sign in; the server appears over the tailnet.
Shared users still need a library share (Settings > Users & Sharing).
Re-run health checks any time: sudo $0 --healthcheck
See README.md for ACLs and the 2026 Plex Pass caveat.
EOF
}
main "$@"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
# Verifier: reads the live Plex process to confirm the crack installed.
# arg1 = PID of "Plex Media Server"
# Checks:
# - apply_feature_list_xml (file vaddr 0x1167490) prologue overwritten with a
# trampoline JMP (FF 25 ...) => hook installed.
# - g_feature_bitset_slots (file vaddr 0x15AE5D8, 14 x u64) => feature bits.
import sys, struct, binascii
pid = int(sys.argv[1])
MAIN = "/usr/lib/plexmediaserver/Plex Media Server"
APPLY = 0x1167490
BITSET = 0x15AE5D8
base = None
with open("/proc/%d/maps" % pid) as f:
for line in f:
if line.rstrip().endswith(MAIN):
base = int(line.split("-", 1)[0], 16)
break
if base is None:
print("ERROR: base mapping not found")
sys.exit(1)
print("base = 0x%x" % base)
def rd(off, n):
with open("/proc/%d/mem" % pid, "rb") as f:
f.seek(base + off)
return f.read(n)
fn = rd(APPLY, 16)
print("apply_feature_list_xml[0:16] = " + binascii.hexlify(fn).decode())
print("hook installed (prologue == jmp FF 25)? %s" % (fn[:2] == b"\xff\x25"))
qs = struct.unpack("<14Q", rd(BITSET, 112))
for i, q in enumerate(qs):
print(" slot %2d = 0x%016x" % (i, q))
print("all 14 qwords fully 0xFF..F? %s" % all(q == 0xFFFFFFFFFFFFFFFF for q in qs))
print("all used low-bytes set (every feature enabled)? %s" % all((q & 0xFF) == 0xFF for q in qs))
+798
View File
@@ -0,0 +1,798 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#include "hook.hpp"
// Use auto-generated patterns from build-time discovery if available.
// Falls back to hardcoded patterns for standalone builds without the
// generated header.
#if __has_include("patterns_generated.h")
#include "patterns_generated.h"
#else
static const char* PATTERN_PREF_GETTER =
"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";
static const char* PATTERN_BITSET_REF =
"48 8D 0D ? ? ? ? 48 8B 94 05 ? ? ? ? 48 87 14 08";
static const char* PATTERN_BS_INIT =
"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";
static const char* PATTERN_LEGACY_USF =
"55 48 89 E5 48 8B 07 48 85 C0 74 09";
static const char* PATTERN_LEGACY_MF =
"55 48 89 E5 41 57 41 56 53 48 83 EC ? 49 89 F7 4C 8D 77";
#endif
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#ifndef __aarch64__
#include "Zydis.h"
#endif
#include "webhook_handler.hpp"
struct FeatureGuidEntry
{
const char* uuid;
int feature_code;
int bitset_slot;
uint8_t bit_mask;
const char* alias;
};
// GUID catalog retained for future targeted hooks or diagnostics.
// Current hook path still uses Godmode and enables every feature.
[[maybe_unused]] static constexpr FeatureGuidEntry kFeatureGuidCatalog[] =
{
{"db965785-ca5c-46fd-bab6-7b3d29c18492", 0, 0, 0x01, nullptr},
{"fd6683b9-1426-4b00-840f-cd5fb0904a6a", 1, 0, 0x02, nullptr},
{"7ef84008-9a02-43f9-a22f-86102fd66922", 2, 0, 0x04, nullptr},
{"075954ad-56ef-4f5e-9519-9cfb0ed05827", 3, 0, 0x08, nullptr},
{"16abced2-1e64-4f01-b64f-f8ef41b1ea6c", 4, 0, 0x10, nullptr},
{"a19d495a-1cef-4f7c-ab77-5186e63e17f7", 5, 0, 0x20, "loudness"},
{"dcecabdf-68cf-4067-8013-73bd9ea3940b", 6, 0, 0x40, nullptr},
{"52ee04dc-2b82-4142-9e0a-e7ce8087c5b6", 7, 0, 0x80, nullptr},
{"59127bcf-acc8-4e97-ad88-8ba6380880b9", 8, 1, 0x01, "pro_install"},
{"ec64b6f6-e804-4ef3-b114-9d5c63e1a941", 9, 1, 0x02, nullptr},
{"ee352392-2934-4061-ba35-5f3189f19ab4", 10, 1, 0x04, nullptr},
{"cc987706-05d8-4c1f-9386-2e86f402706d", 11, 1, 0x08, nullptr},
{"b83c8dc9-5a01-4b7a-a7c9-5870c8a6e21b", 12, 1, 0x10, nullptr},
{"65685ff8-4375-4e4c-a806-ec1f0b4a8b7f", 13, 1, 0x20, "livetv"},
{"6380e085-02fe-43b5-8bff-380fa4f2423c", 14, 1, 0x40, "trailers"},
{"b46d16ae-cbd6-4226-8ee9-ab2b27e5dd42", 15, 1, 0x80, "unsupportedtuners"},
{"9c982beb-c676-4d6f-a777-ff5d37ec3081", 16, 2, 0x01, nullptr},
{"dbdc0575-9fc7-4706-9e2d-fca98d10ad71", 17, 2, 0x02, nullptr},
{"ce30800e-9c3c-4a1f-8bb3-d93d6149ff5f", 18, 2, 0x04, nullptr},
{"e4532fb1-b2e8-4269-9225-b804657cb3ba", 19, 2, 0x08, "roku-dogfood"},
{"84a754b0-d1ca-4433-af2d-c949bf4b4936", 20, 2, 0x10, "hwtranscode"},
{"bf1f3608-e44e-48cb-84d7-11a13f29b090", 21, 2, 0x20, nullptr},
{"850f3d1e-3f38-44c1-9c0c-e3c9127b8b5a", 22, 2, 0x40, "photosV6-edit"},
{"0e2acda2-d70d-4df6-96e0-f63cf264d217", 23, 2, 0x80, nullptr},
{"05690239-443e-43fb-bc1a-95b5d916ca63", 24, 3, 0x01, "session_bandwidth_restrictions"},
{"ea791163-c28d-4b7c-af88-bcc9553b206d", 25, 3, 0x02, nullptr},
{"e093a02d-5532-4506-948a-2e994beb032b", 26, 3, 0x04, nullptr},
{"d4b4e08a-9201-4c99-9a52-8f2de8ff25cd", 27, 3, 0x08, nullptr},
{"00cc618e-eb08-4e0e-9221-82b4835dd89b", 28, 3, 0x10, nullptr},
{"d9f42aea-bc9d-47db-9814-cd7a577aff48", 29, 3, 0x20, nullptr},
{"d85cb60c-0986-4a02-b1e1-36c64c609712", 30, 3, 0x40, nullptr},
{"c55d5900-b546-416d-a8c5-45b24a13e9bc", 31, 3, 0x80, "download_certificates"},
{"2797e341-b062-46ed-862f-0acbba5dd522", 32, 4, 0x01, nullptr},
{"c43d8d0f-7aa3-4fef-b9ad-4902580c90ce", 33, 4, 0x02, "incremental-epg"},
{"e8230c74-0940-4b91-9e20-6571eb068086", 34, 4, 0x04, "dvr"},
{"044a1fac-6b55-47d0-9933-25a035709432", 35, 4, 0x08, nullptr},
{"4ca03b04-54c1-4f9f-aea2-f813ae48f317", 36, 4, 0x10, "session_kick"},
{"a536a6e1-0ece-498a-bf64-99b53c27de3a", 37, 4, 0x20, nullptr},
{"76ddd91e-8321-4916-94b6-ded8e3727a64", 38, 4, 0x40, nullptr},
{"ebbe0bd5-7b9f-4c50-92d2-122eb35b61ad", 39, 4, 0x80, nullptr},
{"b2403ac6-4885-4971-8b96-59353fd87c72", 40, 5, 0x01, "home"},
{"56cd352b-0d47-436d-aced-f20db3508de5", 41, 5, 0x02, nullptr},
{"d49a726d-ef0e-4a04-9ffb-fd018306d3b7", 42, 5, 0x04, nullptr},
{"0eee866d-782b-4dfd-b42b-3bbe8eb0af16", 43, 5, 0x08, "server-manager"},
{"1417df52-986e-4e4b-8dcd-3997fbc5c976", 44, 5, 0x10, "collections"},
{"4264b94c-cb40-4935-83b4-7b5c49d35e7f", 45, 5, 0x20, "remote_watch_pass"}, // g_feature_bits_remote_media&0x20; gates remote playback/download (FeatureManager_matches_client_policy)
{"88aba3a3-bd62-42a5-91bb-0558a4c1db57", 46, 5, 0x40, nullptr},
{"c7ae6f8f-05e6-48bb-9024-c05c1dc3c43e", 47, 5, 0x80, "kevin-bacon"},
{"b58d7f28-7b4a-49bb-97a7-152645505f28", 48, 6, 0x01, "item_clusters"},
{"62b1e357-5450-41d8-9b60-c7705f750849", 49, 6, 0x02, nullptr},
{"c225b90f-d4b6-4286-a4dd-2492aa017b63", 50, 6, 0x04, nullptr},
{"1f952ea5-0837-44cb-8539-a69a14a75d4a", 51, 6, 0x08, nullptr},
{"d20f9af2-fdb1-4927-99eb-a2eb8fbff799", 52, 6, 0x10, "shared-radio"},
{"644c4466-05fa-45e0-a478-c594cf81778f", 53, 6, 0x20, nullptr},
{"6f82ca43-6117-4e55-ae0e-5ea3b3e99a96", 54, 6, 0x40, "webhooks"},
{"9dc1df45-fb45-4be1-9ab2-eb23eb57f082", 55, 6, 0x80, "sync"},
{"0de49fa2-30cc-4b54-a22d-ff860c1bf3af", 56, 7, 0x01, nullptr},
{"a6f3f9b3-c10c-4b94-ad59-755e30ac6c90", 57, 7, 0x02, nullptr},
{"8536058d-e1dd-4ae7-b30f-e8b059b7cc17", 58, 7, 0x04, nullptr},
{"e7cea823-02e5-48c4-a501-d37b82bf132f", 59, 7, 0x08, nullptr},
{"2573654f-0985-4cef-9c53-28e78cc62f26", 60, 7, 0x10, nullptr},
{"e954ef21-08b4-411e-a1f0-7551f1e57b11", 61, 7, 0x20, nullptr},
{"84309650-eb7a-41e8-8b6c-a260f084bb9d", 62, 7, 0x40, nullptr},
{"bcd82ac2-f32e-4a23-bb48-88090248c5db", 63, 7, 0x80, nullptr},
{"ea442c16-044a-4fa7-8461-62643f313c62", 64, 8, 0x01, nullptr},
{"07f804e6-28e6-4beb-b5c3-f2aefc88b938", 65, 8, 0x02, nullptr},
{"32cc8bf5-b425-4582-a52d-71b4f1cf436b", 66, 8, 0x04, "content_filter"},
{"3f28df36-2648-41b6-b2ef-36c2e1509467", 67, 8, 0x08, nullptr},
{"67c80530-eae3-4500-a9fa-9b6947d0f6d1", 68, 8, 0x10, nullptr},
{"222020fb-1504-492d-af33-a0b80a49558a", 69, 8, 0x20, nullptr},
{"5f4ac7c1-a619-4c17-9f06-7b5564566c94", 70, 8, 0x40, nullptr},
{"a548af72-b804-4d05-8569-52785952d31d", 71, 8, 0x80, nullptr},
{"99d17487-7106-4d42-a3b1-c92f68b73165", 72, 9, 0x01, "news"},
{"abd37b14-706c-461f-8255-fa9563882af3", 73, 9, 0x02, "adaptive_bitrate"},
{"c9a08c83-fbd1-4f2c-ac21-6b35a0acea0e", 74, 9, 0x04, nullptr},
{"ba8459cd-81fe-4799-93e2-84358717bfb4", 75, 9, 0x08, nullptr},
{"002c9f1a-2fc0-4812-b85b-0e6140f21a0f", 76, 9, 0x10, "lyrics"},
{"fb34e64d-cd89-47b8-8bae-a6d20c542bae", 77, 9, 0x20, "camera_upload"},
{"c757f4d0-2ce6-42d8-ab73-b4808b97cc81", 78, 9, 0x40, nullptr},
{"ff204a84-8ff1-4d9e-bf5e-378c97bceb10", 79, 9, 0x80, nullptr},
{"1df3cd16-faf2-4d37-8349-1fcf3713bf1d", 80, 10, 0x01, nullptr},
{"53d7b3d9-f1f4-4584-9434-9380295db9fe", 81, 10, 0x02, nullptr},
{"926bc176-58ca-47da-b8e3-080ed14ea6ba", 82, 10, 0x04, nullptr},
{"a0220fbb-3a79-4041-8642-add6abf70eb5", 83, 10, 0x08, nullptr},
{"cbae4949-1643-46f3-a488-71836b025d63", 84, 10, 0x10, nullptr},
{"93bf35b9-3b62-4a8a-b09b-5c85437fa67b", 85, 10, 0x20, nullptr},
{"ea02d5cc-d9d1-49a4-ab46-bc54c39f739a", 86, 10, 0x40, nullptr},
{"bc8d1fca-deb0-4d0a-a6f4-12cfd681002d", 87, 10, 0x80, "hardware_transcoding"},
{"300231e0-69aa-4dce-97f4-52d8c00e3e8c", 88, 11, 0x01, "radio"},
{"e6eda780-8db7-4114-8179-2f581207d58f", 89, 11, 0x02, nullptr},
{"6ab6677b-ad9b-444f-9ca1-b8027d05b3e1", 90, 11, 0x04, nullptr},
{"3c376154-d47e-4bbf-9428-2ea2592fd20a", 91, 11, 0x08, nullptr},
{"82999dd3-a2be-482e-9f44-357879b4f603", 92, 11, 0x10, "pass"},
{"5d819d02-5d04-4116-8eec-f49def4e2d6f", 93, 11, 0x20, "federated-auth"},
{"4866e9e9-ad14-4c2b-bd92-49576f320fd7", 94, 11, 0x40, "ump-matching-pref"},
{"1facf910-786e-46bb-894e-ea0b41e3fa3e", 95, 11, 0x80, nullptr},
{"fef9b829-af7e-430c-a757-d80de818f211", 96, 12, 0x01, nullptr},
{"4b522f91-ae89-4f62-af9c-76f44d8ef61c", 97, 12, 0x02, "tuner-sharing"},
{"3a2b0cb6-1519-4431-98e2-823c248c70eb", 98, 12, 0x04, "photosV6-tv-albums"},
{"d413fb56-de7b-40e4-acd0-f3dbb7c9e104", 99, 12, 0x08, "premium_music_metadata"},
{"9aea4ca5-2095-4619-9339-88c1e662fde6", 100, 12, 0x10, nullptr},
{"cd0ef747-af8a-414b-9d3a-dd02b6454db9", 101, 12, 0x20, nullptr},
{"04d7d794-b76c-49ef-9184-52f8f1f501ee", 102, 12, 0x40, nullptr},
{"1844737f-1a87-45c3-ab20-01435959e63c", 103, 12, 0x80, "music_videos"},
{"8d15fdf2-89dc-407e-a2f6-0e7c31daa09a", 104, 13, 0x01, nullptr},
{"d14556be-ae6d-4407-89d0-b83953f4789a", 105, 13, 0x02, "type-first"},
{"0a348865-4f87-46dc-8bb2-f37637975724", 106, 13, 0x04, "photos-v5"},
{"3d572099-e243-49bb-9f94-17de7703f9f9", 107, 13, 0x08, "advanced-playback-settings"},
{"8fd37970-6e4e-4f00-a64a-e70b52f18e94", 108, 13, 0x10, "music-analysis"},
{"8b46de05-1f96-4278-87b3-010ba5b1e386", 109, 13, 0x20, nullptr},
{"4cd4dc0e-6cbe-456c-9988-9f073fadcd73", 110, 13, 0x40, nullptr},
};
[[maybe_unused]] static constexpr size_t kFeatureGuidCatalogCount = sizeof(kFeatureGuidCatalog) / sizeof(kFeatureGuidCatalog[0]);
static uint64_t (*g_feature_flags)[14] = nullptr;
// Trampoline pointers (thunks to the original functions).
static uint64_t (*_bitset_init)(uintptr_t) = nullptr;
static uint64_t (*_is_feature_available)(uintptr_t, const char**) = nullptr;
static uint64_t* (*_map_find)(uintptr_t*, const char**) = nullptr;
static bool (*_is_user_feature_set)(uintptr_t, int, int) = nullptr;
static uint64_t (*_sub_122B2F2)(uintptr_t, char*) = nullptr;
static uint64_t (*_sub_125ACA6)(uintptr_t) = nullptr;
// Returns the runtime [start, end) of the main program's executable code
// by parsing /proc/self/maps for every r-xp segment of "Plex Media Server".
//
// dl_iterate_phdr cannot be used because under musl's dynamic linker the
// LD_PRELOAD library's constructor runs BEFORE the main PIE binary is
// loaded, so dl_iterate_phdr returns 0 callbacks.
TextSpan get_dottext_info()
{
FILE* maps = fopen("/proc/self/maps", "r");
if(!maps)
return {false, 0, 0};
uintptr_t text_start = UINTPTR_MAX;
uintptr_t text_end = 0;
char line[8192];
while(fgets(line, sizeof(line), maps))
{
// Format: start-end perm offset dev inode [path]
//
// We want every r-xp (read-execute, non-writable) segment of the
// main PIE binary, whose path ends in "Plex Media Server".
// PMS maps the executable text as several adjacent executable ranges;
// scanning only the first range misses the hook signatures.
//
// Codec .so files live under .../Codecs/... and also contain
// "Plex Media Server" in their path, so we check for the exact
// path suffix instead of a substring.
if(const char* path = strstr(line, "/usr/lib/plexmediaserver/"))
{
path += strlen("/usr/lib/plexmediaserver/");
if(strncmp(path, "Plex Media Server", 17) != 0)
continue; // codec .so or plugin, skip
}
else
{
continue; // not our binary
}
// Parse permission field (field #2, after address range).
const char* perm = line;
while(*perm && *perm != ' ') perm++;
while(*perm == ' ') perm++;
if(perm[0] != 'r' || perm[1] != '-' || perm[2] != 'x' || perm[3] != 'p')
continue; // not an executable mapping
// Parse address range.
const char* addr = line;
char* end = nullptr;
const uintptr_t seg_start = strtoull(addr, &end, 16);
if(!end || *end != '-') continue;
const uintptr_t seg_end = strtoull(end + 1, &end, 16);
if(!end) continue;
if(seg_start < text_start) text_start = seg_start;
if(seg_end > text_end) text_end = seg_end;
}
fclose(maps);
if(text_end <= text_start)
return {false, 0, 0};
return {true, text_start, text_end};
}
#ifndef __aarch64__
OptAddr create_hook(uintptr_t from, uintptr_t to)
{
ZydisDecoder decoder;
ZydisDecodedInstruction instruction;
ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64);
auto trampoline_mem = static_cast<uint8_t*>(mmap(nullptr, getpagesize(), PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0));
if(trampoline_mem == MAP_FAILED)
{
return {false, 0};
}
size_t offset = 0;
while(offset < 14)
{
if(ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(&decoder, nullptr, reinterpret_cast<void*>(from + offset), ZYDIS_MAX_INSTRUCTION_LENGTH, &instruction)))
{
memcpy(trampoline_mem + offset, reinterpret_cast<void*>(from + offset), instruction.length);
offset += instruction.length;
}
else
{
munmap(trampoline_mem, getpagesize());
return {false, 0};
}
}
uint8_t shellcode[] =
{
0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, // jmp [rip+0x06]
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // ?
};
// Jump to original code in trampoline and make trampoline executable
*reinterpret_cast<uintptr_t*>(&shellcode[6]) = from + offset;
memcpy(trampoline_mem + offset, shellcode, sizeof(shellcode));
const size_t trampoline_size = offset + sizeof(shellcode);
if(mprotect(trampoline_mem, trampoline_size, PROT_READ|PROT_EXEC) != 0)
{
munmap(trampoline_mem, getpagesize());
return {false, 0};
}
// Jump to target code
*reinterpret_cast<uintptr_t*>(&shellcode[6]) = to;
// mprotect requires a page-aligned address. The target function sits
// inside .text, which is mapped at page granularity. Align down to
// the page boundary so mprotect succeeds.
const uintptr_t from_page = from & ~(static_cast<uintptr_t>(getpagesize()) - 1);
if(mprotect(reinterpret_cast<void*>(from_page), getpagesize(), PROT_READ|PROT_WRITE|PROT_EXEC) != 0)
{
munmap(trampoline_mem, getpagesize());
return {false, 0};
}
memcpy(reinterpret_cast<void*>(from), shellcode, sizeof(shellcode));
// Flush instruction cache so the CPU sees the modified code.
// On x86 this is a no-op at the hardware level (icache is coherent)
// but acts as a compiler barrier to prevent reordering the memcpy
// past the subsequent mprotect.
__builtin___clear_cache(reinterpret_cast<char*>(from),
reinterpret_cast<char*>(from) + sizeof(shellcode));
if(mprotect(reinterpret_cast<void*>(from_page), getpagesize(), PROT_READ|PROT_EXEC) != 0)
{
// Memory is still RWX (suboptimal but not fatal).
}
return {true, reinterpret_cast<uintptr_t>(trampoline_mem)};
}
#endif // __aarch64__
#ifdef __aarch64__
// ARM64 hook: uses a 16-byte LDR X17, [PC, #8]; BR X17 trampoline.
// ARM64 has fixed 4-byte instructions so we don't need Zydis to decode lengths.
//
// Hook injection (16 bytes at `from`):
// LDR X17, [PC, #8] ; 4 bytes: load target address from literal pool
// BR X17 ; 4 bytes: jump to target
// <8 bytes target> ; 8 bytes: literal pool with hook function address
//
// Trampoline (at least 28 bytes):
// 16 bytes original instructions (4 × 4-byte)
// LDR X17, [PC, #8] ; 4 bytes: load from+16 address
// BR X17 ; 4 bytes: jump back
// <8 bytes from+16> ; 8 bytes: literal pool
//
static OptAddr create_hook_arm64(uintptr_t from, uintptr_t to)
{
// Allocate a page for the trampoline.
auto tramp = static_cast<uint8_t*>(mmap(nullptr, getpagesize(),
PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE, -1, 0));
if(tramp == MAP_FAILED)
return {false, 0};
// Copy 16 bytes (4 ARM64 instructions) from the hook point.
memcpy(tramp, reinterpret_cast<void*>(from), 16);
// Append jump-back to original code past the hook point.
// LDR X17, [PC, #8]: offset = 8/4 = 2, encoding = 0x58000051
// BR X17: 0xD61F0220
const uint32_t ldr_x17 = 0x58000051; // LDR X17, [PC, #8]
const uint32_t br_x17 = 0xD61F0220; // BR X17
memcpy(tramp + 16, &ldr_x17, 4);
memcpy(tramp + 20, &br_x17, 4);
*reinterpret_cast<uintptr_t*>(tramp + 24) = from + 16;
const size_t tramp_size = 32; // 16 copied + 16 jump = 32
if(mprotect(tramp, getpagesize(), PROT_READ|PROT_EXEC) != 0)
{
munmap(tramp, getpagesize());
return {false, 0};
}
// Make the hook point writable.
const uintptr_t from_page = from & ~(static_cast<uintptr_t>(getpagesize()) - 1);
if(mprotect(reinterpret_cast<void*>(from_page), getpagesize(),
PROT_READ|PROT_WRITE|PROT_EXEC) != 0)
{
munmap(tramp, getpagesize());
return {false, 0};
}
// Write the hook: LDR X17, [PC, #8]; BR X17; <8-byte target addr>
memcpy(reinterpret_cast<void*>(from), &ldr_x17, 4);
memcpy(reinterpret_cast<void*>(from + 4), &br_x17, 4);
*reinterpret_cast<uintptr_t*>(from + 8) = to;
// Flush instruction cache (required on ARM64 for modified code).
__builtin___clear_cache(reinterpret_cast<char*>(from),
reinterpret_cast<char*>(from) + 16);
__builtin___clear_cache(reinterpret_cast<char*>(tramp),
reinterpret_cast<char*>(tramp) + tramp_size);
// Restore text page to read-execute.
mprotect(reinterpret_cast<void*>(from_page), getpagesize(), PROT_READ|PROT_EXEC);
return {true, reinterpret_cast<uintptr_t>(tramp)};
}
// ARM64 hook callbacks.
// Hook for the feature-check function (SSO-check prologue on x1 or x0).
// On ARM64, functions matching `ldrb w?, [x?, #0x17]` are candidate
// feature checkers. We return `true` to mark every feature as available.
// Currently unimplemented — we discover targets by sig_scan below.
// (The actual callback is chosen based on which signature matched.)
// Bitset lock — same as x86-64: force all 14 slots to UINT64_MAX.
// The ARM64 FeatureManager still uses the same std::bitset<896> layout.
// We locate the feature flags pointer by scanning for LDR+DUP+STP pattern
// that addresses the global bitset, similar to the lea-rel32 on x86-64.
// ARM64 hook that forces all feature bits on after initialization.
static uint64_t (*_arm64_bitset_init)(uintptr_t) = nullptr;
static uint64_t (*_arm64_feature_check)(uintptr_t, uintptr_t) = nullptr;
static uintptr_t g_arm64_feature_flags = 0;
static uint64_t hook_arm64_bitset_init(uintptr_t rcx)
{
auto ret = _arm64_bitset_init(rcx);
if(g_arm64_feature_flags != 0)
{
// Force all 14 × uint64_t feature slots on.
for(int i = 0; i < 14; i++)
reinterpret_cast<uint64_t*>(g_arm64_feature_flags)[i] = UINT64_MAX;
}
return ret;
}
// Generic feature-check interceptor: return true for any feature.
static uint64_t hook_arm64_feature_check(uintptr_t a, uintptr_t b)
{
(void)a; (void)b;
return 1; // feature available
}
#endif // __aarch64__
OptAddr sig_scan(uintptr_t start, uintptr_t end, const char* pattern)
{
static constexpr uint16_t WILDCARD = 0xFFFF;
static constexpr size_t MAX_PAT = 128;
uint16_t pat[MAX_PAT];
size_t pat_count = 0;
size_t i = 0;
size_t len = strlen(pattern);
while(i < len && pat_count < MAX_PAT)
{
if(pattern[i] == ' ')
{
i++;
continue;
}
if(pattern[i] == '?')
{
pat[pat_count++] = WILDCARD;
if(i + 1 < len && pattern[i + 1] == '?') i++;
i++;
continue;
}
char hex[3] = {pattern[i], i + 1 < len ? pattern[i + 1] : '\0', '\0'};
pat[pat_count++] = static_cast<uint16_t>(strtol(hex, nullptr, 16));
i += 2;
}
if(pat_count == 0 || pat_count > end - start)
{
return {false, 0};
}
for(uintptr_t addr = start; addr <= end - pat_count; addr++)
{
bool mismatch = false;
for(size_t x = 0; x < pat_count; x++)
{
if(pat[x] != WILDCARD && *reinterpret_cast<const uint8_t*>(addr + x) != static_cast<uint8_t>(pat[x]))
{
mismatch = true;
break;
}
}
if(!mismatch)
{
return {true, addr};
}
}
return {false, 0};
}
uintptr_t follow_call_rel32(const uintptr_t address)
{
return address + 5 + *reinterpret_cast<uint32_t*>(address + 1);
}
bool process_feature([[maybe_unused]] const char* guid)
{
// Godmode: Enable ALL features regardless of GUID
// This is more robust against PMS updates as it doesn't depend on knowing specific GUIDs
return true;
}
uint64_t hook_is_feature_available(uintptr_t user, const char** feature)
{
if(process_feature(*feature))
{
return true;
}
return _is_feature_available(user, feature);
}
uint64_t* hook_map_find(uintptr_t* rcx, const char** str)
{
if(str != nullptr && process_feature(*str))
{
static uint64_t FAKE_PTR = 0;
return &FAKE_PTR;
}
return _map_find(rcx, str);
}
uint64_t hook_bitset_init(uintptr_t rcx)
{
auto ret = _bitset_init(rcx);
if(g_feature_flags)
{
for(int i = 0; i < 14; i++)
{
(*g_feature_flags)[i] = UINT64_MAX;
}
}
return ret;
}
bool hook_is_user_feature_set([[maybe_unused]] uintptr_t rcx, [[maybe_unused]] int expected, [[maybe_unused]] int feature)
{
return static_cast<bool>(expected);
}
uint64_t hook_sub_122B2F2(uintptr_t this_ptr, char* key)
{
if(_sub_122B2F2) {
if(strcmp(key, "WebHooksEnabled") == 0) {
const char true_str[] = "1";
// "1" is 2 bytes + null within 22-byte SSO threshold.
// Construct SSO std::string: bytes 0=N, byte 23=22-N.
// For length 1: byte 23 = 22-1 = 21 = 0x15.
memcpy(reinterpret_cast<void*>(this_ptr + 16), true_str, 2);
reinterpret_cast<uint8_t*>(this_ptr)[23] = 0x15;
return 1;
}
return _sub_122B2F2(this_ptr, key);
}
return 0;
}
uint64_t hook_sub_125ACA6(uintptr_t manager)
{
if(_sub_125ACA6)
{
const auto ret = _sub_125ACA6(manager);
webhook_set_manager(reinterpret_cast<void*>(manager));
webhook_inject_into_manager(reinterpret_cast<void*>(manager));
return ret;
}
return 0;
}
void hook()
{
auto info = get_dottext_info();
if(!info.ok)
{
return;
}
const uintptr_t start = info.start;
const uintptr_t end = info.end;
#ifdef __aarch64__
// ── ARM64 hook targets ──────────────────────────────────────────────
//
// Use signatures extracted from the ARM64 Plex binary analysis
// (get_arm64_sigs.py output). The binary has no fixed addresses; all
// targets are found by byte-pattern scanning.
//
// Strategy:
// 1. Hook the preference init function (identified by "WebHooksEnabled"
// ADRP+ADD reference) to force the preference on.
// 2. Hook feature-check functions (identified by SSO prologue near
// hasPlexPass string references) to return true.
// 3. If a global feature-bitset is discoverable, force all slots on.
// STEP A1: Feature bitset — scan for LDR + DUP + STP pattern that
// references the global feature flags array.
//
// The ARM64 binary still uses std::bitset<896> = 14 × uint64_t.
// We look for a LDR (literal load of a global address) followed by a
// DUP that broadcasts a feature slot index, followed by an AND/ANDS
// test instruction — the same check_feature pattern on ARM64.
//
// Pattern: LDR Xt, #page (ADRP) / page-offset-of-g_feature_flags (ADD)
// or a global pointer LDR via literal pool.
//
// If found, hook the first FeatureManager init function we can sig_scan
// and force all bits on after init.
// Try to find the feature flags array by scanning for a function that
// loads from a single global pointer (the bitset) in a loop-like pattern.
// The ARM64 equivalent of the x86-64 `lea rcx, [rip+?]` pattern is an
// ADRP+ADD sequence.
//
// We use a heuristic: look for ADRP X0, #page ; ADD X0, X0, #off immediately
// followed by a BL call — this is a common pattern for passing the bitset
// address to FeatureManager functions.
if(const auto fflags_sig = sig_scan(start, end,
"00 00 00 90 ?? ?? ?? 91 ?? ?? ?? 94"); fflags_sig.ok)
{
// fflags_sig.addr points to: ADRP X0, #page
// Decode: ADRP X0, #page @ fflags_sig.addr => base = page
// ADD X0, X0, #off @ fflags_sig.addr + 4 => addr = page + off
//
// ARM64 ADRP encoding:
// |31|29|28|24|23| 5|4|0|
// |1 immlo| 10000 | immhi| Rd|
// imm = immlo:immhi (21-bit signed, shifted by 12)
//
// ARM64 ADD (immediate) encoding:
// |31|24|23|22|21| 10 |9|5|4|0|
// |10010001|sh| imm12 |Rn| Rd|
//
// Extract ADRP immediate:
const uint32_t adrp_inst = *reinterpret_cast<const uint32_t*>(fflags_sig.addr);
const uint32_t adrp_immlo = (adrp_inst >> 29) & 3;
const uint32_t adrp_immhi = (adrp_inst >> 5) & 0x7FFFF;
int64_t adrp_imm = (static_cast<int64_t>((adrp_immlo << 19) | adrp_immhi) << 43) >> 43;
const uintptr_t adrp_page = (fflags_sig.addr & ~0xFFFULL) + adrp_imm;
// Extract ADD immediate:
const uint32_t add_inst = *reinterpret_cast<const uint32_t*>(fflags_sig.addr + 4);
const uint32_t add_imm12 = (add_inst >> 10) & 0xFFF;
const uintptr_t flags_addr = adrp_page + add_imm12;
g_arm64_feature_flags = flags_addr;
}
// Try to find and hook a FeatureManager init function.
// ARM64 FeatureManager functions have a large stack frame (many stp pairs).
// We look for the prologue with 6+ stp pairs (12+ register saves).
if(g_arm64_feature_flags || true) // always try
{
// Signature from the FeatureManager constructor-like function at ~0x658070:
// stp x29, x30, [sp, #-0x30]! ; stp x20, x19, [sp, #0x10] ; ...
if(const auto fm = sig_scan(start, end,
"FD 7B 03 A9 F4 4F 04 A9 FD C3 00 91"); fm.ok)
{
if(auto tramp = create_hook_arm64(fm.addr,
reinterpret_cast<uintptr_t>(hook_arm64_bitset_init)); tramp.ok)
{
_arm64_bitset_init = reinterpret_cast<decltype(_arm64_bitset_init)>(tramp.addr);
}
}
}
// STEP A2: Feature-check function — candidate with SSO-check prologue.
// The function at ~0xeba0b4 reads [x0+0x17] (std::string SSO byte) and
// compares against known feature strings. Hooking it to always return
// true unlocks features regardless of the bitset.
//
// Signature: FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39
// (stp x29,x30,[sp,#-0x10]!; str x19,[sp,#8]; mov x29,sp; ldrb w8,[x0,#0x17])
if(const auto fc = sig_scan(start, end,
"FD 7B BE A9 F3 0B 00 F9 FD 03 00 91 08 5C 40 39"); fc.ok)
{
if(auto tramp = create_hook_arm64(fc.addr,
reinterpret_cast<uintptr_t>(hook_arm64_feature_check)); tramp.ok)
{
_arm64_feature_check = reinterpret_cast<decltype(_arm64_feature_check)>(tramp.addr);
}
}
// STEP A3: Preference init — the function that stores "WebHooksEnabled"
// during startup. Hook it so we can force the default to "1" after init.
//
// Signature (from 0x10dd904): 6 stp pairs + mov x29, sp + sub sp, sp, #0x250
// FD 7B BA A9 FC 6F 01 A9 FA 67 02 A9 F8 5F 03 A9 F6 57 04 A9 F4 4F 05 A9
// FD 03 00 91 FF 43 09 D1
//
// Delay implementation until ARM64 build is verified on target.
// TODO: Implement after verifying feature unlock works.
// STEP A4: WebHooksEnabled preference getter — find by SSO-check on x1 (2nd arg).
// Pattern: `?? 5C 40 39` where ?? encodes register w? and x1 base register.
// ldrb w8, [x1, #0x17] = 0x39400000 | (0x17 << 10) | (1 << 5) | 8 = 0x39405C28
// ldrb w9, [x1, #0x17] = 0x39405C29
// etc. We use a wildcard for the destination register.
//
// The surrounding prologue should also save multiple callee-saved registers.
if(const auto wh = sig_scan(start, end,
"FD 7B ?? A9 ?? ?? ?? A9 FD 03 00 91 ?? 5C 40 39 ??"); wh.ok)
{
if(auto tramp = create_hook_arm64(wh.addr,
reinterpret_cast<uintptr_t>(hook_arm64_feature_check)); tramp.ok)
{
// Same callback as feature check for now — returns true for any
// getter call. TODO: implement string-key matching like x86-64.
_arm64_feature_check = reinterpret_cast<decltype(_arm64_feature_check)>(tramp.addr);
}
}
// ARM64 hook complete.
return;
#endif
// STEP 1: sub_122B2F2 (the generic preference getter): force WebHooksEnabled.
if(const auto wh = sig_scan(start, end,
PATTERN_PREF_GETTER); wh.ok)
{
if(auto trampoline = create_hook(wh.addr, reinterpret_cast<uintptr_t>(hook_sub_122B2F2)); trampoline.ok)
{
_sub_122B2F2 = reinterpret_cast<decltype(_sub_122B2F2)>(trampoline.addr);
}
}
// STEP 2: sub_125E524+0x6DD inline patch (forces feature bit return to true)
// Temporarily disabled — suspected to corrupt adjacent instructions.
// The bitset_init hook (step 3) achieves the same goal cleanly.
#if 0
if(const auto sa = sig_scan(start, end,
"55 48 89 E5 41 57 41 56 41 55 41 54 53 48 81 EC ? ? ? ? 49 89 F7 49 89 FC 41 BE ? ? ? ? 4C 03 77 28"); sa.ok)
{
const uintptr_t target = sa.addr + 0x6DD;
const uintptr_t page = target & ~(getpagesize() - 1);
if(mprotect(reinterpret_cast<void*>(page), getpagesize(), PROT_READ|PROT_WRITE|PROT_EXEC) == 0)
{
const uint8_t patch[] = {0xB0, 0x01, 0x90, 0x90, 0x90};
memcpy(reinterpret_cast<void*>(target), patch, sizeof(patch));
mprotect(reinterpret_cast<void*>(page), getpagesize(), PROT_READ|PROT_EXEC);
}
}
#endif
// [WEBHOOK DISABLED] sub_125ACA6 hook — causes SIGSEGV at startup with
// the mprotect fix. The `create_hook` trampoline now works (page-aligned)
// and this hook fires during PMS init, but the manager layout offsets
// (+0x78 for map, +0x40 for vector pointers) are version-specific and
// crash on this build (1.43.2.10687). The socket-level interposer
// (webhook_handler.cpp) works independently via LD_PRELOAD and handles
// webhook CRUD without this hook.
//
// Re-enable *only* after verifying the struct layout matches.
//
// STEP 3: Modern path (PMS BETA 2024/08/13+): force the entire feature bitset.
if(const auto bitset = sig_scan(start, end, PATTERN_BITSET_REF); bitset.ok)
{
const uintptr_t addr = bitset.addr + 7 + *reinterpret_cast<uint32_t*>(bitset.addr + 3);
g_feature_flags = reinterpret_cast<uint64_t(*)[14]>(addr);
if(const auto bs_init = sig_scan(start, end,
PATTERN_BS_INIT); bs_init.ok)
{
if(auto trampoline = create_hook(bs_init.addr, reinterpret_cast<uintptr_t>(hook_bitset_init)); trampoline.ok)
{
_bitset_init = reinterpret_cast<decltype(_bitset_init)>(trampoline.addr);
return;
}
}
}
// STEP 4: Legacy fallback — pre-2024/08/13 per-feature hooks.
if(const auto usf = sig_scan(start, end, PATTERN_LEGACY_USF); usf.ok)
{
if(auto trampoline = create_hook(usf.addr, reinterpret_cast<uintptr_t>(hook_is_user_feature_set)); trampoline.ok)
{
_is_user_feature_set = reinterpret_cast<decltype(_is_user_feature_set)>(trampoline.addr);
}
}
if(const auto ifa_ref = sig_scan(start, end, "E8 ? ? ? ? 86 43"); ifa_ref.ok) {
const auto ifa = follow_call_rel32(ifa_ref.addr);
if(auto trampoline = create_hook(ifa, reinterpret_cast<uintptr_t>(hook_is_feature_available)); trampoline.ok)
{
_is_feature_available = reinterpret_cast<decltype(_is_feature_available)>(trampoline.addr);
}
}
if(const auto mf = sig_scan(start, end, PATTERN_LEGACY_MF); mf.ok)
{
if(auto trampoline = create_hook(mf.addr, reinterpret_cast<uintptr_t>(hook_map_find)); trampoline.ok)
{
_map_find = reinterpret_cast<decltype(_map_find)>(trampoline.addr);
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
#include <stdint.h>
#include <stddef.h>
// Return type for functions that may fail (no C++ exceptions/rtti).
// check `ok` before using `addr` / `start` / `end`.
struct OptAddr { bool ok; uintptr_t addr; };
struct TextSpan { bool ok; uintptr_t start; uintptr_t end; };
TextSpan get_dottext_info();
OptAddr create_hook(uintptr_t from, uintptr_t to);
OptAddr sig_scan(uintptr_t start, uintptr_t end, const char* pattern);
uintptr_t follow_call_rel32(uintptr_t address);
// Hook callbacks (thunk style).
uint64_t hook_is_feature_available(uintptr_t rcx, const char** guid);
uint64_t* hook_map_find(uintptr_t* rcx, const char** str);
uint64_t hook_bitset_init(uintptr_t rcx);
bool hook_is_user_feature_set(uintptr_t rcx, int expected, int feature);
uint64_t hook_sub_122B2F2(uintptr_t prefs, char* key);
uint64_t hook_sub_125ACA6(uintptr_t manager);
void hook();
+88
View File
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#include "hook.hpp"
#include "webhook_handler.hpp"
#include <stdlib.h>
// ---------------------------------------------------------------------------
// The old constructor called hook() synchronously, but under musl the
// LD_PRELOAD library's constructor runs *before* the main binary is loaded
// → dl_iterate_phdr returned 0 callbacks, and /proc/self/maps doesn't yet
// contain the PMS text segment → hook() = no-op.
//
// We now combine two strategies:
//
// 1. Try hook() immediately (best-effort – will fail if the binary
// isn't mapped yet, which is both harmless and informative).
//
// 2. Override a function that PMS calls during runtime startup but well
// after all libraries are loaded. When that override first fires, it
// calls hook() and then passes through to the real function.
//
// Strategy 2 uses a trigger that's guaranteed to fire during normal PMS
// operation but AFTER boost::uuids, epoll, and the HTTP server are all
// initialised. We override `epoll_create1` (called by the event loop)
// rather than `bind` (which the musl dynamic linker may reference during
// loading, causing boost UUID crashes).
//
// Both strategies are idempotent: hook() is guarded by the TextSpan check
// on the inside (returns immediately if text range not found / hooks
// already set), and the deferred trigger is a one-shot flag.
// ---------------------------------------------------------------------------
static int g_hooks_attempted = 0;
// ---- deferred trigger: epoll_create1 override ----
//
// PMS uses epoll for its event loop. The first epoll_create1 call happens
// during server initialisation, long after the main binary is fully mapped.
// This is a safer trigger than bind (which libc's NSS/resolver may call
// during dlopen, triggering boost::uuids crashes).
#include <sys/epoll.h>
#include <dlfcn.h>
#include <unistd.h>
#include <sys/syscall.h>
typedef int (*RealEpollFn)(int);
static int g_epoll_triggered = 0;
int epoll_create1(int flags)
{
if(!g_epoll_triggered)
{
g_epoll_triggered = 1;
hook();
RealEpollFn real = (RealEpollFn)dlsym(RTLD_NEXT, "epoll_create1");
if(real)
return real(flags);
return syscall(SYS_epoll_create1, flags);
}
RealEpollFn real = (RealEpollFn)dlsym(RTLD_NEXT, "epoll_create1");
if(real)
return real(flags);
return syscall(SYS_epoll_create1, flags);
}
__attribute__((constructor)) void init_so()
{
// This library is LD_PRELOADed into the (musl) "Plex Media Server" process.
// Plex later spawns glibc /bin/sh helpers (Plex Tuner Service, Plex Script
// Host, transcoders) which would inherit LD_PRELOAD and fail to load this
// musl .so ("/bin/sh: error while loading shared libraries"). The constructor
// runs before main() and before any child is spawned, so clearing LD_PRELOAD
// here scopes the preload to this process only.
unsetenv("LD_PRELOAD");
// Socket hooks in webhook_handler.cpp are active as soon as the library is
// preloaded. Resolve their real libc targets before PMS startup code reads
// from /dev/urandom; otherwise our read() interposer would fail early reads.
webhook_handler_init();
// Strategy 1: skipped — hook() will be triggered by the first
// epoll_create1 call, which happens during PMS's event loop init
// after everything is loaded.
}
+487
View File
@@ -0,0 +1,487 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// Traffic logger for Plex Media Server.
// LD_PRELOAD library that hooks socket syscalls and logs all network traffic.
//
// Activation:
// export PLEX_TRAFFIC_LOG=/tmp/pms_traffic.log
// LD_PRELOAD=.../plexmediaserver_traffic_logger.so ...
//
// Log format:
// [HH:MM:SS.mmm] DIR fd=N [peer:port] N bytes
// 00000000 48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b HTTP/1.1 200 OK
// ...
//
// Limitations:
// - Connection tracking uses a fixed-size array (CONN_MAX = 4096).
// FDs beyond that are logged without peer address.
// - No sendmsg/recvmsg support (fallback to byte-count-only log).
#include "traffic_logger.hpp"
#include <arpa/inet.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <time.h>
#include <unistd.h>
#ifndef RTLD_NEXT
#define RTLD_NEXT ((void*)(-1))
#endif
// ── Constants ──────────────────────────────────────────────────────────────
/// Maximum bytes to hex-dump per log entry. 0 = dump entire payload.
static constexpr size_t MAX_DUMP = 256;
/// Maximum tracked file descriptors.
static constexpr int CONN_MAX = 4096;
/// Maximum hex dump line width (bytes displayed per line).
static constexpr int HEX_COLS = 16;
/// Format string length buffers.
static constexpr int TS_LEN = 32;
static constexpr int PEER_LEN = 48;
// ── Per-connection state ───────────────────────────────────────────────────
struct ConnInfo
{
char peer[PEER_LEN]; // "1.2.3.4:56789" or "[::1]:56789"
uint64_t start_ns; // monotonic timestamp of connect/accept
bool active;
};
/// Connection table indexed by file descriptor.
static ConnInfo g_conns[CONN_MAX];
/// Guards g_conns.
static pthread_mutex_t g_conn_lock = PTHREAD_MUTEX_INITIALIZER;
/// Log file descriptor, or -1 if inactive.
static int g_log_fd = -1;
/// Guards g_log_fd writes.
static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
/// Whether we failed initialisation (suppress further attempts).
static bool g_init_failed = false;
// ── Real function pointers (resolved via dlsym) ────────────────────────────
extern "C"
{
static ssize_t (*real_send)(int, const void*, size_t, int) = nullptr;
static ssize_t (*real_sendto)(int, const void*, size_t, int,
const struct sockaddr*, socklen_t) = nullptr;
static ssize_t (*real_recv)(int, void*, size_t, int) = nullptr;
static ssize_t (*real_recvfrom)(int, void*, size_t, int,
struct sockaddr*, socklen_t*) = nullptr;
static int (*real_connect)(int, const struct sockaddr*, socklen_t) = nullptr;
static int (*real_accept)(int, struct sockaddr*, socklen_t*) = nullptr;
static int (*real_accept4)(int, struct sockaddr*, socklen_t*, int) = nullptr;
static int (*real_close)(int) = nullptr;
}
// ── Helpers ────────────────────────────────────────────────────────────────
/// Monotonic time in nanoseconds.
static uint64_t now_ns(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec;
}
/// Format a sockaddr as "ip:port" into buf.
static void fmt_peer(const struct sockaddr* sa, socklen_t salen, char* buf, size_t bufsize)
{
if(!sa || salen < sizeof(sa_family_t))
{
snprintf(buf, bufsize, "???");
return;
}
if(sa->sa_family == AF_INET && salen >= sizeof(struct sockaddr_in))
{
const auto* sin = (const struct sockaddr_in*)sa;
inet_ntop(AF_INET, &sin->sin_addr, buf, bufsize);
size_t n = strlen(buf);
snprintf(buf + n, bufsize - n, ":%d", (int)ntohs(sin->sin_port));
}
else if(sa->sa_family == AF_INET6 && salen >= sizeof(struct sockaddr_in6))
{
const auto* sin6 = (const struct sockaddr_in6*)sa;
buf[0] = '[';
inet_ntop(AF_INET6, &sin6->sin6_addr, buf + 1, bufsize - 8);
size_t n = strlen(buf);
snprintf(buf + n, bufsize - n, "]:%d", (int)ntohs(sin6->sin6_port));
}
else
{
snprintf(buf, bufsize, "af=%d", sa->sa_family);
}
}
/// Return the peer string for an fd, or "?" if unknown.
static const char* peer_for_fd(int fd, char* fallback_buf)
{
if(fd >= 0 && fd < CONN_MAX)
{
pthread_mutex_lock(&g_conn_lock);
if(g_conns[fd].active)
{
strcpy(fallback_buf, g_conns[fd].peer);
pthread_mutex_unlock(&g_conn_lock);
return fallback_buf;
}
pthread_mutex_unlock(&g_conn_lock);
}
fallback_buf[0] = '?';
fallback_buf[1] = '\0';
return fallback_buf;
}
/// Record peer + start time for an fd in the connection table.
static void track_conn(int fd, const char* peer)
{
if(fd < 0 || fd >= CONN_MAX) return;
pthread_mutex_lock(&g_conn_lock);
g_conns[fd].active = true;
g_conns[fd].start_ns = now_ns();
strncpy(g_conns[fd].peer, peer, sizeof(g_conns[fd].peer) - 1);
g_conns[fd].peer[sizeof(g_conns[fd].peer) - 1] = '\0';
pthread_mutex_unlock(&g_conn_lock);
}
// ── Logging ────────────────────────────────────────────────────────────────
/// Write a hex+ASCII dump of `len` bytes starting at `data`.
static void log_hexdump(const unsigned char* data, size_t len)
{
if(len == 0 || g_log_fd < 0) return;
if(len > MAX_DUMP) len = MAX_DUMP;
// Local line buffer to minimise syscall count.
char line[128];
size_t off = 0;
for(size_t i = 0; i < len; i += HEX_COLS)
{
size_t remain = len - i;
size_t row = remain < HEX_COLS ? remain : HEX_COLS;
// Address.
off = (size_t)snprintf(line, sizeof(line), " %08zx ", i);
// Hex bytes (left half, right half).
size_t h;
for(h = 0; h < row && h < 8; h++)
off += (size_t)snprintf(line + off, sizeof(line) - off, " %02x", data[i + h]);
if(row <= 8)
off += (size_t)snprintf(line + off, sizeof(line) - off, " "); // spacer
for(; h < row; h++)
off += (size_t)snprintf(line + off, sizeof(line) - off, " %02x", data[i + h]);
// Pad to column 60.
for(size_t p = row; p < HEX_COLS; p++)
{
off += (size_t)snprintf(line + off, sizeof(line) - off, " ");
if(p == 7) off += (size_t)snprintf(line + off, sizeof(line) - off, " ");
}
// ASCII view.
off += (size_t)snprintf(line + off, sizeof(line) - off, " |");
for(size_t j = 0; j < row; j++)
{
unsigned char c = data[i + j];
line[off++] = (c >= 32 && c < 127) ? (char)c : '.';
}
line[off++] = '|';
line[off++] = '\n';
write(g_log_fd, line, off);
}
}
/// Write a timestamped log line with metadata and optional hex dump.
static void log_traffic(char dir, int fd, const char* peer,
const unsigned char* data, size_t len,
const char* suffix)
{
if(g_log_fd < 0) return;
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
struct tm tm_buf;
localtime_r(&ts.tv_sec, &tm_buf);
char timestamp[TS_LEN];
strftime(timestamp, sizeof(timestamp), "%T", &tm_buf);
char line[512];
int n = snprintf(line, sizeof(line),
"[%s.%03ld] %c %s fd=%d [%s]%s%zu byte%s\n",
timestamp, (long)(ts.tv_nsec / 1000000),
dir,
(dir == 'S' || dir == 's') ? "SEND" :
(dir == 'R' || dir == 'r') ? "RECV" :
(dir == 'C') ? "CONN" :
(dir == 'A') ? "ACPT" :
(dir == 'X') ? "CLSE" :
"????",
fd, peer,
suffix ? suffix : "",
len, len == 1 ? "" : "s");
pthread_mutex_lock(&g_log_lock);
write(g_log_fd, line, (size_t)n);
if(data && len > 0)
{
log_hexdump(data, len);
}
pthread_mutex_unlock(&g_log_lock);
}
// ── Hooked functions ───────────────────────────────────────────────────────
extern "C" ssize_t send(int sockfd, const void* buf, size_t len, int flags)
{
if(!real_send) return (ssize_t)-1;
ssize_t ret = real_send(sockfd, buf, len, flags);
char fallback[PEER_LEN];
log_traffic('S', sockfd, peer_for_fd(sockfd, fallback),
(const unsigned char*)buf, (size_t)(ret > 0 ? ret : 0), nullptr);
return ret;
}
extern "C" ssize_t sendto(int sockfd, const void* buf, size_t len, int flags,
const struct sockaddr* dest_addr, socklen_t addrlen)
{
if(!real_sendto) return (ssize_t)-1;
ssize_t ret = real_sendto(sockfd, buf, len, flags, dest_addr, addrlen);
if(ret > 0)
{
char peer_buf[PEER_LEN];
if(dest_addr)
{
fmt_peer(dest_addr, addrlen, peer_buf, sizeof(peer_buf));
}
else
{
char fallback[PEER_LEN];
strcpy(peer_buf, peer_for_fd(sockfd, fallback));
}
log_traffic('s', sockfd, peer_buf,
(const unsigned char*)buf, (size_t)ret, nullptr);
}
return ret;
}
extern "C" ssize_t recv(int sockfd, void* buf, size_t len, int flags)
{
if(!real_recv) return (ssize_t)-1;
ssize_t ret = real_recv(sockfd, buf, len, flags);
if(ret > 0)
{
char fallback[PEER_LEN];
log_traffic('R', sockfd, peer_for_fd(sockfd, fallback),
(const unsigned char*)buf, (size_t)ret, nullptr);
}
return ret;
}
extern "C" ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags,
struct sockaddr* src_addr, socklen_t* addrlen)
{
if(!real_recvfrom) return (ssize_t)-1;
ssize_t ret = real_recvfrom(sockfd, buf, len, flags, src_addr, addrlen);
if(ret > 0)
{
char peer_buf[PEER_LEN];
if(src_addr && addrlen)
{
fmt_peer(src_addr, *addrlen, peer_buf, sizeof(peer_buf));
}
else
{
char fallback[PEER_LEN];
strcpy(peer_buf, peer_for_fd(sockfd, fallback));
}
log_traffic('r', sockfd, peer_buf,
(const unsigned char*)buf, (size_t)ret, nullptr);
}
return ret;
}
extern "C" int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen)
{
int ret = real_connect ? real_connect(sockfd, addr, addrlen) : -1;
// Track the connection even if it fails (EINPROGRESS is normal for non-blocking).
if(addr)
{
char peer_buf[PEER_LEN];
fmt_peer(addr, addrlen, peer_buf, sizeof(peer_buf));
track_conn(sockfd, peer_buf);
log_traffic('C', sockfd, peer_buf, nullptr, 0,
ret == 0 ? nullptr : (errno == EINPROGRESS ? " (EINPROGRESS)" : " (FAIL)"));
}
return ret;
}
extern "C" int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen)
{
int client = real_accept ? real_accept(sockfd, addr, addrlen) : -1;
if(client >= 0 && addr && addrlen)
{
char peer_buf[PEER_LEN];
fmt_peer(addr, *addrlen, peer_buf, sizeof(peer_buf));
track_conn(client, peer_buf);
log_traffic('A', client, peer_buf, nullptr, 0, nullptr);
// Also log the accept call from the listening socket perspective.
char local[PEER_LEN];
snprintf(local, sizeof(local), "LISTEN");
log_traffic('a', sockfd, local, nullptr, 0, nullptr);
}
return client;
}
extern "C" int accept4(int sockfd, struct sockaddr* addr, socklen_t* addrlen, int flags)
{
int client = real_accept4 ? real_accept4(sockfd, addr, addrlen, flags) : -1;
if(client >= 0 && addr && addrlen)
{
char peer_buf[PEER_LEN];
fmt_peer(addr, *addrlen, peer_buf, sizeof(peer_buf));
track_conn(client, peer_buf);
log_traffic('A', client, peer_buf, nullptr, 0, nullptr);
}
return client;
}
extern "C" int close(int fd)
{
if(fd >= 0 && fd < CONN_MAX)
{
pthread_mutex_lock(&g_conn_lock);
bool was_active = g_conns[fd].active;
if(was_active)
{
uint64_t age_ns = now_ns() - g_conns[fd].start_ns;
char info[64];
if(g_conns[fd].start_ns > 0)
{
unsigned long secs = (unsigned long)(age_ns / 1000000000ULL);
snprintf(info, sizeof(info), " (lifetime %lus)", secs);
}
else
{
info[0] = '\0';
}
log_traffic('X', fd, g_conns[fd].peer, nullptr, 0, info);
g_conns[fd].active = false;
}
pthread_mutex_unlock(&g_conn_lock);
}
return real_close ? real_close(fd) : -1;
}
// ── Initialisation ─────────────────────────────────────────────────────────
__attribute__((constructor)) void traffic_logger_init(void)
{
if(g_init_failed) return;
const char* log_path = getenv("PLEX_TRAFFIC_LOG");
// Always resolve real function pointers so hooks work correctly even when
// logging is disabled. Otherwise every hooked call returns -1 (the nullptr
// guard), which causes PMS to spin at 100 % CPU retrying failed I/O.
bool ok = true;
bool log_active = false;
#define RESOLVE(name_, var_) do { \
var_ = reinterpret_cast<decltype(var_)>(dlsym(RTLD_NEXT, name_)); \
if(!var_) { ok = false; } \
} while(0)
RESOLVE("send", real_send);
RESOLVE("sendto", real_sendto);
RESOLVE("recv", real_recv);
RESOLVE("recvfrom", real_recvfrom);
RESOLVE("connect", real_connect);
RESOLVE("accept", real_accept);
RESOLVE("accept4", real_accept4);
RESOLVE("close", real_close);
#undef RESOLVE
// Open log file if env var is set.
if(log_path && log_path[0] != '\0')
{
g_log_fd = open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
if(g_log_fd >= 0)
{
log_active = true;
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
struct tm tm_buf;
localtime_r(&ts.tv_sec, &tm_buf);
char ts_buf[64];
strftime(ts_buf, sizeof(ts_buf), "%Y-%m-%d %T", &tm_buf);
char hdr[256];
int n = snprintf(hdr, sizeof(hdr),
"# Plex Media Server traffic log — started %s\n"
"# PID=%d LOG_PATH=%s MAX_DUMP=%zu\n"
"## [time] DIR fd [peer] N bytes [hex dump]\n",
ts_buf, (int)getpid(), log_path, MAX_DUMP);
write(g_log_fd, hdr, (size_t)(n > 0 ? n : 0));
char msg[128];
n = snprintf(msg, sizeof(msg),
"# traffic logger initialized — %d hooks installed\n",
ok ? 8 : 0);
write(g_log_fd, msg, (size_t)(n > 0 ? n : 0));
}
}
// When env var is unset: pointers are resolved, g_log_fd stays -1, hooks
// call through to libc with no logging overhead (no-op fast path).
if(!ok)
{
g_init_failed = true;
if(g_log_fd >= 0)
{
const char* warn = "# WARNING: some dlsym(RTLD_NEXT, ...) calls failed — hooks degraded\n";
write(g_log_fd, warn, strlen(warn));
}
}
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// NOTE: this library interposes recv()/recvfrom()/close(), the same symbols the
// webhook handler interposes. The two .so files therefore cannot be LD_PRELOADed
// together — only the first-loaded definition of each symbol wins. Use one or the
// other per process.
// Initialise the traffic logger. Called automatically via library constructor;
// no-op unless PLEX_TRAFFIC_LOG environment variable is set to a writable path.
void traffic_logger_init(void);
#ifdef __cplusplus
}
#endif
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
// NOTE: this handler interposes recv()/recvfrom()/close(), the same symbols the
// traffic logger interposes. The two .so files cannot be LD_PRELOADed together —
// only the first-loaded definition of each symbol wins. Use one or the other.
// Initialise the webhook handler: resolve real socket function pointers
// and set up the webhooks JSON file path. Must be called once at startup
// (e.g. from the LD_PRELOAD library constructor or hook()).
void webhook_handler_init(void);
// Inject webhooks from webhooks.json into the WebhookManager's internal
// per-user std::vector<std::string>. Called from the sub_125ACA6 hook
// right after the original function runs.
void webhook_inject_into_manager(void* manager);
// Remember the live WebhookManager pointer so CRUD changes can refresh the
// in-memory dispatch list without requiring a PMS restart.
void webhook_set_manager(void* manager);
// Set the resolved address of sub_125E524 (webhook map find/create).
void webhook_set_sub_125E524(void* addr);
#ifdef __cplusplus
}
#endif
+12
View File
@@ -0,0 +1,12 @@
# Zydis (vendored)
Single-file amalgamation of the [Zydis](https://github.com/zyantific/zydis)
x86/x86-64 disassembler. The hook engine uses it to decode and copy
instruction-aligned bytes when installing a trampoline (so a relocated prologue
stays valid).
- **Upstream:** https://github.com/zyantific/zydis
- **License:** MIT (see the header in `Zydis.h` and upstream `LICENSE`)
`Zydis.c` / `Zydis.h` are generated amalgamations — regenerate from upstream
instead of hand-editing them here.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+159
View File
@@ -0,0 +1,159 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
# Plex_Patch — Windows x64
Feature-unlock patch for **Plex Media Server** on **Windows x64**. Port of the
Linux `LD_PRELOAD` approach to a DLL injector model.
> ⚠️ **Disclaimer** — Educational / reverse-engineering only, on software you
> legally own and run yourself. No Plex code is shipped. Use at your own risk.
---
## How it works
```
plex_inject.exe ──CreateRemoteThread(LoadLibraryA)──▶ Plex Media Server.exe
│
plex_patch.dll (DllMain)
│
┌────────────────────────────────┘
▼
1. Parse PE image (base, .text, .data bounds)
2. Version guard: scan for "1.43.2.10687" — refuse if wrong
3. Resolve g_feature_bitset via RVA 0x1D9E670 (bounds-checked)
4. Immediate force: 14 × InterlockedExchange(0xFFFFFFFF)
5. Hook FeatureManager_set_features_from_uuids (Zydis trampoline)
→ calls original, then re-forces all bits
6. Guard thread: polls 2s, re-forces if any dword reverted
```
Two complementary mechanisms keep every feature bit on:
- **Hook** (`trampoline.h`): an inline 14-byte `jmp [rip+0]` redirect on the
populator function. After the original runs (so Plex's internal state is
consistent), the hook atomically forces all 14 dwords to `0xFFFFFFFF`. This
is deterministic — every MyPlex refresh immediately becomes "all-enabled."
- **Guard thread** (`feature_patch.h`): belt-and-suspenders. Polls at 2 s
and re-forces if any dword reverted — catches code paths that write the
bitset outside the hooked function, or a failed hook install.
---
## Architecture
```
windows/
├── src/
│ ├── log.h zero-alloc OutputDebugString logging
│ ├── pe_image.h PE base, section bounds, version guard, RVA resolution
│ ├── sig_scan.h byte-pattern scanner + RIP-relative resolver
│ ├── trampoline.h x64 inline hook engine (14-byte, Zydis prologue decode)
│ ├── feature_patch.h orchestration: discover → hook → force → guard
│ ├── dllmain.cpp DLL entry (defers work to a thread: loader-lock safe)
│ └── injector.cpp attach-to-running or launch-suspended injector
├── build.bat zig 0.13.0 build (reuses vendored Zydis)
└── README.md
```
**Layering (inward dependencies only):**
| Layer | Module | Depends on |
|-------|--------|------------|
| Primitives | `log.h` | `<windows.h>` only |
| Discovery | `pe_image.h` | `log` |
| Discovery | `sig_scan.h` | nothing (pure) |
| Engine | `trampoline.h` | `log`, Zydis |
| Orchestration | `feature_patch.h` | `pe_image`, `trampoline`, `log` |
| Entry | `dllmain.cpp` | `feature_patch` |
| Launcher | `injector.cpp` | `<windows.h>` only (standalone binary) |
### Design decisions
- **RVA + version guard** over blind signature scan as the primary discovery.
The version string must be present in the image before any hardcoded RVA is
used, so a wrong build gets a clean refusal, not memory corruption.
`sig_scan.h` is included for future update-resilience.
- **Hook + guard thread** (belt and suspenders) over either alone. The hook
catches refreshes deterministically; the guard catches edge cases and
compensates if the hook fails. Either alone would work; together they are
robust.
- **No Zydis for the injector.** Only the DLL links Zydis (for prologue
decode). The injector is a small standalone binary using only kernel32.
- **`InterlockedExchange`** for all bitset writes, matching the binary's own
atomic store sequence exactly (not a memset; each dword is written atomically).
- **DllMain defers to a thread.** Heavy work (PE parsing, hook install, guard
spawn) runs outside the loader lock, avoiding the DllMain deadlock trap.
### Security
- Version guard: the patch refuses to activate on an unknown build.
- Bounds-check: RVAs are validated against the PE section table.
- No shell: the injector passes an argv list, never a command string.
- The DLL logs to `OutputDebugString`, never to disk (no file creation).
---
## Build
Requires `zig` 0.13.0 (same as the Linux build; auto-downloaded to
`toolchain/` by `build.sh`).
```bat
cd windows
build.bat
```
Outputs:
- `build\plex_patch.dll` (723 KB — includes vendored Zydis)
- `build\plex_inject.exe` (153 KB)
## Use
**Attach to a running PMS:**
```bat
build\plex_inject.exe
```
**Launch PMS through the injector (recommended — hook installs before features load):**
```bat
build\plex_inject.exe --launch "C:\Program Files\Plex\Plex Media Server\Plex Media Server.exe"
```
Copy both files to any directory; the injector resolves `plex_patch.dll`
relative to its own path.
**Verify:** open [DebugView](https://learn.microsoft.com/en-us/sysinternals/downloads/debugview)
and look for `[plex_patch INF]` messages:
```
[plex_patch INF] version guard passed (build 1.43.2.10687)
[plex_patch INF] g_feature_bitset at 0x7FF...
[plex_patch INF] hook installed on FeatureManager_set_features_from_uuids
[plex_patch INF] guard thread started (interval 2000 ms)
[plex_patch INF] feature bitset forced after set_features
```
## Known values (build 1.43.2.10687)
| Symbol | RVA | Size | IDB name |
|--------|-----|------|----------|
| `g_feature_bitset` | `0x1D9E670` | 56 B (14 × DWORD) | `g_feature_bitset` |
| `FeatureManager_set_features_from_uuids` | `0x0BC8060` | — | `FeatureManager_set_features_from_uuids` |
| `g_feature_uuid_code_table` | `0x19C9600` | ~2.2 KB (111 × 20 B) | `g_feature_uuid_code_table` |
| `FeatureManager_refresh_from_myplex` | `0x0BC6D10` | — | `FeatureManager_refresh_from_myplex` |
For a new PMS build: update `kExpectedVersion`, `kBitsetRVA`, and `kPopulatorRVA`
in `feature_patch.h`, or add a signature-scan fallback using `sig_scan.h`.
---
## Differences from the Linux version
| Aspect | Linux (`src/hook.cpp`) | Windows (`windows/`) |
|--------|----------------------|---------------------|
| Injection | `LD_PRELOAD` | `CreateRemoteThread` + `LoadLibraryA` |
| Module discovery | `dl_iterate_phdr` | PE header parsing (`GetModuleHandle`) |
| Memory protection | `mmap` / `mprotect` | `VirtualAlloc` / `VirtualProtect` |
| Cache flush | not needed (x86 coherent) | `FlushInstructionCache` (required by API) |
| Bitset storage | 14 × uint64 (libstdc++ `std::bitset`) | 14 × uint32 (MSVC `std::bitset`) |
| Guard thread | not needed (hook-only on Linux) | 2 s poll (belt-and-suspenders) |
| Disassembler | Zydis (vendored, same) | Zydis (vendored, same) |
+62
View File
@@ -0,0 +1,62 @@
@echo off
REM SPDX-License-Identifier: AGPL-3.0-or-later
REM Build plex_patch.dll + plex_inject.exe for Windows x64.
REM Requires zig 0.13.0 (auto-fetched to toolchain/ by the Linux build).
setlocal
cd /d "%~dp0"
set ROOT=%~dp0..
REM Resolve zig: %ZIG% override, then toolchain dir, then PATH.
if defined ZIG if exist "%ZIG%" goto :found
set ZIG=%ROOT%\toolchain\zig-windows-x86_64-0.13.0\zig.exe
if exist "%ZIG%" goto :found
where zig >nul 2>&1 && set ZIG=zig && goto :found
echo [x] zig not found. Set ZIG= or run build.sh first (which downloads zig).
exit /b 1
:found
echo [*] Using zig: %ZIG%
"%ZIG%" version
set TARGET=x86_64-windows-gnu
set CFLAGS=-target %TARGET% -O2 -I "%ROOT%\third_party\zydis" -I src
set CXXFLAGS=-target %TARGET% -std=c++20 -O2 -I "%ROOT%\third_party\zydis" -I src
if not exist build mkdir build
echo [*] compiling Zydis.c (C)
"%ZIG%" cc %CFLAGS% -c "%ROOT%\third_party\zydis\Zydis.c" -o build\Zydis.o
if errorlevel 1 goto :fail
echo [*] compiling dllmain.cpp (C++)
"%ZIG%" c++ %CXXFLAGS% -c src\dllmain.cpp -o build\dllmain.o
if errorlevel 1 goto :fail
echo [*] linking plex_patch.dll
"%ZIG%" c++ -target %TARGET% -shared -o build\plex_patch.dll build\dllmain.o build\Zydis.o -lkernel32
if errorlevel 1 goto :fail
echo [*] compiling + linking plex_inject.exe
"%ZIG%" c++ %CXXFLAGS% -o build\plex_inject.exe src\injector.cpp -lkernel32
if errorlevel 1 goto :fail
del /q build\Zydis.o build\dllmain.o 2>nul
echo.
echo [+] BUILD SUCCESSFUL
echo build\plex_patch.dll - godmode DLL (inject into PMS)
echo build\plex_inject.exe - injector (finds or launches PMS)
echo.
echo Usage:
echo 1. Copy both files to any directory.
echo 2. Start Plex Media Server normally, then:
echo build\plex_inject.exe
echo Or launch PMS through the injector:
echo build\plex_inject.exe --launch "C:\Program Files\Plex\Plex Media Server\Plex Media Server.exe"
echo 3. Verify with DebugView: look for [plex_patch INF] messages.
exit /b 0
:fail
echo [x] BUILD FAILED
exit /b 1
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// DLL entry point for the Plex feature-unlock patch (Windows x64).
//
// Loaded into the Plex Media Server process via the injector. Work is deferred
// to a background thread: DllMain runs under the loader lock, where calling
// non-trivial APIs (thread sync, LoadLibrary, ...) is forbidden. The
// background thread waits for Plex to finish init, then applies the patch.
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include "feature_patch.h"
#include "log.h"
static DWORD WINAPI patch_entry(LPVOID) {
plex::log(plex::LogLevel::kInfo,
"plex_patch DLL loaded (pid %lu)", ::GetCurrentProcessId());
const auto result = plex::apply_patch();
if (!result.version_ok) {
plex::log(plex::LogLevel::kError,
"patch ABORTED: build mismatch (expected %s)", plex::kExpectedVersion);
return 1;
}
plex::log(plex::LogLevel::kInfo,
"patch applied: bitset=%s hook=%s guard=%s",
result.bitset_ok ? "OK" : "FAIL",
result.hook_ok ? "OK" : "SKIP",
result.guard_ok ? "OK" : "FAIL");
return 0;
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID /*reserved*/) {
if (reason == DLL_PROCESS_ATTACH) {
::DisableThreadLibraryCalls(hModule);
HANDLE t = ::CreateThread(nullptr, 0, patch_entry, nullptr, 0, nullptr);
if (t) ::CloseHandle(t); // detach; thread runs independently
} else if (reason == DLL_PROCESS_DETACH) {
plex::remove_patch();
}
return TRUE;
}
+186
View File
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
//
// Feature-unlock patch for Plex Media Server (Windows x64).
//
// Two complementary mechanisms ensure every feature bit stays on:
//
// 1. **Hook** ``FeatureManager_set_features_from_uuids`` (the function that
// populates the bitset after fetching /api/v2/features). The hook calls
// the original, then atomically forces all 14 dwords to 0xFFFFFFFF. This
// is deterministic: every refresh immediately becomes "all-enabled."
//
// 2. **Guard thread** polls at 2 s and re-forces if any dword reverted
// (belt-and-suspenders for code paths that write the bitset outside the
// hooked function, or if the hook fails to install).
//
// Discovery uses **RVA + version guard**: the expected build string must be
// present in the image before any RVA is applied. If the guard fails (wrong
// build), the patch refuses to activate rather than corrupting memory.
//
// All writes use ``InterlockedExchange``, matching the binary's own atomic
// store sequence exactly (14 × ``_InterlockedExchange``).
//
// Known values — Plex Media Server 1.43.2.10687-563d026ea (Windows x64):
// g_feature_bitset RVA 0x1D9E670 (14 dwords, 56 bytes)
// FeatureManager_set_features_from_uuids RVA 0x0BC8060
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <atomic>
#include <cstdint>
#include "log.h"
#include "pe_image.h"
#include "trampoline.h"
namespace plex {
// ---- constants (build-specific) -------------------------------------------
inline constexpr const char kExpectedVersion[] = "1.43.2.10687";
inline constexpr uint32_t kBitsetRVA = 0x1D9E670;
inline constexpr uint32_t kPopulatorRVA = 0x0BC8060;
inline constexpr uint32_t kBitsetDwords = 14;
inline constexpr uint32_t kBitsetBytes = kBitsetDwords * sizeof(LONG);
inline constexpr DWORD kGuardIntervalMs = 2000;
inline constexpr DWORD kGuardInitialDelayMs = 5000; // let Plex finish startup
// ---- bitset operations (pure, testable) -----------------------------------
inline void force_bitset(volatile LONG* bitset) {
for (uint32_t i = 0; i < kBitsetDwords; ++i)
::InterlockedExchange(&bitset[i], static_cast<LONG>(0xFFFFFFFF));
}
inline bool bitset_is_full(volatile LONG* bitset) {
for (uint32_t i = 0; i < kBitsetDwords; ++i)
if (::InterlockedCompareExchange(&bitset[i], 0, 0) != static_cast<LONG>(0xFFFFFFFF))
return false;
return true;
}
// ---- hook callback --------------------------------------------------------
// Microsoft x64 ABI: __fastcall (rcx, rdx, r8, r9).
// Signature from IDB: void __fastcall sub_140BC8060(char *a1, __int64 a2)
using SetFeaturesFn = void(__fastcall*)(void* this_ptr, void* uuid_vec);
inline volatile LONG* g_bitset_ptr = nullptr;
inline SetFeaturesFn g_original_fn = nullptr;
void __fastcall hooked_set_features(void* this_ptr, void* uuid_vec) {
// Call the real populator so Plex's internal state is consistent.
if (g_original_fn) g_original_fn(this_ptr, uuid_vec);
// Now force every feature bit on.
if (g_bitset_ptr) {
force_bitset(g_bitset_ptr);
log(LogLevel::kInfo, "feature bitset forced after set_features");
}
}
// ---- guard thread ---------------------------------------------------------
inline std::atomic<bool> g_guard_active{false};
inline HANDLE g_guard_thread = nullptr;
DWORD WINAPI guard_thread_fn(LPVOID) {
log(LogLevel::kInfo, "guard thread: waiting %lu ms for Plex startup", kGuardInitialDelayMs);
for (DWORD elapsed = 0; elapsed < kGuardInitialDelayMs && g_guard_active.load(); elapsed += 500)
::Sleep(500);
while (g_guard_active.load()) {
if (g_bitset_ptr && !bitset_is_full(g_bitset_ptr)) {
force_bitset(g_bitset_ptr);
log(LogLevel::kInfo, "guard thread: re-forced feature bitset");
}
::Sleep(kGuardIntervalMs);
}
log(LogLevel::kInfo, "guard thread: stopped");
return 0;
}
// ---- orchestration --------------------------------------------------------
struct PatchResult {
bool version_ok = false;
bool bitset_ok = false;
bool hook_ok = false;
bool guard_ok = false;
};
inline PatchResult apply_patch() {
PatchResult r;
auto img = get_main_image();
if (!img) {
log(LogLevel::kError, "failed to parse PE image");
return r;
}
// Version guard: refuse to patch an unknown build.
r.version_ok = verify_version(*img, kExpectedVersion);
if (!r.version_ok) {
log(LogLevel::kError, "version guard FAILED: expected '%s' not found in image",
kExpectedVersion);
return r;
}
log(LogLevel::kInfo, "version guard passed (build %s)", kExpectedVersion);
// Resolve the feature bitset.
g_bitset_ptr = resolve_data_rva<volatile LONG>(*img, kBitsetRVA, kBitsetBytes);
r.bitset_ok = (g_bitset_ptr != nullptr);
if (!r.bitset_ok) {
log(LogLevel::kError, "bitset RVA 0x%X resolves outside writable data", kBitsetRVA);
return r;
}
log(LogLevel::kInfo, "g_feature_bitset at %p (base %p + 0x%X)",
const_cast<const void*>(reinterpret_cast<const volatile void*>(g_bitset_ptr)),
reinterpret_cast<void*>(img->base), kBitsetRVA);
// Immediate force (features may already be loaded).
force_bitset(g_bitset_ptr);
// Hook the populator so future refreshes are caught deterministically.
const auto* populator = resolve_text_rva(*img, kPopulatorRVA);
if (populator) {
auto tramp = create_hook(
reinterpret_cast<uintptr_t>(populator),
reinterpret_cast<uintptr_t>(&hooked_set_features));
if (tramp) {
g_original_fn = reinterpret_cast<SetFeaturesFn>(*tramp);
r.hook_ok = true;
log(LogLevel::kInfo, "hook installed on FeatureManager_set_features_from_uuids");
} else {
log(LogLevel::kWarn, "hook install failed; guard thread will compensate");
}
} else {
log(LogLevel::kWarn, "populator RVA 0x%X outside .text; skipping hook", kPopulatorRVA);
}
// Guard thread: belt-and-suspenders re-force on a 2 s poll.
g_guard_active.store(true);
g_guard_thread = ::CreateThread(nullptr, 0, guard_thread_fn, nullptr, 0, nullptr);
r.guard_ok = (g_guard_thread != nullptr);
if (r.guard_ok)
log(LogLevel::kInfo, "guard thread started (interval %lu ms)", kGuardIntervalMs);
return r;
}
inline void remove_patch() {
g_guard_active.store(false);
if (g_guard_thread) {
::WaitForSingleObject(g_guard_thread, 5000);
::CloseHandle(g_guard_thread);
g_guard_thread = nullptr;
}
// Note: the trampoline and hook-site patch are NOT reversed on unload.
// Reversing an inline hook while threads may be executing the trampoline is
// unsafe. The DLL stays loaded for the process lifetime anyway.
}
} // namespace plex
+157
View File
@@ -0,0 +1,157 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// plex_inject.exe — Inject ``plex_patch.dll`` into Plex Media Server.
//
// Two modes:
// plex_inject.exe — find the running PMS, inject into it
// plex_inject.exe --launch PATH — spawn PMS suspended, inject, resume
//
// The DLL path is resolved relative to the injector's own location so the two
// files can live side by side anywhere on disk (no need to copy to Program Files).
//
// Elevation: the injector must run as the same user as PMS or as Administrator
// (OpenProcess needs PROCESS_ALL_ACCESS).
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <tlhelp32.h>
#include <cstdio>
#include <cstring>
// ---- helpers ---------------------------------------------------------------
static void err(const char* msg) {
std::fprintf(stderr, "[x] %s (GetLastError=%lu)\n", msg, ::GetLastError());
}
static bool get_own_dir(char* buf, size_t buflen) {
DWORD n = ::GetModuleFileNameA(nullptr, buf, static_cast<DWORD>(buflen));
if (n == 0 || n >= buflen) return false;
// Strip the exe name, keep trailing backslash.
char* last = std::strrchr(buf, '\\');
if (!last) last = std::strrchr(buf, '/');
if (last) *(last + 1) = '\0'; else buf[0] = '\0';
return true;
}
static DWORD find_process(const char* name) {
HANDLE snap = ::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32 pe{};
pe.dwSize = sizeof(pe);
DWORD pid = 0;
if (::Process32First(snap, &pe)) {
do {
if (_stricmp(pe.szExeFile, name) == 0) { pid = pe.th32ProcessID; break; }
} while (::Process32Next(snap, &pe));
}
::CloseHandle(snap);
return pid;
}
static bool inject_dll(HANDLE proc, const char* dll_path) {
const size_t path_len = std::strlen(dll_path) + 1;
// Allocate memory in the target for the DLL path string.
void* remote_buf = ::VirtualAllocEx(
proc, nullptr, path_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!remote_buf) { err("VirtualAllocEx failed"); return false; }
if (!::WriteProcessMemory(proc, remote_buf, dll_path, path_len, nullptr)) {
err("WriteProcessMemory failed");
::VirtualFreeEx(proc, remote_buf, 0, MEM_RELEASE);
return false;
}
// LoadLibraryA is at the same address in every process (kernel32 is always
// mapped at its preferred base on Windows x64).
auto load_lib = reinterpret_cast<LPTHREAD_START_ROUTINE>(
::GetProcAddress(::GetModuleHandleA("kernel32.dll"), "LoadLibraryA"));
if (!load_lib) { err("GetProcAddress(LoadLibraryA) failed"); return false; }
HANDLE thread = ::CreateRemoteThread(
proc, nullptr, 0, load_lib, remote_buf, 0, nullptr);
if (!thread) { err("CreateRemoteThread failed"); return false; }
::WaitForSingleObject(thread, 10000);
DWORD exit_code = 0;
::GetExitCodeThread(thread, &exit_code);
::CloseHandle(thread);
::VirtualFreeEx(proc, remote_buf, 0, MEM_RELEASE);
if (exit_code == 0) {
err("LoadLibraryA returned NULL in the target (DLL load failed)");
return false;
}
return true;
}
// ---- main ------------------------------------------------------------------
int main(int argc, char** argv) {
std::printf("[*] plex_inject — Plex Media Server feature patch injector\n");
// Resolve the DLL path relative to the injector binary.
char dir[MAX_PATH]{};
if (!get_own_dir(dir, sizeof(dir))) { err("cannot determine own directory"); return 1; }
char dll_path[MAX_PATH]{};
std::snprintf(dll_path, sizeof(dll_path), "%splex_patch.dll", dir);
// Check the DLL exists before attempting injection.
if (::GetFileAttributesA(dll_path) == INVALID_FILE_ATTRIBUTES) {
std::fprintf(stderr, "[x] DLL not found: %s\n", dll_path);
return 1;
}
std::printf("[+] DLL: %s\n", dll_path);
bool launched = false;
HANDLE proc = nullptr;
HANDLE main_thread = nullptr;
DWORD pid = 0;
if (argc >= 3 && std::strcmp(argv[1], "--launch") == 0) {
// Spawn PMS suspended, inject before it runs.
STARTUPINFOA si{}; si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!::CreateProcessA(argv[2], nullptr, nullptr, nullptr, FALSE,
CREATE_SUSPENDED, nullptr, nullptr, &si, &pi)) {
err("CreateProcess failed");
return 1;
}
proc = pi.hProcess;
main_thread = pi.hThread;
pid = pi.dwProcessId;
launched = true;
std::printf("[+] launched PMS (pid %lu) suspended\n", pid);
} else {
// Attach to an already-running PMS.
pid = find_process("Plex Media Server.exe");
if (!pid) { err("Plex Media Server.exe not found (is it running?)"); return 1; }
proc = ::OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!proc) { err("OpenProcess failed (run as admin?)"); return 1; }
std::printf("[+] attached to PMS (pid %lu)\n", pid);
}
bool ok = inject_dll(proc, dll_path);
if (ok) {
std::printf("[+] plex_patch.dll injected into pid %lu\n", pid);
} else {
std::fprintf(stderr, "[x] injection failed\n");
}
if (launched) {
if (ok) {
::ResumeThread(main_thread);
std::printf("[+] PMS main thread resumed\n");
} else {
::TerminateProcess(proc, 1);
std::printf("[!] PMS terminated (injection failed)\n");
}
::CloseHandle(main_thread);
}
::CloseHandle(proc);
return ok ? 0 : 1;
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
//
// Minimal, zero-allocation logging to OutputDebugString.
//
// Every log line is prefixed with "plex_patch" so it stands out in DbgView /
// WinDbg. The format matches the Linux side's printf style. No heap allocs;
// buffer is on the stack so this is safe inside DllMain / loader-lock context
// for short messages (truncated at 511 chars rather than crashing).
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <cstdarg>
#include <cstdio>
namespace plex {
enum class LogLevel : uint8_t { kDebug, kInfo, kWarn, kError };
inline void log(LogLevel level, const char* fmt, ...) {
static constexpr const char* kPrefix[] = {"DBG", "INF", "WRN", "ERR"};
char buf[512];
const int hdr = std::snprintf(
buf, sizeof(buf), "[plex_patch %s] ",
kPrefix[static_cast<uint8_t>(level)]);
std::va_list args;
va_start(args, fmt);
std::vsnprintf(buf + hdr, sizeof(buf) - hdr, fmt, args);
va_end(args);
::OutputDebugStringA(buf);
}
} // namespace plex
+106
View File
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
//
// PE image introspection: base address, section bounds, version guard.
//
// Replaces the Linux ``dl_iterate_phdr`` path. All functions operate on the
// in-process image at the address returned by ``GetModuleHandleW(NULL)``,
// so they are valid under ASLR and usable from an injected DLL.
//
// Design:
// - ``ImageInfo`` is a plain aggregate (no methods, no invariants to break),
// constructed by ``get_main_image()``.
// - ``verify_version()`` is a pure scan with no side effects.
// - ``resolve_rva()`` returns a typed pointer, bounds-checked against the
// writable data section so a wrong RVA cannot silently corrupt code.
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <optional>
#include <string_view>
namespace plex {
// Section-level bounds for the main executable.
struct ImageInfo {
uintptr_t base = 0; // Module base (HMODULE)
uintptr_t image_size = 0; // SizeOfImage from the optional header
uintptr_t text_start = 0; // First executable byte
uintptr_t text_end = 0; // One past the last executable byte
uintptr_t data_start = 0; // First writable, non-executable byte
uintptr_t data_end = 0; // One past the last such byte
};
// Parse the PE headers of the main executable.
inline std::optional<ImageInfo> get_main_image() {
const auto base = reinterpret_cast<uintptr_t>(::GetModuleHandleW(nullptr));
if (!base) return std::nullopt;
const auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return std::nullopt;
const auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS64*>(base + dos->e_lfanew);
if (nt->Signature != IMAGE_NT_SIGNATURE) return std::nullopt;
if (nt->FileHeader.Machine != IMAGE_FILE_MACHINE_AMD64) return std::nullopt;
ImageInfo info{};
info.base = base;
info.image_size = nt->OptionalHeader.SizeOfImage;
const auto* sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; ++i, ++sec) {
const uintptr_t start = base + sec->VirtualAddress;
const uintptr_t end = start + sec->Misc.VirtualSize;
if (sec->Characteristics & IMAGE_SCN_MEM_EXECUTE) {
if (!info.text_start || start < info.text_start) info.text_start = start;
if (end > info.text_end) info.text_end = end;
}
// Writable, non-executable = data/bss (where the bitset lives).
if ((sec->Characteristics & IMAGE_SCN_MEM_WRITE) &&
!(sec->Characteristics & IMAGE_SCN_MEM_EXECUTE)) {
if (!info.data_start || start < info.data_start) info.data_start = start;
if (end > info.data_end) info.data_end = end;
}
}
return info;
}
// Scan the image for a build-version string. Returns true iff the exact
// version is found, guarding all hardcoded RVAs against applying to the
// wrong build.
inline bool verify_version(const ImageInfo& img, std::string_view expected) {
if (expected.empty() || !img.base || !img.image_size) return false;
const auto* haystack = reinterpret_cast<const char*>(img.base);
const size_t limit = img.image_size - expected.size();
for (size_t i = 0; i <= limit; ++i) {
if (std::memcmp(haystack + i, expected.data(), expected.size()) == 0)
return true;
}
return false;
}
// Convert an RVA to a typed pointer, bounds-checked against the writable data
// section. Returns nullptr if the address falls outside .data/.bss.
template <typename T>
T* resolve_data_rva(const ImageInfo& img, uint32_t rva, size_t extent = sizeof(T)) {
const uintptr_t va = img.base + rva;
if (va < img.data_start || va + extent > img.data_end) return nullptr;
return reinterpret_cast<T*>(va);
}
// Convert an RVA to a code pointer (bounds-checked against .text).
inline const uint8_t* resolve_text_rva(const ImageInfo& img, uint32_t rva) {
const uintptr_t va = img.base + rva;
if (va < img.text_start || va >= img.text_end) return nullptr;
return reinterpret_cast<const uint8_t*>(va);
}
} // namespace plex
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
//
// Byte-pattern signature scanner.
//
// Ported from the Linux ``hook.cpp`` ``sig_scan()`` with one addition:
// ``resolve_rip_rel32()`` decodes a RIP-relative displacement at a matched
// site, which is how we recover absolute addresses from x64 instructions.
//
// Pattern format (same as IDA/Linux side):
// "48 8D 0D ?? ?? ?? ??" hex bytes; ?? = one-byte wildcard
//
// This is a pure scan over [start, end) — no allocations, no side effects.
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <optional>
#include <string_view>
#include <vector>
namespace plex {
// A compiled pattern ready for scanning. Opaque; use ``compile_pattern()``.
struct Pattern {
struct Atom { uint8_t byte; bool wild; };
std::vector<Atom> atoms;
};
// Compile a hex+wildcard string into a scannable pattern.
inline std::optional<Pattern> compile_pattern(std::string_view text) {
Pattern pat;
for (size_t i = 0; i < text.size(); ) {
const char c = text[i];
if (c == ' ') { ++i; continue; }
if (c == '?') {
// Consume '?' or '??'.
if (i + 1 < text.size() && text[i + 1] == '?') ++i;
pat.atoms.push_back({0, true});
++i;
continue;
}
// Two hex characters.
if (i + 1 >= text.size()) return std::nullopt;
char pair[3] = {text[i], text[i + 1], '\0'};
char* end = nullptr;
const unsigned long v = std::strtoul(pair, &end, 16);
if (end != pair + 2 || v > 0xFF) return std::nullopt;
pat.atoms.push_back({static_cast<uint8_t>(v), false});
i += 2;
}
if (pat.atoms.empty()) return std::nullopt;
return pat;
}
// Scan [start, end) for the first occurrence of ``pat``.
inline std::optional<uintptr_t> sig_scan(
uintptr_t start, uintptr_t end, const Pattern& pat) {
const size_t len = pat.atoms.size();
if (len == 0 || end <= start || end - start < len) return std::nullopt;
const auto* mem = reinterpret_cast<const uint8_t*>(start);
const size_t limit = (end - start) - len;
for (size_t i = 0; i <= limit; ++i) {
bool match = true;
for (size_t j = 0; j < len; ++j) {
if (!pat.atoms[j].wild && mem[i + j] != pat.atoms[j].byte) {
match = false;
break;
}
}
if (match) return start + i;
}
return std::nullopt;
}
// Convenience: compile + scan in one call.
inline std::optional<uintptr_t> sig_scan(
uintptr_t start, uintptr_t end, std::string_view pattern_text) {
auto pat = compile_pattern(pattern_text);
if (!pat) return std::nullopt;
return sig_scan(start, end, *pat);
}
// Resolve a RIP-relative ``disp32`` at ``inst_addr + disp_offset`` within an
// instruction of ``inst_len`` bytes. Returns the absolute target address.
//
// Use case: a ``lea rcx, [rip + disp32]`` at a matched site lets us recover
// the address of a global (e.g. the feature bitset) without hardcoding its RVA.
inline uintptr_t resolve_rip_rel32(
uintptr_t inst_addr, size_t disp_offset, size_t inst_len) {
const auto disp = *reinterpret_cast<const int32_t*>(inst_addr + disp_offset);
return inst_addr + inst_len + disp;
}
} // namespace plex
+110
View File
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#pragma once
//
// x86-64 inline hook via a 14-byte absolute indirect jump.
//
// Ported from the Linux ``create_hook()`` in ``src/hook.cpp``.
// Differences from the POSIX version:
// - VirtualAlloc / VirtualProtect instead of mmap / mprotect.
// - FlushInstructionCache after patching (required on Windows).
// - The hook site and trampoline share the same 14-byte shellcode layout:
// FF 25 00 00 00 00 <8-byte absolute target> // jmp [rip+0]
//
// Zydis (vendored, MIT) decodes the prologue to ensure we relocate only
// complete instructions. The trampoline contains:
// [relocated prologue bytes] [14-byte jmp to original+offset]
// and the patched call site is:
// [14-byte jmp to hook function]
//
// Returns the trampoline address (= pointer to the "original" function that
// the hook body calls through to execute the un-hooked path).
//
// Thread safety: installing a hook while other threads may be executing the
// target function is inherently racy on x64 (no single atomic 14-byte write).
// Install hooks early — from DLL_PROCESS_ATTACH on a suspended process — to
// avoid this.
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <cstdint>
#include <cstring>
#include <optional>
#include "Zydis.h"
#include "log.h"
namespace plex {
// Absolute indirect jump: ``jmp [rip+0]`` followed by an 8-byte address.
inline constexpr size_t kJmpSize = 14;
inline void write_abs_jmp(uint8_t* site, uintptr_t target) {
// FF 25 00 00 00 00 = jmp qword ptr [rip+0]
site[0] = 0xFF;
site[1] = 0x25;
site[2] = site[3] = site[4] = site[5] = 0x00;
std::memcpy(site + 6, &target, 8);
}
// Install a 14-byte inline hook at ``from``, redirecting to ``to``.
// Returns the trampoline (original function entry) on success.
inline std::optional<uintptr_t> create_hook(uintptr_t from, uintptr_t to) {
ZydisDecoder decoder;
ZydisDecoderInit(&decoder, ZYDIS_MACHINE_MODE_LONG_64, ZYDIS_STACK_WIDTH_64);
ZydisDecodedInstruction inst;
// --- 1. Determine prologue length (>= 14 bytes of complete instructions) ---
size_t stolen = 0;
while (stolen < kJmpSize) {
const auto* ip = reinterpret_cast<const void*>(from + stolen);
if (!ZYAN_SUCCESS(ZydisDecoderDecodeInstruction(
&decoder, nullptr, ip, 15 /*max x64 len*/, &inst))) {
log(LogLevel::kError, "trampoline: failed to decode at %p+%zu",
reinterpret_cast<void*>(from), stolen);
return std::nullopt;
}
stolen += inst.length;
}
// --- 2. Allocate the trampoline (RW, flipped to RX after write) ----------
const size_t tramp_size = stolen + kJmpSize;
auto* tramp = static_cast<uint8_t*>(
::VirtualAlloc(nullptr, tramp_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (!tramp) {
log(LogLevel::kError, "trampoline: VirtualAlloc failed (%lu)", ::GetLastError());
return std::nullopt;
}
// Copy the stolen prologue bytes, then append a jump back to from+stolen.
std::memcpy(tramp, reinterpret_cast<const void*>(from), stolen);
write_abs_jmp(tramp + stolen, from + stolen);
DWORD old_prot = 0;
::VirtualProtect(tramp, tramp_size, PAGE_EXECUTE_READ, &old_prot);
::FlushInstructionCache(::GetCurrentProcess(), tramp, tramp_size);
// --- 3. Patch the original site to jump to our hook ----------------------
DWORD site_prot = 0;
if (!::VirtualProtect(reinterpret_cast<void*>(from), kJmpSize,
PAGE_EXECUTE_READWRITE, &site_prot)) {
log(LogLevel::kError, "trampoline: VirtualProtect(hook site) failed (%lu)",
::GetLastError());
::VirtualFree(tramp, 0, MEM_RELEASE);
return std::nullopt;
}
write_abs_jmp(reinterpret_cast<uint8_t*>(from), to);
::VirtualProtect(reinterpret_cast<void*>(from), kJmpSize, site_prot, &site_prot);
::FlushInstructionCache(::GetCurrentProcess(), reinterpret_cast<void*>(from), kJmpSize);
log(LogLevel::kInfo, "hook installed: %p -> %p (trampoline at %p, %zu stolen bytes)",
reinterpret_cast<void*>(from), reinterpret_cast<void*>(to),
static_cast<void*>(tramp), stolen);
return reinterpret_cast<uintptr_t>(tramp);
}
} // namespace plex
Binary file not shown.
+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()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# SPDX-License-Identifier: AGPL-3.0-or-later
# In-container launcher for Plex Media Server. Sets LD_PRELOAD only for the
# final exec so the (musl) PMS process is preloaded without affecting glibc
# helper children (Tuner, Script Host, transcoders).
set -eu
export PLEX_MEDIA_SERVER_INFO_VENDOR="$(grep ^NAME= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
export PLEX_MEDIA_SERVER_INFO_MODEL="$(uname -m)"
export PLEX_MEDIA_SERVER_INFO_PLATFORM_VERSION="$(grep ^VERSION= /etc/os-release | awk -F= '{print $2}' | tr -d '"')"
export LD_PRELOAD="/usr/lib/plexmediaserver/lib/plexmediaserver_crack.so"
exec "/usr/lib/plexmediaserver/Plex Media Server" "$@"