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

+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