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

+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" "$@"