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

+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.