mirror of
https://github.com/djdevin/recnet-plugin.git
synced 2026-09-08 06:31:29 -07:00
commit mostly nonworking code
This commit is contained in:
+22
-12
@@ -11,8 +11,8 @@ if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||
endif()
|
||||
|
||||
# Where to deploy the built proxy. Point at your Rec Room install root; the deploy step below copies
|
||||
# version.dll there. That single file is the whole payload -- see the note above the deploy step.
|
||||
set(GAME_DIR "" CACHE PATH "Rec Room install root to deploy version.dll into")
|
||||
# wintrust.dll + wtrust_orig.dll there. See the note above the deploy step.
|
||||
set(GAME_DIR "" CACHE PATH "Rec Room install root to deploy wintrust.dll into")
|
||||
|
||||
|
||||
add_library(redirector SHARED
|
||||
@@ -20,8 +20,11 @@ add_library(redirector SHARED
|
||||
src/dllmain.c
|
||||
|
||||
|
||||
# Proxy loader (exports wrap the real system version.dll; DllMain starts the hook thread)
|
||||
src/proxy/version_proxy.c
|
||||
# No proxy source: this build defeats every app-dir plant (System32 crypto pre-load; DXGIDisplays
|
||||
# not loaded in VR mode; UnityPlayer self-references and fail-fasts on a stub). With no anti-cheat
|
||||
# in-process, the vector is plain injection -- tools/launcher/launcher.exe suspended-launches
|
||||
# RecRoom.exe and LoadLibrary-injects this DLL before first instruction. DllMain (dllmain.c) starts
|
||||
# the hook thread. No exports needed.
|
||||
|
||||
|
||||
# Core
|
||||
@@ -76,25 +79,32 @@ target_link_libraries(
|
||||
|
||||
|
||||
|
||||
# Output must be named version.dll so RecRoom.exe's VERSION.dll import resolves to us.
|
||||
# Plain injectable DLL: redirector.dll (injected by launcher.exe; no PE export/name requirement).
|
||||
set_target_properties(
|
||||
redirector PROPERTIES
|
||||
|
||||
OUTPUT_NAME "version"
|
||||
OUTPUT_NAME "redirector"
|
||||
PREFIX ""
|
||||
)
|
||||
|
||||
|
||||
# The proxy loads the real system version.dll at runtime (see src/proxy/version_proxy.c), so there is
|
||||
# nothing extra to materialize or ship -- version.dll is fully self-contained.
|
||||
# The launcher that suspended-launches RecRoom.exe and injects redirector.dll. Built as a console EXE
|
||||
# from the same toolchain so a single `cmake --build` produces both artifacts.
|
||||
add_executable(launcher tools/launcher/launcher.c)
|
||||
set_target_properties(launcher PROPERTIES OUTPUT_NAME "launcher")
|
||||
|
||||
|
||||
# Optional one-step deploy: -DGAME_DIR=... to copy version.dll into the game folder after build.
|
||||
# The copy fails while Rec Room is running (DLL locked) -- close the game and rebuild.
|
||||
# Optional one-step deploy: -DGAME_DIR=... copies redirector.dll + launcher.exe into the game root.
|
||||
# The copy fails while Rec Room is running (DLLs locked) -- close the game and rebuild.
|
||||
if(GAME_DIR)
|
||||
add_custom_command(TARGET redirector POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"$<TARGET_FILE:redirector>" "${GAME_DIR}/version.dll"
|
||||
COMMENT "Deploying version.dll to ${GAME_DIR}"
|
||||
"$<TARGET_FILE:redirector>" "${GAME_DIR}/redirector.dll"
|
||||
COMMENT "Deploying redirector.dll to ${GAME_DIR}"
|
||||
)
|
||||
add_custom_command(TARGET launcher POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"$<TARGET_FILE:launcher>" "${GAME_DIR}/launcher.exe"
|
||||
COMMENT "Deploying launcher.exe to ${GAME_DIR}"
|
||||
)
|
||||
endif()
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# RecRoom Dumping Info
|
||||
What each build era does to stop the metadata being read, and what it takes to get a dump out of it anyway.
|
||||
|
||||
# Every build from `14 December 2018 – 19:12:52 UTC` to `12 May 2026 – 18:46:14 UTC` is dumpable. There are four separate protections stacked up over the years, added one on top of another, and each one needs its own answer.
|
||||
|
||||
## Nothing here needs the game's servers, an account, or a debugger. The last two stages do need the game to actually run.
|
||||
|
||||
# Stage 1 - The obfuscated metadata header
|
||||
Added shortly after `21 June 2023`, which is where the original dumps stopped.
|
||||
* A normal `global-metadata.dat` starts with the magic `0xFAB11BAF`, a version int, and then 31 pairs of `{offset, size}` in a fixed order, one per table. These builds shuffle which pair belongs to which table and overwrite the magic and version, so nothing can read it.
|
||||
* It is recoverable because the 31 tables **exactly tile the file** end to end. Solve for the chain through the header ints where `offset[i] + size[i] == offset[i+1]`, running from `0x100` to the end of the file, and you have the tables back in order. Two header slots are left over -- those are where the sanity and version fields used to be.
|
||||
* Tiling alone is **not** enough, and this is the single easiest thing to get wrong. More than one assignment can tile the file perfectly and still be wrong, so every table then has to be identified by its actual contents -- fixed entry sizes, count chains that have to agree with each other, and range checks like every `dataIndex` landing inside the blob it points into. A wrong-but-self-consistent tiling silently mis-pairs one table and the dump looks fine until Cpp2IL chokes on it.
|
||||
* Four metadata versions turn up across the range and each shifts the table layout: `v27`, `v29`, `v31` and `v31.1`.
|
||||
* `v27` to `v29` replaces `attributesInfo` (12 bytes an entry) with `attributeData` + `attributeDataRange` (8 bytes an entry, plus one sentinel entry whose `startOffset` is the total size of the blob).
|
||||
* `v29` to `v31` grows `Il2CppMethodDefinition` from 32 to 36 bytes, for the added `returnParameterToken`.
|
||||
* `v31` to `v31.1` grows `Il2CppCodeRegistration` from 15 to 17 qwords, from Unity `2022.3.33` onward.
|
||||
* These builds also ship deliberately corrupted `<Module>` type definitions -- the `nameIndex` and `genericContainerIndex` are junk and have to be repaired or Cpp2IL falls over. It is a few hundred of them per build (346, 348 and 418 on the builds tested).
|
||||
|
||||
# Stage 2 - The binary side
|
||||
* `Il2CppCodeRegistration` inside `GameAssembly.dll` gets its fields permuted the same way the metadata header does, and on top of that the `codeGenModules` array is shuffled out of image order.
|
||||
* Cpp2IL indexes `codeGenModules` **positionally**, so the array has to be put back into image order or every method pointer ends up attached to the wrong assembly. The `12 May 2026 – 18:46:14 UTC` build has 419 modules.
|
||||
* The rebuilt struct will sometimes overlap the module array it points at, in which case it has to be written somewhere else in free space with the old pointer cleared.
|
||||
* Cpp2IL itself needs patching for these builds. `BinarySearcher.cs` has hardcoded scan limits (`0xA_0000` and `0x70_000`) and a 400 module cap that modern Rec Room blows straight past, and `FindCodeRegistrationPost2019` has to match the module count exactly instead of backtracking.
|
||||
|
||||
# Stage 3 - The dummy metadata and the encrypted binary
|
||||
Starts at `5 September 2024 – 03:21:53 UTC`.
|
||||
* `global-metadata.dat` is replaced with a dummy -- one repeated byte (`0x52`, ASCII `R`) for the entire file. On `12 May 2026 – 18:46:14 UTC` that is `52,403,096` bytes of nothing.
|
||||
* Deleting the dummy does not work. IL2CPP still opens it and the game dies with "Failed to initialize IL2CPP", so the fake file has to stay exactly where it is.
|
||||
* `GameAssembly.dll`'s `.text` and `il2cpp` sections are encrypted at rest as well -- entropy `8.000` on disk against about `6.3` once decrypted. `.rdata` and `.data` are left alone.
|
||||
* So there is nothing left on disk to repair and the game has to genuinely run. Both get decrypted during `il2cpp_init`, in the first moments of startup, long before login, VR or networking, so it does not need to get far. It quits itself shortly after starting, so the dump has to win that race and suspend the process once it has a hit.
|
||||
* Only a fraction of the install is needed to get that far: every root level file, plus `RecRoom_Data`'s `globalgamemanagers`, `boot.config`, `app.info`, the `.json` manifests, and the `Resources`, `UnitySubsystems` and `il2cpp_data` directories. Levels, sharedassets, resources.assets, the asset bundles, Plugins and EasyAntiCheat are all unnecessary. That is what takes the download from about `5.4 GB` down to about `325 MB` a build.
|
||||
* The metadata **keeps its permuted header in memory too**, so there is no magic to scan for. Find it by its string table instead (`mscorlib.dll` is a reliable anchor) and take the start of the allocation it lives in as the header.
|
||||
* Get the length by validation, never by guessing from the header. A wrong length can still tile cleanly and only falls apart at table identification, so candidate lengths have to be checked by actually identifying the tables. On the May 2026 build the obvious answer is `52,263,711` and the correct one is `52,403,096` -- `139,385` bytes out, which looks exactly like "the metadata has another encryption layer on it" when it is really just a bad carve.
|
||||
* For the binary, take **only the code sections from memory** and everything else from the untouched disk file. A straight memory dump is not usable, because `il2cpp_init` rewrites `.data` in place -- type indices become live class pointers, field offsets get resolved -- and a static tool then reads pointers where it expects indices and dies. Base relocations inside the code have to be undone as well, since the module was loaded at an ASLR base, and the runtime only BSS tail grafted back on with its pointers rebased.
|
||||
|
||||
# Stage 4 - The Referee anti-cheat
|
||||
Everything up to `10 November 2025 – 04:35:41 UTC` dumps with stage 3 alone. The builds from `2 December 2025 – 03:58:18 UTC` onward need this as well.
|
||||
* These builds add `Referee.dll`, Themida packed, about 63 MB. `GameAssembly.dll`, `UnityPlayer.dll` and `baselib.dll` all import from it and from **nothing else** -- every real import is resolved at runtime by the protector.
|
||||
* Referee is waiting on a global shared memory section named `KjMpQrStUvWxYzAb`, created at exactly `66,060,456` bytes. The consumer walks it as three chunks of `22,020,144` after a small header, so the size has to be right. It builds the name as `\BaseNamedObjects\...`, an absolute path, so it has to be the **global** namespace and not the per session one.
|
||||
* If that section is not there, the session fails with status `-99903`, the code then reads through the null pointer it was left with, and `GameAssembly`'s DllMain never returns. No `il2cpp_init`, no metadata, nothing to dump. That is the entire wall.
|
||||
* Creating the section is the whole fix. It can be all zeroes -- nothing verifies the contents, and there is no key or signature anywhere in Referee to forge. Then launch the game normally and it boots all the way through, into VR init, far past the point the metadata is decrypted.
|
||||
* It has to be the **real game executable**. Loading `GameAssembly.dll` yourself, or swapping `Referee.dll` for a stub that exports its one function (`jjiEVn`), are both rejected before anything useful happens.
|
||||
* Creating a global section needs `SeCreateGlobalPrivilege`, so this has to be run **elevated**. Without admin you silently get a per session section instead, the game never finds it, and it fails exactly as if there were none.
|
||||
* From there take a full memory dump of the process once `GameAssembly.dll` is loaded, and carve the metadata and the binary out of it offline exactly as in stage 3.
|
||||
* The section name and its size are the only build specific values in any of this. If a later build changes either, it will crash before `il2cpp_init` as though there were no section at all -- the name is a wide string inside that build's `Referee.dll` and the size is the maximum size it passes when creating it.
|
||||
|
||||
- *Automatically documented MD file, some details might be subtly wrong.*
|
||||
@@ -0,0 +1,11 @@
|
||||
#ifndef ANTITAMPER_PATCH_H
|
||||
#define ANTITAMPER_PATCH_H
|
||||
|
||||
// Suppresses the client's anti-tamper report funnel (build 2025-04-29). Every tamper detection --
|
||||
// ImageSignature (signed CDN URL check), Inject, UnknownDll, Memory_Hash_Mismatch, etc. -- routes
|
||||
// through one static method that creates a "Hile" warning (POST api/PlayerReporting/v1/hile) and can
|
||||
// force-quit. Neutralizing it stops the ~30s lockup/exit our own patches + recflare's placeholder URL
|
||||
// signatures would otherwise trigger.
|
||||
void PatchAntiTamper(void);
|
||||
|
||||
#endif
|
||||
@@ -21,4 +21,45 @@ extern char *rewrite_from[MAX_REWRITES];
|
||||
extern char *rewrite_to[MAX_REWRITES];
|
||||
extern int rewrite_count;
|
||||
|
||||
// Photon Cloud app IDs injected into AppSettings at connect time (a self-hosted server can't supply
|
||||
// real Photon Cloud app IDs). Empty string = not configured (that field is left untouched).
|
||||
extern char photon_realtime_appid[64];
|
||||
extern char photon_chat_appid[64];
|
||||
extern char photon_voice_appid[64];
|
||||
|
||||
// Per-hook enable flags (all default 1/on). Lets a hook be disabled from redirector.json without a
|
||||
// rebuild -- used to bisect which hook the anti-cheat's periodic runtime check is reacting to (the
|
||||
// ~13-30s poison/crash -- see memory note unstable-build-identity-rvas.md). enable_spoof toggles
|
||||
// whether SpoofCall4 (retspoof.c) actually uses the scanned gadget or always falls back to a plain
|
||||
// call, so the return-address-spoofing change itself can be A/B tested the same way.
|
||||
extern int enable_ssl;
|
||||
extern int enable_http;
|
||||
extern int enable_photon;
|
||||
extern int enable_spoof;
|
||||
|
||||
// When set, the Application.Quit tracer (quit_trace.c) suppresses the shutdown instead of only
|
||||
// logging its caller. Default 0 (log only) so a diagnostic build never silently blocks a legitimate
|
||||
// exit -- set "blockQuit": 1 in redirector.json to keep the client alive through a spurious quit.
|
||||
extern int block_quit;
|
||||
|
||||
// Use hardware breakpoints (debug registers, zero bytes written) instead of inline detours for the
|
||||
// three GameAssembly.dll hooks, so a native integrity check hashing .text cannot see them. Default 1.
|
||||
// Set "useHwbp": 0 in redirector.json to A/B against the old inline detours without a rebuild.
|
||||
extern int use_hwbp;
|
||||
|
||||
// Install the ws2_32!getaddrinfo detour. Once the SendRequest host rewrite is active the client asks
|
||||
// for real *.recflare.net names, which resolve on their own, so the DNS hook is only a safety net --
|
||||
// turning it off ("enableDns": false) leaves ZERO inline byte patches anywhere in the process.
|
||||
extern int enable_dns;
|
||||
|
||||
// Diagnostics: the vectored AV logger, the hang probe (which SUSPENDS the main thread every 3s) and
|
||||
// the hardware-breakpoint self-test. All three are observers, but a first-in-chain VEH and periodic
|
||||
// thread suspension are exactly the sort of thing that can perturb Themida's exception-driven code
|
||||
// decryption -- so they must be switchable to keep them out of a measurement.
|
||||
extern int enable_diag;
|
||||
|
||||
// Unlink redirector.dll from the PEB loader lists after load so a module-walking anti-tamper scan
|
||||
// can't see our injected DLL. Default 1 (current best guess at what Themida's ~35s check flags).
|
||||
extern int hide_module;
|
||||
|
||||
void LoadConfig(void);
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
// Vectored AV logger: logs access violations as module+RVA (GA/UP/RR) with registers and a walk of
|
||||
// the FAULTING thread's stack. First-chance and non-intrusive.
|
||||
void InstallCrashHandler(void);
|
||||
|
||||
// Periodically samples Unity's main thread (RIP + stack) so a stall shows up as a repeating sample.
|
||||
// See the comment block in src/debug/crash_handler.c.
|
||||
void StartHangProbe(void);
|
||||
|
||||
// Unity's main thread id, published by the SendRequest hook the first time it runs (that hook
|
||||
// executes on the main thread). Read by the hang probe; 0 until the first request.
|
||||
extern volatile DWORD g_mainThreadId;
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
// Neutralises the file-signature-check P/Invoke that calls through an unresolved native pointer and
|
||||
// eventually kills the process. See src/unity/filesig_patch.c.
|
||||
void PatchFileSigCheck(void);
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
//
|
||||
// Hardware-breakpoint hooks: function interception that writes ZERO bytes.
|
||||
//
|
||||
// Every hook in this project so far is an inline detour -- 14+ bytes overwritten at the target's
|
||||
// entry. Any integrity check that hashes GameAssembly.dll's .text sees those edits. On the
|
||||
// recflare-client-unstable build (2025-04-29) the session dies ~35s in with a hard 0xC0000005 inside
|
||||
// the Themida-wrapped RecRoom.exe.dll, which is very likely a NATIVE integrity check reacting to
|
||||
// exactly that (see memory note unstable-build-identity-rvas.md).
|
||||
//
|
||||
// A hardware breakpoint lives in the CPU's debug registers instead of in the code, so the target's
|
||||
// bytes stay pristine and no memory scan -- managed or native -- can see the hook.
|
||||
//
|
||||
// Cost/limits (why this isn't the default everywhere):
|
||||
// * There are only DR0-DR3, so a hard cap of 4 hooks. We spend 3 on the GameAssembly.dll targets
|
||||
// and leave ws2_32!getaddrinfo as a normal inline detour (a system DLL the game's own integrity
|
||||
// check has no reason to hash), keeping one slot free.
|
||||
// * Debug registers are PER-THREAD, so they must be applied to every thread that could reach the
|
||||
// target -- including threads created later (BestHTTP spins up its own). HwbpInit starts a
|
||||
// watcher that applies the current register set to any thread it hasn't seen yet.
|
||||
//
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// STATUS: WORKS. Verified on recflare-client-unstable (2025-04-29) with `"useHwbp": true` --
|
||||
// 176 traps and 77 URL rewrites in one session, and the self-test (arming a function inside this
|
||||
// DLL) passes. Default is nonetheless OFF (`use_hwbp = 0`): it makes no behavioural difference
|
||||
// versus inline detours (the crash was proven NOT to be tamper detection), and it costs a thread
|
||||
// suspend/resume storm, so the simpler path wins.
|
||||
//
|
||||
// Two things had to be right, both of which cost a lot of debugging:
|
||||
// * Arming: Dr0 reads back as the target VA and Dr7 as 0x415 (that is our 0x15 plus bit 10,
|
||||
// which always reads as 1 -- not a bug).
|
||||
// * The protector inside RecRoom.exe.dll ZEROES Dr0-Dr3 a few seconds in (Dr0 -> 0, Dr7 left
|
||||
// alone). Re-arming EVERY thread EVERY pass -- not just newly seen ones -- defeats that.
|
||||
// * Ordering: HwbpInit must run EARLY (it is kicked off from HwbpSelfTest right after the crash
|
||||
// handler). Starting the engine lazily at first-hook time was the real reason for a long
|
||||
// stretch of "armed but never fires" results -- threads were created before the watcher ran.
|
||||
//
|
||||
// Always HwbpDisarm() a slot you are done with, or the watcher keeps suspending every thread in the
|
||||
// process five times a second forever.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
#define HWBP_MAX 4
|
||||
|
||||
// Slot assignment (one per DR register).
|
||||
#define HWBP_SLOT_SSL 0
|
||||
#define HWBP_SLOT_HTTP 1
|
||||
#define HWBP_SLOT_PHOTON 2
|
||||
// slot 3 intentionally free
|
||||
|
||||
// Install the VEH and start the per-thread applier. Safe to call more than once.
|
||||
BOOL HwbpInit(void);
|
||||
|
||||
// Point `slot` at `target`; when any thread executes `target`, control transfers to `hook` with the
|
||||
// register state (and therefore the arguments and return address) untouched. Returns FALSE if the
|
||||
// slot is out of range. Applying to already-running threads happens here; new threads are picked up
|
||||
// by the watcher.
|
||||
BOOL HwbpAdd(int slot, void *target, void *hook);
|
||||
|
||||
// Let THIS thread execute `slot`'s target once without trapping. A call-through hook must call this
|
||||
// immediately before invoking the original, otherwise the call re-triggers the breakpoint and
|
||||
// recurses forever. Per-thread, one-shot, and consumed by the next hit.
|
||||
void HwbpSkipOnce(int slot);
|
||||
|
||||
// Arm the spare slot on a function inside this DLL and call it from a fresh thread, to establish
|
||||
// whether hardware breakpoints function in this process at all. Logs SELF-TEST PASSED/FAILED.
|
||||
// Runs regardless of use_hwbp -- it only touches our own code, never the game's.
|
||||
void HwbpSelfTest(void);
|
||||
|
||||
// Release a slot and push the cleared debug registers to every thread. With no slots left armed the
|
||||
// watcher stops suspending threads entirely, so always disarm what you no longer need.
|
||||
void HwbpDisarm(int slot);
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
|
||||
// Unlink `self` from the PEB loader's three module lists so anything that walks the loaded-module
|
||||
// list (GetModuleHandle, CreateToolhelp32Snapshot's module snapshot, and -- the point here -- a
|
||||
// protector's periodic anti-tamper scan) no longer sees redirector.dll. The DLL stays mapped and all
|
||||
// its hooks/threads keep running; only its visibility in the loader lists is removed. See
|
||||
// src/memory/module_hide.c.
|
||||
void HideModuleFromPeb(HMODULE self);
|
||||
@@ -0,0 +1,8 @@
|
||||
#ifndef PHOTON_PATCH_H
|
||||
#define PHOTON_PATCH_H
|
||||
|
||||
// Injects the operator's Photon Cloud app IDs into the AppSettings the client hands to Photon at
|
||||
// connect time (the self-hosted server can't supply real Photon Cloud app IDs). Build 2025-04-29.
|
||||
void PatchPhotonAppId(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// Detours UnityEngine.Application.Quit() / Quit(int) to log the managed caller and (optionally)
|
||||
// suppress the shutdown. See src/unity/quit_trace.c.
|
||||
void PatchQuitTrace(void);
|
||||
|
||||
// Call-through hooks on kernel32!ExitProcess / !TerminateProcess that log who tore the process
|
||||
// down. Installed by PatchQuitTrace; separate entry point so it can be used standalone.
|
||||
void HookProcessExit(void);
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef RETSPOOF_H
|
||||
#define RETSPOOF_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
//
|
||||
// Return-address spoofing for il2cpp utility calls (il2cpp_string_new, il2cpp_object_new, Uri..ctor,
|
||||
// get_Uri/set_Uri, ...). These are called from inside our hooks (http_rewrite.c, photon_patch.c), which
|
||||
// means the CPU pushes a return address inside redirector.dll before jumping to them -- so for the
|
||||
// duration of that call, any stack walk over the calling thread sees an unrecognized module
|
||||
// (redirector.dll) on the stack. This is exactly the kind of signal an anti-tamper stack-walk check
|
||||
// looks for (see the ~30s freeze/crash: GameAssembly.dll rewrites hooked methods into stack-exhaustion
|
||||
// poison once it detects us -- memory note unstable-build-identity-rvas.md).
|
||||
//
|
||||
// SpoofCall4 calls a function with the return address the CPU sees replaced by a "jmp qword ptr [rbx]"
|
||||
// gadget scanned out of the target's own module, so the call looks -- from the stack's perspective --
|
||||
// like it originated from inside that module instead of from us. See retspoof.asm for the mechanics.
|
||||
//
|
||||
|
||||
// Scans [mod's image] for a usable gadget and caches it. Safe to call once GameAssembly's code has
|
||||
// decrypted (this build's packer XOR-decrypts .text shortly after mapping -- see CLAUDE.md). Logs the
|
||||
// found address, or logs a failure and leaves spoofing unavailable (SpoofCall4 falls back to a direct
|
||||
// call in that case). Returns 1 on success, 0 otherwise.
|
||||
int InitRetSpoof(HMODULE mod);
|
||||
|
||||
// True once InitRetSpoof has found a usable gadget.
|
||||
int RetSpoofReady(void);
|
||||
|
||||
// Waits for GameAssembly.dll to appear and its code to decrypt, then resolves the gadget. Intended to
|
||||
// run on its own thread (mirrors the WaitForCode pattern in ssl_patch.c / http_rewrite.c), started
|
||||
// early so the gadget is ready well before real traffic starts flowing through the hooks that use it.
|
||||
DWORD WINAPI PatchRetSpoof(LPVOID param);
|
||||
|
||||
// Calls target(a1, a2, a3, a4) with a spoofed return address (see above). Pad unused trailing
|
||||
// arguments with 0. Falls back to a direct call if no gadget has been resolved yet.
|
||||
uint64_t SpoofCall4(void *target, uint64_t a1, uint64_t a2, uint64_t a3, uint64_t a4);
|
||||
|
||||
#endif
|
||||
@@ -20,6 +20,54 @@ char *rewrite_to[MAX_REWRITES];
|
||||
int rewrite_count = 0;
|
||||
|
||||
|
||||
char photon_realtime_appid[64] = "";
|
||||
char photon_chat_appid[64] = "";
|
||||
char photon_voice_appid[64] = "";
|
||||
|
||||
int enable_ssl = 1;
|
||||
int enable_http = 1;
|
||||
int enable_photon = 1;
|
||||
int enable_spoof = 1;
|
||||
int block_quit = 0;
|
||||
int use_hwbp = 0; // hardware-breakpoint hooks instead of inline detours -- see include/hwbp.h
|
||||
int enable_dns = 1;
|
||||
int enable_diag = 0; // AV logger + hang probe + HWBP self-test; off by default (hang probe
|
||||
// suspends the main thread every 3s). "enableDiag": true to investigate.
|
||||
int hide_module = 1;
|
||||
|
||||
|
||||
// Copy the JSON string value for "key" ("key" : "value") from buf into out. No-op if key absent.
|
||||
static void ScanStringKey(const char *buf, const char *quotedKey, char *out, size_t outlen)
|
||||
{
|
||||
char *k = strstr(buf, quotedKey);
|
||||
if(!k) return;
|
||||
char *colon = strchr(k, ':');
|
||||
if(!colon) return;
|
||||
char *start = strchr(colon, '"');
|
||||
if(!start) return;
|
||||
start++;
|
||||
char *end = strchr(start, '"');
|
||||
if(!end) return;
|
||||
size_t len = (size_t)(end - start);
|
||||
if(len >= outlen) return;
|
||||
memcpy(out, start, len);
|
||||
out[len] = 0;
|
||||
}
|
||||
|
||||
// Reads a JSON boolean value ("key": true / false) from buf into *out. No-op if key absent.
|
||||
static void ScanBoolKey(const char *buf, const char *quotedKey, int *out)
|
||||
{
|
||||
char *k = strstr(buf, quotedKey);
|
||||
if(!k) return;
|
||||
char *colon = strchr(k, ':');
|
||||
if(!colon) return;
|
||||
colon++;
|
||||
while(*colon == ' ' || *colon == '\t') colon++;
|
||||
if(strncmp(colon, "false", 5) == 0) *out = 0;
|
||||
else if(strncmp(colon, "true", 4) == 0) *out = 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
void LoadConfig()
|
||||
{
|
||||
@@ -395,10 +443,46 @@ void LoadConfig()
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Photon Cloud app IDs (optional; injected into AppSettings at connect time).
|
||||
//
|
||||
ScanStringKey(buffer, "\"photonRealtimeAppId\"", photon_realtime_appid, sizeof(photon_realtime_appid));
|
||||
ScanStringKey(buffer, "\"photonChatAppId\"", photon_chat_appid, sizeof(photon_chat_appid));
|
||||
ScanStringKey(buffer, "\"photonVoiceAppId\"", photon_voice_appid, sizeof(photon_voice_appid));
|
||||
|
||||
//
|
||||
// Per-hook bisection toggles (all default on). e.g. {"enableSsl": false} to test with the SSL
|
||||
// bypass hook disabled.
|
||||
//
|
||||
ScanBoolKey(buffer, "\"enableSsl\"", &enable_ssl);
|
||||
ScanBoolKey(buffer, "\"enableHttp\"", &enable_http);
|
||||
ScanBoolKey(buffer, "\"enablePhoton\"", &enable_photon);
|
||||
ScanBoolKey(buffer, "\"enableSpoof\"", &enable_spoof);
|
||||
ScanBoolKey(buffer, "\"blockQuit\"", &block_quit);
|
||||
ScanBoolKey(buffer, "\"useHwbp\"", &use_hwbp);
|
||||
ScanBoolKey(buffer, "\"enableDns\"", &enable_dns);
|
||||
ScanBoolKey(buffer, "\"enableDiag\"", &enable_diag);
|
||||
ScanBoolKey(buffer, "\"hideModule\"", &hide_module);
|
||||
|
||||
|
||||
free(buffer);
|
||||
|
||||
|
||||
|
||||
Log(
|
||||
"[CONFIG] Photon app ids: realtime=%s chat=%s voice=%s",
|
||||
photon_realtime_appid[0] ? photon_realtime_appid : "(none)",
|
||||
photon_chat_appid[0] ? photon_chat_appid : "(none)",
|
||||
photon_voice_appid[0] ? photon_voice_appid : "(none)"
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[CONFIG] patches: ssl=%d http=%d photon=%d spoof=%d blockQuit=%d useHwbp=%d dns=%d diag=%d hide=%d",
|
||||
enable_ssl, enable_http, enable_photon, enable_spoof, block_quit, use_hwbp, enable_dns, enable_diag, hide_module
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[CONFIG] Loaded %d redirects",
|
||||
redirect_count_config
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
#include "common.h"
|
||||
#include "logger.h"
|
||||
#include "crash_handler.h"
|
||||
|
||||
//
|
||||
// Vectored exception handler that logs access violations (0xC0000005) as GameAssembly-relative RVAs so
|
||||
// the faulting method can be mapped in the Cpp2IL dump. First-chance, non-intrusive: it logs and
|
||||
// returns EXCEPTION_CONTINUE_SEARCH so the game's own handling is unchanged. The LAST AV logged before
|
||||
// the process dies is the fatal one.
|
||||
//
|
||||
|
||||
static uintptr_t g_gaBase, g_gaEnd;
|
||||
static uintptr_t g_upBase, g_upEnd;
|
||||
static uintptr_t g_rrBase, g_rrEnd;
|
||||
|
||||
// Set by the SendRequest hook the first time it runs -- that hook executes on Unity's main thread,
|
||||
// which is exactly the thread we want to watch for the ~11s stall.
|
||||
volatile DWORD g_mainThreadId;
|
||||
|
||||
static void ResolveModuleRanges(void)
|
||||
{
|
||||
HMODULE ga = GetModuleHandleA("GameAssembly.dll");
|
||||
HMODULE up = GetModuleHandleA("UnityPlayer.dll");
|
||||
HMODULE rr = GetModuleHandleA("RecRoom.exe.dll");
|
||||
MODULEINFO mi;
|
||||
if (ga && GetModuleInformation(GetCurrentProcess(), ga, &mi, sizeof(mi)))
|
||||
{ g_gaBase = (uintptr_t)mi.lpBaseOfDll; g_gaEnd = g_gaBase + mi.SizeOfImage; }
|
||||
if (up && GetModuleInformation(GetCurrentProcess(), up, &mi, sizeof(mi)))
|
||||
{ g_upBase = (uintptr_t)mi.lpBaseOfDll; g_upEnd = g_upBase + mi.SizeOfImage; }
|
||||
if (rr && GetModuleInformation(GetCurrentProcess(), rr, &mi, sizeof(mi)))
|
||||
{ g_rrBase = (uintptr_t)mi.lpBaseOfDll; g_rrEnd = g_rrBase + mi.SizeOfImage; }
|
||||
}
|
||||
|
||||
// Format an address as "GA+0xRVA" / "UP+0xRVA" / "RR+0xRVA" / raw, into buf. RR is the
|
||||
// Themida-wrapped RecRoom.exe.dll, which is where the fatal faults land -- worth naming.
|
||||
static void Sym(uintptr_t a, char *buf, size_t n)
|
||||
{
|
||||
if (!g_gaBase) ResolveModuleRanges();
|
||||
if (g_gaBase && a >= g_gaBase && a < g_gaEnd) sprintf_s(buf, n, "GA+0x%llX", (unsigned long long)(a - g_gaBase));
|
||||
else if (g_upBase && a >= g_upBase && a < g_upEnd) sprintf_s(buf, n, "UP+0x%llX", (unsigned long long)(a - g_upBase));
|
||||
else if (g_rrBase && a >= g_rrBase && a < g_rrEnd) sprintf_s(buf, n, "RR+0x%llX", (unsigned long long)(a - g_rrBase));
|
||||
else sprintf_s(buf, n, "%llX", (unsigned long long)a);
|
||||
}
|
||||
|
||||
// Structured-exception-safe read: the values we walk to (poisoned/wild pointers) are
|
||||
// frequently unmapped, so a plain dereference here would just recurse into another AV.
|
||||
static int SafeReadPtr(uintptr_t addr, uint64_t *out)
|
||||
{
|
||||
__try {
|
||||
*out = *(volatile uint64_t *)addr;
|
||||
return 1;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static LONG CALLBACK VehCrash(EXCEPTION_POINTERS *ep)
|
||||
{
|
||||
DWORD code = ep->ExceptionRecord->ExceptionCode;
|
||||
|
||||
// The fatal fault (RecRoom.exe.dll+0x34D7E14) never shows up as an AV in our log, yet WER records
|
||||
// it -- so either a different exception code kills us, or it arrives on a path our AV-only filter
|
||||
// drops. Log EVERY exception's code + faulting address once, cheaply, so we can see what actually
|
||||
// precedes death. Skip the two noisy, benign, self-decrypt guard-page/breakpoint codes Themida
|
||||
// raises constantly, and skip C0000005 here (handled in full below) to avoid double-logging.
|
||||
if (code != EXCEPTION_ACCESS_VIOLATION &&
|
||||
code != 0x80000001 /* STATUS_GUARD_PAGE_VIOLATION */ &&
|
||||
code != EXCEPTION_BREAKPOINT && code != EXCEPTION_SINGLE_STEP)
|
||||
{
|
||||
char at[64];
|
||||
Sym(ep->ExceptionRecord->ExceptionAddress ? (uintptr_t)ep->ExceptionRecord->ExceptionAddress : 0,
|
||||
at, sizeof(at));
|
||||
|
||||
// 0xE06D7363 ('msc') is a C++ throw. Its ExceptionAddress is always KernelBase!RaiseException,
|
||||
// which tells us nothing -- what matters is WHO threw. For a C++ throw the ExceptionInformation
|
||||
// is [magic, &exception_object, &ThrowInfo], and ThrowInfo lives in the throwing module's
|
||||
// rdata, so its address identifies that module. Walk the faulting thread's stack for the first
|
||||
// few return addresses that land in a known module -- that is the throw path. This is the
|
||||
// event that precedes the fatal RecRoom.exe.dll abort, so its origin is the real lead.
|
||||
if (code == 0xE06D7363)
|
||||
{
|
||||
// These come in bursts; a full stack dump on each would flood the log. Show the detailed
|
||||
// walk only a handful of times.
|
||||
static volatile LONG cppSeen;
|
||||
if (InterlockedIncrement(&cppSeen) > 6) return EXCEPTION_CONTINUE_SEARCH;
|
||||
|
||||
uintptr_t throwInfo = (ep->ExceptionRecord->NumberParameters >= 4)
|
||||
? (uintptr_t)ep->ExceptionRecord->ExceptionInformation[3] : 0;
|
||||
char ti[64]; Sym(throwInfo, ti, sizeof(ti));
|
||||
Log("[CRASH] C++ exception (E06D7363) at %s throwInfo=%s", at, ti);
|
||||
|
||||
uintptr_t rsp = ep->ContextRecord ? (uintptr_t)ep->ContextRecord->Rsp : 0;
|
||||
int shown = 0;
|
||||
for (int i = 0; i < 40 && shown < 8 && rsp; i++)
|
||||
{
|
||||
uint64_t v;
|
||||
if (!SafeReadPtr(rsp + (uintptr_t)i * 8, &v)) break;
|
||||
uintptr_t a = (uintptr_t)v;
|
||||
if ((g_gaBase && a >= g_gaBase && a < g_gaEnd) ||
|
||||
(g_upBase && a >= g_upBase && a < g_upEnd) ||
|
||||
(g_rrBase && a >= g_rrBase && a < g_rrEnd))
|
||||
{
|
||||
char f[64]; Sym(a, f, sizeof(f));
|
||||
Log("[CRASH] throw-stack [rsp+0x%X]=%s", i * 8, f);
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
Log("[CRASH] exception code=%08lX at %s (firstChance)", (unsigned long)code, at);
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
if (code != EXCEPTION_ACCESS_VIOLATION) return EXCEPTION_CONTINUE_SEARCH;
|
||||
|
||||
if (!g_gaBase) ResolveModuleRanges();
|
||||
|
||||
PCONTEXT ctx = ep->ContextRecord;
|
||||
char at[64], acc[64];
|
||||
uintptr_t fault = ep->ExceptionRecord->ExceptionAddress ? (uintptr_t)ep->ExceptionRecord->ExceptionAddress : 0;
|
||||
Sym(fault, at, sizeof(at));
|
||||
uintptr_t accAddr = (ep->ExceptionRecord->NumberParameters >= 2) ? (uintptr_t)ep->ExceptionRecord->ExceptionInformation[1] : 0;
|
||||
uintptr_t accType = (ep->ExceptionRecord->NumberParameters >= 1) ? (uintptr_t)ep->ExceptionRecord->ExceptionInformation[0] : 0;
|
||||
Sym(accAddr, acc, sizeof(acc));
|
||||
// 0 = read fault, 1 = write fault, 8 = DEP/NX (attempted execute of non-executable page).
|
||||
const char *kind = (accType == 8) ? "EXECUTE" : (accType == 1) ? "WRITE" : "READ";
|
||||
|
||||
Log("[CRASH] ACCESS_VIOLATION at %s %s addr=%s (raw fault=%llX access=%llX)",
|
||||
at, kind, acc, (unsigned long long)fault, (unsigned long long)accAddr);
|
||||
|
||||
Log("[CRASH] rax=%llX rbx=%llX rcx=%llX rdx=%llX rsi=%llX rdi=%llX rbp=%llX rsp=%llX",
|
||||
(unsigned long long)ctx->Rax, (unsigned long long)ctx->Rbx, (unsigned long long)ctx->Rcx,
|
||||
(unsigned long long)ctx->Rdx, (unsigned long long)ctx->Rsi, (unsigned long long)ctx->Rdi,
|
||||
(unsigned long long)ctx->Rbp, (unsigned long long)ctx->Rsp);
|
||||
Log("[CRASH] r8=%llX r9=%llX r10=%llX r11=%llX r12=%llX r13=%llX r14=%llX r15=%llX",
|
||||
(unsigned long long)ctx->R8, (unsigned long long)ctx->R9, (unsigned long long)ctx->R10,
|
||||
(unsigned long long)ctx->R11, (unsigned long long)ctx->R12, (unsigned long long)ctx->R13,
|
||||
(unsigned long long)ctx->R14, (unsigned long long)ctx->R15);
|
||||
|
||||
// Walk the FAULTING thread's actual stack (ctx->Rsp), not our own -- CaptureStackBackTrace()
|
||||
// here would only see the VEH dispatch frames. When rip itself is the bad address (a call
|
||||
// through a corrupt pointer), [rsp] at fault time is the caller's return address.
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
uint64_t v;
|
||||
if (!SafeReadPtr((uintptr_t)ctx->Rsp + (uintptr_t)i * 8, &v)) break;
|
||||
char s[64]; Sym((uintptr_t)v, s, sizeof(s));
|
||||
Log("[CRASH] [rsp+0x%X]=%s", i * 8, s);
|
||||
}
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
void InstallCrashHandler(void)
|
||||
{
|
||||
ResolveModuleRanges();
|
||||
AddVectoredExceptionHandler(1, VehCrash); // 1 = call first
|
||||
Log("[CRASH] vectored AV logger installed");
|
||||
}
|
||||
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Hang probe.
|
||||
//
|
||||
// Player.log stops dead at PhotonNetwork.Disconnect() (~11s of game time) and the process then
|
||||
// lingers ~20s before aborting, with fault sites that move around (RecRoom.exe.dll+0x34D7E14 /
|
||||
// +0x34F2AE8 / +0x35FB437, and ucrtbase abort). That pattern says the main thread stops making
|
||||
// progress and the death is a downstream consequence -- so the useful question is not "where did it
|
||||
// crash" but "where is the main thread stuck".
|
||||
//
|
||||
// This periodically suspends Unity's main thread, samples RIP and the return addresses on its stack,
|
||||
// and logs them as module+RVA. If the same RIP/stack repeats across samples, that's the stall site;
|
||||
// if it keeps moving, the thread is live and the model is wrong. Cheap, needs no debugger, no
|
||||
// registry changes, and no PageHeap (which on a process this size risks exhausting memory).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
//
|
||||
static DWORD WINAPI HangProbeThread(LPVOID param)
|
||||
{
|
||||
(void)param;
|
||||
for (;;)
|
||||
{
|
||||
Sleep(3000);
|
||||
|
||||
DWORD tid = g_mainThreadId;
|
||||
if (!tid) continue;
|
||||
|
||||
HANDLE th = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT, FALSE, tid);
|
||||
if (!th) continue;
|
||||
|
||||
CONTEXT c;
|
||||
ZeroMemory(&c, sizeof(c));
|
||||
c.ContextFlags = CONTEXT_CONTROL | CONTEXT_INTEGER;
|
||||
|
||||
DWORD64 rip = 0, rsp = 0;
|
||||
uint64_t slots[12];
|
||||
int nslots = 0;
|
||||
BOOL got = FALSE;
|
||||
|
||||
if (SuspendThread(th) != (DWORD)-1)
|
||||
{
|
||||
if (GetThreadContext(th, &c))
|
||||
{
|
||||
rip = c.Rip; rsp = c.Rsp; got = TRUE;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
uint64_t v;
|
||||
if (!SafeReadPtr((uintptr_t)rsp + (uintptr_t)i * 8, &v)) break;
|
||||
slots[nslots++] = v;
|
||||
}
|
||||
}
|
||||
ResumeThread(th);
|
||||
}
|
||||
CloseHandle(th);
|
||||
|
||||
// Log only after resuming -- logging while the target is suspended can deadlock on the
|
||||
// logger's own lock.
|
||||
if (!got) continue;
|
||||
|
||||
char s[64]; Sym((uintptr_t)rip, s, sizeof(s));
|
||||
Log("[HANG] main tid=%lu rip=%s rsp=%llX", tid, s, (unsigned long long)rsp);
|
||||
|
||||
for (int i = 0; i < nslots; i++)
|
||||
{
|
||||
uintptr_t v = (uintptr_t)slots[i];
|
||||
if ((g_gaBase && v >= g_gaBase && v < g_gaEnd) ||
|
||||
(g_upBase && v >= g_upBase && v < g_upEnd) ||
|
||||
(g_rrBase && v >= g_rrBase && v < g_rrEnd))
|
||||
{
|
||||
char f[64]; Sym(v, f, sizeof(f));
|
||||
Log("[HANG] [rsp+0x%X]=%s", i * 8, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void StartHangProbe(void)
|
||||
{
|
||||
ResolveModuleRanges();
|
||||
HANDLE t = CreateThread(NULL, 0, HangProbeThread, NULL, 0, NULL);
|
||||
if (t) CloseHandle(t);
|
||||
Log("[HANG] probe started (samples Unity's main thread every 3s)");
|
||||
}
|
||||
+24
-1
@@ -15,11 +15,34 @@ BOOL WINAPI DllMain(
|
||||
{
|
||||
DisableThreadLibraryCalls(hinst);
|
||||
|
||||
// DIAGNOSTIC breadcrumb (unconditional, before any gate): proves our proxy actually loaded and
|
||||
// into which process. Written next to the host EXE via raw kernel32 calls (loader-lock safe).
|
||||
{
|
||||
char host[MAX_PATH];
|
||||
DWORD hn = GetModuleFileNameA(NULL, host, sizeof(host));
|
||||
char marker[MAX_PATH];
|
||||
char dir[MAX_PATH];
|
||||
strcpy_s(dir, sizeof(dir), host);
|
||||
char *slash = strrchr(dir, '\\');
|
||||
if(slash) *(slash + 1) = 0; else dir[0] = 0;
|
||||
sprintf_s(marker, sizeof(marker), "%srr_proxy_attach_%lu.txt", dir, GetCurrentProcessId());
|
||||
HANDLE hf = CreateFileA(marker, GENERIC_WRITE, FILE_SHARE_READ, NULL,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||
if(hf != INVALID_HANDLE_VALUE)
|
||||
{
|
||||
DWORD wr;
|
||||
WriteFile(hf, host, hn, &wr, NULL);
|
||||
CloseHandle(hf);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass our own module base to the hook thread so it can (optionally) unlink us from the PEB
|
||||
// loader lists once config is read.
|
||||
HANDLE thread = CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
HookThread,
|
||||
NULL,
|
||||
(LPVOID)hinst,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
+188
-3
@@ -16,6 +16,14 @@
|
||||
#include "http_rewrite.h"
|
||||
#include "memcheck_patch.h"
|
||||
#include "eac_patch.h"
|
||||
#include "photon_patch.h"
|
||||
#include "quit_trace.h"
|
||||
#include "filesig_patch.h"
|
||||
#include "hwbp.h"
|
||||
#include "antitamper_patch.h"
|
||||
#include "crash_handler.h"
|
||||
#include "module_hide.h"
|
||||
#include "retspoof.h"
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +151,14 @@ BOOL InstallHooks()
|
||||
// Install DNS hook
|
||||
//
|
||||
|
||||
if(real_getaddrinfo)
|
||||
if(!enable_dns)
|
||||
{
|
||||
// Safety net only: with the SendRequest host rewrite active the client already asks for real
|
||||
// *.recflare.net names. Skipping this leaves ZERO inline byte patches in the process, which
|
||||
// is how we test whether our patching is what the protector reacts to.
|
||||
Log("[HOOK] DNS hook disabled via config -- no inline patches will be installed");
|
||||
}
|
||||
else if(real_getaddrinfo)
|
||||
{
|
||||
Log(
|
||||
"[HOOK] Installing getaddrinfo"
|
||||
@@ -235,6 +250,25 @@ DWORD WINAPI HookThread(
|
||||
MyExceptionHandler
|
||||
);
|
||||
|
||||
// Diagnostics are observers, but a first-in-chain VEH plus a probe that suspends the main
|
||||
// thread every 3s can themselves perturb a protected process -- keep them switchable so a
|
||||
// measurement can exclude them.
|
||||
if(enable_diag)
|
||||
{
|
||||
InstallCrashHandler();
|
||||
|
||||
// Establish once per run whether hardware breakpoints are usable in this process at all.
|
||||
// Only touches our own code, so it is safe even with use_hwbp off (the default).
|
||||
HwbpSelfTest();
|
||||
|
||||
// Watch for a main-thread stall.
|
||||
StartHangProbe();
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[DIAG] diagnostics disabled via config (no AV logger, no hang probe, no HWBP self-test)");
|
||||
}
|
||||
|
||||
|
||||
Log(
|
||||
"[THREAD] Hook thread started"
|
||||
@@ -276,9 +310,12 @@ DWORD WINAPI HookThread(
|
||||
|
||||
|
||||
//
|
||||
// Wait for Unity. If this isn't the game process (EAC launcher, crash handler, ...), bail so we
|
||||
// don't spin forever or install hooks where they don't belong.
|
||||
// Optionally unlink ourselves from the PEB loader lists. Best current guess at what Themida's
|
||||
// ~35s anti-tamper check flags: a foreign module in the loader list. Done right after config so
|
||||
// it happens before the game's periodic scans get going. `param` is our own HMODULE from DllMain.
|
||||
//
|
||||
if(hide_module)
|
||||
HideModuleFromPeb((HMODULE)param);
|
||||
|
||||
if(!WaitForUnity())
|
||||
return 0;
|
||||
@@ -304,6 +341,31 @@ DWORD WINAPI HookThread(
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Return-address spoofing gadget scan. Started before every other il2cpp patch: http_rewrite.c and
|
||||
// photon_patch.c route their il2cpp utility calls (string_new, object_new, Uri..ctor, get/set_Uri)
|
||||
// through SpoofCall4 so those calls don't show redirector.dll on the stack. The scan itself only
|
||||
// takes tens of ms once GameAssembly's code is decrypted, but starting it first gives it the most
|
||||
// lead time before real traffic starts flowing through the hooks that depend on it. SpoofCall4
|
||||
// falls back to a plain call if the scan hasn't finished yet, so nothing blocks on this thread.
|
||||
//
|
||||
|
||||
HANDLE spoofThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
PatchRetSpoof,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(spoofThread)
|
||||
CloseHandle(spoofThread);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Memory-integrity scan neutralizer. Started FIRST among the il2cpp patches because the boot
|
||||
// step that awaits the scan can fire early -- its reflection search needs a head start so the
|
||||
@@ -335,6 +397,8 @@ DWORD WINAPI HookThread(
|
||||
// fails the handshake (mismatched/pinned cert).
|
||||
//
|
||||
|
||||
if(enable_ssl)
|
||||
{
|
||||
HANDLE sslThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
@@ -349,6 +413,11 @@ DWORD WINAPI HookThread(
|
||||
|
||||
if(sslThread)
|
||||
CloseHandle(sslThread);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[HOOK] SSL bypass disabled via config -- skipping");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -357,6 +426,8 @@ DWORD WINAPI HookThread(
|
||||
// the SSL patch it waits for the il2cpp runtime before resolving+hooking SendRequest.
|
||||
//
|
||||
|
||||
if(enable_http)
|
||||
{
|
||||
HANDLE httpThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
@@ -371,6 +442,11 @@ DWORD WINAPI HookThread(
|
||||
|
||||
if(httpThread)
|
||||
CloseHandle(httpThread);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[HOOK] HTTP host rewrite disabled via config -- skipping");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -396,6 +472,115 @@ DWORD WINAPI HookThread(
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Photon app-id injection. Own thread; waits for GameAssembly then detours the Photon connect seam
|
||||
// to fill in the operator's Photon Cloud app IDs (empty otherwise -> InvalidAuthentication ~30s in).
|
||||
//
|
||||
|
||||
if(enable_photon)
|
||||
{
|
||||
HANDLE photonThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchPhotonAppId,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(photonThread)
|
||||
CloseHandle(photonThread);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[HOOK] Photon app-id injection disabled via config -- skipping");
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// File-signature-check neutraliser. Own thread; waits for GameAssembly then detours the
|
||||
// file_sig_check P/Invoke whose unresolved native pointer is what actually crashes the process
|
||||
// ~35s in (see src/unity/filesig_patch.c for the full evidence chain).
|
||||
//
|
||||
|
||||
HANDLE fileSigThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchFileSigCheck,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(fileSigThread)
|
||||
CloseHandle(fileSigThread);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Anti-tamper report funnel suppression. Own thread; waits for GameAssembly then detours the tamper
|
||||
// funnel so ImageSignature (placeholder CDN sig) + our own hooks don't create a Hile warning that
|
||||
// POSTs api/PlayerReporting/v1/hile and force-quits ~30s in.
|
||||
//
|
||||
|
||||
// TEMPORARILY DISABLED for crash isolation: does the 0xC0000005 go away without the funnel's
|
||||
// null-return?
|
||||
#if 0
|
||||
HANDLE antitamperThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchAntiTamper,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(antitamperThread)
|
||||
CloseHandle(antitamperThread);
|
||||
#endif
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Application.Quit tracer. Own thread; waits for GameAssembly then detours both Quit overloads to
|
||||
// log the managed caller (map with il2cpp-tools/whatis.py) and, when "blockQuit" is set in
|
||||
// redirector.json, swallow the shutdown. This is what identifies WHO ends the session ~11s in --
|
||||
// Player.log stops at PhotonNetwork.Disconnect() without ever naming a reason.
|
||||
//
|
||||
|
||||
// DISABLED: it did its job -- both Application.Quit overloads and TerminateProcess(self) NEVER
|
||||
// fire, so the session ends in a hard crash, not a requested exit. Leaving it on would add three
|
||||
// more inline patches to the very code the integrity scan is suspected of hashing, which would
|
||||
// pollute the memcheck experiment. Re-enable only to re-test the exit path.
|
||||
#if 0
|
||||
HANDLE quitThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchQuitTrace,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(quitThread)
|
||||
CloseHandle(quitThread);
|
||||
#endif
|
||||
|
||||
|
||||
|
||||
Log(
|
||||
"[THREAD] Redirector initialized"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
#include "common.h"
|
||||
#include "hwbp.h"
|
||||
#include "logger.h"
|
||||
#include <tlhelp32.h>
|
||||
|
||||
//
|
||||
// See include/hwbp.h for why this exists. Mechanics:
|
||||
//
|
||||
// An execute breakpoint in DR0-3 makes the CPU raise #DB *before* the instruction at that address
|
||||
// runs; Windows delivers it as EXCEPTION_SINGLE_STEP to vectored handlers. Our handler rewrites
|
||||
// CONTEXT.Rip to the hook and resumes -- so the hook is entered with RCX/RDX/R8/R9 and the return
|
||||
// address exactly as the real function would have seen them, and a plain `return` from the hook
|
||||
// goes straight back to the game's caller. No bytes are touched anywhere.
|
||||
//
|
||||
// Calling the original from a hook needs care: the call re-enters the same address and would trap
|
||||
// again forever. HwbpSkipOnce arms a thread-local flag; the handler consumes it and resumes with
|
||||
// EFlags.RF (resume flag) set, which suppresses the instruction breakpoint for exactly one
|
||||
// instruction. RF is essential -- without it, resuming at an un-executed faulting address just
|
||||
// re-faults immediately.
|
||||
//
|
||||
|
||||
static void *g_target[HWBP_MAX];
|
||||
static void *g_hook[HWBP_MAX];
|
||||
static int g_active; // bitmask of armed slots
|
||||
static void *g_veh;
|
||||
static CRITICAL_SECTION g_lock;
|
||||
static BOOL g_ready;
|
||||
|
||||
// One-shot pass-through, per thread per slot (see HwbpSkipOnce).
|
||||
static __declspec(thread) int t_skip[HWBP_MAX];
|
||||
|
||||
// DR7 layout: bit (2*i) = local enable for slot i. The 4 bits at (16 + 4*i) are that slot's
|
||||
// condition (bits 0-1) and length (bits 2-3); 0b0000 = break on execute, length 1 -- the only
|
||||
// combination valid for an execute breakpoint.
|
||||
static DWORD64 BuildDr7(int mask)
|
||||
{
|
||||
DWORD64 dr7 = 0;
|
||||
for (int i = 0; i < HWBP_MAX; i++)
|
||||
if (mask & (1 << i))
|
||||
dr7 |= (DWORD64)1 << (i * 2); // Ln enable; RW/LEN nibble stays 0 = execute
|
||||
return dr7;
|
||||
}
|
||||
|
||||
// Push the current register set into one thread. The thread must not be us.
|
||||
static BOOL ApplyToThread(HANDLE th)
|
||||
{
|
||||
CONTEXT ctx;
|
||||
ZeroMemory(&ctx, sizeof(ctx));
|
||||
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
|
||||
|
||||
if (SuspendThread(th) == (DWORD)-1) return FALSE;
|
||||
|
||||
BOOL ok = FALSE;
|
||||
if (GetThreadContext(th, &ctx))
|
||||
{
|
||||
ctx.Dr0 = (DWORD64)(uintptr_t)g_target[0];
|
||||
ctx.Dr1 = (DWORD64)(uintptr_t)g_target[1];
|
||||
ctx.Dr2 = (DWORD64)(uintptr_t)g_target[2];
|
||||
ctx.Dr3 = (DWORD64)(uintptr_t)g_target[3];
|
||||
ctx.Dr6 = 0;
|
||||
ctx.Dr7 = BuildDr7(g_active);
|
||||
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
|
||||
ok = SetThreadContext(th, &ctx);
|
||||
}
|
||||
|
||||
ResumeThread(th);
|
||||
return ok;
|
||||
}
|
||||
|
||||
//
|
||||
// Apply to every thread in this process except ourselves. `seen` is a caller-owned list of TIDs we
|
||||
// have already programmed, so the watcher only pays for genuinely new threads.
|
||||
//
|
||||
// Deliberately does no logging while a thread is suspended: the logger takes a lock, and suspending
|
||||
// the thread that happens to hold it and then trying to log would deadlock the process.
|
||||
//
|
||||
static int ApplyToAllThreads(DWORD *seen, int *seenCount, int seenMax)
|
||||
{
|
||||
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
|
||||
if (snap == INVALID_HANDLE_VALUE) return 0;
|
||||
|
||||
THREADENTRY32 te;
|
||||
te.dwSize = sizeof(te);
|
||||
DWORD pid = GetCurrentProcessId(), self = GetCurrentThreadId();
|
||||
int applied = 0;
|
||||
|
||||
if (Thread32First(snap, &te))
|
||||
{
|
||||
do {
|
||||
if (te.th32OwnerProcessID != pid) continue;
|
||||
if (te.th32ThreadID == self) continue;
|
||||
|
||||
int known = 0;
|
||||
for (int i = 0; i < *seenCount; i++)
|
||||
if (seen[i] == te.th32ThreadID) { known = 1; break; }
|
||||
if (known) continue;
|
||||
|
||||
HANDLE th = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
|
||||
FALSE, te.th32ThreadID);
|
||||
if (th)
|
||||
{
|
||||
if (ApplyToThread(th)) applied++;
|
||||
CloseHandle(th);
|
||||
if (*seenCount < seenMax) seen[(*seenCount)++] = te.th32ThreadID;
|
||||
}
|
||||
} while (Thread32Next(snap, &te));
|
||||
}
|
||||
|
||||
CloseHandle(snap);
|
||||
return applied;
|
||||
}
|
||||
|
||||
// Diagnostics: did the CPU ever deliver a #DB to us at all? If this stays 0 while the debug
|
||||
// registers read back as armed, the registers are being faked (the protector hooking
|
||||
// NtGet/SetContextThread) and hardware breakpoints cannot work in this process.
|
||||
static volatile LONG g_singleStepSeen;
|
||||
static volatile LONG g_vehCalls;
|
||||
|
||||
static LONG CALLBACK HwbpVeh(EXCEPTION_POINTERS *ep)
|
||||
{
|
||||
InterlockedIncrement(&g_vehCalls);
|
||||
|
||||
if (ep->ExceptionRecord->ExceptionCode != EXCEPTION_SINGLE_STEP)
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
|
||||
InterlockedIncrement(&g_singleStepSeen);
|
||||
|
||||
PCONTEXT ctx = ep->ContextRecord;
|
||||
DWORD64 dr6 = ctx->Dr6;
|
||||
|
||||
for (int i = 0; i < HWBP_MAX; i++)
|
||||
{
|
||||
if (!(dr6 & ((DWORD64)1 << i))) continue; // not this slot
|
||||
if (!(g_active & (1 << i))) continue; // not ours
|
||||
|
||||
ctx->Dr6 = 0; // ack the hit
|
||||
|
||||
if (t_skip[i])
|
||||
{
|
||||
// Deliberate pass-through from a call-through hook: run the real instruction once.
|
||||
t_skip[i] = 0;
|
||||
ctx->EFlags |= 0x10000; // RF -- suppress this breakpoint for 1 insn
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
|
||||
// Enter the hook with the callee's exact register state.
|
||||
ctx->Rip = (DWORD64)(uintptr_t)g_hook[i];
|
||||
return EXCEPTION_CONTINUE_EXECUTION;
|
||||
}
|
||||
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
// Re-apply to newly created threads. BestHTTP and Unity both spawn threads well after our hooks go
|
||||
// in, and a thread born without the debug registers set would sail straight through the target.
|
||||
//
|
||||
// Read DR7/DR0 back out of some thread that is not us, so we can tell whether our registers are
|
||||
// actually sticking. Themida-class protectors routinely zero the debug registers to kill hardware
|
||||
// breakpoints, and that failure is otherwise silent -- the hook simply never fires.
|
||||
//
|
||||
static void CheckPersistence(void)
|
||||
{
|
||||
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
|
||||
if (snap == INVALID_HANDLE_VALUE) return;
|
||||
|
||||
THREADENTRY32 te; te.dwSize = sizeof(te);
|
||||
DWORD pid = GetCurrentProcessId(), self = GetCurrentThreadId();
|
||||
// Survey EVERY thread rather than sampling one -- if the protector only scrubs the threads that
|
||||
// actually run game code, a single sample can look perfectly healthy while the threads we care
|
||||
// about are disarmed.
|
||||
int total = 0, armed = 0, cleared = 0, unreadable = 0;
|
||||
|
||||
if (Thread32First(snap, &te))
|
||||
{
|
||||
do {
|
||||
if (te.th32OwnerProcessID != pid || te.th32ThreadID == self) continue;
|
||||
total++;
|
||||
HANDLE th = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT, FALSE, te.th32ThreadID);
|
||||
if (!th) { unreadable++; continue; }
|
||||
CONTEXT c; ZeroMemory(&c, sizeof(c)); c.ContextFlags = CONTEXT_DEBUG_REGISTERS;
|
||||
if (SuspendThread(th) != (DWORD)-1)
|
||||
{
|
||||
if (GetThreadContext(th, &c))
|
||||
{
|
||||
if (c.Dr0 == (DWORD64)(uintptr_t)g_target[0] && g_target[0]) armed++;
|
||||
else cleared++;
|
||||
}
|
||||
else unreadable++;
|
||||
ResumeThread(th);
|
||||
}
|
||||
else unreadable++;
|
||||
CloseHandle(th);
|
||||
} while (Thread32Next(snap, &te));
|
||||
}
|
||||
CloseHandle(snap);
|
||||
|
||||
// Log outside the suspend window (logging while a thread is suspended can deadlock on the
|
||||
// logger lock).
|
||||
Log("[HWBP] survey: %d threads -- Dr0 armed=%d cleared=%d unreadable=%d | vehCalls=%ld singleStep=%ld",
|
||||
total, armed, cleared, unreadable, (long)g_vehCalls, (long)g_singleStepSeen);
|
||||
}
|
||||
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Self-test: prove whether hardware breakpoints work AT ALL in this process.
|
||||
//
|
||||
// Everything we hook lives in code the protector cares about, so "the hook didn't fire" is
|
||||
// ambiguous -- it could be the target, the thread, or the registers being faked. This arms the spare
|
||||
// slot on a function inside OUR OWN DLL and calls it from a fresh thread. That target is code no
|
||||
// integrity check is watching, so:
|
||||
// fires -> the engine is sound; the failure is specific to the GameAssembly targets/threads.
|
||||
// no fire -> debug registers are non-functional process-wide (faked Get/SetContextThread), and
|
||||
// hardware breakpoints are a dead end on this build. Definitive.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
//
|
||||
#define HWBP_SLOT_SELFTEST 3
|
||||
|
||||
static volatile LONG g_selfTestSideEffect;
|
||||
static volatile LONG g_selfTestHookRan;
|
||||
|
||||
__declspec(noinline) static void HwbpSelfTestTarget(void)
|
||||
{
|
||||
// Volatile side effect so the optimiser can neither inline nor elide this function.
|
||||
InterlockedIncrement(&g_selfTestSideEffect);
|
||||
}
|
||||
|
||||
static void HwbpSelfTestHook(void)
|
||||
{
|
||||
g_selfTestHookRan = 1;
|
||||
// Replace-only: returning here goes straight back to HwbpSelfTestTarget's caller.
|
||||
}
|
||||
|
||||
static DWORD WINAPI HwbpSelfTestThread(LPVOID param)
|
||||
{
|
||||
(void)param;
|
||||
// Give the applier a couple of passes to program this newly created thread.
|
||||
Sleep(1200);
|
||||
|
||||
LONG before = g_selfTestSideEffect;
|
||||
HwbpSelfTestTarget();
|
||||
|
||||
//
|
||||
// Disarm immediately. Leaving the self-test slot armed keeps the watcher suspending and
|
||||
// resuming every thread in the process (~135 of them) five times a second forever, which is
|
||||
// pure overhead once the question is answered -- and poking every thread's context that often
|
||||
// inside a protected process is exactly the kind of thing worth NOT doing while hunting a crash.
|
||||
//
|
||||
HwbpDisarm(HWBP_SLOT_SELFTEST);
|
||||
|
||||
if (g_selfTestHookRan)
|
||||
Log("[HWBP] SELF-TEST PASSED -- breakpoint fired on our own function; the engine works "
|
||||
"(hooks disarmed again)");
|
||||
else
|
||||
Log("[HWBP] SELF-TEST FAILED -- armed our own function and it did NOT trap (side effect ran: "
|
||||
"%ld->%ld, vehCalls=%ld singleStep=%ld). Debug registers are non-functional in this "
|
||||
"process; hardware breakpoints are a DEAD END here.",
|
||||
(long)before, (long)g_selfTestSideEffect, (long)g_vehCalls, (long)g_singleStepSeen);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void HwbpSelfTest(void)
|
||||
{
|
||||
if (!HwbpAdd(HWBP_SLOT_SELFTEST, (void *)HwbpSelfTestTarget, (void *)HwbpSelfTestHook))
|
||||
{
|
||||
Log("[HWBP] self-test could not arm its slot");
|
||||
return;
|
||||
}
|
||||
HANDLE t = CreateThread(NULL, 0, HwbpSelfTestThread, NULL, 0, NULL);
|
||||
if (t) CloseHandle(t);
|
||||
}
|
||||
|
||||
static DWORD WINAPI HwbpWatcher(LPVOID param)
|
||||
{
|
||||
(void)param;
|
||||
static DWORD seen[512];
|
||||
int pass = 0;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
if (g_active)
|
||||
{
|
||||
//
|
||||
// Re-arm EVERY thread on EVERY pass, not just threads we haven't seen. The protector
|
||||
// inside RecRoom.exe.dll zeroes DR0-DR3 a few seconds in (observed: Dr0 goes
|
||||
// 7FFC54B6FD00 -> 0 while Dr7 stays 0x415), which is a standard anti-debug move. A
|
||||
// one-shot arm is therefore silently disarmed long before the hooked functions are ever
|
||||
// called, so persistence is the whole game here -- hence the deliberately throwaway
|
||||
// seen-list.
|
||||
//
|
||||
int seenCount = 0;
|
||||
EnterCriticalSection(&g_lock);
|
||||
ApplyToAllThreads(seen, &seenCount, (int)(sizeof(seen) / sizeof(seen[0])));
|
||||
LeaveCriticalSection(&g_lock);
|
||||
|
||||
// Report the first few passes, then occasionally -- enough to see whether the registers
|
||||
// survive without spamming the log.
|
||||
if (pass < 3 || (pass % 25) == 0) CheckPersistence();
|
||||
pass++;
|
||||
}
|
||||
Sleep(200);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
BOOL HwbpInit(void)
|
||||
{
|
||||
if (g_ready) return TRUE;
|
||||
|
||||
InitializeCriticalSection(&g_lock);
|
||||
|
||||
// First in the chain: this must see #DB before anything else decides to swallow it.
|
||||
g_veh = AddVectoredExceptionHandler(1, HwbpVeh);
|
||||
if (!g_veh) { Log("[HWBP] AddVectoredExceptionHandler failed (%lu)", GetLastError()); return FALSE; }
|
||||
|
||||
HANDLE w = CreateThread(NULL, 0, HwbpWatcher, NULL, 0, NULL);
|
||||
if (w) CloseHandle(w);
|
||||
|
||||
g_ready = TRUE;
|
||||
Log("[HWBP] engine ready (VEH installed, per-thread watcher running)");
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
BOOL HwbpAdd(int slot, void *target, void *hook)
|
||||
{
|
||||
if (slot < 0 || slot >= HWBP_MAX || !target || !hook) return FALSE;
|
||||
if (!HwbpInit()) return FALSE;
|
||||
|
||||
EnterCriticalSection(&g_lock);
|
||||
g_target[slot] = target;
|
||||
g_hook[slot] = hook;
|
||||
g_active |= (1 << slot);
|
||||
|
||||
// Program every existing thread now; the watcher covers ones created later. Pass a throwaway
|
||||
// seen-list so this call reprograms all current threads with the new register set.
|
||||
DWORD seen[512]; int n = 0;
|
||||
int applied = ApplyToAllThreads(seen, &n, (int)(sizeof(seen) / sizeof(seen[0])));
|
||||
LeaveCriticalSection(&g_lock);
|
||||
|
||||
Log("[HWBP] slot %d armed: target=%p hook=%p (applied to %d existing threads)",
|
||||
slot, target, hook, applied);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
void HwbpSkipOnce(int slot)
|
||||
{
|
||||
if (slot >= 0 && slot < HWBP_MAX) t_skip[slot] = 1;
|
||||
}
|
||||
|
||||
void HwbpDisarm(int slot)
|
||||
{
|
||||
if (slot < 0 || slot >= HWBP_MAX) return;
|
||||
|
||||
EnterCriticalSection(&g_lock);
|
||||
g_target[slot] = NULL;
|
||||
g_hook[slot] = NULL;
|
||||
g_active &= ~(1 << slot);
|
||||
|
||||
// Push the cleared register set out to every thread. With g_active back to 0 the watcher then
|
||||
// idles instead of suspending the whole process on a loop.
|
||||
DWORD seen[512]; int n = 0;
|
||||
ApplyToAllThreads(seen, &n, (int)(sizeof(seen) / sizeof(seen[0])));
|
||||
LeaveCriticalSection(&g_lock);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "common.h"
|
||||
#include "module_hide.h"
|
||||
#include "logger.h"
|
||||
|
||||
//
|
||||
// Hide our injected DLL from the PEB loader lists.
|
||||
//
|
||||
// The Themida assertion that ends the session fires on a consistent ~35s cadence, never earlier, and
|
||||
// is invariant to how we launch (suspended vs running) and to whether we patch any code (inline vs
|
||||
// hardware breakpoints vs nothing). The one thing constant across every one of those tests is that
|
||||
// redirector.dll is present in the module list. A periodic anti-tamper scan that walks the loaded
|
||||
// modules and flags an unexpected DLL matches that fingerprint exactly.
|
||||
//
|
||||
// Every loaded module is described by one LDR_DATA_TABLE_ENTRY that is simultaneously a member of the
|
||||
// PEB loader's three doubly-linked lists (load order, memory order, init order). Unlinking that entry
|
||||
// from all three removes the module from every standard enumeration without unmapping it -- our code,
|
||||
// threads and hooks are unaffected because they were resolved at load time and don't depend on the
|
||||
// list membership.
|
||||
//
|
||||
// x64 struct offsets (stable across modern Windows 10/11):
|
||||
// PEB: gs:[0x60]
|
||||
// PEB.Ldr: +0x18 -> PEB_LDR_DATA*
|
||||
// PEB_LDR_DATA.InLoadOrderModuleList: +0x10 (list head; entries linked at LDR entry +0x00)
|
||||
// LDR_DATA_TABLE_ENTRY.InLoadOrderLinks: +0x00
|
||||
// LDR_DATA_TABLE_ENTRY.InMemoryOrderLinks: +0x10
|
||||
// LDR_DATA_TABLE_ENTRY.InInitializationOrderLinks:+0x20
|
||||
// LDR_DATA_TABLE_ENTRY.DllBase: +0x30
|
||||
// LDR_DATA_TABLE_ENTRY.BaseDllName (UNICODE_STRING.Buffer at +0x08): +0x58
|
||||
//
|
||||
|
||||
typedef struct _LIST_ENTRY_X { struct _LIST_ENTRY_X *Flink, *Blink; } LIST_ENTRY_X;
|
||||
|
||||
static void UnlinkOne(LIST_ENTRY_X *e)
|
||||
{
|
||||
// Standard doubly-linked-list removal. Point it at itself afterwards so a stray re-walk of the
|
||||
// (now detached) entry can't crash.
|
||||
if (!e || !e->Flink || !e->Blink) return;
|
||||
e->Blink->Flink = e->Flink;
|
||||
e->Flink->Blink = e->Blink;
|
||||
e->Flink = e;
|
||||
e->Blink = e;
|
||||
}
|
||||
|
||||
void HideModuleFromPeb(HMODULE self)
|
||||
{
|
||||
BYTE *peb = (BYTE *)__readgsqword(0x60);
|
||||
if (!peb) { Log("[HIDE] no PEB"); return; }
|
||||
|
||||
BYTE *ldr = *(BYTE **)(peb + 0x18);
|
||||
if (!ldr) { Log("[HIDE] no PEB.Ldr"); return; }
|
||||
|
||||
LIST_ENTRY_X *head = (LIST_ENTRY_X *)(ldr + 0x10); // InLoadOrderModuleList
|
||||
for (LIST_ENTRY_X *cur = head->Flink; cur && cur != head; cur = cur->Flink)
|
||||
{
|
||||
BYTE *entry = (BYTE *)cur; // InLoadOrderLinks is at offset 0 of the entry
|
||||
HMODULE dllBase = *(HMODULE *)(entry + 0x30);
|
||||
if (dllBase != self) continue;
|
||||
|
||||
UnlinkOne((LIST_ENTRY_X *)(entry + 0x00)); // InLoadOrderLinks
|
||||
UnlinkOne((LIST_ENTRY_X *)(entry + 0x10)); // InMemoryOrderLinks
|
||||
UnlinkOne((LIST_ENTRY_X *)(entry + 0x20)); // InInitializationOrderLinks
|
||||
|
||||
Log("[HIDE] redirector.dll unlinked from PEB loader lists (base=%p) -- hidden from module walks",
|
||||
(void *)self);
|
||||
return;
|
||||
}
|
||||
|
||||
Log("[HIDE] own module not found in loader list (base=%p) -- nothing hidden", (void *)self);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
; Return-address spoofing thunk for il2cpp utility calls (defeats stack-walk-based anti-cheat
|
||||
; detection -- see include/retspoof.h for the rationale).
|
||||
;
|
||||
; uint64_t spoof_call(void* gadget, void* target, uint64_t a1, uint64_t a2, uint64_t a3, uint64_t a4)
|
||||
; rcx=gadget address of a "jmp qword ptr [rbx]" (bytes FF 23) instruction inside the game module
|
||||
; rdx=target function to call
|
||||
; r8=a1 r9=a2 [rsp+28h]=a3 [rsp+30h]=a4 (standard Windows x64 arg registers/stack)
|
||||
;
|
||||
; Calls target(a1,a2,a3,a4) with a spoofed return address: instead of target's `ret` landing back in
|
||||
; this thunk (inside redirector.dll), it lands on `gadget` -- a real instruction inside the game's own
|
||||
; module -- which does `jmp [rbx]` back to us. rbx is a callee-saved (non-volatile) register, so target
|
||||
; is required by the calling convention to preserve it across its own execution; we use that guarantee
|
||||
; to smuggle our real resume address across the call via a stack slot rbx points at. Any stack walk
|
||||
; that reads target's return address off the stack (RtlCaptureStackBackTrace, manual unwind, etc.) sees
|
||||
; `gadget` -- an address inside the legitimate module -- instead of redirector.dll.
|
||||
|
||||
.code
|
||||
|
||||
spoof_call PROC
|
||||
mov r10, [rsp+28h] ; a3 (5th arg, passed on the stack)
|
||||
mov r11, [rsp+30h] ; a4 (6th arg, passed on the stack)
|
||||
|
||||
push rbx ; non-volatile; target must preserve it across its own call (ABI)
|
||||
sub rsp, 40h ; local frame (64B, 16-aligned): gadget/target/a1..a4/resumeAddr/pad
|
||||
|
||||
mov [rsp+00h], rcx ; gadget
|
||||
mov [rsp+08h], rdx ; target
|
||||
mov [rsp+10h], r8 ; a1
|
||||
mov [rsp+18h], r9 ; a2
|
||||
mov [rsp+20h], r10 ; a3
|
||||
mov [rsp+28h], r11 ; a4
|
||||
|
||||
lea rax, resume_lbl
|
||||
mov [rsp+30h], rax ; stash the address to resume at once target returns
|
||||
|
||||
lea rbx, [rsp+30h] ; rbx -> pointer to the resume-address slot; the gadget dereferences
|
||||
; this via `jmp [rbx]` once target's `ret` "returns" to it.
|
||||
|
||||
mov rcx, [rsp+10h]
|
||||
mov rdx, [rsp+18h]
|
||||
mov r8, [rsp+20h]
|
||||
mov r9, [rsp+28h]
|
||||
mov r10, [rsp+00h] ; gadget
|
||||
mov r11, [rsp+08h] ; target
|
||||
|
||||
sub rsp, 20h ; shadow space for target's own arg spill (real, unused scratch)
|
||||
push r10 ; fake return address (the gadget) -- mimics what `call` would push
|
||||
jmp r11 ; enter target with a spoofed return address already on the stack
|
||||
|
||||
resume_lbl:
|
||||
; Reached via: target's `ret` pops `gadget` and "returns" to it; the gadget's `jmp [rbx]` reads the
|
||||
; resume-address slot rbx still points at (target preserved rbx per the ABI) and jumps here. rax
|
||||
; holds target's return value, untouched by the detour through the gadget.
|
||||
add rsp, 20h ; undo the shadow-space reservation
|
||||
add rsp, 40h ; undo the local frame
|
||||
pop rbx ; restore caller's rbx
|
||||
ret
|
||||
|
||||
spoof_call ENDP
|
||||
|
||||
END
|
||||
@@ -0,0 +1,96 @@
|
||||
#include "common.h"
|
||||
#include "retspoof.h"
|
||||
#include "logger.h"
|
||||
#include "config.h"
|
||||
|
||||
extern uint64_t spoof_call(void *gadget, void *target, uint64_t a1, uint64_t a2, uint64_t a3, uint64_t a4);
|
||||
|
||||
static void * volatile g_gadget;
|
||||
|
||||
// Same probe RVA ssl_patch.c uses to detect the packer has finished decrypting .text (build 2025-04-29,
|
||||
// LegacyTlsAuthentication.NotifyServerCertificate). We don't hook it here -- just reuse it as a "is code
|
||||
// decrypted yet" marker so the gadget scan doesn't run over still-encrypted bytes.
|
||||
#define DECRYPT_PROBE_RVA 0x71CFD00
|
||||
|
||||
static void WaitForCodeDecrypt(const BYTE *p)
|
||||
{
|
||||
for (int i = 0; i < 600; i++) // ~60s cap
|
||||
{
|
||||
BOOL ok = FALSE;
|
||||
__try { ok = (p[0] != 0x00 && p[0] != 0xCC); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) { ok = FALSE; }
|
||||
if (ok) return;
|
||||
Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
int InitRetSpoof(HMODULE mod)
|
||||
{
|
||||
MODULEINFO mi;
|
||||
if (!GetModuleInformation(GetCurrentProcess(), mod, &mi, sizeof(mi)))
|
||||
{
|
||||
Log("[SPOOF] GetModuleInformation failed -- return-address spoofing unavailable");
|
||||
return 0;
|
||||
}
|
||||
|
||||
BYTE *base = (BYTE *)mi.lpBaseOfDll;
|
||||
SIZE_T size = mi.SizeOfImage;
|
||||
const SIZE_T PAGE = 0x1000;
|
||||
|
||||
for (SIZE_T off = 0; off + 1 < size; off += PAGE)
|
||||
{
|
||||
// +1 so a match straddling a page boundary (gadget's 2nd byte in the next page) isn't missed.
|
||||
SIZE_T chunk = (off + PAGE + 1 <= size) ? (PAGE + 1) : (size - off);
|
||||
|
||||
__try
|
||||
{
|
||||
for (SIZE_T i = 0; i + 1 < chunk; i++)
|
||||
{
|
||||
if (base[off + i] == 0xFF && base[off + i + 1] == 0x23) // jmp qword ptr [rbx]
|
||||
{
|
||||
g_gadget = base + off + i;
|
||||
Log("[SPOOF] gadget (jmp [rbx]) found at %p (rva=0x%zX)", g_gadget, off + i);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER)
|
||||
{
|
||||
continue; // unreadable page (gap between sections) -- skip it
|
||||
}
|
||||
}
|
||||
|
||||
Log("[SPOOF] no jmp[rbx] gadget found in module -- return-address spoofing unavailable");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int RetSpoofReady(void)
|
||||
{
|
||||
return g_gadget != NULL;
|
||||
}
|
||||
|
||||
DWORD WINAPI PatchRetSpoof(LPVOID param)
|
||||
{
|
||||
(void)param;
|
||||
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
WaitForCodeDecrypt((BYTE *)ga + DECRYPT_PROBE_RVA);
|
||||
|
||||
InitRetSpoof(ga);
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint64_t SpoofCall4(void *target, uint64_t a1, uint64_t a2, uint64_t a3, uint64_t a4)
|
||||
{
|
||||
void *gadget = enable_spoof ? g_gadget : NULL;
|
||||
if (!gadget)
|
||||
{
|
||||
// No gadget resolved (yet, or scan failed) -- fall back to a direct call. Functionally
|
||||
// identical; just doesn't hide redirector.dll from a stack walk during the call.
|
||||
typedef uint64_t (*fn4_t)(uint64_t, uint64_t, uint64_t, uint64_t);
|
||||
return ((fn4_t)target)(a1, a2, a3, a4);
|
||||
}
|
||||
return spoof_call(gadget, target, a1, a2, a3, a4);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#include "common.h"
|
||||
|
||||
//
|
||||
// DXGIDisplays.dll proxy -- loader vector for the recflare-client-unstable build.
|
||||
//
|
||||
// This build hardens against app-dir DLL planting: RecRoom.exe.dll pre-loads the real System32 crypto
|
||||
// DLLs (wintrust/bcrypt/crypt32) by full path, so none of those static-import proxies ever win (all
|
||||
// three were observed loading from C:\Windows\System32). What the loader CANNOT redirect to System32 is
|
||||
// a Rec Room first-party Unity plugin: UnityPlayer LoadLibrary's DXGIDisplays.dll from
|
||||
// RecRoom_Data\Plugins\x86_64 by that path, so replacing the file there is a legitimate in-process load
|
||||
// of our code -- no injection, no foreign thread. DXGIDisplays is enumerated during display/screen-mode
|
||||
// setup at engine startup (early and unconditional, well before the RecNet connection at ~5 s), which
|
||||
// is exactly when we need to be resident. (RRTexture.dll loads too late -- only on the first texture-
|
||||
// compression call, gated behind server content the client never fetches.)
|
||||
//
|
||||
// Its 15 exports are forwarded to DXGIDisplays_orig.dll (a copy of the genuine plugin, deployed
|
||||
// alongside us in the same Plugins dir; different basename, so no loop). DllMain (dllmain.c) starts the
|
||||
// hook thread -- the full redirector runs unchanged from here.
|
||||
//
|
||||
// Deploy: rename the real Plugins\x86_64\DXGIDisplays.dll -> DXGIDisplays_orig.dll, drop this in place.
|
||||
//
|
||||
// The exports all take <=4 args and are pure passthrough (the game calls them for real display data),
|
||||
// so a generic 4-pointer thunk forwards them faithfully: on x64 the first four args are RCX/RDX/R8/R9
|
||||
// and the return is RAX. We never inspect the args.
|
||||
//
|
||||
|
||||
static volatile HMODULE g_real;
|
||||
|
||||
// Load (once) the genuine plugin, renamed DXGIDisplays_orig.dll, from THIS module's own directory
|
||||
// (Plugins\x86_64) by full path so the plugin search can't loop back into us.
|
||||
static HMODULE RealDxgi(void)
|
||||
{
|
||||
HMODULE h = g_real;
|
||||
if (h) return h;
|
||||
|
||||
wchar_t path[MAX_PATH];
|
||||
HMODULE self = NULL;
|
||||
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
(LPCWSTR)&RealDxgi, &self);
|
||||
DWORD n = GetModuleFileNameW(self, path, MAX_PATH);
|
||||
if (n == 0 || n >= MAX_PATH) return NULL;
|
||||
for (DWORD i = n; i > 0; i--) { if (path[i-1] == L'\\') { path[i] = 0; break; } }
|
||||
lstrcatW(path, L"DXGIDisplays_orig.dll");
|
||||
|
||||
HMODULE loaded = LoadLibraryW(path);
|
||||
HMODULE prev = (HMODULE)InterlockedCompareExchangePointer((volatile PVOID *)&g_real, loaded, NULL);
|
||||
if (prev) { if (loaded) FreeLibrary(loaded); return prev; }
|
||||
return loaded;
|
||||
}
|
||||
|
||||
typedef void *(*gen_t)(void *, void *, void *, void *);
|
||||
static gen_t P(const char *name) { HMODULE h = RealDxgi(); return h ? (gen_t)GetProcAddress(h, name) : NULL; }
|
||||
|
||||
#define FWD(NAME) \
|
||||
void *my_##NAME(void *a, void *b, void *c, void *d) { \
|
||||
static gen_t fn; if (!fn) fn = P(#NAME); return fn ? fn(a,b,c,d) : NULL; }
|
||||
|
||||
FWD(Finalize) FWD(GetDisplayBottom) FWD(GetDisplayCount) FWD(GetDisplayDpiX) FWD(GetDisplayDpiY)
|
||||
FWD(GetDisplayHeight) FWD(GetDisplayLeft) FWD(GetDisplayRight) FWD(GetDisplayRotation)
|
||||
FWD(GetDisplayTop) FWD(GetDisplayWidth) FWD(Initialize) FWD(IsDisplayPrimary) FWD(IsInitialized)
|
||||
FWD(LinkUnityDebugCallback)
|
||||
|
||||
#pragma comment(linker, "/export:Finalize=my_Finalize")
|
||||
#pragma comment(linker, "/export:GetDisplayBottom=my_GetDisplayBottom")
|
||||
#pragma comment(linker, "/export:GetDisplayCount=my_GetDisplayCount")
|
||||
#pragma comment(linker, "/export:GetDisplayDpiX=my_GetDisplayDpiX")
|
||||
#pragma comment(linker, "/export:GetDisplayDpiY=my_GetDisplayDpiY")
|
||||
#pragma comment(linker, "/export:GetDisplayHeight=my_GetDisplayHeight")
|
||||
#pragma comment(linker, "/export:GetDisplayLeft=my_GetDisplayLeft")
|
||||
#pragma comment(linker, "/export:GetDisplayRight=my_GetDisplayRight")
|
||||
#pragma comment(linker, "/export:GetDisplayRotation=my_GetDisplayRotation")
|
||||
#pragma comment(linker, "/export:GetDisplayTop=my_GetDisplayTop")
|
||||
#pragma comment(linker, "/export:GetDisplayWidth=my_GetDisplayWidth")
|
||||
#pragma comment(linker, "/export:Initialize=my_Initialize")
|
||||
#pragma comment(linker, "/export:IsDisplayPrimary=my_IsDisplayPrimary")
|
||||
#pragma comment(linker, "/export:IsInitialized=my_IsInitialized")
|
||||
#pragma comment(linker, "/export:LinkUnityDebugCallback=my_LinkUnityDebugCallback")
|
||||
@@ -0,0 +1,56 @@
|
||||
#include "common.h"
|
||||
|
||||
//
|
||||
// UnityPlayer.dll proxy -- loader vector for the recflare-client-unstable build.
|
||||
//
|
||||
// This build defeats every app-dir *System32* plant (RecRoom.exe.dll pre-loads the real
|
||||
// wintrust/bcrypt/crypt32 by full path) AND never loads the DXGIDisplays plugin in this launch mode, so
|
||||
// those vectors are dead. But UnityPlayer.dll is app-local (game root, no System32 equivalent) and
|
||||
// RecRoom.exe.dll MUST LoadLibrary it to call UnityMain -- the earliest engine entry, before
|
||||
// GameAssembly.dll. It exports exactly ONE function, UnityMain, so the proxy is a single forwarder.
|
||||
// There is no anti-cheat in this process (no Referee/EAC ever load), so nothing inspects us.
|
||||
//
|
||||
// UnityMain is forwarded to UnityPlayer_orig.dll (a copy of the genuine 29 MB engine, deployed
|
||||
// alongside us in the game root; different basename, no loop). DllMain (dllmain.c) starts the hook
|
||||
// thread, then RecRoom.exe.dll's GetProcAddress(UnityMain)+call flows through us into the real engine.
|
||||
//
|
||||
// Deploy: rename the real UnityPlayer.dll -> UnityPlayer_orig.dll, drop this in its place.
|
||||
//
|
||||
// UnityMain has the WinMain-style signature int(HINSTANCE,HINSTANCE,LPSTR,int) -- four args, so the
|
||||
// generic 4-pointer thunk forwards it faithfully (x64: RCX/RDX/R8/R9, return in RAX; the caller reads
|
||||
// the low 32 bits as int). It blocks for the whole game lifetime, exactly as the caller expects.
|
||||
//
|
||||
|
||||
static volatile HMODULE g_real;
|
||||
|
||||
static HMODULE RealUnity(void)
|
||||
{
|
||||
HMODULE h = g_real;
|
||||
if (h) return h;
|
||||
|
||||
wchar_t path[MAX_PATH];
|
||||
HMODULE self = NULL;
|
||||
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
(LPCWSTR)&RealUnity, &self);
|
||||
DWORD n = GetModuleFileNameW(self, path, MAX_PATH);
|
||||
if (n == 0 || n >= MAX_PATH) return NULL;
|
||||
for (DWORD i = n; i > 0; i--) { if (path[i-1] == L'\\') { path[i] = 0; break; } }
|
||||
lstrcatW(path, L"UnityPlayer_orig.dll");
|
||||
|
||||
HMODULE loaded = LoadLibraryW(path);
|
||||
HMODULE prev = (HMODULE)InterlockedCompareExchangePointer((volatile PVOID *)&g_real, loaded, NULL);
|
||||
if (prev) { if (loaded) FreeLibrary(loaded); return prev; }
|
||||
return loaded;
|
||||
}
|
||||
|
||||
typedef void *(*gen_t)(void *, void *, void *, void *);
|
||||
|
||||
void *my_UnityMain(void *a, void *b, void *c, void *d)
|
||||
{
|
||||
static gen_t fn;
|
||||
if (!fn) { HMODULE h = RealUnity(); fn = h ? (gen_t)GetProcAddress(h, "UnityMain") : NULL; }
|
||||
return fn ? fn(a, b, c, d) : NULL;
|
||||
}
|
||||
|
||||
#pragma comment(linker, "/export:UnityMain=my_UnityMain")
|
||||
@@ -0,0 +1,186 @@
|
||||
#include "common.h"
|
||||
|
||||
//
|
||||
// wintrust.dll proxy (PE forwarders) -- loader vector for the recflare-client-unstable build.
|
||||
//
|
||||
// This build's RecRoom.exe is only a stub importing RecRoom.exe.dll (the 56 MB bootstrap). That
|
||||
// bootstrap -- which loads before UnityPlayer -- statically imports exactly ONE function each from
|
||||
// wintrust.dll (WinVerifyTrust), bcrypt.dll and crypt32.dll, and imports NOTHING from version.dll or
|
||||
// winhttp.dll. So the old version.dll vector cannot load here. wintrust is the cleanest of the three:
|
||||
// it is not a KnownDLL and (unlike bcrypt/crypt32, which are pulled in during process init) is unlikely
|
||||
// to be resolved from System32 before our app-dir copy is consulted. The loader searches the app dir
|
||||
// first, so our wintrust.dll loads very early -- our DllMain (dllmain.c) starts the hook thread.
|
||||
//
|
||||
// Every one of wintrust's 165 exports is forwarded to wtrust_orig.dll (a copy of the genuine system
|
||||
// wintrust.dll, deployed alongside us). Forwarders (target has a dot) mean the loader satisfies each
|
||||
// call from the real DLL directly -- correct for all 165 regardless of signature, and the game only
|
||||
// ever actually calls WinVerifyTrust. wtrust_orig.dll has a different basename, so there is no loop.
|
||||
//
|
||||
// Generated from System32\wintrust.dll (build-agnostic: these export names are stable Win32 API).
|
||||
//
|
||||
|
||||
#pragma comment(linker, "/export:AddPersonalTrustDBPages=wtrust_orig.AddPersonalTrustDBPages")
|
||||
#pragma comment(linker, "/export:CatalogCompactHashDatabase=wtrust_orig.CatalogCompactHashDatabase")
|
||||
#pragma comment(linker, "/export:ComputeFirstPageHash=wtrust_orig.ComputeFirstPageHash")
|
||||
#pragma comment(linker, "/export:ConfigCiFinalPolicy=wtrust_orig.ConfigCiFinalPolicy")
|
||||
#pragma comment(linker, "/export:ConfigCiPackageFamilyNameCheck=wtrust_orig.ConfigCiPackageFamilyNameCheck")
|
||||
#pragma comment(linker, "/export:CryptCATAdminAcquireContext=wtrust_orig.CryptCATAdminAcquireContext")
|
||||
#pragma comment(linker, "/export:CryptCATAdminAcquireContext2=wtrust_orig.CryptCATAdminAcquireContext2")
|
||||
#pragma comment(linker, "/export:CryptCATAdminAddCatalog=wtrust_orig.CryptCATAdminAddCatalog")
|
||||
#pragma comment(linker, "/export:CryptCATAdminCalcHashFromFileHandle=wtrust_orig.CryptCATAdminCalcHashFromFileHandle")
|
||||
#pragma comment(linker, "/export:CryptCATAdminCalcHashFromFileHandle2=wtrust_orig.CryptCATAdminCalcHashFromFileHandle2")
|
||||
#pragma comment(linker, "/export:CryptCATAdminCalcHashFromFileHandle3=wtrust_orig.CryptCATAdminCalcHashFromFileHandle3")
|
||||
#pragma comment(linker, "/export:CryptCATAdminEnumCatalogFromHash=wtrust_orig.CryptCATAdminEnumCatalogFromHash")
|
||||
#pragma comment(linker, "/export:CryptCATAdminPauseServiceForBackup=wtrust_orig.CryptCATAdminPauseServiceForBackup")
|
||||
#pragma comment(linker, "/export:CryptCATAdminReleaseCatalogContext=wtrust_orig.CryptCATAdminReleaseCatalogContext")
|
||||
#pragma comment(linker, "/export:CryptCATAdminReleaseContext=wtrust_orig.CryptCATAdminReleaseContext")
|
||||
#pragma comment(linker, "/export:CryptCATAdminRemoveCatalog=wtrust_orig.CryptCATAdminRemoveCatalog")
|
||||
#pragma comment(linker, "/export:CryptCATAdminResolveCatalogPath=wtrust_orig.CryptCATAdminResolveCatalogPath")
|
||||
#pragma comment(linker, "/export:CryptCATAllocSortedMemberInfo=wtrust_orig.CryptCATAllocSortedMemberInfo")
|
||||
#pragma comment(linker, "/export:CryptCATCDFClose=wtrust_orig.CryptCATCDFClose")
|
||||
#pragma comment(linker, "/export:CryptCATCDFEnumAttributes=wtrust_orig.CryptCATCDFEnumAttributes")
|
||||
#pragma comment(linker, "/export:CryptCATCDFEnumAttributesWithCDFTag=wtrust_orig.CryptCATCDFEnumAttributesWithCDFTag")
|
||||
#pragma comment(linker, "/export:CryptCATCDFEnumCatAttributes=wtrust_orig.CryptCATCDFEnumCatAttributes")
|
||||
#pragma comment(linker, "/export:CryptCATCDFEnumMembers=wtrust_orig.CryptCATCDFEnumMembers")
|
||||
#pragma comment(linker, "/export:CryptCATCDFEnumMembersByCDFTag=wtrust_orig.CryptCATCDFEnumMembersByCDFTag")
|
||||
#pragma comment(linker, "/export:CryptCATCDFEnumMembersByCDFTagEx=wtrust_orig.CryptCATCDFEnumMembersByCDFTagEx")
|
||||
#pragma comment(linker, "/export:CryptCATCDFOpen=wtrust_orig.CryptCATCDFOpen")
|
||||
#pragma comment(linker, "/export:CryptCATCatalogInfoFromContext=wtrust_orig.CryptCATCatalogInfoFromContext")
|
||||
#pragma comment(linker, "/export:CryptCATClose=wtrust_orig.CryptCATClose")
|
||||
#pragma comment(linker, "/export:CryptCATEnumerateAttr=wtrust_orig.CryptCATEnumerateAttr")
|
||||
#pragma comment(linker, "/export:CryptCATEnumerateCatAttr=wtrust_orig.CryptCATEnumerateCatAttr")
|
||||
#pragma comment(linker, "/export:CryptCATEnumerateMember=wtrust_orig.CryptCATEnumerateMember")
|
||||
#pragma comment(linker, "/export:CryptCATFreeSortedMemberInfo=wtrust_orig.CryptCATFreeSortedMemberInfo")
|
||||
#pragma comment(linker, "/export:CryptCATGetAttrInfo=wtrust_orig.CryptCATGetAttrInfo")
|
||||
#pragma comment(linker, "/export:CryptCATGetCatAttrInfo=wtrust_orig.CryptCATGetCatAttrInfo")
|
||||
#pragma comment(linker, "/export:CryptCATGetMemberInfo=wtrust_orig.CryptCATGetMemberInfo")
|
||||
#pragma comment(linker, "/export:CryptCATHandleFromStore=wtrust_orig.CryptCATHandleFromStore")
|
||||
#pragma comment(linker, "/export:CryptCATOpen=wtrust_orig.CryptCATOpen")
|
||||
#pragma comment(linker, "/export:CryptCATPersistStore=wtrust_orig.CryptCATPersistStore")
|
||||
#pragma comment(linker, "/export:CryptCATPutAttrInfo=wtrust_orig.CryptCATPutAttrInfo")
|
||||
#pragma comment(linker, "/export:CryptCATPutCatAttrInfo=wtrust_orig.CryptCATPutCatAttrInfo")
|
||||
#pragma comment(linker, "/export:CryptCATPutMemberInfo=wtrust_orig.CryptCATPutMemberInfo")
|
||||
#pragma comment(linker, "/export:CryptCATStoreFromHandle=wtrust_orig.CryptCATStoreFromHandle")
|
||||
#pragma comment(linker, "/export:CryptCATVerifyMember=wtrust_orig.CryptCATVerifyMember")
|
||||
#pragma comment(linker, "/export:CryptSIPCreateIndirectData=wtrust_orig.CryptSIPCreateIndirectData")
|
||||
#pragma comment(linker, "/export:CryptSIPGetCaps=wtrust_orig.CryptSIPGetCaps")
|
||||
#pragma comment(linker, "/export:CryptSIPGetInfo=wtrust_orig.CryptSIPGetInfo")
|
||||
#pragma comment(linker, "/export:CryptSIPGetRegWorkingFlags=wtrust_orig.CryptSIPGetRegWorkingFlags")
|
||||
#pragma comment(linker, "/export:CryptSIPGetSealedDigest=wtrust_orig.CryptSIPGetSealedDigest")
|
||||
#pragma comment(linker, "/export:CryptSIPGetSignedDataMsg=wtrust_orig.CryptSIPGetSignedDataMsg")
|
||||
#pragma comment(linker, "/export:CryptSIPPutSignedDataMsg=wtrust_orig.CryptSIPPutSignedDataMsg")
|
||||
#pragma comment(linker, "/export:CryptSIPRemoveSignedDataMsg=wtrust_orig.CryptSIPRemoveSignedDataMsg")
|
||||
#pragma comment(linker, "/export:CryptSIPVerifyIndirectData=wtrust_orig.CryptSIPVerifyIndirectData")
|
||||
#pragma comment(linker, "/export:DllRegisterServer=wtrust_orig.DllRegisterServer")
|
||||
#pragma comment(linker, "/export:DllUnregisterServer=wtrust_orig.DllUnregisterServer")
|
||||
#pragma comment(linker, "/export:DriverCleanupPolicy=wtrust_orig.DriverCleanupPolicy")
|
||||
#pragma comment(linker, "/export:DriverFinalPolicy=wtrust_orig.DriverFinalPolicy")
|
||||
#pragma comment(linker, "/export:DriverInitializePolicy=wtrust_orig.DriverInitializePolicy")
|
||||
#pragma comment(linker, "/export:FindCertsByIssuer=wtrust_orig.FindCertsByIssuer")
|
||||
#pragma comment(linker, "/export:GenericChainCertificateTrust=wtrust_orig.GenericChainCertificateTrust")
|
||||
#pragma comment(linker, "/export:GenericChainFinalProv=wtrust_orig.GenericChainFinalProv")
|
||||
#pragma comment(linker, "/export:GetAuthenticodeSha256Hash=wtrust_orig.GetAuthenticodeSha256Hash")
|
||||
#pragma comment(linker, "/export:HTTPSCertificateTrust=wtrust_orig.HTTPSCertificateTrust")
|
||||
#pragma comment(linker, "/export:HTTPSFinalProv=wtrust_orig.HTTPSFinalProv")
|
||||
#pragma comment(linker, "/export:IsCatalogFile=wtrust_orig.IsCatalogFile")
|
||||
#pragma comment(linker, "/export:MsCatConstructHashTag=wtrust_orig.MsCatConstructHashTag")
|
||||
#pragma comment(linker, "/export:MsCatFreeHashTag=wtrust_orig.MsCatFreeHashTag")
|
||||
#pragma comment(linker, "/export:OfficeCleanupPolicy=wtrust_orig.OfficeCleanupPolicy")
|
||||
#pragma comment(linker, "/export:OfficeInitializePolicy=wtrust_orig.OfficeInitializePolicy")
|
||||
#pragma comment(linker, "/export:OpenPersonalTrustDBDialog=wtrust_orig.OpenPersonalTrustDBDialog")
|
||||
#pragma comment(linker, "/export:OpenPersonalTrustDBDialogEx=wtrust_orig.OpenPersonalTrustDBDialogEx")
|
||||
#pragma comment(linker, "/export:SetMessageDigestInfo=wtrust_orig.SetMessageDigestInfo")
|
||||
#pragma comment(linker, "/export:SoftpubAuthenticode=wtrust_orig.SoftpubAuthenticode")
|
||||
#pragma comment(linker, "/export:SoftpubCheckCert=wtrust_orig.SoftpubCheckCert")
|
||||
#pragma comment(linker, "/export:SoftpubCleanup=wtrust_orig.SoftpubCleanup")
|
||||
#pragma comment(linker, "/export:SoftpubDefCertInit=wtrust_orig.SoftpubDefCertInit")
|
||||
#pragma comment(linker, "/export:SoftpubDllRegisterServer=wtrust_orig.SoftpubDllRegisterServer")
|
||||
#pragma comment(linker, "/export:SoftpubDllUnregisterServer=wtrust_orig.SoftpubDllUnregisterServer")
|
||||
#pragma comment(linker, "/export:SoftpubDumpStructure=wtrust_orig.SoftpubDumpStructure")
|
||||
#pragma comment(linker, "/export:SoftpubFreeDefUsageCallData=wtrust_orig.SoftpubFreeDefUsageCallData")
|
||||
#pragma comment(linker, "/export:SoftpubInitialize=wtrust_orig.SoftpubInitialize")
|
||||
#pragma comment(linker, "/export:SoftpubLoadDefUsageCallData=wtrust_orig.SoftpubLoadDefUsageCallData")
|
||||
#pragma comment(linker, "/export:SoftpubLoadMessage=wtrust_orig.SoftpubLoadMessage")
|
||||
#pragma comment(linker, "/export:SoftpubLoadSignature=wtrust_orig.SoftpubLoadSignature")
|
||||
#pragma comment(linker, "/export:SrpCheckSmartlockerEAandProcessToken=wtrust_orig.SrpCheckSmartlockerEAandProcessToken")
|
||||
#pragma comment(linker, "/export:TrustDecode=wtrust_orig.TrustDecode")
|
||||
#pragma comment(linker, "/export:TrustFindIssuerCertificate=wtrust_orig.TrustFindIssuerCertificate")
|
||||
#pragma comment(linker, "/export:TrustFreeDecode=wtrust_orig.TrustFreeDecode")
|
||||
#pragma comment(linker, "/export:TrustIsCertificateSelfSigned=wtrust_orig.TrustIsCertificateSelfSigned")
|
||||
#pragma comment(linker, "/export:TrustOpenStores=wtrust_orig.TrustOpenStores")
|
||||
#pragma comment(linker, "/export:WTConfigCiFreePrivateData=wtrust_orig.WTConfigCiFreePrivateData")
|
||||
#pragma comment(linker, "/export:WTConvertCertCtxToChainInfo=wtrust_orig.WTConvertCertCtxToChainInfo")
|
||||
#pragma comment(linker, "/export:WTGetBioSignatureInfo=wtrust_orig.WTGetBioSignatureInfo")
|
||||
#pragma comment(linker, "/export:WTGetPluginSignatureInfo=wtrust_orig.WTGetPluginSignatureInfo")
|
||||
#pragma comment(linker, "/export:WTGetSignatureInfo=wtrust_orig.WTGetSignatureInfo")
|
||||
#pragma comment(linker, "/export:WTHelperCertCheckValidSignature=wtrust_orig.WTHelperCertCheckValidSignature")
|
||||
#pragma comment(linker, "/export:WTHelperCertFindIssuerCertificate=wtrust_orig.WTHelperCertFindIssuerCertificate")
|
||||
#pragma comment(linker, "/export:WTHelperCertIsSelfSigned=wtrust_orig.WTHelperCertIsSelfSigned")
|
||||
#pragma comment(linker, "/export:WTHelperCheckCertUsage=wtrust_orig.WTHelperCheckCertUsage")
|
||||
#pragma comment(linker, "/export:WTHelperGetAgencyInfo=wtrust_orig.WTHelperGetAgencyInfo")
|
||||
#pragma comment(linker, "/export:WTHelperGetFileHandle=wtrust_orig.WTHelperGetFileHandle")
|
||||
#pragma comment(linker, "/export:WTHelperGetFileHash=wtrust_orig.WTHelperGetFileHash")
|
||||
#pragma comment(linker, "/export:WTHelperGetFileName=wtrust_orig.WTHelperGetFileName")
|
||||
#pragma comment(linker, "/export:WTHelperGetKnownUsages=wtrust_orig.WTHelperGetKnownUsages")
|
||||
#pragma comment(linker, "/export:WTHelperGetProvCertFromChain=wtrust_orig.WTHelperGetProvCertFromChain")
|
||||
#pragma comment(linker, "/export:WTHelperGetProvPrivateDataFromChain=wtrust_orig.WTHelperGetProvPrivateDataFromChain")
|
||||
#pragma comment(linker, "/export:WTHelperGetProvSignerFromChain=wtrust_orig.WTHelperGetProvSignerFromChain")
|
||||
#pragma comment(linker, "/export:WTHelperIsChainedToMicrosoft=wtrust_orig.WTHelperIsChainedToMicrosoft")
|
||||
#pragma comment(linker, "/export:WTHelperIsChainedToMicrosoftFromStateData=wtrust_orig.WTHelperIsChainedToMicrosoftFromStateData")
|
||||
#pragma comment(linker, "/export:WTHelperIsInRootStore=wtrust_orig.WTHelperIsInRootStore")
|
||||
#pragma comment(linker, "/export:WTHelperOpenKnownStores=wtrust_orig.WTHelperOpenKnownStores")
|
||||
#pragma comment(linker, "/export:WTHelperProvDataFromStateData=wtrust_orig.WTHelperProvDataFromStateData")
|
||||
#pragma comment(linker, "/export:WTIsFirstConfigCiResultPreferred=wtrust_orig.WTIsFirstConfigCiResultPreferred")
|
||||
#pragma comment(linker, "/export:WTLogConfigCiScriptEvent=wtrust_orig.WTLogConfigCiScriptEvent")
|
||||
#pragma comment(linker, "/export:WTLogConfigCiScriptEvent2=wtrust_orig.WTLogConfigCiScriptEvent2")
|
||||
#pragma comment(linker, "/export:WTLogConfigCiSignerEvent=wtrust_orig.WTLogConfigCiSignerEvent")
|
||||
#pragma comment(linker, "/export:WTLogSmartAppControlDefenderInfo=wtrust_orig.WTLogSmartAppControlDefenderInfo")
|
||||
#pragma comment(linker, "/export:WTValidateBioSignaturePolicy=wtrust_orig.WTValidateBioSignaturePolicy")
|
||||
#pragma comment(linker, "/export:WVTAsn1CatMemberInfo2Decode=wtrust_orig.WVTAsn1CatMemberInfo2Decode")
|
||||
#pragma comment(linker, "/export:WVTAsn1CatMemberInfo2Encode=wtrust_orig.WVTAsn1CatMemberInfo2Encode")
|
||||
#pragma comment(linker, "/export:WVTAsn1CatMemberInfoDecode=wtrust_orig.WVTAsn1CatMemberInfoDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1CatMemberInfoEncode=wtrust_orig.WVTAsn1CatMemberInfoEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1CatNameValueDecode=wtrust_orig.WVTAsn1CatNameValueDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1CatNameValueEncode=wtrust_orig.WVTAsn1CatNameValueEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1IntentToSealAttributeDecode=wtrust_orig.WVTAsn1IntentToSealAttributeDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1IntentToSealAttributeEncode=wtrust_orig.WVTAsn1IntentToSealAttributeEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SealingSignatureAttributeDecode=wtrust_orig.WVTAsn1SealingSignatureAttributeDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SealingSignatureAttributeEncode=wtrust_orig.WVTAsn1SealingSignatureAttributeEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SealingTimestampAttributeDecode=wtrust_orig.WVTAsn1SealingTimestampAttributeDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SealingTimestampAttributeEncode=wtrust_orig.WVTAsn1SealingTimestampAttributeEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcFinancialCriteriaInfoDecode=wtrust_orig.WVTAsn1SpcFinancialCriteriaInfoDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcFinancialCriteriaInfoEncode=wtrust_orig.WVTAsn1SpcFinancialCriteriaInfoEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcIndirectDataContentDecode=wtrust_orig.WVTAsn1SpcIndirectDataContentDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcIndirectDataContentEncode=wtrust_orig.WVTAsn1SpcIndirectDataContentEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcLinkDecode=wtrust_orig.WVTAsn1SpcLinkDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcLinkEncode=wtrust_orig.WVTAsn1SpcLinkEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcMinimalCriteriaInfoDecode=wtrust_orig.WVTAsn1SpcMinimalCriteriaInfoDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcMinimalCriteriaInfoEncode=wtrust_orig.WVTAsn1SpcMinimalCriteriaInfoEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcPeImageDataDecode=wtrust_orig.WVTAsn1SpcPeImageDataDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcPeImageDataEncode=wtrust_orig.WVTAsn1SpcPeImageDataEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcSigInfoDecode=wtrust_orig.WVTAsn1SpcSigInfoDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcSigInfoEncode=wtrust_orig.WVTAsn1SpcSigInfoEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcSpAgencyInfoDecode=wtrust_orig.WVTAsn1SpcSpAgencyInfoDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcSpAgencyInfoEncode=wtrust_orig.WVTAsn1SpcSpAgencyInfoEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcSpOpusInfoDecode=wtrust_orig.WVTAsn1SpcSpOpusInfoDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcSpOpusInfoEncode=wtrust_orig.WVTAsn1SpcSpOpusInfoEncode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcStatementTypeDecode=wtrust_orig.WVTAsn1SpcStatementTypeDecode")
|
||||
#pragma comment(linker, "/export:WVTAsn1SpcStatementTypeEncode=wtrust_orig.WVTAsn1SpcStatementTypeEncode")
|
||||
#pragma comment(linker, "/export:WinVerifyTrust=wtrust_orig.WinVerifyTrust")
|
||||
#pragma comment(linker, "/export:WinVerifyTrustEx=wtrust_orig.WinVerifyTrustEx")
|
||||
#pragma comment(linker, "/export:WintrustAddActionID=wtrust_orig.WintrustAddActionID")
|
||||
#pragma comment(linker, "/export:WintrustAddDefaultForUsage=wtrust_orig.WintrustAddDefaultForUsage")
|
||||
#pragma comment(linker, "/export:WintrustAddProviderToProcess=wtrust_orig.WintrustAddProviderToProcess")
|
||||
#pragma comment(linker, "/export:WintrustCertificateTrust=wtrust_orig.WintrustCertificateTrust")
|
||||
#pragma comment(linker, "/export:WintrustGetDefaultForUsage=wtrust_orig.WintrustGetDefaultForUsage")
|
||||
#pragma comment(linker, "/export:WintrustGetHash=wtrust_orig.WintrustGetHash")
|
||||
#pragma comment(linker, "/export:WintrustGetRegPolicyFlags=wtrust_orig.WintrustGetRegPolicyFlags")
|
||||
#pragma comment(linker, "/export:WintrustLoadFunctionPointers=wtrust_orig.WintrustLoadFunctionPointers")
|
||||
#pragma comment(linker, "/export:WintrustRemoveActionID=wtrust_orig.WintrustRemoveActionID")
|
||||
#pragma comment(linker, "/export:WintrustSetDefaultIncludePEPageHashes=wtrust_orig.WintrustSetDefaultIncludePEPageHashes")
|
||||
#pragma comment(linker, "/export:WintrustSetRegPolicyFlags=wtrust_orig.WintrustSetRegPolicyFlags")
|
||||
#pragma comment(linker, "/export:WintrustUserWriteabilityCheck=wtrust_orig.WintrustUserWriteabilityCheck")
|
||||
#pragma comment(linker, "/export:mscat32DllRegisterServer=wtrust_orig.mscat32DllRegisterServer")
|
||||
#pragma comment(linker, "/export:mscat32DllUnregisterServer=wtrust_orig.mscat32DllUnregisterServer")
|
||||
#pragma comment(linker, "/export:mssip32DllRegisterServer=wtrust_orig.mssip32DllRegisterServer")
|
||||
#pragma comment(linker, "/export:mssip32DllUnregisterServer=wtrust_orig.mssip32DllUnregisterServer")
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "common.h"
|
||||
#include "antitamper_patch.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
|
||||
//
|
||||
// Anti-tamper report funnel suppression (recflare-client-unstable, build 2025-04-29).
|
||||
//
|
||||
// The client funnels EVERY tamper detection through one static method:
|
||||
// DKABIBJEBOC BBFGNPPNKOG(GDMJABNKMGN kind, string detail, int? code, bool flag) [RVA 0x774F610]
|
||||
// (the equivalent of the managed patcher's AAKMENFOFEI.GIOHEBODOAC anti-tamper funnel). It creates a
|
||||
// "Hile" warning -> POST api/PlayerReporting/v1/hile -> and warnings flagged shouldAlwaysQuit force the
|
||||
// app to quit. On this setup two things trip it: our own inline hooks (Inject/UnknownDll/
|
||||
// Memory_Hash_Mismatch) and, most visibly, ImageSignature (kind=6) -- recflare stamps CDN image URLs
|
||||
// with a placeholder `sig=p1` the client's signature check rejects. Result: ~30s in, the client reports
|
||||
// the ImageSignature failure and locks up / exits.
|
||||
//
|
||||
// The violation taxonomy (enum names survive obfuscation): Obscured=0 Time=1 Inject=2 GiftCount=3
|
||||
// Engine=4 UnknownDll=5 ImageSignature=6 AvatarHack=7 NetworkCertificate*=100.. Memory_Hash_Mismatch=500
|
||||
// Native_Memory_Hash_Mismatch=700.
|
||||
//
|
||||
// We detour the funnel replace-only and return null (a report is fire-and-forget; the callers don't
|
||||
// await the result), so no warning is created, nothing is POSTed, and nothing quits. Static il2cpp
|
||||
// method ABI: args in RCX/RDX/R8/R9 (kind, detail, code, flag), MethodInfo* on the stack; caller cleans
|
||||
// up, so a null-returning replacement is safe.
|
||||
//
|
||||
|
||||
#define ANTITAMPER_FUNNEL_RVA 0x774F610
|
||||
|
||||
typedef void* (*funnel_fn_t)(void *kind, void *detail, void *code, void *flag);
|
||||
static BYTE backup_funnel[32];
|
||||
|
||||
// Log the first few suppressed reports so a launch reveals which detections fired. kind arrives as the
|
||||
// enum's integer value in RCX; detail is an il2cpp string in RDX (length@0x10, chars@0x14).
|
||||
static void LogSuppressed(void *kind, void *detail)
|
||||
{
|
||||
// Log each DISTINCT kind once (so memory-hash detections kind=500/700 surface even amid a flood of
|
||||
// ImageSignature=6 reports), plus the first dozen overall.
|
||||
static volatile LONG n = 0;
|
||||
static LONG seenKinds[64]; static volatile LONG seenCount = 0;
|
||||
unsigned long long kv = (unsigned long long)(uintptr_t)kind;
|
||||
int known = 0;
|
||||
for (LONG s = 0; s < seenCount && s < 64; s++) if (seenKinds[s] == (LONG)kv) { known = 1; break; }
|
||||
LONG i = InterlockedIncrement(&n);
|
||||
if (known && i > 12) return;
|
||||
if (!known) { LONG idx = InterlockedIncrement(&seenCount) - 1; if (idx < 64) seenKinds[idx] = (LONG)kv; }
|
||||
char msg[256] = "";
|
||||
__try {
|
||||
if (detail) {
|
||||
int len = *(int *)((BYTE *)detail + 0x10);
|
||||
if (len > 0 && len < (int)sizeof(msg)) {
|
||||
uint16_t *w = (uint16_t *)((BYTE *)detail + 0x14);
|
||||
for (int j = 0; j < len; j++) msg[j] = (w[j] < 0x80) ? (char)w[j] : '?';
|
||||
msg[len] = 0;
|
||||
}
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { msg[0] = 0; }
|
||||
Log("[ANTITAMPER] suppressed report kind=%llu detail=\"%s\"",
|
||||
(unsigned long long)(uintptr_t)kind, msg);
|
||||
}
|
||||
|
||||
static void* FunnelHook(void *kind, void *detail, void *code, void *flag)
|
||||
{
|
||||
(void)code; (void)flag;
|
||||
LogSuppressed(kind, detail);
|
||||
return NULL; // no warning, no /hile POST, no quit
|
||||
}
|
||||
|
||||
void PatchAntiTamper(void)
|
||||
{
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
BYTE *code = (BYTE *)ga + ANTITAMPER_FUNNEL_RVA;
|
||||
for (int i = 0; i < 600; i++) { if (code[0] != 0x00 && code[0] != 0xCC) break; Sleep(100); }
|
||||
Log("[ANTITAMPER] funnel code=%p prologue=%02X %02X %02X %02X",
|
||||
code, code[0], code[1], code[2], code[3]);
|
||||
|
||||
// Replace-only: we never call the original, so a blind 14-byte overwrite is safe.
|
||||
if (InstallDetour(code, FunnelHook, backup_funnel, NULL))
|
||||
Log("[ANTITAMPER] tamper-report funnel neutralized (returns null)");
|
||||
else
|
||||
Log("[ANTITAMPER] funnel detour refused -- anti-tamper NOT suppressed");
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
#include "common.h"
|
||||
#include "filesig_patch.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
#include "config.h"
|
||||
|
||||
//
|
||||
// Referee anti-cheat P/Invoke neutraliser -- the fix for the recurring junk-pointer crashes.
|
||||
//
|
||||
// How this was found (every earlier theory was wrong, so the evidence chain matters):
|
||||
// * The session ends in a hard 0xC0000005, NOT a managed quit -- hooks on both Application.Quit
|
||||
// overloads and on TerminateProcess(self) never fire.
|
||||
// * It is NOT anti-tamper reacting to our hooks: running with ZERO inline byte patches anywhere
|
||||
// (all three GameAssembly hooks on hardware breakpoints + the DNS detour off) still died at ~35s.
|
||||
// * The main thread is NOT hung -- the hang probe shows its RIP moving normally right up to death.
|
||||
// * The LAST line before the process dies is a first-chance AV: EXECUTE at 0xFFFFFFFF52520000,
|
||||
// i.e. execution transferred TO a junk pointer. It recurs on an exact 10.000s timer and is
|
||||
// survivable several times before one takes the process down.
|
||||
// * Resolved against the CORRECT dump (RecRoom_Info\Code\2025-04-29_02-57-34 -- il2cpp-tools/out is
|
||||
// a DIFFERENT build; use il2cpp-tools/whatis2025.py): the return addresses GA+0x7FAC63 /
|
||||
// 0x7FACE2 / 0x7FACEE all sit below the first managed method, i.e. inside libil2cpp's own
|
||||
// P/Invoke glue, and the managed frames above them land in class `BEDNFMIFJNG` -- whose async
|
||||
// state machine carries the string:
|
||||
// "Unable to initialize Referee telemetry. Is the game protected by Referee?"
|
||||
//
|
||||
// So `BEDNFMIFJNG` is the Referee anti-cheat integration layer, and its `extern` methods are Referee
|
||||
// native P/Invokes. Referee's native side does not exist in this build (RecRoom.exe.dll exports
|
||||
// exactly one Themida-mangled symbol, `qxPzOD`), so il2cpp resolves those imports to junk and every
|
||||
// call jumps into unmapped memory. The 0x52520000 / ...10 / ...20 pattern is consecutive slots of the
|
||||
// same unresolved import table.
|
||||
//
|
||||
// Return values: 0 across the board (false / NULL / 0). That is deliberate -- the presence of the
|
||||
// "Is the game protected by Referee?" message proves the client has a designed, supported path for
|
||||
// "Referee is not available", so reporting failure keeps it on a code path its authors intended,
|
||||
// rather than claiming success and then handing back garbage handles and uninitialised [Out] values
|
||||
// that later calls would use. (Returning true for the first three was tried first: it removed the
|
||||
// 0x52520000 fault and took the session from ~35s to ~60s, but left the sibling slots faulting.)
|
||||
//
|
||||
// All replace-only detours: calling the original is precisely what we must avoid, so no trampoline is
|
||||
// needed and a complex prologue cannot be mis-decoded.
|
||||
//
|
||||
// FFOAJEEOIAI is included even though it hands the native side an Action<int> callback -- stubbing it
|
||||
// means the callback never fires, but the status quo is a guaranteed access violation, which is
|
||||
// strictly worse. If something turns out to await that callback, this is the first hook to drop.
|
||||
//
|
||||
|
||||
// RVAs from the 2025-04-29 dump, class BEDNFMIFJNG (+ its nested DIMHKPJGDLP).
|
||||
static const struct { DWORD rva; const char *name; } g_referee[] = {
|
||||
{ 0x1119690, "CEGIDPKIMDF(IntPtr) -> bool" },
|
||||
{ 0x1119710, "IAEGMFEFGPN(Guid,IntPtr,long,uint) -> IntPtr" },
|
||||
{ 0x11197D0, "ICLJICIDFJN(...) -> bool" },
|
||||
{ 0x1119890, "JHNMFGBECCA(string,...) -> IntPtr" },
|
||||
{ 0x1119970, "KKNIDOLEIGJ(...) -> bool" },
|
||||
{ 0x1119A50, "LJBMIMJMHIP(...) -> bool" },
|
||||
{ 0x111A590, "FFOAJEEOIAI(Action<int>) -> void" },
|
||||
{ 0x111A5B0, "FGIGMCJGLCJ(5x[Out]) -> bool (file sig check)" },
|
||||
{ 0x111A5E0, "GOFCGECPGIC() -> bool" },
|
||||
{ 0x111A8C0, "LHBIHCCNGDL(IntPtr,uint) -> bool" },
|
||||
{ 0x112A1E0, "PHMMLPOMALE(int,int,IntPtr,int) -> int" },
|
||||
|
||||
//
|
||||
// NOT hooked, though it is tempting: JBMCKPKFHLD.MoveNext @0x1127670, the Referee telemetry init
|
||||
// state machine. The two remaining faults (0xFFFFFFFF52520010 / ...20) both trace back to
|
||||
// GA+0x11277D6 = MoveNext+0x166, and stubbing it DOES remove them -- but measured end to end it
|
||||
// is a net LOSS: session length dropped from ~60s to ~40s, presumably because the async task it
|
||||
// drives then never completes and something downstream waits on it. Faults that are survivable
|
||||
// beat a task that never finishes. Left alone deliberately; do not "fix" this without measuring
|
||||
// uptime again.
|
||||
//
|
||||
};
|
||||
|
||||
#define REFEREE_COUNT ((int)(sizeof(g_referee) / sizeof(g_referee[0])))
|
||||
|
||||
static BYTE g_backup[REFEREE_COUNT][16];
|
||||
|
||||
//
|
||||
// One stub serves every signature here. Static il2cpp methods take their first four args in
|
||||
// RCX/RDX/R8/R9 with the rest (and MethodInfo*) on the stack, and the Win64 caller cleans the stack,
|
||||
// so declaring only the register args is safe regardless of the real arity. Returning 0 in RAX is a
|
||||
// valid false / NULL / 0 for every return type in the table; for the void one it is simply ignored.
|
||||
//
|
||||
static uint64_t ReplRefereeFail(void *a, void *b, void *c, void *d)
|
||||
{
|
||||
(void)a; (void)b; (void)c; (void)d;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Spin until the byte looks like decrypted code rather than a zero/int3 fill (the packer decrypts
|
||||
// .text shortly after the module maps) -- same guard as ssl_patch.c.
|
||||
static void WaitForCodeFs(const BYTE *p)
|
||||
{
|
||||
for (int i = 0; i < 600; i++)
|
||||
{
|
||||
BYTE b = p[0];
|
||||
if (b != 0x00 && b != 0xCC) return;
|
||||
Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Generic fix: repair the unresolved import slots themselves.
|
||||
//
|
||||
// Hooking named externs only covers the ones we can identify -- two faults remain
|
||||
// (0xFFFFFFFF52520010 / ...20) whose managed caller we could not pin to a nameable extern, and there
|
||||
// may be more we have never triggered. But every one of these faults jumps to an address of the form
|
||||
// 0xFFFFFFFF525200xx, and that value has to be *stored* somewhere for the code to call it: il2cpp
|
||||
// caches resolved P/Invoke targets in static slots inside GameAssembly.dll's data.
|
||||
//
|
||||
// So instead of chasing callers, scan GameAssembly's mapped image for those exact qwords and
|
||||
// overwrite each with a pointer to a stub that just returns 0. That neutralises every call site --
|
||||
// present and future, named and unnamed -- in one pass, and it is far more precise than a detour: the
|
||||
// value is so specific (0xFFFFFFFF525200xx, "RR" poison) that a false positive is implausible.
|
||||
//
|
||||
// Re-scanned a few times because P/Invoke resolution is lazy: a slot may still be empty on the first
|
||||
// pass and only get its junk value written once the owning method is first called.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
//
|
||||
#define REFEREE_POISON_BASE 0xFFFFFFFF52520000ULL
|
||||
#define REFEREE_POISON_MASK 0xFFFFFFFFFFFFFF00ULL // catch ...00 through ...FF
|
||||
|
||||
static int PatchPoisonSlots(HMODULE ga)
|
||||
{
|
||||
MODULEINFO mi;
|
||||
if (!GetModuleInformation(GetCurrentProcess(), ga, &mi, sizeof(mi))) return 0;
|
||||
|
||||
uintptr_t base = (uintptr_t)mi.lpBaseOfDll;
|
||||
uintptr_t end = base + mi.SizeOfImage;
|
||||
uint64_t stub = (uint64_t)(uintptr_t)ReplRefereeFail;
|
||||
int patched = 0;
|
||||
|
||||
// Walk region by region so we only touch committed, readable pages -- a 224MB image has plenty
|
||||
// of reserved-but-not-committed holes and blind reads would fault.
|
||||
uintptr_t p = base;
|
||||
while (p < end)
|
||||
{
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
if (!VirtualQuery((void *)p, &mbi, sizeof(mbi))) break;
|
||||
|
||||
uintptr_t regionEnd = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (regionEnd > end) regionEnd = end;
|
||||
|
||||
BOOL readable = (mbi.State == MEM_COMMIT) && !(mbi.Protect & PAGE_GUARD) &&
|
||||
(mbi.Protect & (PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY |
|
||||
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE |
|
||||
PAGE_EXECUTE_WRITECOPY));
|
||||
if (readable)
|
||||
{
|
||||
// Step ONE byte, not eight. The first slot found this way (GA+0x7FACD8, holding
|
||||
// ...52520020) happened to be 8-aligned, but these values also appear as 64-bit
|
||||
// immediates embedded in instructions (`mov rax, imm64` / `call rax`), which are almost
|
||||
// never aligned -- an aligned-only scan silently misses them. Patching an immediate is
|
||||
// just as valid as patching a data slot: the instruction then loads our stub instead.
|
||||
for (uintptr_t a = (uintptr_t)mbi.BaseAddress; a + 8 <= regionEnd; a += 1)
|
||||
{
|
||||
uint64_t v = *(volatile uint64_t *)a;
|
||||
if ((v & REFEREE_POISON_MASK) != REFEREE_POISON_BASE) continue;
|
||||
|
||||
DWORD old;
|
||||
if (VirtualProtect((void *)a, 8, PAGE_READWRITE, &old))
|
||||
{
|
||||
*(volatile uint64_t *)a = stub;
|
||||
VirtualProtect((void *)a, 8, old, &old);
|
||||
patched++;
|
||||
Log("[REFEREE] repaired import slot at GA+0x%llX (was %llX -> safe stub)",
|
||||
(unsigned long long)(a - base), (unsigned long long)v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p = regionEnd > p ? regionEnd : p + 0x1000;
|
||||
}
|
||||
return patched;
|
||||
}
|
||||
|
||||
void PatchFileSigCheck(void)
|
||||
{
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
int ok = 0;
|
||||
for (int i = 0; i < REFEREE_COUNT; i++)
|
||||
{
|
||||
BYTE *code = (BYTE *)ga + g_referee[i].rva;
|
||||
WaitForCodeFs(code);
|
||||
|
||||
if (InstallDetour(code, ReplRefereeFail, g_backup[i], NULL))
|
||||
{
|
||||
ok++;
|
||||
Log("[REFEREE] neutralised GA+0x%X %s", g_referee[i].rva, g_referee[i].name);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[REFEREE] FAILED to hook GA+0x%X %s (prologue %02X %02X %02X %02X)",
|
||||
g_referee[i].rva, g_referee[i].name, code[0], code[1], code[2], code[3]);
|
||||
}
|
||||
}
|
||||
|
||||
Log("[REFEREE] %d/%d Referee P/Invokes neutralised (all return 0 = 'Referee unavailable')",
|
||||
ok, REFEREE_COUNT);
|
||||
|
||||
// Sweep the unresolved import slots too. Lazy resolution means a slot can be written long after
|
||||
// startup, so repeat for a while rather than scanning once.
|
||||
int total = 0;
|
||||
for (int pass = 0; pass < 10; pass++)
|
||||
{
|
||||
DWORD t0 = GetTickCount();
|
||||
int n = PatchPoisonSlots(ga);
|
||||
total += n;
|
||||
if (n || pass == 0)
|
||||
Log("[REFEREE] pass %d: repaired %d poisoned import slot(s) (scan %lu ms)",
|
||||
pass, n, GetTickCount() - t0);
|
||||
Sleep(3000);
|
||||
}
|
||||
Log("[REFEREE] import-slot sweep finished, %d slot(s) repaired in total", total);
|
||||
}
|
||||
+279
-1
@@ -4,6 +4,9 @@
|
||||
#include "config.h"
|
||||
#include "strings.h"
|
||||
#include "detour.h"
|
||||
#include "retspoof.h"
|
||||
#include "hwbp.h"
|
||||
#include "crash_handler.h"
|
||||
|
||||
//
|
||||
// HTTP-layer host rewrite.
|
||||
@@ -53,6 +56,16 @@ typedef void* (*SendRequest_t)(void *req, void *method);
|
||||
static SendRequest_t original_SendRequest;
|
||||
static BYTE backup_sendrequest[32];
|
||||
|
||||
// HWBP call-through: the target's bytes are untouched, so "the original" is just the target address
|
||||
// itself -- but calling it would re-trap, hence the one-shot skip. original_SendRequest is aimed at
|
||||
// this shim when the hardware-breakpoint path is used, keeping every call site identical.
|
||||
static SendRequest_t g_realSendRequest;
|
||||
static void* SendRequestViaHwbp(void *req, void *method)
|
||||
{
|
||||
HwbpSkipOnce(HWBP_SLOT_HTTP);
|
||||
return g_realSendRequest(req, method);
|
||||
}
|
||||
|
||||
|
||||
static void* FindClass(void *domain, const char *ns, const char *name)
|
||||
{
|
||||
@@ -165,6 +178,265 @@ static void* SendRequestHook(void *req, void *method)
|
||||
return original_SendRequest(req, method);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hardcoded-RVA path for the recflare-client-unstable build (Rec Room 2025-04-29).
|
||||
//
|
||||
// GameAssembly.dll on this build has NO export table (stripped), so the reflection API above is
|
||||
// unreachable. Everything here is resolved by hardcoded address instead:
|
||||
// - il2cpp_string_new / il2cpp_object_new: found by byte-signature scan of the decrypted libil2cpp
|
||||
// (Pistol Whip 2021.3.7, metadata v29 == ours, was the reference). See memory note
|
||||
// unstable-build-identity-rvas. VA = GameAssembly_base + RVA.
|
||||
// - SendRequest(HTTPRequest): RVA from the matching Cpp2IL dump (2025-04-29).
|
||||
// - HTTPRequest.Uri is read/written as a FIELD (offset 0x168) -- no get_Uri/set_Uri needed.
|
||||
// - Uri..ctor(string) is resolved by WALKING the Uri Il2CppClass method table at runtime (struct
|
||||
// offsets from Pistol Whip il2cpp.h), since it's a framework method whose body doesn't
|
||||
// signature-scan. The Uri class comes from the live object header (*(void**)uri).
|
||||
// The hook runs on the game's own il2cpp/GC thread (it's the caller of SendRequest), so no
|
||||
// thread_attach is needed.
|
||||
// ============================================================================
|
||||
#define SENDREQUEST_RVA 0x71D7BE0
|
||||
#define STRING_NEW_RVA 0x8D9EA0
|
||||
#define OBJECT_NEW_RVA 0x8E1460
|
||||
|
||||
// HTTPRequest.Uri auto-property accessors (RecRoom methods -> RVAs from our build's dump). They ignore
|
||||
// MethodInfo, so a NULL trailing arg is fine.
|
||||
#define GET_URI_RVA 0x9C9460 // HTTPRequest.get_Uri() -> Uri
|
||||
#define SET_URI_RVA 0x9C91D0 // HTTPRequest.set_Uri(Uri)
|
||||
|
||||
typedef void* (*get_uri_fn_t)(void* thisp, void* mi);
|
||||
typedef void (*set_uri_fn_t)(void* thisp, void* uri, void* mi);
|
||||
static get_uri_fn_t g_get_uri;
|
||||
static set_uri_fn_t g_set_uri;
|
||||
|
||||
#define URI_MSTRING_OFF 0x10 // System.Uri.m_String (reliable instance-field offset; reads the URL)
|
||||
#define STR_LEN_OFF 0x10 // Il2CppString.length (int32)
|
||||
#define STR_CHARS_OFF 0x14 // Il2CppString.chars (utf16)
|
||||
|
||||
// Shuffled Il2CppClass / MethodInfo offsets on this build (found empirically, see memory note):
|
||||
#define CLASS_METHODS_OFF 0x60 // Il2CppClass.methods (MethodInfo** array)
|
||||
#define MI_METHODPTR_OFF 0x10 // MethodInfo.methodPointer, XOR-obfuscated (== virtualMethodPointer@0x48)
|
||||
#define MI_NAME_OFF 0x30 // MethodInfo.name (const char*)
|
||||
#define MI_PARAMCOUNT_OFF 0x51 // MethodInfo.parameters_count (uint8)
|
||||
|
||||
typedef void* (*string_new_fn_t)(const char*);
|
||||
typedef void* (*object_new_fn_t)(void*);
|
||||
typedef void (*uri_ctor_fn_t)(void* thisUri, void* strArg, void* methodInfo);
|
||||
static string_new_fn_t g_string_new;
|
||||
static object_new_fn_t g_object_new;
|
||||
|
||||
static int SafeReadAscii(const char *p, char *out, int n); // fwd
|
||||
|
||||
// Recovered at runtime: the global XOR key that deobfuscates MethodInfo.methodPointer
|
||||
// (real_VA = *(u64*)(mi+0x10) ^ key), plus the resolved Uri..ctor(string) target.
|
||||
static uint64_t g_mp_key;
|
||||
static void *g_uri_ctor_entry; // decrypted compiled entry of Uri..ctor(string)
|
||||
static void *g_uri_ctor_mi; // its MethodInfo* (il2cpp instance methods take MethodInfo* last)
|
||||
static void *g_uri_class; // System.Uri Il2CppClass*
|
||||
|
||||
// Walk a shuffled Il2CppClass' method table for `name` with parameters_count==pc (pc<0 = any); returns
|
||||
// the MethodInfo*, or NULL. SEH-guarded against torn reads.
|
||||
static void* FindMethod(void *klass, const char *name, int pc)
|
||||
{
|
||||
void **methods;
|
||||
__try { methods = *(void ***)((BYTE *)klass + CLASS_METHODS_OFF); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) { return NULL; }
|
||||
if (!methods) return NULL;
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
void *mi;
|
||||
__try { mi = methods[i]; } __except (EXCEPTION_EXECUTE_HANDLER) { break; }
|
||||
if (!mi) break;
|
||||
const char *nm; uint8_t mc;
|
||||
__try { nm = *(const char **)((BYTE *)mi + MI_NAME_OFF); mc = *(uint8_t *)((BYTE *)mi + MI_PARAMCOUNT_OFF); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) { continue; }
|
||||
char s[48];
|
||||
if (!SafeReadAscii(nm, s, sizeof(s))) continue;
|
||||
if (strcmp(s, name) != 0) continue;
|
||||
if (pc >= 0 && mc != (uint8_t)pc) continue;
|
||||
return mi;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Spin until the byte at p is decrypted code (packer decrypts .text shortly after the module maps).
|
||||
static void HttpWaitForCode(const BYTE *p)
|
||||
{
|
||||
for (int i = 0; i < 600; i++) { BYTE b = p[0]; if (b != 0x00 && b != 0xCC) return; Sleep(100); }
|
||||
}
|
||||
|
||||
// SEH-safe: copy up to n-1 printable-ASCII chars from p; 1 if it looked like a C string, else 0.
|
||||
static int SafeReadAscii(const char *p, char *out, int n)
|
||||
{
|
||||
if (!p) return 0;
|
||||
__try {
|
||||
for (int j = 0; j < n - 1; j++) {
|
||||
char c = p[j];
|
||||
if (c == 0) { out[j] = 0; return j > 0; }
|
||||
if (c < 0x20 || c > 0x7e) return 0;
|
||||
out[j] = c;
|
||||
}
|
||||
out[n - 1] = 0; return 1;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return 0; }
|
||||
}
|
||||
|
||||
// Read an il2cpp string's ASCII into out (SEH-safe). Returns length, or -1 on failure.
|
||||
static int ReadIl2CppAscii(void *str, char *out, int cap)
|
||||
{
|
||||
__try {
|
||||
if (!str) return -1;
|
||||
int len = *(int *)((BYTE *)str + STR_LEN_OFF);
|
||||
if (len < 0 || len >= cap) return -1;
|
||||
uint16_t *w = (uint16_t *)((BYTE *)str + STR_CHARS_OFF);
|
||||
for (int i = 0; i < len; i++) out[i] = (w[i] < 0x80) ? (char)w[i] : '?';
|
||||
out[len] = 0;
|
||||
return len;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return -1; }
|
||||
}
|
||||
|
||||
// Extract the host component from an absolute URL into host[].
|
||||
static int UrlHost(const char *url, char *host, size_t cap)
|
||||
{
|
||||
const char *p = strstr(url, "://");
|
||||
if (!p) return 0;
|
||||
p += 3;
|
||||
const char *e = p;
|
||||
while (*e && *e != '/' && *e != ':') e++;
|
||||
size_t hl = (size_t)(e - p);
|
||||
if (hl == 0 || hl >= cap) return 0;
|
||||
memcpy(host, p, hl); host[hl] = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Deobfuscate a MethodInfo's compiled entry. This build XOR-obfuscates MethodInfo.methodPointer with a
|
||||
// global key: real_VA = *(u64*)(mi + 0x10) ^ key. We recover the key from get_Uri (whose real RVA we
|
||||
// know), then apply it to any other MethodInfo. Returns the decrypted entry, or NULL.
|
||||
static void* DecryptMethodPtr(void *mi)
|
||||
{
|
||||
if (!g_mp_key || !mi) return NULL;
|
||||
uint64_t enc;
|
||||
__try { enc = *(uint64_t *)((BYTE *)mi + MI_METHODPTR_OFF); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) { return NULL; }
|
||||
return (void *)(uintptr_t)(enc ^ g_mp_key);
|
||||
}
|
||||
|
||||
// One-time resolve: derive the key from get_Uri in HTTPRequest's class, then decrypt Uri..ctor(string)
|
||||
// from the live Uri object's class. Returns 1 on success.
|
||||
static int ResolveUriCtor(void *req, void *uri, HMODULE ga)
|
||||
{
|
||||
uintptr_t base = (uintptr_t)ga;
|
||||
|
||||
void *reqKlass = *(void **)req;
|
||||
void *miGetUri = FindMethod(reqKlass, "get_Uri", 0);
|
||||
if (!miGetUri) { Log("[HTTP] resolve: get_Uri MethodInfo not found"); return 0; }
|
||||
uint64_t encGetUri;
|
||||
__try { encGetUri = *(uint64_t *)((BYTE *)miGetUri + MI_METHODPTR_OFF); }
|
||||
__except (EXCEPTION_EXECUTE_HANDLER) { return 0; }
|
||||
g_mp_key = encGetUri ^ (uint64_t)(base + GET_URI_RVA);
|
||||
Log("[HTTP] methodPointer key = %llX (from get_Uri)", (unsigned long long)g_mp_key);
|
||||
|
||||
g_uri_class = *(void **)uri;
|
||||
void *miCtor = FindMethod(g_uri_class, ".ctor", 1); // Uri(string) is the sole 1-param ctor
|
||||
if (!miCtor) { Log("[HTTP] resolve: Uri..ctor(string) MethodInfo not found"); return 0; }
|
||||
void *entry = DecryptMethodPtr(miCtor);
|
||||
// Sanity: entry must land inside GameAssembly's image.
|
||||
uintptr_t rva = (uintptr_t)entry - base;
|
||||
if (rva >= 0xE000000) { Log("[HTTP] resolve: Uri..ctor entry %p out of range (rva=%llX)", entry, (unsigned long long)rva); return 0; }
|
||||
g_uri_ctor_entry = entry;
|
||||
g_uri_ctor_mi = miCtor;
|
||||
Log("[HTTP] Uri..ctor(string) entry=%p (rva=%llX) mi=%p", entry, (unsigned long long)rva, miCtor);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// SendRequest(HTTPRequest req, MethodInfo*) hook. DNS already routes ns.rec.net to recflare's IP; the
|
||||
// remaining problem is TLS SNI + HTTP Host header still say ns.rec.net (Cloudflare routes by those).
|
||||
// Both derive from req.Uri. So we build a fresh System.Uri from the rewritten URL and set it back --
|
||||
// a real ctor parse (no cache corruption/truncation like in-place mutation). il2cpp_string_new /
|
||||
// il2cpp_object_new come from the signature scan; Uri..ctor is resolved by decrypting its obfuscated
|
||||
// MethodInfo.methodPointer. Runs on the game's own il2cpp/GC thread, so allocation needs no attach.
|
||||
static volatile LONG g_sr_calls = 0;
|
||||
|
||||
static void* SendRequestHookRVA(void *req, void *method)
|
||||
{
|
||||
LONG n = InterlockedIncrement(&g_sr_calls);
|
||||
|
||||
// This hook runs on Unity's main thread; publish it so the hang probe knows what to sample.
|
||||
if (!g_mainThreadId) g_mainThreadId = GetCurrentThreadId();
|
||||
|
||||
if (!req) return original_SendRequest(req, method);
|
||||
|
||||
void *uri = (void *)SpoofCall4(g_get_uri, (uint64_t)req, 0, 0, 0);
|
||||
if (!uri) return original_SendRequest(req, method);
|
||||
|
||||
char url[1024];
|
||||
if (ReadIl2CppAscii(*(void **)((BYTE *)uri + URI_MSTRING_OFF), url, sizeof(url)) < 0)
|
||||
return original_SendRequest(req, method);
|
||||
|
||||
if (n <= 200) Log("[HTTP] req#%ld %s", n, url); // DIAGNOSTIC: log every request URL
|
||||
|
||||
char host[256], newHost[256], newUrl[1100];
|
||||
if (!UrlHost(url, host, sizeof(host))) return original_SendRequest(req, method);
|
||||
if (!RewriteHost(host, newHost, sizeof(newHost))) return original_SendRequest(req, method); // not a target
|
||||
if (!RewriteUrlHost(url, newUrl, sizeof(newUrl))) return original_SendRequest(req, method);
|
||||
|
||||
if (!g_uri_ctor_entry)
|
||||
{
|
||||
if (!ResolveUriCtor(req, uri, GetModuleHandleA("GameAssembly.dll")))
|
||||
return original_SendRequest(req, method); // couldn't resolve -- leave request unchanged
|
||||
}
|
||||
|
||||
__try {
|
||||
void *newStr = (void *)SpoofCall4(g_string_new, (uint64_t)newUrl, 0, 0, 0);
|
||||
void *newUri = (void *)SpoofCall4(g_object_new, (uint64_t)g_uri_class, 0, 0, 0);
|
||||
if (newStr && newUri)
|
||||
{
|
||||
// new Uri(newUrl)
|
||||
SpoofCall4(g_uri_ctor_entry, (uint64_t)newUri, (uint64_t)newStr, (uint64_t)g_uri_ctor_mi, 0);
|
||||
// req.Uri = newUri
|
||||
SpoofCall4(g_set_uri, (uint64_t)req, (uint64_t)newUri, 0, 0);
|
||||
if (n <= 60) Log("[HTTP] %s -> %s", url, newUrl);
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
if (n <= 60) Log("[HTTP] fresh-Uri build faulted for %s -- passing through", url);
|
||||
}
|
||||
return original_SendRequest(req, method);
|
||||
}
|
||||
|
||||
// Install the hardcoded-RVA host rewrite (call-through detour on SendRequest).
|
||||
static void PatchHttpHostRewriteRVA(HMODULE ga)
|
||||
{
|
||||
g_string_new = (string_new_fn_t)((BYTE *)ga + STRING_NEW_RVA);
|
||||
g_object_new = (object_new_fn_t)((BYTE *)ga + OBJECT_NEW_RVA);
|
||||
g_get_uri = (get_uri_fn_t)((BYTE *)ga + GET_URI_RVA);
|
||||
g_set_uri = (set_uri_fn_t)((BYTE *)ga + SET_URI_RVA);
|
||||
|
||||
BYTE *code = (BYTE *)ga + SENDREQUEST_RVA;
|
||||
HttpWaitForCode(code);
|
||||
Log("[HTTP] RVA path (build 2025-04-29): SendRequest code=%p prologue=%02X %02X %02X %02X",
|
||||
code, code[0], code[1], code[2], code[3]);
|
||||
|
||||
//
|
||||
// Prefer a hardware breakpoint (no bytes written -- see hwbp.h). This is a call-through hook, so
|
||||
// `original_SendRequest` is pointed at a shim that arms the one-shot pass-through and then calls
|
||||
// the real address; every existing `original_SendRequest(...)` call site keeps working unchanged.
|
||||
//
|
||||
if (use_hwbp)
|
||||
{
|
||||
g_realSendRequest = (SendRequest_t)code;
|
||||
if (HwbpAdd(HWBP_SLOT_HTTP, code, SendRequestHookRVA))
|
||||
{
|
||||
original_SendRequest = SendRequestViaHwbp;
|
||||
Log("[HTTP] host rewrite installed on SendRequest via HWBP (no bytes patched)");
|
||||
return;
|
||||
}
|
||||
Log("[HTTP] HWBP arm failed -- falling back to inline detour");
|
||||
}
|
||||
|
||||
if (InstallDetour(code, SendRequestHookRVA, backup_sendrequest, (LPVOID *)&original_SendRequest))
|
||||
Log("[HTTP] host rewrite installed on SendRequest (RVA path)");
|
||||
else
|
||||
Log("[HTTP] SendRequest detour refused (RVA path) -- host rewrite NOT active");
|
||||
}
|
||||
|
||||
static BOOL ResolveApi(HMODULE ga)
|
||||
{
|
||||
p_domain_get = (il2cpp_domain_get_t) GetProcAddress(ga, "il2cpp_domain_get");
|
||||
@@ -195,7 +467,13 @@ void PatchHttpHostRewrite(void)
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
if (!ResolveApi(ga)) { Log("[HTTP] missing il2cpp exports -- aborting host rewrite"); return; }
|
||||
if (!ResolveApi(ga))
|
||||
{
|
||||
// No il2cpp exports (recflare-client-unstable build) -- use the hardcoded-RVA path.
|
||||
Log("[HTTP] no il2cpp exports -- using hardcoded RVA path (build 2025-04-29)");
|
||||
PatchHttpHostRewriteRVA(ga);
|
||||
return;
|
||||
}
|
||||
|
||||
void *domain = NULL;
|
||||
for (int i = 0; i < 600 && !domain; i++) { domain = p_domain_get(); if (!domain) Sleep(100); }
|
||||
|
||||
@@ -164,12 +164,91 @@ static void* ScanHook(void *self, void *methodInfo)
|
||||
return p_invoke(g_resolvedGetter, NULL, NULL, &exc);
|
||||
}
|
||||
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Hardcoded-RVA path for build 2025-04-29 (recflare-client-unstable).
|
||||
//
|
||||
// GameAssembly.dll on this build has no export table, so ResolveApi() fails and the shape-based
|
||||
// search above can't run at all -- the integrity scan has been completely UNPATCHED here, while we
|
||||
// carry four inline .text detours. That makes it the prime suspect for the hard 0xC0000005 in the
|
||||
// Themida-wrapped RecRoom.exe.dll ~35s in (see memory note unstable-build-identity-rvas.md).
|
||||
//
|
||||
// The scanner on this build is the static class `BLGELNMKAKM` in the Cpp2IL dump
|
||||
// (RecRoom_Info/Code/2025-04-29_02-57-34) -- identified by shape, NOT by name (CLAUDE.md gotcha 7):
|
||||
// it owns the const `"verification.sig"`, a 65536 chunk size, RSA modulus/exponent byte[] fields, and
|
||||
// the `<CheckHashesInBackground>` compiler-generated closures. NOTE the obfuscated class name recorded
|
||||
// for an older build (`CHPCJHMCKMA`) does NOT exist in this dump -- never reuse one across builds.
|
||||
//
|
||||
// BLGELNMKAKM.JEGANAFJCLA() RVA 0x133B720 public static, 0 params -> NCOKFFGPIJM<LOBLPLMBPEO>
|
||||
//
|
||||
// That is the promise-returning scan entry the boot step awaits (the same shape the export-based
|
||||
// search looks for above). We start as a pure WITNESS: a call-through tracer that logs entry/exit and
|
||||
// changes nothing, so we can first establish whether the scan even runs and whether it correlates
|
||||
// with the crash -- returning a bogus promise here would risk the same null-deref crash the
|
||||
// antitamper funnel hook caused. Only once that's confirmed should this become a neutralizer.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
//
|
||||
#define MEMCHECK_SCAN_RVA 0x133B720
|
||||
|
||||
typedef void* (*scan_fn_t)(void *methodInfo);
|
||||
static scan_fn_t real_scan_rva;
|
||||
static BYTE backup_scan_rva[32];
|
||||
|
||||
// Static il2cpp method: MethodInfo* arrives in RCX, no declared params.
|
||||
static void* ScanTraceHook(void *methodInfo)
|
||||
{
|
||||
Log("[MEMCHECK] *** integrity scan ENTERED (BLGELNMKAKM.JEGANAFJCLA) ***");
|
||||
void *r = real_scan_rva(methodInfo);
|
||||
Log("[MEMCHECK] *** integrity scan RETURNED promise=%p ***", r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Spin until the byte looks like decrypted code rather than a zero/int3 fill (the packer decrypts
|
||||
// .text shortly after the module maps) -- same guard as ssl_patch.c.
|
||||
static void WaitForCodeMc(const BYTE *p)
|
||||
{
|
||||
for (int i = 0; i < 600; i++)
|
||||
{
|
||||
BYTE b = p[0];
|
||||
if (b != 0x00 && b != 0xCC) return;
|
||||
Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
static void PatchMemcheckByRVA(HMODULE ga)
|
||||
{
|
||||
(void)ga; (void)backup_scan_rva; (void)real_scan_rva;
|
||||
(void)ScanTraceHook; (void)WaitForCodeMc;
|
||||
|
||||
//
|
||||
// DISABLED -- MEMCHECK_SCAN_RVA IS WRONG FOR THIS BUILD. DO NOT RE-ENABLE AS-IS.
|
||||
//
|
||||
// 0x133B720 was read out of il2cpp-tools/out/dump.cs, which turned out to be a DIFFERENT BUILD
|
||||
// than the installed client. Proof: that dump puts BestHTTP SendRequest at 0x3161AF0 and
|
||||
// NotifyServerCertificate at 0x3F447C0, but the RVAs that actually work at runtime here are
|
||||
// 0x71D7BE0 and 0x71CFD00. The correct dump for recflare-client-unstable is
|
||||
// C:\Games\RecRoom_Info\Code\2025-04-29_02-57-34 (it lists SendRequest at 0x71D7BE0 -- match).
|
||||
//
|
||||
// Consequence: the byte at GA+0x133B720 is not a function entry on this build (observed prologue
|
||||
// "DF C7 47 10" -- mid-instruction), so installing a detour there writes 14 bytes into the middle
|
||||
// of unrelated code. The tracer never fired because nothing calls that address.
|
||||
//
|
||||
// Also: `CheckHashesInBackground` / `verification.sig` / class `BLGELNMKAKM` DO NOT EXIST in the
|
||||
// correct dump, so the managed file-hash scanner those names came from is not present in this
|
||||
// build at all. The integrity check that matters here is very likely NATIVE, inside the
|
||||
// Themida-wrapped RecRoom.exe.dll -- which is exactly the module the fatal 0xC0000005 lands in.
|
||||
// Re-deriving a scan entry from the CORRECT dump is the prerequisite for any RVA hook here.
|
||||
//
|
||||
Log("[MEMCHECK] no il2cpp exports and no verified scan RVA for this build -- not hooking "
|
||||
"(see src/unity/memcheck_patch.c: out/dump.cs is the WRONG build)");
|
||||
}
|
||||
|
||||
void PatchMemoryIntegrityCheck(void)
|
||||
{
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
if (!ResolveApi(ga)) { Log("[MEMCHECK] missing il2cpp exports -- aborting"); return; }
|
||||
if (!ResolveApi(ga)) { PatchMemcheckByRVA(ga); return; }
|
||||
|
||||
void *domain = NULL;
|
||||
for (int i = 0; i < 600 && !domain; i++) { domain = p_domain_get(); if (!domain) Sleep(100); }
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#include "common.h"
|
||||
#include "photon_patch.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
#include "config.h"
|
||||
#include "retspoof.h"
|
||||
#include "hwbp.h"
|
||||
|
||||
//
|
||||
// Photon app-id injection (recflare-client-unstable, build 2025-04-29).
|
||||
//
|
||||
// The client authenticates to Photon Cloud (ns.exitgames.com) using AppSettings.AppIdRealtime/Voice/
|
||||
// Chat. A self-hosted RecNet server can't hand out real Photon Cloud app IDs (they're account secrets),
|
||||
// so with an empty app id the client's startup GetRegions fails "-2: Empty application id" ->
|
||||
// InvalidAuthentication -> BootSequence error -> clean exit ~30s in. The old managed patcher solved this
|
||||
// with PhotonPatches (inject app IDs client-side). We do the same natively: detour the concrete
|
||||
// ConnectUsingSettings-style method IMJEOJBJIMB(AppSettings) [RVA 0x7BECE70, call-through] and, before
|
||||
// the original runs, overwrite the app-id / region fields on the passed AppSettings object.
|
||||
//
|
||||
// AppSettings is a Photon SDK type (Photon.Realtime.AppSettings) not in the Cpp2IL dump; its instance-
|
||||
// field offsets were discovered at runtime (pre-Fusion layout confirmed by the empty app-id slots +
|
||||
// the "..._prod" AppVersion string):
|
||||
// AppIdRealtime @ 0x10 (empty -> the GetRegions "-2 Empty application id" failure)
|
||||
// AppIdChat @ 0x18
|
||||
// AppIdVoice @ 0x20
|
||||
// AppVersion @ 0x28 ("20250424_prod") UseNameServer(bool) @ 0x30
|
||||
// We overwrite the three app-id string fields from redirector.json (photonRealtimeAppId / ChatAppId /
|
||||
// VoiceAppId) with fresh il2cpp strings (il2cpp_string_new @ RVA 0x8D9EA0, from the signature scan;
|
||||
// exports are stripped). The hook runs on the game's own il2cpp/GC thread, so allocation is safe.
|
||||
//
|
||||
|
||||
#define PHOTON_CONNECT_RVA 0x7BECE70 // concrete IMJEOJBJIMB(AppSettings) -> bool
|
||||
#define STRING_NEW_RVA 0x8D9EA0
|
||||
|
||||
#define APPID_REALTIME_OFF 0x10
|
||||
#define APPID_CHAT_OFF 0x18
|
||||
#define APPID_VOICE_OFF 0x20
|
||||
|
||||
typedef void* (*string_new_fn_t)(const char*);
|
||||
typedef int (*photon_connect_fn_t)(void *self, void *appSettings, void *methodInfo);
|
||||
|
||||
static string_new_fn_t g_string_new;
|
||||
static photon_connect_fn_t original_connect;
|
||||
static BYTE backup_connect[32];
|
||||
|
||||
// HWBP call-through shim -- see the same pattern in http_rewrite.c and hwbp.h.
|
||||
static photon_connect_fn_t g_realConnect;
|
||||
static int PhotonConnectViaHwbp(void *self, void *appSettings, void *mi)
|
||||
{
|
||||
HwbpSkipOnce(HWBP_SLOT_PHOTON);
|
||||
return g_realConnect(self, appSettings, mi);
|
||||
}
|
||||
|
||||
// SEH-safe printable-ASCII read (for the discovery dump).
|
||||
static int SafeAscii(const char *p, char *out, int n)
|
||||
{
|
||||
if (!p) return 0;
|
||||
__try {
|
||||
for (int j = 0; j < n - 1; j++) {
|
||||
char c = p[j];
|
||||
if (c == 0) { out[j] = 0; return 1; }
|
||||
if (c < 0x20 || c > 0x7e) return 0;
|
||||
out[j] = c;
|
||||
}
|
||||
out[n - 1] = 0; return 1;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return 0; }
|
||||
}
|
||||
|
||||
// Read an il2cpp string object's ASCII (length@0x10, chars@0x14).
|
||||
static int ReadStr(void *str, char *out, int cap)
|
||||
{
|
||||
__try {
|
||||
if (!str) return -1;
|
||||
int len = *(int *)((BYTE *)str + 0x10);
|
||||
if (len < 0 || len >= cap) return -1;
|
||||
uint16_t *w = (uint16_t *)((BYTE *)str + 0x14);
|
||||
for (int i = 0; i < len; i++) out[i] = (w[i] < 0x80) ? (char)w[i] : '?';
|
||||
out[len] = 0;
|
||||
return len;
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) { return -1; }
|
||||
}
|
||||
|
||||
// Overwrite one app-id string field if a value is configured; log before/after once.
|
||||
static void SetAppId(void *appSettings, int off, const char *cfg, const char *label, int logIt)
|
||||
{
|
||||
if (!cfg || !cfg[0]) return;
|
||||
__try {
|
||||
char before[128]; int had = ReadStr(*(void **)((BYTE *)appSettings + off), before, sizeof(before));
|
||||
void *ns = (void *)SpoofCall4(g_string_new, (uint64_t)cfg, 0, 0, 0);
|
||||
if (ns) {
|
||||
*(void **)((BYTE *)appSettings + off) = ns;
|
||||
if (logIt) Log("[PHOTON] %s +0x%02X: \"%s\" -> \"%s\"", label, off, had >= 0 ? before : "?", cfg);
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
if (logIt) Log("[PHOTON] %s injection faulted", label);
|
||||
}
|
||||
}
|
||||
|
||||
static int PhotonConnectHook(void *self, void *appSettings, void *mi)
|
||||
{
|
||||
static int done = 0;
|
||||
int logIt = !done; done = 1;
|
||||
if (appSettings)
|
||||
{
|
||||
SetAppId(appSettings, APPID_REALTIME_OFF, photon_realtime_appid, "AppIdRealtime", logIt);
|
||||
SetAppId(appSettings, APPID_CHAT_OFF, photon_chat_appid, "AppIdChat", logIt);
|
||||
SetAppId(appSettings, APPID_VOICE_OFF, photon_voice_appid, "AppIdVoice", logIt);
|
||||
}
|
||||
return original_connect(self, appSettings, mi);
|
||||
}
|
||||
|
||||
void PatchPhotonAppId(void)
|
||||
{
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
g_string_new = (string_new_fn_t)((BYTE *)ga + STRING_NEW_RVA);
|
||||
|
||||
BYTE *code = (BYTE *)ga + PHOTON_CONNECT_RVA;
|
||||
for (int i = 0; i < 600; i++) { if (code[0] != 0x00 && code[0] != 0xCC) break; Sleep(100); }
|
||||
Log("[PHOTON] connect(AppSettings) code=%p prologue=%02X %02X %02X %02X",
|
||||
code, code[0], code[1], code[2], code[3]);
|
||||
|
||||
if (use_hwbp)
|
||||
{
|
||||
g_realConnect = (photon_connect_fn_t)code;
|
||||
if (HwbpAdd(HWBP_SLOT_PHOTON, code, PhotonConnectHook))
|
||||
{
|
||||
original_connect = PhotonConnectViaHwbp;
|
||||
Log("[PHOTON] app-id injection installed via HWBP (no bytes patched)");
|
||||
return;
|
||||
}
|
||||
Log("[PHOTON] HWBP arm failed -- falling back to inline detour");
|
||||
}
|
||||
|
||||
if (InstallDetour(code, PhotonConnectHook, backup_connect, (LPVOID *)&original_connect))
|
||||
Log("[PHOTON] app-id injection hook installed on IMJEOJBJIMB(AppSettings)");
|
||||
else
|
||||
Log("[PHOTON] connect(AppSettings) detour refused -- app-id injection NOT active");
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
#include "common.h"
|
||||
#include "quit_trace.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
#include "config.h"
|
||||
|
||||
//
|
||||
// Application.Quit tracer / blocker.
|
||||
//
|
||||
// The client boots fully (login, avatar, Photon spawn, title screen) and then shuts itself down at
|
||||
// ~11 s of game time. Player.log's last line is "PhotonNetwork.Disconnect() called!" with no reason
|
||||
// logged, and the api/PlayerReporting/v1/referee POST that looked like the trigger actually lands
|
||||
// ~3 s AFTER the shutdown starts -- it is part of the shutdown flush, not its cause. So something
|
||||
// calls UnityEngine.Application.Quit() and nothing tells us who.
|
||||
//
|
||||
// This detours both Quit overloads to log the CALLER's return address, which maps straight back to
|
||||
// a method via il2cpp-tools/whatis.py (subtract the logged GameAssembly base). With
|
||||
// `blockQuit` set in redirector.json the hook also swallows the shutdown by simply returning, which
|
||||
// both confirms causation and -- if the quit is a spurious anti-cheat/telemetry reaction rather
|
||||
// than a real fatal condition -- keeps the client alive.
|
||||
//
|
||||
// Replace-only detours (we never call the original), so no trampoline is needed and a complex
|
||||
// prologue can't be mis-decoded -- same reasoning as ssl_patch.c.
|
||||
//
|
||||
// RVAs from the Cpp2IL dump for build 2025-04-29 (RecRoom_Info/Code/2025-04-29_02-57-34):
|
||||
// UnityEngine.Application$$Quit 0x3EA5FA0 (Quit(), 0 params)
|
||||
// UnityEngine.Application$$Quit 0x3EA5FE0 (Quit(int exitCode))
|
||||
// GameAssembly.dll on this build has NO export table, so the il2cpp reflection API is unreachable
|
||||
// and hardcoded RVAs are the only option (see CLAUDE.md / ssl_patch.c).
|
||||
//
|
||||
#define APP_QUIT_RVA 0x3EA5FA0
|
||||
#define APP_QUIT_INT_RVA 0x3EA5FE0
|
||||
|
||||
static BYTE backup_quit[14];
|
||||
static BYTE backup_quit_int[14];
|
||||
|
||||
static uintptr_t g_gaBase;
|
||||
|
||||
// Log the caller as GA+RVA so whatis.py can name it, plus a few stack slots -- the immediate
|
||||
// return address is often a compiler-generated wrapper, and the real decision site is a frame or
|
||||
// two up.
|
||||
static void LogQuitCaller(const char *which, int exitCode, void *retAddr)
|
||||
{
|
||||
uintptr_t ra = (uintptr_t)retAddr;
|
||||
if (g_gaBase && ra >= g_gaBase)
|
||||
Log("[QUIT] %s(exitCode=%d) called from GA+0x%llX", which, exitCode,
|
||||
(unsigned long long)(ra - g_gaBase));
|
||||
else
|
||||
Log("[QUIT] %s(exitCode=%d) called from %p", which, exitCode, retAddr);
|
||||
|
||||
Log("[QUIT] blockQuit=%d -- %s", block_quit, block_quit ? "SUPPRESSING shutdown" : "allowing shutdown");
|
||||
}
|
||||
|
||||
//
|
||||
// il2cpp static methods still receive MethodInfo* -- Quit() takes it in RCX, Quit(int) takes the
|
||||
// int in RCX and MethodInfo* in RDX. Both return void, so returning here simply skips the quit.
|
||||
//
|
||||
static void ReplQuit(void *methodInfo)
|
||||
{
|
||||
(void)methodInfo;
|
||||
LogQuitCaller("Application.Quit", 0, _ReturnAddress());
|
||||
if (block_quit) return;
|
||||
// Not blocking: fall through to a plain return anyway. We are a replace-only detour, so the
|
||||
// real Quit body is unreachable from here -- "allow" is implemented by not installing the hook
|
||||
// at all (see PatchQuitTrace), never by reaching this line.
|
||||
}
|
||||
|
||||
static void ReplQuitInt(int exitCode, void *methodInfo)
|
||||
{
|
||||
(void)methodInfo;
|
||||
LogQuitCaller("Application.Quit", exitCode, _ReturnAddress());
|
||||
if (block_quit) return;
|
||||
}
|
||||
|
||||
// Spin until the byte at `p` looks like decrypted code rather than a zero/int3 fill (the packer
|
||||
// decrypts .text shortly after the module maps).
|
||||
static void WaitForCode(const BYTE *p)
|
||||
{
|
||||
for (int i = 0; i < 600; i++) // ~60s cap
|
||||
{
|
||||
BYTE b = p[0];
|
||||
if (b != 0x00 && b != 0xCC) return;
|
||||
Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
void PatchQuitTrace(void)
|
||||
{
|
||||
HMODULE ga = NULL;
|
||||
while (!ga)
|
||||
{
|
||||
ga = GetModuleHandleA("GameAssembly.dll");
|
||||
if (!ga) Sleep(100);
|
||||
}
|
||||
g_gaBase = (uintptr_t)ga;
|
||||
Log("[QUIT] GameAssembly.dll at %p (map callers with il2cpp-tools/whatis.py)", ga);
|
||||
|
||||
BYTE *q = (BYTE *)ga + APP_QUIT_RVA;
|
||||
BYTE *qi = (BYTE *)ga + APP_QUIT_INT_RVA;
|
||||
WaitForCode(q);
|
||||
WaitForCode(qi);
|
||||
Log("[QUIT] Application.Quit code=%p prologue=%02X %02X %02X %02X", q, q[0], q[1], q[2], q[3]);
|
||||
Log("[QUIT] Application.Quit(int) code=%p prologue=%02X %02X %02X %02X", qi, qi[0], qi[1], qi[2], qi[3]);
|
||||
|
||||
if (InstallDetour(q, ReplQuit, backup_quit, NULL))
|
||||
Log("[QUIT] tracer installed on Application.Quit()");
|
||||
else
|
||||
Log("[QUIT] FAILED to hook Application.Quit()");
|
||||
|
||||
if (InstallDetour(qi, ReplQuitInt, backup_quit_int, NULL))
|
||||
Log("[QUIT] tracer installed on Application.Quit(int)");
|
||||
else
|
||||
Log("[QUIT] FAILED to hook Application.Quit(int)");
|
||||
|
||||
HookProcessExit();
|
||||
}
|
||||
|
||||
//
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Native process-exit tracer.
|
||||
//
|
||||
// The managed Application.Quit hooks above never fire, so the session is NOT ended by Unity's
|
||||
// managed shutdown path. Something tears the process down natively instead. These call-through
|
||||
// hooks on the two kernel32 exits log who did it (and with what code) and then proceed normally, so
|
||||
// behaviour is unchanged -- purely a witness.
|
||||
//
|
||||
// ExitProcess is the clean path (Unity's player calls it after its main loop returns);
|
||||
// TerminateProcess is the abrupt one a watchdog would use. Which of the two fires -- and whether the
|
||||
// caller is UnityPlayer.dll or GameAssembly.dll -- distinguishes "Unity decided to shut down" from
|
||||
// "something killed us".
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
//
|
||||
|
||||
typedef void (WINAPI *exitprocess_t)(UINT);
|
||||
typedef BOOL (WINAPI *terminateprocess_t)(HANDLE, UINT);
|
||||
|
||||
static exitprocess_t real_ExitProcess;
|
||||
static terminateprocess_t real_TerminateProcess;
|
||||
static BYTE backup_exitproc[24];
|
||||
static BYTE backup_termproc[24];
|
||||
|
||||
static uintptr_t g_upBase, g_upEnd;
|
||||
|
||||
// Name an address as GA+RVA / UP+RVA so the caller is identifiable without a debugger.
|
||||
static void SymAddr(uintptr_t a, char *buf, size_t n)
|
||||
{
|
||||
MODULEINFO mi;
|
||||
if (!g_upBase)
|
||||
{
|
||||
HMODULE up = GetModuleHandleA("UnityPlayer.dll");
|
||||
if (up && GetModuleInformation(GetCurrentProcess(), up, &mi, sizeof(mi)))
|
||||
{ g_upBase = (uintptr_t)mi.lpBaseOfDll; g_upEnd = g_upBase + mi.SizeOfImage; }
|
||||
}
|
||||
if (g_gaBase && a >= g_gaBase && a < g_gaBase + 0x10000000)
|
||||
sprintf_s(buf, n, "GA+0x%llX", (unsigned long long)(a - g_gaBase));
|
||||
else if (g_upBase && a >= g_upBase && a < g_upEnd)
|
||||
sprintf_s(buf, n, "UP+0x%llX", (unsigned long long)(a - g_upBase));
|
||||
else
|
||||
sprintf_s(buf, n, "%llX", (unsigned long long)a);
|
||||
}
|
||||
|
||||
static void WINAPI HookExitProcess(UINT code)
|
||||
{
|
||||
char s[64]; SymAddr((uintptr_t)_ReturnAddress(), s, sizeof(s));
|
||||
Log("[QUIT] *** ExitProcess(%u) called from %s ***", code, s);
|
||||
real_ExitProcess(code);
|
||||
}
|
||||
|
||||
static BOOL WINAPI HookTerminateProcess(HANDLE proc, UINT code)
|
||||
{
|
||||
// Only interesting when it targets US (the game also spawns/kills helper processes).
|
||||
if (proc == GetCurrentProcess() || GetProcessId(proc) == GetCurrentProcessId())
|
||||
{
|
||||
char s[64]; SymAddr((uintptr_t)_ReturnAddress(), s, sizeof(s));
|
||||
Log("[QUIT] *** TerminateProcess(SELF, %u) called from %s ***", code, s);
|
||||
}
|
||||
return real_TerminateProcess(proc, code);
|
||||
}
|
||||
|
||||
void HookProcessExit(void)
|
||||
{
|
||||
HMODULE k32 = GetModuleHandleA("kernel32.dll");
|
||||
if (!k32) { Log("[QUIT] kernel32 not found -- no exit tracer"); return; }
|
||||
|
||||
void *ep = (void *)GetProcAddress(k32, "ExitProcess");
|
||||
void *tp = (void *)GetProcAddress(k32, "TerminateProcess");
|
||||
|
||||
if (ep && InstallDetour(ep, HookExitProcess, backup_exitproc, (void **)&real_ExitProcess))
|
||||
Log("[QUIT] exit tracer installed on ExitProcess");
|
||||
else
|
||||
Log("[QUIT] FAILED to hook ExitProcess");
|
||||
|
||||
if (tp && InstallDetour(tp, HookTerminateProcess, backup_termproc, (void **)&real_TerminateProcess))
|
||||
Log("[QUIT] exit tracer installed on TerminateProcess");
|
||||
else
|
||||
Log("[QUIT] FAILED to hook TerminateProcess");
|
||||
}
|
||||
+55
-1
@@ -2,6 +2,8 @@
|
||||
#include "ssl_patch.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
#include "hwbp.h"
|
||||
#include "config.h"
|
||||
|
||||
//
|
||||
// Native TLS pinning bypass.
|
||||
@@ -85,6 +87,28 @@ static BOOL ResolveIl2CppApi(HMODULE ga)
|
||||
p_class_from_name && p_class_get_method_from_name;
|
||||
}
|
||||
|
||||
//
|
||||
// Hardcoded fallback: on the recflare-client-unstable build (Rec Room 2025-04-29) GameAssembly.dll has
|
||||
// NO export table at all (stripped; confirmed RVA=0 even at runtime), so the il2cpp reflection API is
|
||||
// unreachable. That build's code/metadata are decrypted in memory but the framework method addresses
|
||||
// are known from the matching Cpp2IL dump (RecRoom_Info/Code/2025-04-29_02-57-34). Since this is a dead
|
||||
// game with no future release, we hardcode the RVA. VA at runtime = GameAssembly_base + RVA.
|
||||
// LegacyTlsAuthentication.NotifyServerCertificate(Certificate) = RVA 0x71CFD00 (vtable slot 6).
|
||||
//
|
||||
#define NOTIFY_SERVER_CERT_RVA 0x71CFD00
|
||||
|
||||
// Spin until the byte at `p` looks like decrypted code rather than a zero/int3 fill (the packer
|
||||
// decrypts .text shortly after the module maps).
|
||||
static void WaitForCode(const BYTE *p)
|
||||
{
|
||||
for (int i = 0; i < 600; i++) // ~60s cap
|
||||
{
|
||||
BYTE b = p[0];
|
||||
if (b != 0x00 && b != 0xCC) return;
|
||||
Sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
void PatchBestHTTPSSL(void)
|
||||
{
|
||||
//
|
||||
@@ -100,7 +124,37 @@ void PatchBestHTTPSSL(void)
|
||||
|
||||
if (!ResolveIl2CppApi(ga))
|
||||
{
|
||||
Log("[SSL] missing il2cpp exports -- aborting TLS patch");
|
||||
//
|
||||
// No il2cpp exports (this build). Fall back to the hardcoded RVA: detour the compiled
|
||||
// NotifyServerCertificate entry directly. This is a replace-only hook (we never call the
|
||||
// original), so no il2cpp API, thread-attach, or metadata is required at all.
|
||||
//
|
||||
Log("[SSL] no il2cpp exports -- using hardcoded RVA 0x%X (build 2025-04-29)", NOTIFY_SERVER_CERT_RVA);
|
||||
BYTE *code = (BYTE *)ga + NOTIFY_SERVER_CERT_RVA;
|
||||
WaitForCode(code);
|
||||
Log("[SSL] NotifyServerCertificate code=%p prologue=%02X %02X %02X %02X",
|
||||
code, code[0], code[1], code[2], code[3]);
|
||||
|
||||
//
|
||||
// Prefer a hardware breakpoint: it writes no bytes, so a native integrity check hashing
|
||||
// GameAssembly.dll's .text can't see it (see hwbp.h). This is a replace-only hook -- the
|
||||
// hook just returns, which from the breakpoint's perspective returns straight to the
|
||||
// game's caller, so no HwbpSkipOnce is needed here.
|
||||
//
|
||||
if (use_hwbp)
|
||||
{
|
||||
if (HwbpAdd(HWBP_SLOT_SSL, code, ReplNotifyServerCertificate))
|
||||
{
|
||||
Log("[SSL] TLS pinning bypassed via HWBP (no bytes patched)");
|
||||
return;
|
||||
}
|
||||
Log("[SSL] HWBP arm failed -- falling back to inline detour");
|
||||
}
|
||||
|
||||
if (InstallDetour(code, ReplNotifyServerCertificate, backup_notify, NULL))
|
||||
Log("[SSL] TLS pinning bypassed via RVA (NotifyServerCertificate -> accept-all)");
|
||||
else
|
||||
Log("[SSL] failed to install NotifyServerCertificate detour (RVA path)");
|
||||
return;
|
||||
}
|
||||
Log("[SSL] il2cpp API resolved");
|
||||
|
||||
Reference in New Issue
Block a user