mirror of
https://github.com/djdevin/recnet-plugin.git
synced 2026-09-08 14:41:30 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b15039ea08 | |||
| 54c97ef6d9 | |||
| 36f1730715 |
+20
-3
@@ -1,3 +1,20 @@
|
||||
GamePath.props
|
||||
obj
|
||||
bin
|
||||
build
|
||||
CMakeLists.txt.user
|
||||
CMakeCache.txt
|
||||
CMakeFiles
|
||||
CMakeScripts
|
||||
Testing
|
||||
Makefile
|
||||
cmake_install.cmake
|
||||
install_manifest.txt
|
||||
compile_commands.json
|
||||
CTestTestfile.cmake
|
||||
_deps
|
||||
CMakeUserPresets.json
|
||||
|
||||
# CLion
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#cmake-build-*
|
||||
|
||||
@@ -1,309 +1,189 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repo. This is a **BepInEx 6 (IL2CPP)** Harmony plugin that points the
|
||||
Rec Room client at a self-hosted server. Read the README for the user-facing overview; this file is
|
||||
the stuff you only learn by getting burned.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
This is the **native (C/Win32) redirector** that points the Rec Room client at a self-hosted server
|
||||
**without any managed mod loader**. It replaces the sibling `../recnet-patcher` project (a BepInEx/
|
||||
MelonLoader Harmony plugin) — both loaders fail on current Rec Room builds (BepInEx crashes in
|
||||
`il2cpp_init`; the loader trips the anti-cheat memory-integrity scan). This build is loaded as a
|
||||
`version.dll` proxy and hooks Winsock + il2cpp methods directly in native code. Read `README.md` for
|
||||
the user-facing overview; this file is the stuff you only learn by getting burned.
|
||||
|
||||
## Build & deploy
|
||||
|
||||
```sh
|
||||
dotnet build -c Debug -p:GamePath="C:\Games\depots\471711\23191908"
|
||||
Rec Room / `GameAssembly.dll` is **64-bit — you must build x64**. A 32-bit DLL silently fails to load.
|
||||
The default VS dev shell is x86, and the PowerShell tool **does not persist env vars between calls**,
|
||||
so the amd64 env import and the cmake/build must run in the **same** call, else you get an x86 DLL:
|
||||
|
||||
```powershell
|
||||
$vcvars = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat"
|
||||
cmd /c "`"$vcvars`" amd64 >nul 2>&1 && set" | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { Set-Item -Path "Env:$($matches[1])" -Value $matches[2] } }
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release # add -DGAME_DIR="C:\Games\recflare-client-unstable" to deploy
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
- `GamePath` points at the Rec Room install root. It's normally set in the gitignored
|
||||
`GamePath.props` (see `GamePath.props.example`); the `-p:GamePath=...` override is handy for one-offs.
|
||||
- The project references ~300 interop DLLs from `$(GamePath)\BepInEx\interop`. Those are generated by
|
||||
Il2CppInterop the first time the game runs under BepInEx — if they're missing, launch the game once.
|
||||
- A post-build `DeployPlugin` target copies `RecNetPlugin.dll` into `$(GamePath)\BepInEx\plugins\`.
|
||||
**The copy fails while Rec Room is running** (the DLL is locked) — that's an MSB3027 error, not a
|
||||
compile error. Close the game and rebuild. The DLL is also always left in `bin/Debug/net6.0/`.
|
||||
- **Verify arch** from the PE header after building: machine word at `(int32 @0x3C)+4` must be **0x8664**,
|
||||
not 0x014C. `cmake` refuses to configure a non-64-bit toolchain (guard in `CMakeLists.txt`).
|
||||
- Toolchain: VS 2022 Community; `cmake`/`ninja` ship with it. Installed Windows SDK is **10.0.19041.0**;
|
||||
the VS-generator MSBuild probe can't find it without the VC env — another reason to use Ninja inside
|
||||
the imported amd64 env.
|
||||
- Build output is **`build\version.dll`** (`OUTPUT_NAME version`, `PREFIX ""`) and that is the *only*
|
||||
artifact — it is self-contained. It used to ship alongside a `version_orig.dll` (a copy of the system
|
||||
DLL) that its exports statically forwarded to; that dependency was removed in f3eb296 in favour of
|
||||
runtime forwarding, so **do not** re-add a copy step for it. `-DGAME_DIR=...` copies `version.dll`
|
||||
into the game folder. **The copy fails while Rec Room is running** (DLL locked) — close the game first.
|
||||
|
||||
## Hard-won gotchas (read before patching anything)
|
||||
## Architecture
|
||||
|
||||
1. **Interop assemblies are stubs. The real code is native.** The DLLs under `BepInEx/interop` are
|
||||
Il2CppInterop proxies — method bodies just marshal into `GameAssembly.dll`. dnSpy / managed
|
||||
decompilation of the interop shows *no real logic*. You cannot read the actual algorithms
|
||||
statically; you learn behavior by patching + logging at runtime.
|
||||
`DllMain` (`src/dllmain.c`) spawns one background thread — `HookThread` in
|
||||
`src/hooks/hook_manager.c` — which gates on `IsGameProcess()`, installs the ws2_32 hooks inline, then
|
||||
fans each il2cpp patch out onto **its own thread**, since they each wait independently on the runtime
|
||||
coming up. Order matters only for the memcheck patch (started first, see 5). The pipeline:
|
||||
|
||||
2. **Obfuscated names differ per game build.** Rec Room's type/method names are obfuscated
|
||||
(`PGECJHKNIEN`, `MDBMGOBECDJ`, `cm_did_ppk`, etc.). A dnSpy dump from *some* build will not
|
||||
necessarily match the interop you compile against. Real example from the DUID work: a dnSpy dump
|
||||
called the method `CheckForMismatch` and the pref field `DBAIOPIEJNC`, but in our interop the
|
||||
method is `CheckForDUIDMismatch` and neither `DBAIOPIEJNC` nor `MDBMGOBECDJ` exist at all. **Always
|
||||
resolve members against the interop DLLs you actually build against** (see the Cecil snippet below),
|
||||
never against a dump from an unknown build.
|
||||
1. **Loader vector — `version.dll` proxy** (`src/proxy/version_proxy.c`). `RecRoom.exe` imports
|
||||
`VERSION.dll` by name, and the loader searches the app dir before System32, so our `version.dll`
|
||||
loads very early (before `UnityPlayer.dll`). All 17 real exports are satisfied by local `my_*`
|
||||
wrappers, aliased to the real names via `#pragma comment(linker, "/export:NAME=my_NAME")` (aliases,
|
||||
**not** PE forwarders — no dot in the target). Each wrapper lazily `LoadLibraryW`s the genuine
|
||||
`%SYSTEM32%\version.dll` **by absolute path** (so the app-dir search can't loop back into us) and
|
||||
calls through. `DllMain` starts the hook thread. To retarget at `winhttp.dll` instead, swap this
|
||||
file's export list for winhttp's.
|
||||
|
||||
3. **Patch the concrete class, not the IL2CPP "interface".** Il2CppInterop renders IL2CPP interfaces
|
||||
as abstract classes deriving from `Il2CppObjectBase`. Harmony will happily patch an abstract method
|
||||
and throw no error, but the prefix **never runs** because the game dispatches to the concrete
|
||||
implementation. This cost us a whole "shipped fix" that did nothing. Concrete impls live in
|
||||
`Assembly-CSharp.dll`. Example: patch `CheatManager.CheckForDUIDMismatch`, *not* the interface
|
||||
`PGECJHKNIEN.CheckForDUIDMismatch`. Verify with Cecil that `IsAbstract == false` before trusting a
|
||||
patch.
|
||||
2. **DNS host rewrite** (`src/hooks/dns_hook.c`, detours `ws2_32!getaddrinfo`). On an **exact-match**
|
||||
lookup it swaps the hostname (`ns.rec.net` → `ns.recflare.net`) and delegates to the real
|
||||
`getaddrinfo`, so the client reaches the target's *current* IP. This alone is **not sufficient** —
|
||||
it only changes DNS resolution; SNI and the HTTP `Host:` header still say `ns.rec.net`. Kept as a
|
||||
safety net.
|
||||
|
||||
4. **Obfuscated members live in the global namespace** and are referenced unqualified in this codebase
|
||||
(e.g. `typeof(LEALBOODIEE)`, `PGECJHKNIEN`). No `using` needed.
|
||||
3. **HTTP host rewrite** (`src/unity/http_rewrite.c`) — the real fix. The alternate backend
|
||||
(`ns.recflare.net`) serves its own vhost/cert, so requests must carry that host in URL + SNI + Host.
|
||||
This replicates the managed `SendRequestPatch`: it waits for the il2cpp runtime, resolves the
|
||||
concrete static `BestHTTP.HTTPManager.SendRequest(HTTPRequest)`, reads `req.get_Uri().get_AbsoluteUri()`,
|
||||
swaps the host, and `req.set_Uri(new System.Uri(...))` before forwarding. **This is a call-through
|
||||
hook** (must run the original) so it depends on the trampoline in `detour.c`.
|
||||
|
||||
5. **Harmony prefix conventions here:** force a value + `return false` to skip the original (see
|
||||
`Patches/EACPatches.cs`). **Bind parameters positionally (`__0`, `__1`, …), never by their
|
||||
obfuscated name** — obfuscated *parameter* names change per build just like type names, and binding
|
||||
by name fails at load with `Parameter "XXX" not found in method ...`. For out-params take
|
||||
`ref string __0` plus `ref bool __result`.
|
||||
4. **TLS pinning bypass** (`src/unity/ssl_patch.c`). Redirecting to a mismatched cert fails the
|
||||
handshake; this resolves the concrete `Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication`
|
||||
`.NotifyServerCertificate` and detours it to an accept-all no-op. **Replace-only** hook (never calls
|
||||
the original).
|
||||
|
||||
6. **Method names in `[HarmonyPatch]` are strings — the compiler does not check them.** A renamed
|
||||
*type* is a build error; a renamed *method* builds fine and only shows up as a HarmonyX error in
|
||||
`LogOutput.log` at load, or (worse) as a patch that silently never runs. After a game upgrade,
|
||||
re-verify every string method name with Cecil, don't just trust a green build.
|
||||
5. **Memory-integrity scan neutralizer** (`src/unity/memcheck_patch.c`) — **the reason 3/4/6 are
|
||||
possible at all.** A background scan hashes `GameAssembly.dll` code against baked-in hashes; our
|
||||
inline patches change that memory, so boot dies *"Launch validation failed. Is Rec Room installed
|
||||
correctly?"*. The scanner class is obfuscated and **rotates every build**, so it is matched by
|
||||
*shape*: the class in `Assembly-CSharp.dll` carrying both a `System.Threading.Thread` and a
|
||||
`CancellationTokenSource` field. Its public/instance/0-param/non-void method is the scan entry; its
|
||||
return type is the promise the boot step awaits. We detour that entry (replace-only) to return an
|
||||
already-resolved promise, obtained by `il2cpp_runtime_invoke` on the promise type's static
|
||||
special-name 0-param `Resolved` getter (found by sweeping every image for a getter returning that
|
||||
exact class, skipping `_k__BackingField`). The getter is test-invoked **before** committing the
|
||||
detour — if it returns null or throws we skip the hook rather than hand boot a null promise.
|
||||
`PatchMemoryIntegrityCheck` is started **first** among the il2cpp patches: the boot step that awaits
|
||||
the scan can fire early and the reflection sweep takes ~700 ms, so it needs the head start.
|
||||
|
||||
### Surviving a game-version upgrade
|
||||
6. **EAC neutralizer** (`src/unity/eac_patch.c`). Two replace-only hooks on the literal
|
||||
`RecRoom.AntiCheat.EACManager` (namespace+class are *not* obfuscated; the methods are):
|
||||
- **Readiness → true.** The real check needs EasyAntiCheat services that no longer exist. Matched by
|
||||
shape: the sole static, 0-param, `bool`-returning, **non**-special-name method (excluding
|
||||
special-name is what keeps property getters out). Hook returns 1 — note the native signature is
|
||||
`(void *methodInfo)`, since a static il2cpp method still gets `MethodInfo*` in RCX.
|
||||
- **`GenerateChallengeResponse(string)` → `base64(challenge)`**, `base64("nothing")` for null/empty.
|
||||
Unobfuscated name, resolved directly. `g_gcr_static` is read from the method flags because it
|
||||
decides whether the string arg arrives in RCX or RDX — get that wrong and you base64 a `this`
|
||||
pointer.
|
||||
|
||||
Obfuscated names are re-rolled every build. Unobfuscated names (`CheckForDUIDMismatch`, `WriteDUIDs`,
|
||||
`ClearDUIDs`, `SendRequest`, `NotifyServerCertificate`, `GenerateChallengeResponse`) have been stable
|
||||
across upgrades so far; everything else must be re-resolved. Don't guess from the old name — **search
|
||||
the new interop by signature**, which is what actually identifies the target:
|
||||
Both are only safe because 5 has already neutralized the hash check.
|
||||
|
||||
| Patch | Target | Signature that identifies it |
|
||||
| --- | --- | --- |
|
||||
| `PhotonPatches` | `HPEENKELKDJ.MGKINLFMJLB` | only instance, 0-param method returning `Photon.Realtime.AppSettings` in `Assembly-CSharp` (the static/2-param `PUNNetworkManager` sibling also returns it — exclude it) |
|
||||
| `EACPatches` (is-ready) | `EACManager.MCFIOBHCFBB` | only static, 0-param `bool` on `EACManager` that isn't a property getter; type lives in `RecRoom.Rranticheat.Runtime.dll` |
|
||||
`src/core/` = logger + JSON-ish config + process info (incl. `IsGameProcess`). `src/utils/strings.c` = host match/rewrite.
|
||||
`src/debug/` and `connect_hook.c` are logging stubs / the disabled connect hook. Config is
|
||||
`redirector.json` next to `RecRoom.exe` (sample `.example`), parsed by a **flat key-scan, not real
|
||||
JSON** — keep it flat, one object per rewrite.
|
||||
|
||||
> **Il2CppInterop regenerates on launch and obfuscation can differ between generations.** Only scan the
|
||||
> interop the game *actually loaded* — check that its mtime is *after* the last game launch, and treat a
|
||||
> clean HarmonyX load (no "Could not find method") as the real proof. A stale/mismatched interop
|
||||
> generation once produced a whole different name set (`IHODDIDPEOD.JEGOHKJDPFH`, `EACManager.KOIFGPGJGKB`)
|
||||
> that got overwritten on the next launch back to the names below — patching against it failed at load.
|
||||
## Hard-won gotchas (read before touching hooks)
|
||||
|
||||
Renames observed in the **20230414 build** (`C:\Games\recflare-client`, Steam manifest
|
||||
`6426603215211043630`):
|
||||
1. **The detour engine has two modes; picking wrong corrupts code** (`src/memory/detour.c`).
|
||||
`InstallDetour(target, hook, backup, outTrampoline)`:
|
||||
- `outTrampoline != NULL` → **call-through**: it length-decodes the prologue (`decode`/`steal_len`),
|
||||
copies **whole instructions** (≥14 bytes) into a trampoline with relocation fixups
|
||||
(`RelocateInto`), and NOP-pads. Use when the hook calls the original (DNS, HTTP).
|
||||
- `outTrampoline == NULL` → **replace-only**: a blind 14-byte overwrite. Only safe when the hook
|
||||
never calls through (SSL), because we jump away immediately so a torn trailing instruction is never
|
||||
executed.
|
||||
The original crash bug was a *blind* 14-byte copy on a call-through target: `ws2_32!getaddrinfo`'s
|
||||
prologue has instruction boundaries at 3/7/11/**15**, so 14 bytes tore the 4th `mov` and the
|
||||
trampoline ran garbage → whichever thread called the original died. The symptom was subtle: the game
|
||||
booted (the first lookup runs on a Unity *background* thread that died silently) but the client's real
|
||||
API lookup later hit the same broken trampoline and its thread died before any request left the process.
|
||||
|
||||
- `LEALBOODIEE.GBNKOFMAJPA` → `HPEENKELKDJ.MGKINLFMJLB`
|
||||
- `EACManager.IMMGELPFGCK` → `EACManager.MCFIOBHCFBB`
|
||||
- `CheckForDUIDMismatch` out-param → `BPOGCIINKBB` (still bound as `__0`, no source change)
|
||||
2. **The length decoder relocates two cases and bails on the rest.** `decode()` classifies each
|
||||
instruction: `RK_RIPREL` (rip-relative disp32, `mod=00,rm=101`) and `RK_REL32` (`E8`/`E9`) are
|
||||
**relocated** — `AllocNear` places the trampoline within ±2 GB of the target so the rewritten
|
||||
displacements still fit in int32, and each fixup is range-checked. rip is computed from the *end of
|
||||
the whole instruction*, past any trailing immediate. `decode` returns 0 on a two-byte (`0x0F`)
|
||||
opcode or anything it doesn't model, and `rel8` branches are measured but flagged `RK_UNSUPPORTED`
|
||||
(they'd need a rel8→rel32 rewrite); in all those cases `InstallDetour` **refuses the hook** (logs
|
||||
it) rather than corrupt code. This is what unblocked the call-through hook on
|
||||
`HTTPManager.SendRequest`, whose prologue is the usual il2cpp class-init check `cmp byte [rip+disp],
|
||||
0` + `jne` — it now steals 16 bytes and relocates cleanly. ws2_32 stubs are position-independent and
|
||||
hook fine as-is (`getaddrinfo` steals 15).
|
||||
|
||||
Renames observed in the **07-21 build** (`C:\Games\recflare-client`), for reference:
|
||||
3. **il2cpp method resolution.** Resolve types by literal namespace+name across all loaded assemblies
|
||||
(`il2cpp_domain_get_assemblies` → `il2cpp_assembly_get_image` → `il2cpp_class_from_name` — the last
|
||||
only searches the image you give it, so sweep). Get methods with `il2cpp_class_get_method_from_name`
|
||||
(argc counts declared params only). **There is no `il2cpp_method_get_pointer` export** — read the
|
||||
compiled entry from `MethodInfo` offset 0 (`methodPointer`, `*(void**)method`). Wait for
|
||||
`il2cpp_domain_get()` to be non-NULL (runtime init) before resolving, and `il2cpp_thread_attach` your
|
||||
native thread before any metadata call. Interop DLLs name the type `Il2CppSystem.Uri`, but the runtime
|
||||
metadata namespace is plain `System`/`Uri`.
|
||||
|
||||
- `CheckForDUIDMismatch` out-param `ALOMDLLNIMD` → `LICOPEEMHHG` (now bound as `__0`)
|
||||
- `GenerateChallengeResponse` param `PGCINMIEBJP` → `__0`
|
||||
- `GPFPFDBGCEK.AMOHMPKKGHL` → `LEALBOODIEE.GBNKOFMAJPA`
|
||||
- `EACManager.FJLMLEPOKGE` → `EACManager.IMMGELPFGCK`
|
||||
- `JAPJPGNBMNM`, `HPHDJAFFHCN<>`, `HAAHJPGNIMD` — **gone** from `Assembly-CSharp`. These were the
|
||||
image-signing `PromisePatch`, now deleted and replaced by `ImageSigningPatch` (see below), which
|
||||
hooks framework types instead and so has no obfuscated names left to break.
|
||||
4. **Framework names are stable; patch the concrete class.** `SendRequest`, `get_Uri`/`set_Uri`,
|
||||
`AbsoluteUri`, `NotifyServerCertificate`, `HTTPManager`, `LegacyTlsAuthentication` are unobfuscated and
|
||||
have survived build changes — this is *why* these are the hook points. As in the managed project, hook
|
||||
the **concrete** impl, never an il2cpp interface. Verify signatures with Mono.Cecil against the interop
|
||||
in `../recflare-client/BepInEx/interop` when they drift (see the sibling `../recnet-patcher/CLAUDE.md`
|
||||
for the Cecil load snippet; that project's interop has the same types).
|
||||
|
||||
## Image signature verification
|
||||
5. **version.dll loads into multiple processes — log per-PID.** Our DLL loads into the game, the
|
||||
EasyAntiCheat launcher/bootstrap, and the crash handler. They previously shared `redirector.log` opened
|
||||
with `"w"` and truncated each other (the EAC process, stuck forever in `WaitForUnity` because
|
||||
`UnityPlayer.dll` never loads there, buried the game's diagnostics under module dumps). The logger now
|
||||
writes **`redirector_<pid>.log`**, and `WaitForUnity` is time-bounded. `HookThread` now returns
|
||||
immediately unless `IsGameProcess()` (`src/core/process.c`, basename == `RecRoom.exe`) — that check
|
||||
sits **before** `InitConsole`/`InitLogger`, because `AllocConsole` in the crash-handler process was
|
||||
opening a second debug window on every launch. So only `RecRoom.exe` writes a log at all now; if you
|
||||
need diagnostics from a sibling process, move the gate to wrap `InitConsole` alone.
|
||||
|
||||
The client verifies images against an RSA public key whose modulus is a string literal in
|
||||
`global-metadata.dat`. Patching that literal is fragile; **don't**. Hook the framework instead.
|
||||
6. **The connect hook is intentionally disabled.** `src/hooks/connect_hook.c` blindly redirects *all*
|
||||
:443 traffic (would break Photon/CDN/telemetry). `getaddrinfo` covers the il2cpp DNS path surgically.
|
||||
`gethostbyname` also has a latent self-recursion bug (calls `real_gethostbyname`, not a trampoline) if
|
||||
ever installed.
|
||||
|
||||
The decisive observation: the literal base64-decodes to **exactly 256 bytes and does not start with
|
||||
`0x30`**, so it is a *raw* 2048-bit modulus, not a DER/SPKI blob. The client base64-decodes it and
|
||||
hand-builds `RSAParameters`, then verifies with `mscorlib` RSA — plain unobfuscated names. Rather than
|
||||
touch the modulus, we just force the verify to succeed. Confirmed working at runtime: images load with
|
||||
no signing check.
|
||||
|
||||
`Patches/ImageSigningPatch.cs` hooks, all in `Il2Cppmscorlib.dll`:
|
||||
|
||||
| Hook | Purpose |
|
||||
| --- | --- |
|
||||
| `RSACryptoServiceProvider.VerifyData` / `VerifyHash` ×2 | **the fix:** forces the verify to succeed |
|
||||
|
||||
Config lives in `[Signing]`, a single knob: `Disable Signature Verification` defaults **true** —
|
||||
that's the shipped behaviour, since this setup doesn't use image signing. The patch only *removes* the
|
||||
check (forces verify-true); it does not swap in a replacement key.
|
||||
|
||||
Two things to remember:
|
||||
|
||||
- **Blast radius is the point, not a wart.** Forcing verify-true affects *all* mscorlib RSA
|
||||
verification, not just images. That breadth is load-bearing — see the warning below. BestHTTP's TLS
|
||||
uses its own bundled BouncyCastle, so cert validation appears unaffected — inferred from assembly
|
||||
layout, not proven. Keep it behind the config knob.
|
||||
- **If it ever stops working:** images failing to load with the knob on means verification moved off
|
||||
mscorlib RSA onto `BestHTTP.SecureProtocol.Org.BouncyCastle`; the equivalent hooks there are the
|
||||
**concrete** `RsaDigestSigner`/`PssSigner.VerifySignature` (not the abstract `ISigner` — gotcha 3).
|
||||
|
||||
## Analytics / telemetry
|
||||
|
||||
**One knob**, `[Analytics] Disable Telemetry`, default **true**, gating all three patch files below.
|
||||
One switch is the deliberate choice: nobody wants Amplitude gone but the collector alive, and per-vendor
|
||||
knobs are just more ways to end up half-configured.
|
||||
|
||||
**But they are not one mechanism.** Each vendor sends over a different stack, and *that*, not the
|
||||
hostname, is what decides how you block it — a host blocklist would be dead code for two of these four:
|
||||
|
||||
| Vendor | Patch file | Stack it sends over | How it's blocked |
|
||||
| --- | --- | --- | --- |
|
||||
| Amplitude | `AmplitudePatch.cs` | BestHTTP | `Log*` prefixes + host block on `amplitude.com` |
|
||||
| Data collector | `AmplitudePatch.cs` | BestHTTP | host block on first label `datacollection*` |
|
||||
| Backtrace | `BacktracePatch.cs` | **UnityWebRequest** | `BacktraceHttpClient.Post` ×4 |
|
||||
| Unity Analytics | `UnityTelemetryPatch.cs` | **native (neither)** | Unity's own opt-out properties — **doesn't work, see below** |
|
||||
|
||||
The collector is matched on the hostname's *first label*, not a fixed domain, so it stays right across
|
||||
deployments (`…recflare.net`, `…rec.net`). It's a first-party endpoint, hence its own knob — you may
|
||||
want Amplitude gone but the collector alive, or the reverse.
|
||||
|
||||
The collector is matched on the hostname's *first label*, not a fixed domain, so it stays right across
|
||||
deployments (`…recflare.net`, `…rec.net`). It's a first-party endpoint, hence its own knob — you may
|
||||
want Amplitude gone but the collector alive, or the reverse.
|
||||
|
||||
**Blocking the `Log*` entrypoints is not sufficient, and this is the trap.** `LogEventAsync`,
|
||||
`LogSerializedEventAsync`, `LogIdentifyAsync` etc. only *queue*; the queue is persisted to the
|
||||
`pending_room_stats` PlayerPref and drained later by the client's own flush coroutines. So a batch
|
||||
queued before the plugin existed (or during a session where a `Log*` door we didn't cover was used)
|
||||
still ships on the next launch, and mitmproxy keeps showing `api2.amplitude.com` even though
|
||||
LogOutput.log says `[AMPLITUDE] ... blocked LogEventAsync`. **Symptom-to-cause: prefixes visibly
|
||||
firing + traffic still leaving means you blocked the producer, not the sender.**
|
||||
|
||||
The send path, for the record: `AmplitudeAnalyticsClient` → transport interface `FOMPBHDLPDO`
|
||||
(`MAJANJBIDMF` / `KBECOPLKGHL`) → concrete impl **`MHBPNDOGOLG` in `RecNet.Runtime.dll`** → BestHTTP.
|
||||
Note `RecRoom.Analytics.Runtime.dll` has *no* assembly reference to BestHTTP/UnityWebRequest — the
|
||||
transport is injected, so grepping the analytics assembly for an HTTP type finds nothing.
|
||||
|
||||
So the actual block is at the BestHTTP layer: a second prefix on
|
||||
`HTTPManager.SendRequest(HTTPRequest)` (the same method `SendRequestPatch` hooks) that drops any
|
||||
request to a blocked host. Two properties worth keeping:
|
||||
|
||||
- **No obfuscated names.** `HTTPManager`/`HTTPRequest`/`HTTPResponse` are BestHTTP's own types, so
|
||||
this survives a game upgrade even though every name in the analytics path above will re-roll.
|
||||
- **It fakes a 200, not a failure.** We build an `HTTPResponse` (status 200, Amplitude's real body
|
||||
shapes: `success` for `/identify`, the `{"code":200,...}` envelope otherwise; `{"success":true}` for
|
||||
the collector, whose shape we don't know — an empty body would trip the RecNet wrapper's "Response
|
||||
was empty"), set `State = Finished`, and invoke `request.Callback` inline. The transport resolves its
|
||||
promise, the client considers the batch delivered and clears `pending_room_stats`. Failing the
|
||||
request instead would leave the batch queued and retried every session forever.
|
||||
|
||||
RudderStack (`get_OutOfSessionRudderStackKey`) and gamesight are *not* covered — add their hosts to
|
||||
`AmplitudeDomains` / `CollectorLabelPrefixes` if they need to go too.
|
||||
|
||||
### Backtrace (`submit.backtrace.io`) — UnityWebRequest, not BestHTTP
|
||||
|
||||
`Backtrace.Unity.dll` references `UnityEngine.UnityWebRequestModule` and nothing else HTTP-shaped, so
|
||||
the BestHTTP host block never sees it. Two things make this one easy: the SDK is a third-party package
|
||||
and therefore **unobfuscated** (no per-build churn to survive), and every submission it makes — crash
|
||||
reports, minidumps, metrics — funnels through the four **concrete** `BacktraceHttpClient.Post`
|
||||
overloads (`IBacktraceHttpClient` is the interface — gotcha 3).
|
||||
|
||||
The overloads split by *who sends the request*, which decides the patch shape:
|
||||
|
||||
- `void Post(url, jObject, onComplete)` — the SDK sends internally. Prefix + skip, then invoke
|
||||
`onComplete(200, false, "{}")` ourselves. Answering it matters: the metrics queue holds the batch
|
||||
until the callback reports success, so a silent skip means it retries forever.
|
||||
- `UnityWebRequest Post(…)` ×3 — builds the request and hands it back; **the caller** sends it
|
||||
(`yield return request.SendWebRequest()`). A prefix returning null gets dereferenced, so instead a
|
||||
*postfix* repoints the finished request at `http://127.0.0.1:1/` — nothing listens, so it fails
|
||||
connection-refused in microseconds with no packet leaving the box, and the SDK takes its ordinary
|
||||
offline path. Repointing beats rebuilding: the SDK keeps its own handlers and headers, so there's
|
||||
nothing to guess about what the caller dereferences next.
|
||||
|
||||
Not covered: `RecRoomNativeClient` installs a native crash handler, and a minidump it uploads on the
|
||||
launch after a hard crash never passes through managed code. A multipart minidump POST to
|
||||
submit.backtrace.io with the hooks visibly firing is that path.
|
||||
|
||||
### Unity Analytics / Performance Reporting (`perf-events.cloud.unity3d.com`) — ⚠️ UNSOLVED, and fine
|
||||
|
||||
**Confirmed not working on the 20230414 build; accepted as-is — don't re-litigate it.** The setters are
|
||||
refused, `enabled` reads back `True` on every attempt, and the uploads keep flowing. The other three
|
||||
`[Analytics]` knobs all confirmed dropping at runtime, and they account for the bulk of the traffic, so
|
||||
this one is noise-floor. If it ever has to go for real it needs a hosts-file/DNS block or a native
|
||||
hook — there is no managed lever. What follows is why, kept because the *approach* is right even
|
||||
though this build refuses it.
|
||||
|
||||
|
||||
`UnityEngine.Analytics.Analytics` and `PerformanceReporting` are thin managed shims over native engine
|
||||
code; the uploads happen inside the player, on no managed send path and over neither HTTP stack. What
|
||||
they do have is a documented opt-out, so `UnityTelemetryPatch` just sets it: `PerformanceReporting.
|
||||
enabled = false`, `Analytics.enabled = false`, `deviceStatsEnabled = false`, `limitUserTracking = true`.
|
||||
|
||||
Two traps, both handled there: these are native setters that can be **silently refused**, so the patch
|
||||
*reads the properties back* and only believes it worked if they read false — the `[UNITY-TELEMETRY]`
|
||||
log line is the proof, the assignment isn't. (That read-back is what caught this build refusing them;
|
||||
without it the knob would have looked like it worked.) And since "not initialised yet" was the leading
|
||||
theory for the refusal, it retries from `OnSceneLoaded`, capped at 5 attempts — which ruled that theory
|
||||
out, because all five read back `True`.
|
||||
7. **Rec Room's own names are obfuscated and rotate every build — match by shape, never by name.**
|
||||
Gotcha 4's framework names are the exception; anything in `Assembly-CSharp` is an 11-char scramble
|
||||
(`NAEMGPMOPED`, `JMCKLNABHHJ`) that differs next build, so **never hard-code one you saw in a log**.
|
||||
The two patches that need such a target (5, 6) locate it through the il2cpp reflection API by
|
||||
structure instead — field types, static-ness, param count, return type, special-name flag. Rules
|
||||
that follow from getting this wrong:
|
||||
- **Log every candidate; warn on >1.** The *method* searches count matches and log
|
||||
`WARNING N candidates` when ambiguous (scan-start in 5, readiness in 6). The **scanner class**
|
||||
search in 5 does not — it logs each `[MEMCHECK] scanner candidate` and silently keeps the last.
|
||||
Real logs already show **two**, so that field signature is *not* unique and the patch is riding on
|
||||
ordering. It works today; treat it as the most fragile thing here, and read those lines before
|
||||
trusting a boot.
|
||||
- **Never fall back to "close enough".** Every resolver bails with a log line rather than hooking a
|
||||
guess — a wrong detour on a rotating target corrupts an unrelated method.
|
||||
- **Verify before committing an irreversible detour** where you can (5 test-invokes the `Resolved`
|
||||
getter first).
|
||||
|
||||
## Inspecting the game
|
||||
|
||||
Use **Mono.Cecil** for static metadata/signature checks (accessibility, abstract-ness, exact param
|
||||
names, which assembly a concrete impl lives in). Mark-of-the-web will block loading `Mono.Cecil.dll`
|
||||
directly — copy it somewhere local, `Unblock-File`, then load via bytes:
|
||||
|
||||
```powershell
|
||||
$interop = "$env:GamePath\BepInEx\interop"
|
||||
$dst = "$scratch\Mono.Cecil.dll"
|
||||
# NOTE: Mono.Cecil.dll ships in BepInEx/core, NOT in BepInEx/interop.
|
||||
Copy-Item "$env:GamePath\BepInEx\core\Mono.Cecil.dll" $dst; Unblock-File $dst
|
||||
[System.Reflection.Assembly]::Load([System.IO.File]::ReadAllBytes($dst)) | Out-Null
|
||||
$asm = [Mono.Cecil.AssemblyDefinition]::ReadAssembly("$interop\Assembly-CSharp.dll")
|
||||
# then walk $asm.MainModule.GetTypes(), inspect .Methods / .Fields / .IsAbstract / .IsStatic ...
|
||||
```
|
||||
|
||||
Not every target is in `Assembly-CSharp` — `EACManager` is in `RecRoom.Rranticheat.Runtime.dll`,
|
||||
`HTTPManager`/`LegacyTlsAuthentication` in `RecNet.Runtime.dll`. When a lookup comes up empty, sweep
|
||||
all ~305 DLLs in `interop/` (`ReadAssembly` each, `.Dispose()` after) before concluding it's gone.
|
||||
|
||||
Notes:
|
||||
- Windows PowerShell 5.1 has **no** `?.` null-conditional operator — use explicit `$x -eq $null` checks.
|
||||
- String literals (pref keys, endpoints, GUIDs) are in `RecRoom_Data/il2cpp_data/Metadata/global-metadata.dat`.
|
||||
`grep -a -o -E '[ -~]{4,}' global-metadata.dat | grep -i <thing>` extracts them.
|
||||
- The BepInEx runtime log is `$(GamePath)/BepInEx/LogOutput.log`. Our plugin logs under the
|
||||
`RecNet Plugin` source. `[HTTP]`, `[DUID]`, `[DUID-PROBE]`, `[DEVICEID]`, `[CORRUPT]` are our tags.
|
||||
- PlayerPrefs on Windows live in the registry at `HKCU\Software\Against Gravity\Rec Room`, value names
|
||||
are `<key>_h<unityHash>`, values are `REG_BINARY`. CodeStage AntiCheat stores strings *obscured*
|
||||
(XOR-encrypted), so a stored id will not appear as plaintext in registry or files.
|
||||
|
||||
## Case study: the Create Account / DUID hang
|
||||
|
||||
The gnarliest bug so far; the diagnostic tooling for it still lives in the repo. Summary:
|
||||
|
||||
- **Symptom:** on some machines Create Account hangs. Log shows a `PlayerReporting/v1/deviceId` POST
|
||||
returning `200 {"success":true}`, after which the client never calls the `create_account` OAuth and
|
||||
never persists the id (`WriteDUIDs` never runs).
|
||||
- **Trigger:** a device-id **mismatch**. `CheatManager.CheckForDUIDMismatch(out string)` returns true
|
||||
when the stored id differs from `SystemInfo.deviceUniqueIdentifier`. True → migration path → POST →
|
||||
hang. Machines whose stored id matches never take the path.
|
||||
- **Stored id:** PlayerPrefs key `cm_did_ppk` (registry `cm_did_ppk_h3478365449`), CodeStage
|
||||
ObscuredString-encoded. `CheatManager.WriteDUIDs()` writes it, `ClearDUIDs()` deletes it. In our
|
||||
interop these are **instance** methods (a dnSpy dump showed them static — build difference again).
|
||||
- **Two surprises:**
|
||||
1. Deleting the registry value did **not** change the `oldDeviceId` in the POST, and the probe
|
||||
showed no `cm_did_ppk` read that session — i.e. the "old" id is **not sourced from local
|
||||
PlayerPrefs** on the failing path. Consequence: **a registry-reset script does not fix it.**
|
||||
2. `game callback attached = True` on that request → the client is genuinely waiting on the
|
||||
response. But the real server already returns `{"success":true}` at 200 and it still hangs, so the
|
||||
accepting response shape (if one exists) is something more specific.
|
||||
|
||||
> ### ⚠️ OPEN QUESTION — where does the old DUID actually live?
|
||||
> We have **not** found the source of the `oldDeviceId` value. It survives a full delete of the
|
||||
> `HKCU\Software\Against Gravity\Rec Room` registry key, it does not appear as plaintext anywhere in
|
||||
> `AppData/LocalLow/Against Gravity/Rec Room`, and on the failing run the `cm_did_ppk` PlayerPref is
|
||||
> never read. So `cm_did_ppk` is *a* copy but not the one that seeds the migration POST. Leading (but
|
||||
> unconfirmed) theory: it's held server-side by the archival server, which recorded it from prior
|
||||
> POSTs, and/or cached in memory from a server response. **Until this is found, the only reliable fix
|
||||
> is `Suppress` (client) or correcting the value server-side — not clearing local storage.** Next
|
||||
> steps to try: inspect what the recflare backend stores/returns for the account's device id; dump the
|
||||
> `HTTPCache` entries decoded (not plaintext); trace who sets the field the POST body reads from.
|
||||
- **Working client-side workaround:** force `CheckForDUIDMismatch` → false (skips the migration path
|
||||
entirely). Config: `Suppress DUID Mismatch = true`.
|
||||
- **Proper root-cause fix:** server-side — make the endpoint stop reporting a stale `old` id (so
|
||||
`old == new`, no mismatch) or return whatever the client needs to proceed.
|
||||
|
||||
### Diagnostic knobs (all in `[Advanced]`)
|
||||
|
||||
`Suppress DUID Mismatch` defaults **true** (it's the shipped fix). Everything else defaults **false** —
|
||||
those are investigation tools, not normal config. See the patch files for details.
|
||||
|
||||
| Config key | Patch file | What it does |
|
||||
| --- | --- | --- |
|
||||
| `Suppress DUID Mismatch` | `DUIDMismatchPatch.cs` | **The fix (default true).** Force `CheckForDUIDMismatch` → false. |
|
||||
| `Simulate DUID Mismatch` | `DUIDMismatchPatch.cs` | Force it → true. Reproduce the hang without a corrupt value. |
|
||||
| `Corrupt Stored DUID` | `CorruptDUIDPatch.cs` | One-shot: write a truncated id via `WriteDUIDs` (spoofing `SystemInfo.deviceUniqueIdentifier`) to create a *genuinely* corrupt stored value. |
|
||||
| `Restore Stored DUID` | `CorruptDUIDPatch.cs` | One-shot undo: `WriteDUIDs` with the real id. |
|
||||
| `DeviceId Response Override` / `Status` | `DeviceIdResponsePatch.cs` | Rewrite the `deviceId` response body/status in-flight to probe what shape the client will accept. |
|
||||
| (probe) | `DUIDProbePatch.cs` | Logs every PlayerPrefs get/set and the `WriteDUIDs`/`ClearDUIDs` calls — `WriteDUIDs() called` is the "client accepted the response" signal. |
|
||||
|
||||
`DUIDMismatchPatch` has three modes: `Simulate` → force true, `Suppress` → force false, neither →
|
||||
pass through to the real check (needed to observe a genuinely corrupt stored value).
|
||||
|
||||
**The diagnostic patches ship** — the DUID hang is still unsolved, so the tooling stays in the build
|
||||
where affected users can turn it on. Before cutting a release, confirm their knobs still default
|
||||
false (`Simulate`, `Corrupt`, `Restore`, `DeviceId Response Override`); an accidentally-true default
|
||||
would break normal play. `Suppress DUID Mismatch` is the real fix and defaults **true**, so it stays on.
|
||||
- **Runtime**: the per-PID log is the source of truth. Our tags: `[STATUS] [HOOK] [DETOUR] [DNS ...]`
|
||||
`[REWRITE] [REDIRECT] [SSL] [HTTP] [MEMCHECK] [EAC]`. Success = `[HTTP] host rewrite installed on
|
||||
SendRequest` then `[HTTP] https://ns.rec.net/... -> https://ns.recflare.net/...` per request. A
|
||||
`[DETOUR] ... refusing hook` line means gotcha 2 — the decoder hit a prologue it won't relocate
|
||||
(`0x0F` opcode, `rel8` branch, or an unmodelled opcode), and **that patch is not active**.
|
||||
- **Static il2cpp**: dump prologue bytes from `GameAssembly.dll` by converting the logged runtime `code=`
|
||||
address to an RVA (subtract the logged `GameAssembly.dll Base`) and mapping RVA→file offset via the PE
|
||||
section headers. Method/field signatures: Mono.Cecil over the interop DLLs (see gotcha 4).
|
||||
- PowerShell here is Windows PowerShell 5.1 — no `?.`; use explicit `$x -eq $null`. Avoid `2>&1` on native
|
||||
exes.
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
project(RRRedirector C)
|
||||
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
|
||||
# Rec Room is 64-bit; a 32-bit build silently fails to load. Guard against an x86 toolchain.
|
||||
if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||
message(FATAL_ERROR "Must build 64-bit. Import the amd64 VC env (vcvarsall.bat amd64) before cmake.")
|
||||
endif()
|
||||
|
||||
# Where to deploy the built proxy. Point at your Rec Room install root; the deploy step below copies
|
||||
# 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
|
||||
|
||||
src/dllmain.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
|
||||
src/core/logger.c
|
||||
src/core/config.c
|
||||
src/core/process.c
|
||||
|
||||
|
||||
# Hooks
|
||||
src/hooks/dns_hook.c
|
||||
src/hooks/connect_hook.c
|
||||
src/hooks/hook_manager.c
|
||||
|
||||
|
||||
# Memory
|
||||
src/memory/detour.c
|
||||
|
||||
|
||||
# Unity
|
||||
src/unity/module_watch.c
|
||||
src/unity/ssl_patch.c
|
||||
src/unity/http_rewrite.c
|
||||
src/unity/memcheck_patch.c
|
||||
src/unity/eac_patch.c
|
||||
|
||||
|
||||
# Utils
|
||||
src/utils/strings.c
|
||||
|
||||
|
||||
# Debug
|
||||
src/debug/api_logger.c
|
||||
src/debug/tls_logger.c
|
||||
src/debug/packet_logger.c
|
||||
)
|
||||
|
||||
|
||||
target_include_directories(
|
||||
redirector PRIVATE
|
||||
include
|
||||
)
|
||||
|
||||
|
||||
|
||||
target_link_libraries(
|
||||
redirector
|
||||
|
||||
ws2_32
|
||||
dbghelp
|
||||
psapi
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Plain injectable DLL: redirector.dll (injected by launcher.exe; no PE export/name requirement).
|
||||
set_target_properties(
|
||||
redirector PROPERTIES
|
||||
|
||||
OUTPUT_NAME "redirector"
|
||||
PREFIX ""
|
||||
)
|
||||
|
||||
|
||||
# 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=... 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}/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.*
|
||||
@@ -1,10 +0,0 @@
|
||||
<Project>
|
||||
<!-- Copy this file to "GamePath.props" (same folder) and set GamePath to your local
|
||||
Rec Room install. GamePath.props is gitignored, so your local path stays out of the repo.
|
||||
|
||||
The install must have been launched once under BepInEx 6 (IL2CPP) so that
|
||||
BepInEx/interop/ is populated with the proxy assemblies this project references. -->
|
||||
<PropertyGroup>
|
||||
<GamePath>C:\Path\To\RecRoom</GamePath>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,283 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using BestHTTP;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Amplitude analytics: the client ships telemetry to Amplitude, which a self-hosted setup has no use
|
||||
// for (and which leaks play data off-box). Prefix every event-logging entrypoint and swallow the call
|
||||
// so nothing is ever queued, batched or sent.
|
||||
//
|
||||
// One knob for all telemetry, `[Analytics] Disable Telemetry`, default true — it gates this file plus
|
||||
// BacktracePatch and UnityTelemetryPatch. Deliberately not split per vendor: nobody wants Amplitude
|
||||
// gone but the collector alive, and four switches for one intention is four ways to be half-configured.
|
||||
//
|
||||
// Target resolution: AmplitudeAnalytics.AmplitudeAnalyticsClient in RecRoom.Analytics.Runtime.dll,
|
||||
// concrete (it derives from SingletonMonoBehaviour<T>, so there is no abstract-interface dispatch
|
||||
// trap here — see gotcha 3 in CLAUDE.md). All five Log* names are UNobfuscated in the 20230414 build.
|
||||
// They are still strings, so a rename shows up only as a HarmonyX "Could not find method" in
|
||||
// LogOutput.log, not as a build error — the per-method [AMPLITUDE] blocked log line below is the real
|
||||
// proof a hook is live.
|
||||
//
|
||||
// Why all five: blocking LogEventAsync alone was not enough — a session's room_stats/perf_stats
|
||||
// events still went out, and the LogEventAsync prefix never logged at all, so that entrypoint was
|
||||
// never even called. Those events are the pre-serialized batch the client parks in the
|
||||
// `pending_room_stats` PlayerPref (visible in the DUID-PROBE log), which points at
|
||||
// LogSerializedEventAsync rather than LogEventAsync.
|
||||
//
|
||||
// Why the Log* prefixes alone STILL are not enough — and why `BlockAnalyticsUploadPatch` below is the
|
||||
// part that actually stops the traffic: uploads to api2.amplitude.com kept showing up in mitmproxy
|
||||
// with LogEventAsync/LogIdentifyAsync visibly blocked in LogOutput.log. The Log* methods only *queue*;
|
||||
// the queue is persisted (`pending_room_stats`) and drained later by the client's own flush coroutines
|
||||
// (Flush / AMEAMPDLJPN / PPOCFIHNKPP), which reach the network through the transport interface
|
||||
// `FOMPBHDLPDO` — concrete impl `MHBPNDOGOLG` in RecNet.Runtime.dll, i.e. BestHTTP. So a batch queued
|
||||
// in an earlier session ships on the next launch no matter what we do to the Log* doors. Blocking at
|
||||
// the BestHTTP layer catches every path, present and future, and costs no obfuscated names.
|
||||
[HarmonyPatch]
|
||||
public static class AmplitudePatch
|
||||
{
|
||||
private static readonly HashSet<string> _loggedBlocked = new();
|
||||
|
||||
// Returns false to skip the original. Logs once per entrypoint so LogOutput.log shows which door
|
||||
// the client actually used.
|
||||
private static bool Block(string entrypoint)
|
||||
{
|
||||
if (_loggedBlocked.Add(entrypoint))
|
||||
Plugin.Log.LogInfo($"[AMPLITUDE] analytics disabled — blocked {entrypoint}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogEventAsync")]
|
||||
private static bool LogEventAsyncPrefix() =>
|
||||
Plugin.DisableTelemetry.Value && Block("LogEventAsync");
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogPrevSessionEventAsync")]
|
||||
private static bool LogPrevSessionEventAsyncPrefix() =>
|
||||
Plugin.DisableTelemetry.Value && Block("LogPrevSessionEventAsync");
|
||||
|
||||
// The likely culprit for the room_stats/perf_stats batch — takes the already-serialized
|
||||
// Dictionary<string, object> that gets parked in `pending_room_stats`.
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogSerializedEventAsync")]
|
||||
private static bool LogSerializedEventAsyncPrefix() =>
|
||||
Plugin.DisableTelemetry.Value && Block("LogSerializedEventAsync");
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogIdentifyAsync")]
|
||||
private static bool LogIdentifyAsyncPrefix() =>
|
||||
Plugin.DisableTelemetry.Value && Block("LogIdentifyAsync");
|
||||
|
||||
// The odd one out: static, and it returns a promise instead of void. Skipping it with a null
|
||||
// __result would hand the caller something it will chain .Then() on, so we substitute an
|
||||
// already-resolved promise — the call looks like it succeeded instantly. If we cannot build one,
|
||||
// we let the original run rather than risk a null-deref at quit time.
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(AmplitudeAnalytics.AmplitudeAnalyticsClient), "LogOutOfSessionEvent")]
|
||||
private static bool LogOutOfSessionEventPrefix(ref LAHBDKNMNHN __result)
|
||||
{
|
||||
if (!Plugin.DisableTelemetry.Value)
|
||||
return true;
|
||||
|
||||
var resolved = ResolvedPromise();
|
||||
if (resolved == null)
|
||||
return true;
|
||||
|
||||
__result = resolved;
|
||||
return Block("LogOutOfSessionEvent");
|
||||
}
|
||||
|
||||
private static bool _promiseResolved;
|
||||
private static MethodInfo _resolvedPromiseGetter;
|
||||
|
||||
// Finds the concrete Promise class's static `Resolved` property getter without hardcoding its
|
||||
// obfuscated name. Among the static, 0-param property getters in RecRoom.Promises.Runtime that
|
||||
// return the promise interface there are exactly two: the real (obfuscated) property getter and
|
||||
// the compiler-generated `get_<Name>_k__BackingField`. The backing field may be null if the
|
||||
// property initialises lazily, so we drop it by its compiler-generated name — which the
|
||||
// obfuscator leaves alone — and keep the other one.
|
||||
private static LAHBDKNMNHN ResolvedPromise()
|
||||
{
|
||||
if (!_promiseResolved)
|
||||
{
|
||||
_promiseResolved = true;
|
||||
|
||||
var candidates = typeof(LAHBDKNMNHN).Assembly.GetTypes()
|
||||
.SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
.Where(m => m.ReturnType == typeof(LAHBDKNMNHN)
|
||||
&& m.GetParameters().Length == 0
|
||||
&& m.IsSpecialName
|
||||
&& !m.Name.EndsWith("_k__BackingField"))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 1)
|
||||
_resolvedPromiseGetter = candidates[0];
|
||||
else
|
||||
Plugin.Log.LogWarning(
|
||||
$"[AMPLITUDE] expected exactly one resolved-promise getter, found {candidates.Count} " +
|
||||
"— LogOutOfSessionEvent will NOT be blocked");
|
||||
}
|
||||
|
||||
if (_resolvedPromiseGetter == null)
|
||||
return null;
|
||||
|
||||
return _resolvedPromiseGetter.Invoke(null, null) as LAHBDKNMNHN;
|
||||
}
|
||||
|
||||
// Analytics hosts we refuse to talk to, matched two different ways because they identify
|
||||
// themselves two different ways:
|
||||
//
|
||||
// AmplitudeDomains — a registrable domain plus everything under it, so api2.amplitude.com
|
||||
// and api.eu.amplitude.com are both covered.
|
||||
// CollectorLabelPrefixes — the *first* hostname label, for the telemetry collector that lives
|
||||
// on whatever domain the deployment uses (datacollection.recflare.net,
|
||||
// datacollection.rec.net, datacollection-eu.…). The domain varies, the
|
||||
// label doesn't, so match on the label and stay deployment-agnostic.
|
||||
//
|
||||
// Split into separate lists because they're *matched* differently, not configured differently —
|
||||
// one knob covers the lot.
|
||||
private static readonly string[] AmplitudeDomains = { "amplitude.com" };
|
||||
private static readonly string[] CollectorLabelPrefixes = { "datacollection" };
|
||||
|
||||
// Backtrace and Unity's perf-events don't normally reach BestHTTP at all — Backtrace goes through
|
||||
// UnityWebRequest (see BacktracePatch) and Unity's is native (see UnityTelemetryPatch). They're
|
||||
// listed here anyway because it costs a string comparison to be right if that ever changes, and
|
||||
// because "the host block covers every host we don't want talked to" is easier to reason about
|
||||
// than a list with holes in it. Only perf-events is named, not all of cloud.unity3d.com — the
|
||||
// client uses other Unity services.
|
||||
private static readonly string[] BacktraceDomains = { "backtrace.io" };
|
||||
private static readonly string[] UnityTelemetryHosts = { "perf-events.cloud.unity3d.com" };
|
||||
|
||||
private static bool IsUnderAnyDomain(string host, string[] domains) =>
|
||||
domains.Any(d => host.Equals(d, StringComparison.OrdinalIgnoreCase)
|
||||
|| host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsAmplitudeHost(string host) => IsUnderAnyDomain(host, AmplitudeDomains);
|
||||
|
||||
private static bool IsCollectorHost(string host)
|
||||
{
|
||||
var dot = host.IndexOf('.');
|
||||
var firstLabel = dot < 0 ? host : host.Substring(0, dot);
|
||||
|
||||
return CollectorLabelPrefixes.Any(p => firstLabel.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// One knob for the lot. The vendors are split into separate lists above because they're matched
|
||||
// differently, not because they're configured differently.
|
||||
private static bool IsBlockedHost(string host)
|
||||
{
|
||||
if (string.IsNullOrEmpty(host) || !Plugin.DisableTelemetry.Value)
|
||||
return false;
|
||||
|
||||
return IsAmplitudeHost(host)
|
||||
|| IsCollectorHost(host)
|
||||
|| IsUnderAnyDomain(host, BacktraceDomains)
|
||||
|| UnityTelemetryHosts.Any(h => host.Equals(h, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
// The part that actually stops the traffic — see the note at the top of the file. Prefixes the
|
||||
// same BestHTTP entrypoint SendRequestPatch hooks (HTTPManager is BestHTTP's own type, so no
|
||||
// obfuscated names are involved and this survives game upgrades) and, for analytics hosts, hands
|
||||
// the caller a synthetic 200 instead of sending anything.
|
||||
//
|
||||
// Faking success rather than failure is deliberate: the transport resolves its promise, the flush
|
||||
// coroutine considers the batch delivered, and the client clears `pending_room_stats` — so nothing
|
||||
// accumulates and nothing retries. Failing the request instead would leave the batch queued and
|
||||
// re-attempted every session.
|
||||
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
|
||||
public static class BlockAnalyticsUploadPatch
|
||||
{
|
||||
private static bool Prefix(HTTPRequest request, ref HTTPRequest __result)
|
||||
{
|
||||
// SendRequest returns the request it was handed; callers chain off it, so hand it back
|
||||
// even though we never send it.
|
||||
__result = request;
|
||||
|
||||
return !Drop(request);
|
||||
}
|
||||
|
||||
// Second net, one layer down. Every SendRequest overload funnels into SendRequestImpl, and
|
||||
// IL2CPP is free to inline the tiny SendRequest(HTTPRequest) body into its callers — a hook on
|
||||
// it then never fires for those call sites (gotcha: a Harmony patch that loads clean can still
|
||||
// never run). SendRequestImpl is the last managed-visible chokepoint before the connection, so
|
||||
// anything that slipped past the hook above is caught here.
|
||||
[HarmonyPatch(typeof(HTTPManager), "SendRequestImpl", [typeof(HTTPRequest)])]
|
||||
public static class ImplPatch
|
||||
{
|
||||
private static bool Prefix(HTTPRequest request) => !Drop(request);
|
||||
}
|
||||
|
||||
// True when the request was blocked (caller should skip the original).
|
||||
private static bool Drop(HTTPRequest request)
|
||||
{
|
||||
if (!IsBlockedHost(request.Uri.Host))
|
||||
return false;
|
||||
|
||||
// Once per host normally; every request under [Advanced] Debug, since "did this specific
|
||||
// upload get dropped or did it slip past?" is exactly the question a mitmproxy trace
|
||||
// raises, and a deduped line can't answer it.
|
||||
if (Plugin.Debug.Value)
|
||||
Plugin.Log.LogInfo($"[ANALYTICS] dropped {request.MethodType} {request.Uri.AbsoluteUri}");
|
||||
else if (_loggedBlocked.Add("upload:" + request.Uri.Host))
|
||||
Plugin.Log.LogInfo($"[ANALYTICS] dropping uploads to {request.Uri.Host}");
|
||||
|
||||
try
|
||||
{
|
||||
CompleteWithFakeSuccess(request);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Couldn't synthesize the response — still don't send. The request's callback never
|
||||
// fires, so whatever promise the transport made stays pending; that's a stalled flush
|
||||
// coroutine at worst, versus telemetry leaving the box.
|
||||
Plugin.Log.LogWarning($"[ANALYTICS] blocked {request.Uri.Host} but could not fake a response: {e.Message}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void CompleteWithFakeSuccess(HTTPRequest request)
|
||||
{
|
||||
var body = FakeBodyFor(request);
|
||||
|
||||
var response = new HTTPResponse(request, new Il2CppSystem.IO.MemoryStream(), false, false)
|
||||
{
|
||||
StatusCode = 200,
|
||||
Message = "OK",
|
||||
Data = new Il2CppStructArray<byte>(Encoding.UTF8.GetBytes(body)),
|
||||
};
|
||||
|
||||
request.Response = response;
|
||||
request.State = HTTPRequestStates.Finished;
|
||||
|
||||
// BestHTTP would normally fire this from HTTPManager's update loop a frame or more later.
|
||||
// Firing it inline is safe here because the callback is assigned before SendRequest is
|
||||
// called, and the promise it resolves already exists by then.
|
||||
request.Callback?.Invoke(request, response);
|
||||
}
|
||||
|
||||
// Whatever the endpoint would have said on a good day. Amplitude's real shapes are known:
|
||||
// /identify answers with the literal "success", the v2 batch endpoint with a small JSON
|
||||
// envelope. The collector's shape isn't known, so we fall back to `{"success":true}` — the
|
||||
// envelope every first-party RecNet endpoint uses (it's what the real deviceId endpoint
|
||||
// returns, see the DUID case study in CLAUDE.md) and a far better guess than an empty body,
|
||||
// which the RecNet HTTP wrapper rejects outright with "Response was empty".
|
||||
private static string FakeBodyFor(HTTPRequest request)
|
||||
{
|
||||
if (!IsAmplitudeHost(request.Uri.Host))
|
||||
return "{\"success\":true}";
|
||||
|
||||
return request.Uri.AbsoluteUri.Contains("/identify", StringComparison.OrdinalIgnoreCase)
|
||||
? "success"
|
||||
: "{\"code\":200,\"events_ingested\":0,\"payload_size_bytes\":0,\"server_upload_time\":"
|
||||
+ DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + "}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Backtrace.Unity.Json;
|
||||
using Backtrace.Unity.Model;
|
||||
using HarmonyLib;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Backtrace crash reporting — the uploads to submit.backtrace.io.
|
||||
//
|
||||
// This one does not go through BestHTTP, so `AmplitudePatch`'s host block never sees it:
|
||||
// `Backtrace.Unity.dll` references `UnityEngine.UnityWebRequestModule` and nothing else HTTP-shaped.
|
||||
// The whole SDK is unobfuscated (it's a third-party package, so no per-build name churn to survive),
|
||||
// and every submission it makes — crash reports, minidumps, metrics — funnels through the four
|
||||
// `BacktraceHttpClient.Post` overloads. That's the concrete class; `IBacktraceHttpClient` is the
|
||||
// interface and patching it would silently never run (gotcha 3 in CLAUDE.md).
|
||||
//
|
||||
// The two overload shapes need different treatment, because of who owns the send:
|
||||
//
|
||||
// void Post(url, jObject, onComplete) - fire-and-forget, the SDK sends internally. We skip it and
|
||||
// invoke the callback with a 200 ourselves.
|
||||
// UnityWebRequest Post(...) x3 - builds the request and hands it back; *the caller* sends it
|
||||
// (`yield return request.SendWebRequest()`). Skipping the
|
||||
// original would hand the caller a null to dereference, so
|
||||
// instead we let it build whatever it likes and repoint the
|
||||
// finished request at a black hole.
|
||||
//
|
||||
// Not covered: `RecRoomNativeClient` installs a native crash handler, and a minidump uploaded from
|
||||
// native code on the next launch never passes through here. If submit.backtrace.io still shows a
|
||||
// multipart minidump POST with everything below firing, that's the path it took.
|
||||
[HarmonyPatch]
|
||||
public static class BacktracePatch
|
||||
{
|
||||
// Loopback port 1: nothing listens there, so the send fails with connection-refused in
|
||||
// microseconds without a packet leaving the machine, and the SDK takes its ordinary offline path.
|
||||
private const string BlackHoleUrl = "http://127.0.0.1:1/blocked-by-recnet-plugin";
|
||||
|
||||
private static readonly HashSet<string> _loggedBlocked = new();
|
||||
|
||||
// Fire-and-forget path (metrics). The SDK sends this one itself, so skipping the original is
|
||||
// enough — but the callback has to be answered or the submission queue keeps the batch it just
|
||||
// handed us and retries it forever. (statusCode, isError, response): a 200 with no error is what
|
||||
// it waits for before clearing the batch.
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(BacktraceJObject), typeof(Il2CppSystem.Action<long, bool, string>))]
|
||||
private static bool PostWithCallbackPrefix(string __0, Il2CppSystem.Action<long, bool, string> __2)
|
||||
{
|
||||
if (!Plugin.DisableTelemetry.Value)
|
||||
return true;
|
||||
|
||||
LogBlocked(__0);
|
||||
__2?.Invoke(200, false, "{}");
|
||||
return false;
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(BacktraceJObject))]
|
||||
private static void PostJObjectPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(string),
|
||||
typeof(Il2CppSystem.Collections.Generic.IEnumerable<string>),
|
||||
typeof(Il2CppSystem.Collections.Generic.IDictionary<string, string>))]
|
||||
private static void PostJsonPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(BacktraceHttpClient), nameof(BacktraceHttpClient.Post),
|
||||
typeof(string), typeof(Il2CppSystem.Collections.Generic.List<IMultipartFormSection>))]
|
||||
private static void PostFormPostfix(string __0, UnityWebRequest __result) => Neuter(__0, __result);
|
||||
|
||||
// Leave the request the SDK built exactly as it is — handlers, headers, body — and change only
|
||||
// where it points. Rebuilding it ourselves would mean guessing which handlers the caller goes on
|
||||
// to dereference; this way the coroutine keeps its shape and just gets an error back.
|
||||
private static void Neuter(string url, UnityWebRequest request)
|
||||
{
|
||||
if (!Plugin.DisableTelemetry.Value || request == null)
|
||||
return;
|
||||
|
||||
LogBlocked(url);
|
||||
request.url = BlackHoleUrl;
|
||||
}
|
||||
|
||||
// Once per host normally, every submission under [Advanced] Debug — same rule as the analytics
|
||||
// host block, and for the same reason: a deduped line can't answer "did *this* upload get
|
||||
// dropped?" when you're staring at a proxy trace.
|
||||
private static void LogBlocked(string url)
|
||||
{
|
||||
if (Plugin.Debug.Value)
|
||||
{
|
||||
Plugin.Log.LogInfo($"[BACKTRACE] dropped submission to {url}");
|
||||
return;
|
||||
}
|
||||
|
||||
var host = HostOf(url);
|
||||
if (_loggedBlocked.Add(host))
|
||||
Plugin.Log.LogInfo($"[BACKTRACE] telemetry disabled — dropping submissions to {host}");
|
||||
}
|
||||
|
||||
private static string HostOf(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Uri(url).Host;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Test tool: persist a genuinely corrupt STORED device id on this machine, matching the friend's
|
||||
// condition (stored id truncated, current id healthy).
|
||||
//
|
||||
// We can't hand-craft the stored value: it lives in PlayerPrefs under an obfuscated key, encoded as a
|
||||
// CodeStage ObscuredString, and both the key and the encode method are renamed per game build. So we
|
||||
// let the game write it: WriteDUIDs() stores ObscuredString(SystemInfo.deviceUniqueIdentifier) under
|
||||
// the right key. We temporarily spoof deviceUniqueIdentifier to a truncated value around that one
|
||||
// call, so the game encrypts+stores a bad id with its own (unknown-to-us) key. Afterwards the spoof
|
||||
// is off, so the current id reads healthy again -> stored != current -> real mismatch on next launch.
|
||||
[HarmonyPatch]
|
||||
public static class CorruptDUIDPatch
|
||||
{
|
||||
// Only true for the duration of the WriteDUIDs() call below, so the SystemInfo getter is spoofed
|
||||
// exactly there and nowhere else.
|
||||
private static bool _spoofActive;
|
||||
private static string _spoofValue = "";
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(SystemInfo), "get_deviceUniqueIdentifier")]
|
||||
private static bool DeviceIdGetterPrefix(ref string __result)
|
||||
{
|
||||
if (!_spoofActive)
|
||||
return true;
|
||||
__result = _spoofValue;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns true if the corruption was written (so the caller marks it done and won't repeat).
|
||||
public static bool CorruptStored(GameObject cheatMgrGo)
|
||||
{
|
||||
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
|
||||
if (cm == null)
|
||||
{
|
||||
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing written");
|
||||
return false;
|
||||
}
|
||||
|
||||
var real = SystemInfo.deviceUniqueIdentifier; // spoof off -> real id
|
||||
var bad = real is { Length: >= 7 } ? real.Substring(0, 7) : "badduid";
|
||||
|
||||
_spoofValue = bad;
|
||||
_spoofActive = true;
|
||||
try
|
||||
{
|
||||
cm.WriteDUIDs(); // encodes+stores ObscuredString(bad) under the real key
|
||||
}
|
||||
finally
|
||||
{
|
||||
_spoofActive = false;
|
||||
}
|
||||
|
||||
Plugin.Log.LogWarning($"[CORRUPT] wrote truncated stored DUID = \"{bad}\" (real id = \"{real}\"). " +
|
||||
"Set 'Corrupt Stored DUID' back to false and relaunch to drive the real mismatch path.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Undo: overwrite the stored value with the real id by calling WriteDUIDs with the spoof off.
|
||||
public static bool RestoreStored(GameObject cheatMgrGo)
|
||||
{
|
||||
var cm = cheatMgrGo.GetComponent<CheatManager>() ?? cheatMgrGo.GetComponentInChildren<CheatManager>();
|
||||
if (cm == null)
|
||||
{
|
||||
Plugin.Log.LogError("[CORRUPT] could not find CheatManager component; nothing restored");
|
||||
return false;
|
||||
}
|
||||
|
||||
cm.WriteDUIDs(); // spoof off -> stores ObscuredString(real deviceUniqueIdentifier)
|
||||
Plugin.Log.LogWarning($"[CORRUPT] restored stored DUID to real id = \"{SystemInfo.deviceUniqueIdentifier}\". " +
|
||||
"Set 'Restore Stored DUID' back to false.");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using HarmonyLib;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Controls CheatManager.CheckForDUIDMismatch, which returns true when the machine's stored device id
|
||||
// differs from the freshly-derived one. A true result sends the client down the migration path that
|
||||
// POSTs PlayerReporting/v1/deviceId and then stalls on Create Account.
|
||||
//
|
||||
// Three modes, chosen by config:
|
||||
// Simulate = true -> force TRUE (fake a mismatch to reproduce the hang without a corrupt value)
|
||||
// Suppress = true -> force FALSE (the workaround fix: never migrate, never hang)
|
||||
// both false -> pass through, let the REAL check run against the actual stored value
|
||||
// (needed to observe a genuinely corrupt stored id, e.g. after Corrupt Stored DUID)
|
||||
//
|
||||
// Patch the concrete CheatManager method, NOT the abstract PGECJHKNIEN interface, or the prefix
|
||||
// never runs.
|
||||
[HarmonyPatch]
|
||||
public static class DUIDMismatchPatch
|
||||
{
|
||||
private const string SimulatedStoredDeviceId = "491e8b9";
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(CheatManager), "CheckForDUIDMismatch")]
|
||||
// __0 = the out-param (positional). Its obfuscated name changes every game build, so binding it
|
||||
// by name throws "Parameter ... not found" on upgrade.
|
||||
private static bool Prefix(ref string __0, ref bool __result)
|
||||
{
|
||||
if (Plugin.SimulateDUIDMismatch.Value)
|
||||
{
|
||||
__0 = SimulatedStoredDeviceId;
|
||||
__result = true;
|
||||
Plugin.Log.LogWarning($"[DUID] simulating mismatch, stored id = {SimulatedStoredDeviceId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Plugin.SuppressDUIDMismatch.Value)
|
||||
{
|
||||
__0 = string.Empty;
|
||||
__result = false;
|
||||
Plugin.Log.LogInfo("[DUID] mismatch check forced to false (suppressed)");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Pass through to the real check.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Diagnostic only. Two jobs:
|
||||
// 1. Show where the stored device id lives, by logging PlayerPrefs reads/writes.
|
||||
// 2. Show how far the DUID migration branch gets, by logging CheatManager's other DUID methods.
|
||||
// If WriteDUIDs() never fires after the deviceId POST, the flow stalls before it.
|
||||
[HarmonyPatch]
|
||||
public static class DUIDProbePatch
|
||||
{
|
||||
// PlayerPrefs.GetString is called constantly, so log each key only once — except device/DUID
|
||||
// keys, which we always log so we can watch them change across the migration.
|
||||
private static readonly HashSet<string> SeenKeys = new();
|
||||
|
||||
private static bool IsInteresting(string key) =>
|
||||
key != null && (key.Contains("DUID") || key.Contains("Duid") || key.Contains("duid")
|
||||
|| key.Contains("Device") || key.Contains("device")
|
||||
|| key.Contains("Anon") || key.Contains("anon"));
|
||||
|
||||
private static void Note(string op, string key, string value)
|
||||
{
|
||||
if (IsInteresting(key))
|
||||
Plugin.Log.LogWarning($"[DUID-PROBE] {op} {key} = \"{value}\"");
|
||||
else if (SeenKeys.Add($"{op}:{key}"))
|
||||
Plugin.Log.LogInfo($"[DUID-PROBE] {op} {key} = \"{value}\"");
|
||||
}
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string)])]
|
||||
private static void GetStringPostfix(string key, string __result) => Note("get", key, __result);
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.GetString), [typeof(string), typeof(string)])]
|
||||
private static void GetStringDefaultPostfix(string key, string __result) => Note("get", key, __result);
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.SetString))]
|
||||
private static void SetStringPrefix(string key, string value) => Note("SET", key, value);
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(PlayerPrefs), nameof(PlayerPrefs.DeleteKey))]
|
||||
private static void DeleteKeyPrefix(string key) => Note("DEL", key, "<deleted>");
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
|
||||
private static void WriteDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() called");
|
||||
|
||||
[HarmonyPostfix]
|
||||
[HarmonyPatch(typeof(CheatManager), "WriteDUIDs")]
|
||||
private static void WriteDUIDsPostfix() => Plugin.Log.LogWarning("[DUID-PROBE] WriteDUIDs() returned");
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(CheatManager), "ClearDUIDs")]
|
||||
private static void ClearDUIDsPrefix() => Plugin.Log.LogWarning("[DUID-PROBE] ClearDUIDs() called");
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using BestHTTP;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Experiment harness for the Create Account hang.
|
||||
//
|
||||
// On a device-id mismatch the client POSTs PlayerReporting/v1/deviceId, the server answers
|
||||
// 200 {"success":true}, and then the client stops: CheatManager.WriteDUIDs() is never called, so the
|
||||
// new id is never persisted and the flow never reaches create_account. That means the client can't
|
||||
// proceed on what it got back.
|
||||
//
|
||||
// This rewrites that one response body before the game sees it, so response shapes can be tried
|
||||
// without redeploying the server. WriteDUIDs() appearing in the log (see DUIDProbePatch) is the
|
||||
// pass signal: it means the client accepted the response and resumed the migration.
|
||||
[HarmonyPatch]
|
||||
public static class DeviceIdResponsePatch
|
||||
{
|
||||
private const string Endpoint = "/PlayerReporting/v1/deviceId";
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
|
||||
private static void Prefix(HTTPRequest request)
|
||||
{
|
||||
if (!request.Uri.AbsoluteUri.Contains(Endpoint, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
var original = request.Callback;
|
||||
|
||||
// Whether the game attached a completion callback at all. If this logs False, the client is
|
||||
// not waiting on this request through the callback API and the "stuck on the response" model
|
||||
// is wrong -- that would be worth knowing before chasing response shapes any further.
|
||||
Plugin.Log.LogWarning($"[DEVICEID] request seen; game callback attached = {original != null}");
|
||||
|
||||
var body = Plugin.DeviceIdResponseOverride.Value;
|
||||
if (string.IsNullOrEmpty(body))
|
||||
return;
|
||||
|
||||
var status = Plugin.DeviceIdResponseStatus.Value;
|
||||
|
||||
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
|
||||
(Action<HTTPRequest, HTTPResponse>)((req, resp) =>
|
||||
{
|
||||
if (resp != null)
|
||||
{
|
||||
// Set both: DataAsText is computed from Data but cached in dataAsText once read,
|
||||
// and our own HTTP logger may already have read it.
|
||||
resp.Data = new Il2CppStructArray<byte>(Encoding.UTF8.GetBytes(body));
|
||||
resp.dataAsText = body;
|
||||
resp.StatusCode = status;
|
||||
Plugin.Log.LogWarning($"[DEVICEID] response overridden -> {status} {body}");
|
||||
}
|
||||
|
||||
original?.Invoke(req, resp);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using HarmonyLib;
|
||||
using Org.BouncyCastle.Crypto.Tls;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
/**
|
||||
Disables TLS certificate pinning. Even though we connect over SSL it seems some certificates
|
||||
might be pinned.
|
||||
*/
|
||||
public class DisableTLSPinning
|
||||
{
|
||||
[HarmonyPatch(typeof(LegacyTlsAuthentication), "NotifyServerCertificate")]
|
||||
public class TlsPatch
|
||||
{
|
||||
private static bool Prefix()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using HarmonyLib;
|
||||
using RecRoom.AntiCheat;
|
||||
using System.Text;
|
||||
using Il2CppSystem;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
[HarmonyPatch]
|
||||
public static class EACPatches
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
// The "is ready" check: the only static, 0-param bool method on EACManager that isn't a property
|
||||
// getter. 20230414 build: MCFIOBHCFBB (was IMMGELPFGCK, was FJLMLEPOKGE). Method names here are
|
||||
// strings, so a rename is not a compile error — it shows up as a HarmonyX "method not found" at load.
|
||||
[HarmonyPatch(typeof(EACManager), "MCFIOBHCFBB")]
|
||||
private static bool IsReadyPatch(ref bool __result)
|
||||
{
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(EACManager), "GenerateChallengeResponse")]
|
||||
// __0 = the challenge string (positional); obfuscated param names shift between game builds.
|
||||
private static bool GenerateChallengeResponsePatch(string __0, ref string __result)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(__0))
|
||||
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes(__0));
|
||||
else
|
||||
__result = Convert.ToBase64String(Encoding.UTF8.GetBytes("nothing"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime.InteropTypes.Arrays;
|
||||
using Il2CppSystem.Security.Cryptography;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Image signing: the client verifies images against an RSA public key whose modulus is a string
|
||||
// literal in global-metadata.dat. Patching that literal is fragile, so we intervene at the framework
|
||||
// level instead, by forcing the mscorlib RSA verify to succeed.
|
||||
//
|
||||
// One knob, see [Signing] in the .cfg:
|
||||
// Disable Signature Verification -> THE FIX (default true). Forces the RSA verify to succeed, so
|
||||
// the modulus never has to match and unsigned images load. This
|
||||
// self-hosted setup does not use image signing.
|
||||
//
|
||||
// If images ever stop loading with this on, the client has moved verification off mscorlib RSA onto
|
||||
// BestHTTP.SecureProtocol.Org.BouncyCastle; the equivalent hooks there are the concrete
|
||||
// RsaDigestSigner/PssSigner.VerifySignature (NOT the abstract ISigner "interface", which never
|
||||
// dispatches).
|
||||
[HarmonyPatch]
|
||||
public static class ImageSigningPatch
|
||||
{
|
||||
private static bool _loggedForced;
|
||||
|
||||
// Forces EVERY mscorlib RSA verification to succeed, not just image signatures. BestHTTP's TLS
|
||||
// uses its own bundled BouncyCastle rather than mscorlib RSA, so this should not touch
|
||||
// certificate validation — but it is a blunt instrument, so it stays behind a config knob rather
|
||||
// than being unconditional.
|
||||
private static bool ForceVerifyTrue(ref bool __result)
|
||||
{
|
||||
if (!Plugin.DisableSignatureVerification.Value)
|
||||
return true;
|
||||
|
||||
if (!_loggedForced)
|
||||
{
|
||||
_loggedForced = true;
|
||||
Plugin.Log.LogWarning("[SIG] signature verification disabled — RSA verify forced to true");
|
||||
}
|
||||
|
||||
__result = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyData))]
|
||||
private static bool VerifyDataPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyHash),
|
||||
[typeof(Il2CppStructArray<byte>), typeof(int), typeof(Il2CppStructArray<byte>)])]
|
||||
private static bool VerifyHashPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
|
||||
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(RSACryptoServiceProvider), nameof(RSACryptoServiceProvider.VerifyHash),
|
||||
[typeof(Il2CppStructArray<byte>), typeof(Il2CppStructArray<byte>), typeof(HashAlgorithmName),
|
||||
typeof(RSASignaturePadding)])]
|
||||
private static bool VerifyHashPaddingPrefix(ref bool __result) => ForceVerifyTrue(ref __result);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using ExitGames.Client.Photon;
|
||||
using HarmonyLib;
|
||||
using Photon.Realtime;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
/**
|
||||
Patches Photon to use the App IDs and server hostname/port specified in the plugin config.
|
||||
*/
|
||||
// Obfuscated names shift every game build. Re-resolve by signature: the target is the only
|
||||
// instance, 0-param method returning Photon.Realtime.AppSettings in Assembly-CSharp.
|
||||
// 20230414 build: HPEENKELKDJ.MGKINLFMJLB (was LEALBOODIEE.GBNKOFMAJPA, was GPFPFDBGCEK.AMOHMPKKGHL).
|
||||
[HarmonyPatch(typeof(HPEENKELKDJ), "MGKINLFMJLB")]
|
||||
public class PhotonPatches
|
||||
{
|
||||
[HarmonyPostfix]
|
||||
private static void Postfix(ref AppSettings __result)
|
||||
{
|
||||
if (__result != null)
|
||||
{
|
||||
__result.AppIdRealtime = Plugin.AppIdRT.Value;
|
||||
__result.AppIdVoice = Plugin.AppIdVoice.Value;
|
||||
__result.AppIdChat = Plugin.AppIdChat.Value;
|
||||
__result.FixedRegion = "us";
|
||||
__result.UseNameServer = true;
|
||||
__result.Protocol = ConnectionProtocol.Udp;
|
||||
|
||||
if (Plugin.EnableAdvancedSettings.Value)
|
||||
{
|
||||
__result.Server = Plugin.PhotonHostname.Value;
|
||||
__result.Port = Plugin.PhotonPort.Value == 0
|
||||
? 4533
|
||||
: Plugin.PhotonPort.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
using System;
|
||||
using BestHTTP;
|
||||
using HarmonyLib;
|
||||
using Il2CppInterop.Runtime;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
/**
|
||||
Intercept a variety of HTTP requests and rewrite them to point to our own custom server.
|
||||
*/
|
||||
public class SendRequestPatch
|
||||
{
|
||||
// Official name server host to redirect away from, swapped for the custom server.
|
||||
private const string OfficialNameServer = "ns.rec.net";
|
||||
|
||||
// Skip when HTTP-logging so we don't spam the logs.
|
||||
private static readonly string[] LogIgnoreSubstrings =
|
||||
{
|
||||
"/api/gamesight/event",
|
||||
"/data/heartbeat",
|
||||
"/identify",
|
||||
"/httpapi",
|
||||
"/data/event",
|
||||
};
|
||||
|
||||
private static bool IsIgnoredForLogging(string url)
|
||||
{
|
||||
foreach (var s in LogIgnoreSubstrings)
|
||||
if (url.Contains(s, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cap logged bodies so a large response/request doesn't flood the log.
|
||||
private const int MaxLoggedBodyLength = 10000;
|
||||
|
||||
private static string Truncate(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s.Length <= MaxLoggedBodyLength)
|
||||
return s;
|
||||
return s.Substring(0, MaxLoggedBodyLength) + $"... <truncated {s.Length - MaxLoggedBodyLength} chars>";
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(HTTPManager), "SendRequest", [typeof(HTTPRequest)])]
|
||||
public class ConnectToRecNetPatch
|
||||
{
|
||||
private static void Prefix(ref HTTPRequest request)
|
||||
{
|
||||
var debug = Plugin.Debug.Value && !IsIgnoredForLogging(request.Uri.AbsoluteUri);
|
||||
|
||||
if (debug)
|
||||
{
|
||||
var entityBody = request.GetEntityBody();
|
||||
string body;
|
||||
if (entityBody == null)
|
||||
body = "<none>";
|
||||
else if (IsBinaryContentType(request.GetFirstHeaderValue("content-type")) || LooksBinary(entityBody))
|
||||
body = BinaryPreview(entityBody);
|
||||
else
|
||||
body = System.Text.Encoding.UTF8.GetString(entityBody);
|
||||
Plugin.Log.LogInfo($"[HTTP] {request.MethodType} {request.Uri.AbsoluteUri} body={Truncate(body)}");
|
||||
}
|
||||
|
||||
var host = request.Uri.Host;
|
||||
if (host == OfficialNameServer)
|
||||
{
|
||||
// Redirect the nameserver lookup to the custom server, swapping only the host.
|
||||
var newHost = new System.Uri(Plugin.ServerHostname.Value).Host;
|
||||
var builder = new Il2CppSystem.UriBuilder(request.Uri) { Host = newHost };
|
||||
request.Uri = builder.Uri;
|
||||
|
||||
if (debug)
|
||||
Plugin.Log.LogInfo($"[HTTP] intercepted {host} -> {newHost}");
|
||||
}
|
||||
|
||||
if (debug)
|
||||
LogResponseWhenDone(request);
|
||||
}
|
||||
}
|
||||
|
||||
// Wraps the request's completion callback so we log the response (status + body) when it
|
||||
// finishes, then forwards to the game's original callback. This is how we see *which*
|
||||
// request comes back empty (RecNet throws "Response was empty" on a blank body).
|
||||
private static void LogResponseWhenDone(HTTPRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var original = request.Callback;
|
||||
var url = request.Uri.AbsoluteUri;
|
||||
|
||||
request.Callback = DelegateSupport.ConvertDelegate<OnRequestFinishedDelegate>(
|
||||
(Action<HTTPRequest, HTTPResponse>)((req, resp) =>
|
||||
{
|
||||
if (resp == null)
|
||||
Plugin.Log.LogWarning($"[HTTP] <- {url} NO RESPONSE (state={req.State})");
|
||||
else
|
||||
{
|
||||
string text;
|
||||
if (IsBinaryContentType(resp.GetFirstHeaderValue("content-type")))
|
||||
text = "<binary>";
|
||||
else
|
||||
{
|
||||
text = resp.DataAsText;
|
||||
if (string.IsNullOrEmpty(text)) text = "<empty>";
|
||||
}
|
||||
var msg = $"[HTTP] <- {resp.StatusCode} {url} body={Truncate(text)}";
|
||||
if (resp.StatusCode is >= 200 and < 300)
|
||||
Plugin.Log.LogInfo(msg);
|
||||
else
|
||||
Plugin.Log.LogError(msg);
|
||||
}
|
||||
|
||||
original?.Invoke(req, resp);
|
||||
}));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Plugin.Log.LogError($"[HTTP] failed to attach response logger: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Content-Type prefixes/keywords we treat as textual; anything else is logged as <binary> so we
|
||||
// don't dump image/asset bytes into the log.
|
||||
private static readonly string[] TextContentTypes =
|
||||
{
|
||||
"text/", "application/json", "application/xml", "application/javascript",
|
||||
"application/x-www-form-urlencoded", "+json", "+xml",
|
||||
};
|
||||
|
||||
// True if the body is (probably) binary and shouldn't be logged as text. Defaults to text when
|
||||
// there's no Content-Type, so we err toward logging rather than hiding.
|
||||
private static bool IsBinaryContentType(string contentType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(contentType)) return false;
|
||||
|
||||
foreach (var t in TextContentTypes)
|
||||
if (contentType.Contains(t, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render the leading bytes of a binary body as text so structured framing (e.g. multipart form
|
||||
// boundaries and part headers) stays readable, while raw bytes are shown as \xNN escapes. Capped
|
||||
// at MaxLoggedBodyLength since the interesting framing is at the front.
|
||||
private static string BinaryPreview(byte[] data)
|
||||
{
|
||||
if (data.Length == 0) return "<binary empty>";
|
||||
|
||||
var sb = new System.Text.StringBuilder(MaxLoggedBodyLength + 32);
|
||||
sb.Append("<binary ").Append(data.Length).Append(" bytes> ");
|
||||
var i = 0;
|
||||
// Cap on rendered length, not byte count: escapes expand a byte to 4 chars, so this keeps the
|
||||
// preview near MaxLoggedBodyLength and avoids a second pass by Truncate at the log site.
|
||||
for (; i < data.Length && sb.Length < MaxLoggedBodyLength; i++)
|
||||
{
|
||||
var b = data[i];
|
||||
if (b == 0x09 || b == 0x0A || b == 0x0D || (b >= 0x20 && b < 0x7F))
|
||||
sb.Append((char)b);
|
||||
else
|
||||
sb.Append("\\x").Append(b.ToString("x2"));
|
||||
}
|
||||
if (i < data.Length)
|
||||
sb.Append($"... <truncated {data.Length - i} bytes>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Content sniff for raw request bytes — the Content-Type header isn't reliably set at
|
||||
// SendRequest time (e.g. multipart form bodies set it lazily, and the body still embeds the
|
||||
// raw image), so look at the bytes: a NUL byte, or a high ratio of non-text control bytes in
|
||||
// the first chunk, means it's binary (or binary-mixed like a multipart upload).
|
||||
private static bool LooksBinary(byte[] data)
|
||||
{
|
||||
if (data.Length == 0) return false;
|
||||
|
||||
var sample = Math.Min(data.Length, 4096);
|
||||
var nonText = 0;
|
||||
for (var i = 0; i < sample; i++)
|
||||
{
|
||||
var b = data[i];
|
||||
if (b == 0) return true;
|
||||
// Control chars other than tab/newline/carriage-return.
|
||||
if (b < 0x20 && b != 0x09 && b != 0x0A && b != 0x0D) nonText++;
|
||||
}
|
||||
return nonText * 100 / sample > 10;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace RecNetPlugin.Patches;
|
||||
|
||||
// Unity's own telemetry — the uploads to perf-events.cloud.unity3d.com.
|
||||
//
|
||||
// !! THIS DOES NOT WORK on the 20230414 build, and that is a known, accepted limitation — don't spend
|
||||
// another afternoon on it. Confirmed at runtime: the setters below are refused, `enabled` reads back
|
||||
// True on all five attempts, and perf-events uploads keep flowing. It's left in because it costs
|
||||
// nothing, is the correct thing to do if a future build stops refusing, and the read-back logs the
|
||||
// truth either way rather than pretending. The parts of `Disable Telemetry` that carry the actual win
|
||||
// are Amplitude, the collector and Backtrace — all confirmed dropping — and they take out the bulk of
|
||||
// the noise. If perf-events ever has to go for real, it needs a hosts-file/DNS block or a native hook;
|
||||
// there is no managed lever.
|
||||
//
|
||||
// There is nothing to hook here, and that's the point: `UnityEngine.Analytics.Analytics` and
|
||||
// `PerformanceReporting` are thin managed shims over native engine code, and the uploads happen inside
|
||||
// the player, not on any managed send path a Harmony prefix could sit on. Blocking this one at the HTTP
|
||||
// layer is equally hopeless — it never touches BestHTTP or UnityWebRequest. What it *does* have is a
|
||||
// documented opt-out, so we flip the switches at startup and read them back.
|
||||
//
|
||||
// Performance Reporting is the exception/crash reporter, Analytics is the event stream; both feed
|
||||
// perf-events, so both go off. `limitUserTracking` and `deviceStatsEnabled` cover the case where
|
||||
// something re-enables the event stream behind our back — with those set, what it can collect is
|
||||
// nothing worth sending.
|
||||
internal static class UnityTelemetryPatch
|
||||
{
|
||||
// Applied from Plugin.Load and again on each scene load until it sticks — these are native
|
||||
// properties whose setters can be refused (service not initialised yet, build flags), so
|
||||
// "set it once at load and assume" is exactly how this silently does nothing.
|
||||
private const int MaxAttempts = 5;
|
||||
|
||||
private static bool _done;
|
||||
private static int _attempts;
|
||||
|
||||
public static void Apply()
|
||||
{
|
||||
if (_done || !Plugin.DisableTelemetry.Value || _attempts >= MaxAttempts)
|
||||
return;
|
||||
|
||||
_attempts++;
|
||||
|
||||
try
|
||||
{
|
||||
UnityEngine.Analytics.PerformanceReporting.enabled = false;
|
||||
UnityEngine.Analytics.Analytics.enabled = false;
|
||||
UnityEngine.Analytics.Analytics.deviceStatsEnabled = false;
|
||||
UnityEngine.Analytics.Analytics.limitUserTracking = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// No _done: a later scene load gets another go, up to MaxAttempts.
|
||||
if (_attempts >= MaxAttempts)
|
||||
Plugin.Log.LogWarning($"[UNITY-TELEMETRY] gave up flipping the opt-out switches after {_attempts} attempts: {e.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// The read-back is the proof, not the assignment above. A refused setter is silent.
|
||||
var perf = UnityEngine.Analytics.PerformanceReporting.enabled;
|
||||
var analytics = UnityEngine.Analytics.Analytics.enabled;
|
||||
|
||||
_done = !perf && !analytics;
|
||||
|
||||
if (_done)
|
||||
Plugin.Log.LogInfo(
|
||||
$"[UNITY-TELEMETRY] disabled — PerformanceReporting.enabled={perf} Analytics.enabled={analytics} " +
|
||||
$"deviceStats={UnityEngine.Analytics.Analytics.deviceStatsEnabled} " +
|
||||
$"limitUserTracking={UnityEngine.Analytics.Analytics.limitUserTracking}");
|
||||
else if (_attempts >= MaxAttempts)
|
||||
// Info, not a warning: this is the known outcome on this build (see the header), not a
|
||||
// fault to go chasing. It stays logged so a build that *does* accept the switches is
|
||||
// visible as a change rather than a surprise.
|
||||
Plugin.Log.LogInfo(
|
||||
$"[UNITY-TELEMETRY] switches refused after {_attempts} attempts — " +
|
||||
$"PerformanceReporting.enabled={perf} Analytics.enabled={analytics}. " +
|
||||
"Known limitation: perf-events.cloud.unity3d.com uploads continue. The rest of Disable Telemetry is unaffected.");
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using System;
|
||||
using BepInEx;
|
||||
using BepInEx.Configuration;
|
||||
using BepInEx.Logging;
|
||||
using BepInEx.Unity.IL2CPP;
|
||||
using HarmonyLib;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace RecNetPlugin;
|
||||
|
||||
[BepInPlugin("net.rec.plugin", "RecNet Plugin", "1.0.0")]
|
||||
public class Plugin : BasePlugin
|
||||
{
|
||||
internal static new ManualLogSource Log;
|
||||
|
||||
public static ConfigEntry<string> AppIdRT { get; private set; }
|
||||
public static ConfigEntry<string> AppIdVoice { get; private set; }
|
||||
public static ConfigEntry<string> AppIdChat { get; private set; }
|
||||
public static ConfigEntry<string> ServerHostname { get; private set; }
|
||||
public static ConfigEntry<bool> EnableAdvancedSettings { get; private set; }
|
||||
public static ConfigEntry<string> PhotonHostname { get; private set; }
|
||||
public static ConfigEntry<int> PhotonPort { get; private set; }
|
||||
public static ConfigEntry<bool> Debug { get; private set; }
|
||||
public static ConfigEntry<bool> SimulateDUIDMismatch { get; private set; }
|
||||
public static ConfigEntry<bool> SuppressDUIDMismatch { get; private set; }
|
||||
public static ConfigEntry<bool> CorruptStoredDUID { get; private set; }
|
||||
public static ConfigEntry<bool> RestoreStoredDUID { get; private set; }
|
||||
public static ConfigEntry<string> DeviceIdResponseOverride { get; private set; }
|
||||
public static ConfigEntry<int> DeviceIdResponseStatus { get; private set; }
|
||||
public static ConfigEntry<bool> DisableSignatureVerification { get; private set; }
|
||||
public static ConfigEntry<bool> DisableTelemetry { get; private set; }
|
||||
|
||||
private static bool _corruptDone;
|
||||
|
||||
public override void Load()
|
||||
{
|
||||
Log = base.Log;
|
||||
|
||||
AppIdRT = Config.Bind("Photon", "App Id Realtime", "", "Photon Realtime App ID");
|
||||
AppIdVoice = Config.Bind("Photon", "App Id Voice", "", "Photon Voice App ID");
|
||||
AppIdChat = Config.Bind("Photon", "App Id Chat", "", "Photon Chat App ID");
|
||||
EnableAdvancedSettings = Config.Bind("Advanced", "Enabled Advanced Settings", false, "Allows other fields below in the advanced section to be modified.");
|
||||
PhotonHostname = Config.Bind("Advanced", "Photon NameServer", "", "Custom Photon NameServer");
|
||||
PhotonPort = Config.Bind("Advanced", "Photon NameServer Port", 0, "Custom Photon NameServer Port (if 0, it will be default)");
|
||||
ServerHostname = Config.Bind("Server", "RecNet NameServer Host", "https://ns.rec.net", "Host for the RecNet NameServer.");
|
||||
Debug = Config.Bind("Advanced", "Debug", false, "Show debug logs (HTTP tracing, etc. WARNING: will include sensitive information such as passwords and auth tokens in the logs, be careful when sharing them!)");
|
||||
SimulateDUIDMismatch = Config.Bind("Advanced", "Simulate DUID Mismatch", false, "Force CheckForDUIDMismatch to return TRUE (fakes the comparison only). Reproduces the hang path but does not corrupt any stored value. Leave false for normal play.");
|
||||
SuppressDUIDMismatch = Config.Bind("Advanced", "Suppress DUID Mismatch", true, "Force CheckForDUIDMismatch to return FALSE (the workaround fix, ON by default): the client never migrates and never takes the Create Account hang path. No-op on healthy machines (the real check returns false anyway); on mismatched machines it skips the hang. Set false only to observe the real mismatch behavior for debugging.");
|
||||
CorruptStoredDUID = Config.Bind("Advanced", "Corrupt Stored DUID", false, "ONE-SHOT TEST: on next launch, write a truncated device id into the DUID pref via the game's own WriteDUIDs, producing a genuinely corrupt STORED value (real current id) — exactly the friend's condition. After it logs '[CORRUPT] wrote', set this back to false and relaunch to drive the real mismatch path. Use 'Restore Stored DUID' to undo.");
|
||||
RestoreStoredDUID = Config.Bind("Advanced", "Restore Stored DUID", false, "ONE-SHOT UNDO: on next launch, call WriteDUIDs with the real device id, overwriting any corrupt stored value with a good one. Set back to false after it logs '[CORRUPT] restored'.");
|
||||
DeviceIdResponseOverride = Config.Bind("Advanced", "DeviceId Response Override", "", "Replace the body of the PlayerReporting/v1/deviceId response with this text, to test what shape the client will accept. Empty = leave the server's response alone.");
|
||||
DeviceIdResponseStatus = Config.Bind("Advanced", "DeviceId Response Status", 200, "HTTP status to force on the PlayerReporting/v1/deviceId response. Only applies when the override body is set.");
|
||||
|
||||
DisableSignatureVerification = Config.Bind("Signing", "Disable Signature Verification", true, "Force RSA signature verification to succeed (ON by default), so the client stops checking that images are signed with Rec Room's private key. This is what lets a self-hosted server serve its own images without the baked-in modulus matching. NOTE: this forces ALL mscorlib RSA verification to pass, not just image signatures — that breadth is deliberate, see CLAUDE.md.");
|
||||
|
||||
DisableTelemetry = Config.Bind("Analytics", "Disable Telemetry", true, "Stop the client reporting to third-party telemetry services (ON by default). Covers: Amplitude analytics (every AmplitudeAnalyticsClient.Log* call plus any upload to amplitude.com, so batches queued in earlier sessions can't be flushed later); the data-collection endpoint (any host whose name starts with 'datacollection', e.g. datacollection.recflare.net); and Backtrace crash reports, minidumps and metrics to submit.backtrace.io. Blocked uploads get a synthetic 200 so the client carries on as if they had been accepted. It also asks Unity's own Analytics and Performance Reporting to switch off, but that part is KNOWN NOT TO WORK on this game build — those send from native engine code and the opt-out is refused, so perf-events.cloud.unity3d.com uploads continue; see the [UNITY-TELEMETRY] line in LogOutput.log. Not covered: RudderStack, gamesight, and minidumps sent by the native crash handler on the launch after a hard crash. Set false to let all of it through.");
|
||||
|
||||
// Not a patch — Unity's telemetry has a real opt-out, so we just set it. Retried from
|
||||
// OnSceneLoaded until it takes, since the native setters can refuse this early.
|
||||
Patches.UnityTelemetryPatch.Apply();
|
||||
|
||||
Harmony.CreateAndPatchAll(typeof(Plugin).Assembly);
|
||||
|
||||
SceneManager.sceneLoaded += (Action<Scene, LoadSceneMode>)OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
// No-op once the switches have stuck; must run before the early return below.
|
||||
Patches.UnityTelemetryPatch.Apply();
|
||||
|
||||
// CheatManager boots us out of rooms when it runs, but it's ALSO the DUID service the DI
|
||||
// container resolves for account creation / login (destroying it removes that service).
|
||||
// So instead of destroying it, *deactivate* the GameObject: it stops running (no Update /
|
||||
// coroutines, so no boot) while the component still exists, so the DI container can still
|
||||
// resolve PGECJHKNIEN and call its DUID methods. It's recreated per scene, so deactivate
|
||||
// each freshly-spawned (active) instance on every load. (GameObject.Find only returns active
|
||||
// objects, so once deactivated it isn't found again.)
|
||||
var cheatMgr = GameObject.Find("GameRoot/(Startup)(Clone)/Core Systems/[CheatManager]");
|
||||
if (cheatMgr == null)
|
||||
return;
|
||||
|
||||
// One-shot corruption for testing: must run while the component is still active (before we
|
||||
// deactivate it below), because it calls the live CheatManager.WriteDUIDs().
|
||||
if (CorruptStoredDUID.Value && !_corruptDone)
|
||||
_corruptDone = Patches.CorruptDUIDPatch.CorruptStored(cheatMgr);
|
||||
else if (RestoreStoredDUID.Value && !_corruptDone)
|
||||
_corruptDone = Patches.CorruptDUIDPatch.RestoreStored(cheatMgr);
|
||||
|
||||
cheatMgr.SetActive(false);
|
||||
Log.LogInfo("cheatmanager deactivated");
|
||||
}
|
||||
}
|
||||
@@ -1,175 +1,142 @@
|
||||
# RecNet Plugin
|
||||
# RR Redirector (native)
|
||||
|
||||
A [BepInEx 6](https://github.com/BepInEx/BepInEx) (IL2CPP) plugin that points the Rec Room client at a self-hosted / private server.
|
||||
A native (C/Win32) DLL that points the Rec Room client at a self-hosted server, **without any
|
||||
managed mod loader**. BepInEx 6 and MelonLoader both fail on current Rec Room builds (crash in
|
||||
`il2cpp_init` / loader trips the anti-cheat memory-integrity scan). This build sidesteps that: it is
|
||||
loaded as a `version.dll` proxy and applies its patches — Winsock DNS, the HTTP request URL, TLS
|
||||
pinning, the memory-integrity scan and EAC — directly in native code.
|
||||
|
||||
It does this entirely client-side with [Harmony](https://harmony.pardeike.net/) patches — no game files are modified on disk. The plugin rewrites the RecNet name-server lookups, swaps in your own Photon credentials, and disables the client-side guards (EasyAntiCheat, TLS certificate pinning, image signature verification) that would otherwise reject a non-official server.
|
||||
## How it works
|
||||
|
||||
> ⚠️ This disables anti-cheat, certificate validation, and RSA signature verification on the client. Use at your own risk.
|
||||
1. **Loading vector.** `RecRoom.exe`/`UnityPlayer.dll` import `VERSION.dll` by name, and the loader
|
||||
searches the game folder before `System32`. We ship our own `version.dll` there; each of its 17
|
||||
exports is a thin wrapper that lazily loads the real system `version.dll` (by full path, so no
|
||||
recursion) and calls through, so the game keeps working. Its `DllMain` starts the hook thread.
|
||||
Loads very early, before `UnityPlayer.dll`. Self-contained — nothing else to ship.
|
||||
|
||||
## Projects
|
||||
`RecRoom.exe` spawns `UnityCrashHandler64.exe` from the same folder, so our DLL loads there too.
|
||||
The hook thread checks the host executable and exits immediately in anything but `RecRoom.exe` —
|
||||
otherwise every launch opened a second debug console and left a stray process waiting on Unity.
|
||||
|
||||
[<img width="100" height="100" alt="image" src="https://github.com/user-attachments/assets/f0b91aa3-49f5-4077-8eb9-5ae676888709" />](https://www.recflare.net)
|
||||
2. **DNS host rewrite** (`src/hooks/dns_hook.c`). Detours `ws2_32!getaddrinfo`. A lookup for an exact
|
||||
`from` host in `redirector.json` (e.g. `ns.rec.net`) is resolved as its `to` host
|
||||
(`ns.recflare.net`) instead — we hand the rewritten name to real DNS, so the client reaches the
|
||||
target's *current* IP (survives dynamic IPs) rather than a pinned address. Surgical: only the
|
||||
configured hosts are affected. Necessary but **not sufficient** on its own — it changes only name
|
||||
resolution, leaving SNI and the `Host:` header saying `ns.rec.net`. Kept as a safety net under (3).
|
||||
|
||||
This plugin powers [RecFlare](https://www.recflare.net) - an open source, cloud-native Rec Room server.
|
||||
3. **HTTP host rewrite** (`src/unity/http_rewrite.c`) — the patch that actually moves traffic. Hooks
|
||||
the concrete static `BestHTTP.HTTPManager.SendRequest(HTTPRequest)`, reads
|
||||
`req.Uri.AbsoluteUri`, swaps the host through the same `redirector.json` pairs, and assigns a
|
||||
fresh `new Uri(...)` back before letting the real `SendRequest` run. The new host therefore
|
||||
carries end-to-end — URL, SNI and `Host:` — so the target can serve it as its own vhost with its
|
||||
own cert. Native equivalent of the managed build's `SendRequestPatch`. This is the one
|
||||
**call-through** hook, so it depends on the relocating trampoline in `src/memory/detour.c`.
|
||||
|
||||
## Safety
|
||||
4. **TLS pinning bypass** (`src/unity/ssl_patch.c`). Redirecting HTTPS means the handshake presents a
|
||||
cert the client would reject. Resolves the **concrete**
|
||||
`Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication.NotifyServerCertificate` and detours its
|
||||
compiled body to a no-op that accepts unconditionally — the native equivalent of the managed
|
||||
build's `DisableTLSPinning` Harmony patch.
|
||||
|
||||
Using BepInEx plugins may cause anti-virus scanners or Windows Defender to pick it up as a threat.
|
||||
5. **Memory-integrity scan neutralizer** (`src/unity/memcheck_patch.c`). The client runs a background
|
||||
scan that hashes `GameAssembly.dll` code against baked-in hashes; the inline hooks above change
|
||||
that memory, so boot dies with *"Launch validation failed."* The scanner's name is obfuscated and
|
||||
rotates every build, so it is found **by signature** instead: the class in `Assembly-CSharp` that
|
||||
holds both a `Thread` and a `CancellationTokenSource` field. Its public instance 0-param non-void
|
||||
method is the scan entry point; we detour it to return an already-resolved promise (fetched from
|
||||
the promise type's static `Resolved` getter), so boot's await satisfies instantly. Started first
|
||||
among the il2cpp patches — the boot step that awaits the scan can fire early, and the reflection
|
||||
sweep needs a head start.
|
||||
|
||||
If you don't trust the compiled .DLL, you can build it yourself.
|
||||
6. **EAC neutralizer** (`src/unity/eac_patch.c`). Two replace-only hooks on
|
||||
`RecRoom.AntiCheat.EACManager`: the readiness check (the sole static 0-param `bool`
|
||||
non-property-getter method — again resolved by signature, since the name rotates) is forced to
|
||||
`true`, because the real check needs EasyAntiCheat services that no longer exist; and
|
||||
`GenerateChallengeResponse(string)` (unobfuscated) returns `base64(challenge)`, with
|
||||
`base64("nothing")` for an empty/null challenge. Safe to patch only because (5) has already
|
||||
neutralized the hash check.
|
||||
|
||||
See https://github.com/djdevin/recnet-plugin#from-source
|
||||
Everything from (2) on runs off one background thread spawned in `DllMain`; each il2cpp patch gets
|
||||
its own thread, since they must wait on the runtime independently. `src/unity/module_watch.c` just
|
||||
logs `GameAssembly.dll` / `UnityPlayer.dll` as they appear and then stops.
|
||||
|
||||
## What it does
|
||||
The `connect` and `gethostbyname` hooks are present but **intentionally not installed**: the
|
||||
`connect` hook redirects *all* :443 traffic (would break Photon/CDN/telemetry), and `getaddrinfo`
|
||||
already covers the il2cpp DNS path.
|
||||
|
||||
| Patch | File | Effect |
|
||||
| --- | --- | --- |
|
||||
| Name-server redirect | `Patches/SendRequestPatch.cs` | Intercepts `BestHTTP` requests and rewrites the host `ns.rec.net` → your configured server. Also provides optional HTTP request/response logging for development. |
|
||||
| Photon override | `Patches/PhotonPatches.cs` | Replaces the Realtime / Voice / Chat App IDs (and optionally the Photon name server + port) with your own. |
|
||||
| EAC bypass | `Patches/EACPatches.cs` | Forces EasyAntiCheat "ready" and stubs the challenge-response so the client connects without the official anti-cheat. |
|
||||
| TLS bypass | `Patches/DisableTLSPinning.cs` | Skips server-certificate validation so a custom server's cert is accepted. |
|
||||
| Image signing bypass | `Patches/ImageSigningPatch.cs` | Forces the mscorlib RSA verify to succeed, so images your server serves load without being signed by Rec Room's key. **On by default.** |
|
||||
| CheatManager handling | `Plugin.cs` | Deactivates the in-game `CheatManager` (which would otherwise boot you from rooms) while keeping it resolvable for account creation / login. |
|
||||
| DUID mismatch workaround | `Patches/DUIDMismatchPatch.cs` | Forces the device-id mismatch check to "no mismatch" so the Create Account hang (below) is skipped. **On by default**; no-op on healthy machines. |
|
||||
| DUID diagnostics | `Patches/DUIDProbePatch.cs`, `Patches/CorruptDUIDPatch.cs`, `Patches/DeviceIdResponsePatch.cs` | Investigation tooling for the hang: PlayerPrefs/DUID call logging, deliberately corrupting or restoring the stored id, and rewriting the `deviceId` response in flight. All off by default — see [Configuration](#configuration). |
|
||||
### Resolving obfuscated targets
|
||||
|
||||
## The Create Account / DUID hang
|
||||
Rec Room obfuscates its own type/method names and they rotate every build, so nothing here
|
||||
hard-codes one. Framework names (`SendRequest`, `get_Uri`, `NotifyServerCertificate`,
|
||||
`GenerateChallengeResponse`, `EACManager`) are stable and resolved literally; the anti-cheat internals
|
||||
are resolved by **shape** — field types, method signature, return type — through the il2cpp
|
||||
reflection API at runtime. Every candidate is logged, and an ambiguous match logs a `WARNING` rather
|
||||
than silently guessing.
|
||||
|
||||
Some machines hang forever on **Create Account**. This turned out to be a genuinely nasty one, so it's
|
||||
worth documenting.
|
||||
## Build
|
||||
|
||||
**What happens:** when the client's *stored* device id (DUID) differs from the one derived at runtime,
|
||||
the client takes a "migration" path — it POSTs to `PlayerReporting/v1/deviceId`, the server answers
|
||||
`200 {"success":true}`, and then the client **stalls**: it never makes the `create_account` OAuth call
|
||||
and never persists the new id. Machines whose stored id already matches never take this path, which is
|
||||
why the bug hits some players and not others (and is hard to reproduce if your own machine is fine).
|
||||
Requires VS 2022 (C toolchain) + CMake + Ninja (both ship with VS). **Must build x64** — a 32-bit
|
||||
DLL silently fails to load. Import the amd64 VC environment first:
|
||||
|
||||
**The decision point** is `CheatManager.CheckForDUIDMismatch`. Forcing it to return *true* reproduces
|
||||
the hang on any machine; forcing it *false* skips the whole path. That false-forcing is the shipping
|
||||
workaround, exposed as the `Suppress DUID Mismatch` config option, which is **on by default**. It's a
|
||||
no-op on healthy machines (their real check already returns false) and skips the hang on affected ones.
|
||||
|
||||
**Still unsolved:** we have not found where the "old" device id in that POST actually comes from. It
|
||||
survives deleting the entire `HKCU\Software\Against Gravity\Rec Room` registry key, and on the failing
|
||||
run the local `cm_did_ppk` PlayerPref is never even read — so clearing local storage does **not** fix
|
||||
it. The leading theory is that it's held server-side (recorded by the server from earlier reports)
|
||||
and/or cached in memory from a server response, which would make the proper fix server-side. See
|
||||
`CLAUDE.md` for the full investigation and the diagnostic tooling.
|
||||
|
||||
> ⚠️ `Suppress DUID Mismatch` is a workaround: it lets account creation through but does **not** repair
|
||||
> a genuinely corrupt stored/served id — it just stops the client from acting on the mismatch.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Rec Room install set up with **BepInEx 6 (IL2CPP, bleeding-edge)**, launched at least once so the IL2CPP interop assemblies have been generated under `BepInEx/interop/`.
|
||||
- **.NET 6 SDK** to build the plugin.
|
||||
- Your own server endpoints: a RecNet name server, and [Photon](https://www.photonengine.com) app keys.
|
||||
|
||||
_Looking for a custom RecNet server?_ Try https://github.com/djdevin/recflare
|
||||
|
||||
## Installing
|
||||
|
||||
1. Download the game using https://github.com/SteamRE/DepotDownloader. The manifest ID is `6426603215211043630` (the **20230414** build).
|
||||
Example: `depotdownloader -app 471710 -depot 471711 -manifest 6426603215211043630`
|
||||
**You must use this specific version.** Rec Room's type and method names are obfuscated and
|
||||
re-rolled every build, so the patches only bind against the build they were written for.
|
||||
2. Install BepInEx to the game. See https://docs.bepinex.dev/articles/user_guide/installation/index.html. **Note that you must use version 6!**
|
||||
3. Launch the game once so BepInEx generates its `config/` folder and the IL2CPP interop assemblies.
|
||||
|
||||
Alternatively, use the [RecFlare client](https://github.com/djdevin/recflare-client)
|
||||
|
||||
### From release
|
||||
|
||||
1. Download a release from [Releases](https://github.com/djdevin/recnet-plugin/releases)
|
||||
2. Drop the `.dll` file into `BepInEx/plugins/`
|
||||
|
||||
### From source
|
||||
|
||||
The project references the game's interop DLLs, so the build needs to know where your Rec Room install lives. Set `GamePath` using any one of:
|
||||
|
||||
1. **A local props file** (recommended):
|
||||
```sh
|
||||
cp GamePath.props.example GamePath.props
|
||||
```
|
||||
then edit `GamePath` in `GamePath.props` to point at your Rec Room install root. This file is local-only and stays out of the repo.
|
||||
|
||||
2. **An environment variable:**
|
||||
```sh
|
||||
set RECROOM_PATH=C:\Path\To\RecRoom # cmd
|
||||
$env:RECROOM_PATH = "C:\Path\To\RecRoom" # PowerShell
|
||||
```powershell
|
||||
& "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvarsall.bat" amd64
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
3. **On the command line:**
|
||||
```sh
|
||||
dotnet build -p:GamePath="C:\Path\To\RecRoom"
|
||||
Output: `build\version.dll` — a single self-contained proxy (it loads the real system `version.dll`
|
||||
at runtime, so there is nothing else to ship).
|
||||
|
||||
One-step deploy into the game folder (close Rec Room first — the DLL is locked while it runs):
|
||||
|
||||
```powershell
|
||||
cmake -S . -B build -G Ninja -DGAME_DIR="C:\Games\recflare-client-unstable"
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
Then build:
|
||||
## Install (manual)
|
||||
|
||||
```sh
|
||||
dotnet build
|
||||
1. Copy `build\version.dll` into the Rec Room install root (next to `RecRoom.exe`). If BepInEx's
|
||||
`version.dll` is there, replace it (this build does not use BepInEx).
|
||||
2. Copy `redirector.json.example` to `redirector.json` there and set the `rewrite` pairs
|
||||
(`{ "from": "ns.rec.net", "to": "ns.recflare.net" }`). The same pairs drive both the DNS and the
|
||||
HTTP rewrite. Matching is exact — add one entry per host. Parsed by a flat key scan, not a real
|
||||
JSON parser, so keep it flat: one object per rewrite.
|
||||
3. Launch. A console window opens; logs also go to `redirector_<pid>.log` beside `RecRoom.exe`.
|
||||
|
||||
A healthy run logs all of these (each patch runs on its own thread, so they interleave; `[MEMCHECK]`
|
||||
lands last — its reflection sweep takes a moment):
|
||||
|
||||
```
|
||||
[STATUS] DNS REDIRECT ACTIVE
|
||||
[SSL] TLS pinning bypassed (NotifyServerCertificate -> accept-all)
|
||||
[EAC] readiness check forced true
|
||||
[EAC] GenerateChallengeResponse -> base64(challenge)
|
||||
[HTTP] host rewrite installed on SendRequest
|
||||
[MEMCHECK] native memory integrity scan skipped (scan-start -> resolved promise)
|
||||
[HTTP] https://ns.rec.net/ -> https://ns.recflare.net/ (one per request)
|
||||
```
|
||||
|
||||
The build validates that `GamePath` is set and that `$(GamePath)\BepInEx\interop` exists, and fails with a clear message otherwise.
|
||||
The per-request `[HTTP] ... -> ...` lines are the proof traffic is actually moving; everything above
|
||||
them only says the hooks installed. `[DETOUR] ... refusing hook` means the detour engine wouldn't
|
||||
touch that prologue (see below) and that patch is **not** active.
|
||||
|
||||
A post-build step (the `DeployPlugin` target in the `.csproj`) automatically copies the built `RecNetPlugin.dll` into your Rec Room install's `BepInEx/plugins/` folder after every build. (The copy will fail if Rec Room is running, since the DLL is locked — close the game and rebuild.) Since `GamePath` already points at your install, you don't need to copy anything by hand — just `dotnet build` and launch the game.
|
||||
## Known limitations / open items
|
||||
|
||||
If you need the DLL elsewhere, it's also left in `bin/Debug/net6.0/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Start the game for the first time. In `BepInEx` you should now see a `config` folder. If not, verify BepInEx installation and version.
|
||||
|
||||
Inside `config`, edit the `net.rec.plugin.cfg` file and update as needed:
|
||||
|
||||
**[Server]**
|
||||
- `RecNet NameServer Host` — base URL of your RecNet name server (like `https://ns.rec.net`).
|
||||
|
||||
**[Photon]**
|
||||
- `App Id Realtime` — Photon Realtime App ID.
|
||||
- `App Id Voice` — Photon Voice App ID.
|
||||
- `App Id Chat` — Photon Chat App ID.
|
||||
|
||||
**[Signing]**
|
||||
- `Disable Signature Verification` — stops the client checking that images are signed with Rec Room's
|
||||
private key, so your own server can serve images. **On by default**; leave it alone.
|
||||
> ⚠️ This forces **all** mscorlib RSA verification to pass, not just image signatures. TLS is
|
||||
> unaffected (BestHTTP uses its own bundled BouncyCastle).
|
||||
|
||||
**[Advanced]**
|
||||
- `Enabled Advanced Settings` — must be `true` to apply the custom Photon name server / port below.
|
||||
- `Photon NameServer` — custom Photon name server host.
|
||||
- `Photon NameServer Port` — custom port (`0` uses the default, `4533`).
|
||||
- `Debug` — verbose HTTP request/response logging (only needed for development)
|
||||
> ⚠️ Debug logs include **sensitive data** (passwords, auth tokens). Be careful when sharing them.
|
||||
- `Suppress DUID Mismatch` — skips the Create Account / DUID hang (see above). **On by default**; the
|
||||
only DUID option meant for normal use. Set `false` only to observe the real mismatch for debugging.
|
||||
|
||||
The remaining `[Advanced]` DUID options — `Simulate DUID Mismatch`, `Corrupt Stored DUID`,
|
||||
`Restore Stored DUID`, `DeviceId Response Override`, `DeviceId Response Status` — are **diagnostic
|
||||
tools** used to investigate the hang. Leave them at their defaults unless you're debugging it; see
|
||||
`CLAUDE.md` for what each one does.
|
||||
|
||||
## Project layout
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `Plugin.cs` | Plugin entry point, config bindings, Harmony bootstrap |
|
||||
| `Patches/` | Harmony patches (HTTP, EAC, TLS, Photon, image signing, DUID) |
|
||||
| `CLAUDE.md` | Developer notes: build gotchas, IL2CPP/interop caveats, and the full DUID-hang investigation |
|
||||
| `RecNetPlugin.csproj` | Build config + interop references (driven by `GamePath`), and the `DeployPlugin` post-build copy |
|
||||
| `GamePath.props.example` | Template for your local `GamePath.props` |
|
||||
|
||||
## FAQ
|
||||
|
||||
**Can I use this for my own Rec Room server?**
|
||||
|
||||
Yes. That's the point.
|
||||
|
||||
## Credits
|
||||
|
||||
Based on https://github.com/CannedNet/CannedNet.Client
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
- **Obfuscated targets are matched by shape, not name.** A Rec Room build that changes the *structure*
|
||||
of the scanner class or the EAC readiness method — not just its name — will break that patch. The
|
||||
logs list every candidate considered, and warn when more than one matched, so a drift shows up as a
|
||||
`WARNING` or a "not identified" line rather than a silent misfire. Watch for `[MEMCHECK] scanner
|
||||
candidate` lines: more than one means the field-signature match is no longer unique.
|
||||
- **The detour engine's length decoder is minimal.** It relocates rip-relative `disp32` and `rel32`
|
||||
branches into a trampoline allocated within ±2 GB, but bails on two-byte (`0F`) opcodes, `rel8`
|
||||
branches, and anything it doesn't model — and `InstallDetour` then **refuses the hook** rather than
|
||||
corrupt code. This only constrains call-through hooks (currently just `SendRequest`); replace-only
|
||||
hooks take a blind 14-byte overwrite, which is safe because they jump away and never execute the
|
||||
torn tail.
|
||||
- **Nothing is undone on unload.** The detours stay installed for the life of the process; the saved
|
||||
original bytes are kept but never restored.
|
||||
- **The anti-cheat may catch up.** The memory-integrity scan is neutralized at its managed entry
|
||||
point, not at the native scanner itself — a build that calls the scan from somewhere else, or adds a
|
||||
second check, would reject the client again.
|
||||
|
||||
@@ -1,948 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<AssemblyName>RecNetPlugin</AssemblyName>
|
||||
<Product>RecNetPlugin</Product>
|
||||
<Version>1.0.0</Version>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RestoreAdditionalProjectSources>
|
||||
https://api.nuget.org/v3/index.json;
|
||||
https://nuget.bepinex.dev/v3/index.json;
|
||||
https://nuget.samboy.dev/v3/index.json
|
||||
</RestoreAdditionalProjectSources>
|
||||
<RootNamespace>RecNetPlugin</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- GamePath = root of a Rec Room install whose BepInEx/interop/ has been populated.
|
||||
It is intentionally NOT hardcoded here so the repo is distributable. Set it via ONE of:
|
||||
1. a local GamePath.props file (copy GamePath.props.example -> GamePath.props; gitignored)
|
||||
2. a RECROOM_PATH environment variable
|
||||
3. the command line: dotnet build -p:GamePath="D:\Path\To\RecRoom" -->
|
||||
<Import Project="$(MSBuildThisFileDirectory)GamePath.props" Condition="Exists('$(MSBuildThisFileDirectory)GamePath.props')" />
|
||||
|
||||
<PropertyGroup>
|
||||
<GamePath Condition="'$(GamePath)' == '' and '$(RECROOM_PATH)' != ''">$(RECROOM_PATH)</GamePath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="ValidateGamePath" BeforeTargets="ResolveAssemblyReferences;Build">
|
||||
<Error Condition="'$(GamePath)' == ''"
|
||||
Text="GamePath is not set. Copy GamePath.props.example to GamePath.props and set your Rec Room install path (or set the RECROOM_PATH env var, or pass -p:GamePath=...). See CLAUDE.md." />
|
||||
<Error Condition="'$(GamePath)' != '' and !Exists('$(GamePath)\BepInEx\interop')"
|
||||
Text="GamePath '$(GamePath)' has no BepInEx\interop folder. Point it at a Rec Room install that has been launched once under BepInEx so the IL2CPP interop assemblies are generated." />
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BepInEx.Unity.IL2CPP" Version="6.0.0-be.*" IncludeAssets="compile"/>
|
||||
<PackageReference Include="BepInEx.PluginInfoProps" Version="2.*"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Assembly-CSharp">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Assembly-CSharp.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Assembly-CSharp-firstpass">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Assembly-CSharp-firstpass.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="AstarPathfindingProject">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\AstarPathfindingProject.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Backtrace.Unity">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Backtrace.Unity.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.All.Injection.Debugging">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.All.Injection.Debugging.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.All.Injection.PhotonNetSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.All.Injection.PhotonNetSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.All.Injection.UnityEngine">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.All.Injection.UnityEngine.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.All.Mock">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.All.Mock.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.All.RecRoom">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.All.RecRoom.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Dynamic.Core.NetSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Dynamic.Core.NetSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Dynamic.Mock">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Dynamic.Mock.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Shared.Core.ByteCode">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Shared.Core.ByteCode.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Shared.CV2.Dependencies">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Shared.CV2.Dependencies.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Shared.RecRoom.Engine">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Shared.RecRoom.Engine.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Shared.RecRoom.Objects">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Shared.RecRoom.Objects.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Shared.Utilities">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Shared.Utilities.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.CompileSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.CompileSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.GraphSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.GraphSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.NetSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.NetSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.RequestReduce">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.RequestReduce.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.TreeSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.TreeSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.TypeCheckSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.TypeCheckSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.TypeSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.TypeSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Core.UnificationSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Core.UnificationSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.EV">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.EV.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.RecRoom">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.RecRoom.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.Static.Utilities">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.Static.Utilities.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Circuits.V2">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Circuits.V2.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Codestage.Anticheattoolkit.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Codestage.Anticheattoolkit.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="CSCore">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\CSCore.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="EasyAntiCheat.Client">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\EasyAntiCheat.Client.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Google.Protobuf">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Google.Protobuf.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="ICSharpCode.SharpZipLib">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\ICSharpCode.SharpZipLib.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppMicrosoft.Bcl.HashCode">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppMicrosoft.Bcl.HashCode.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppMicrosoft.CognitiveServices.Speech.csharp">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppMicrosoft.CognitiveServices.Speech.csharp.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppMicrosoft.Toolkit.HighPerformance">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppMicrosoft.Toolkit.HighPerformance.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppMono.Security">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppMono.Security.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2Cppmscorlib">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2Cppmscorlib.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Buffers">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Buffers.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Configuration">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Configuration.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Core">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Core.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Data">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Data.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Drawing">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Drawing.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Memory">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Memory.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Numerics">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Numerics.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Numerics.Vectors">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Numerics.Vectors.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Runtime.CompilerServices.Unsafe">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Runtime.CompilerServices.Unsafe.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Runtime.Serialization">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Runtime.Serialization.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Xml">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Xml.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Il2CppSystem.Xml.Linq">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Il2CppSystem.Xml.Linq.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Kyub.EmojiSearch">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Kyub.EmojiSearch.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Logger.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Logger.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="NewPlayerChallenges.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\NewPlayerChallenges.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Newtonsoft.Json">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Newtonsoft.Json.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Nito.Collections.Deque">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Nito.Collections.Deque.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Oculus.Platform">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Oculus.Platform.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Oculus.VR">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Oculus.VR.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="OSA">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\OSA.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Pathfinding.ClipperLib">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Pathfinding.ClipperLib.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Pathfinding.Ionic.Zip.Reduced">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Pathfinding.Ionic.Zip.Reduced.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Pathfinding.Poly2Tri">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Pathfinding.Poly2Tri.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Photon3Unity3D">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Photon3Unity3D.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="PhotonChat">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\PhotonChat.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="PhotonRealtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\PhotonRealtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="PhotonUnityNetworking">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\PhotonUnityNetworking.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="PhotonUnityNetworking.Utilities">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\PhotonUnityNetworking.Utilities.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="PhotonVoice">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\PhotonVoice.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="PhotonVoice.API">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\PhotonVoice.API.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="PhotonVoice.PUN">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\PhotonVoice.PUN.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Pngcs">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Pngcs.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecNet.Interfaces">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecNet.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecNet.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecNet.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Agdxgidisplays.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Agdxgidisplays.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.AgInitialization.Interfaces">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.AgInitialization.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.AgInitialization.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.AgInitialization.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Agmobilear.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Agmobilear.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Analytics.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Analytics.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ApplicationLifecycle.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ApplicationLifecycle.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Assetbundles.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Assetbundles.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Async">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Async.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Attributes.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Attributes.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Audio.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Audio.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.AutomationTests.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.AutomationTests.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.BitPacker.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.BitPacker.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Build.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Build.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Challenges.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Challenges.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Chat.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Chat.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.CircuitsV1.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.CircuitsV1.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ClusterLods.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ClusterLods.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.CodeGen.Attributes">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.CodeGen.Attributes.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Commandline.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Commandline.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.CommonDataTypes.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.CommonDataTypes.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Configloader.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Configloader.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Connectables.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Connectables.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Content.Authoring.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Content.Authoring.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Creation.Interfaces.UX.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Creation.Interfaces.UX.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Creation.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Creation.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.CultureUtil.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.CultureUtil.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Datastructures.CollisionMesh.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Datastructures.CollisionMesh.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Datastructures.CullingGroupManager.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Datastructures.CullingGroupManager.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Datastructures.OverridableFields.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Datastructures.OverridableFields.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Datastructures.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Datastructures.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Datastructures.Singletons.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Datastructures.Singletons.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Debugging.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Debugging.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.EditorHelpers.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.EditorHelpers.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Encoding.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Encoding.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Experiments.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Experiments.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.FastLines.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.FastLines.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.FuzzySearch.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.FuzzySearch.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.GameSystems.Interfaces">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.GameSystems.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Imageutils.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Imageutils.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Imposters.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Imposters.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Instantiation.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Instantiation.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.iOSNative.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.iOSNative.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.JuniorAccountVisibility.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.JuniorAccountVisibility.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Keepsakes.Interfaces">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Keepsakes.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Keepsakes.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Keepsakes.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Keepsakes.UnityExtensions">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Keepsakes.UnityExtensions.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Localization.Service">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Localization.Service.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Maker.Core.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Maker.Core.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Maker.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Maker.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.MemoryStats.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.MemoryStats.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Minijson.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Minijson.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.MobileHome.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.MobileHome.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Nativemesh.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Nativemesh.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Networking.DataTypes.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Networking.DataTypes.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Networking.NetworkedObjects.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Networking.NetworkedObjects.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Networking.PhotonImpl.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Networking.PhotonImpl.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Networking.RoomLoading.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Networking.RoomLoading.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Networking.RPC.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Networking.RPC.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Networking.SynchronizedFields.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Networking.SynchronizedFields.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.NoEngine.Algorithms.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.NoEngine.Algorithms.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.NoEngine.Common.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.NoEngine.Common.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.NoEngine.DataStructures.Performance.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.NoEngine.DataStructures.Performance.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.NoEngine.DataStructures.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.NoEngine.DataStructures.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.NoEngine.Debugging.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.NoEngine.Debugging.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.NoEngine.JetBrains.Annotations">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.NoEngine.JetBrains.Annotations.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Attributes.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Attributes.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.BitPacker.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.BitPacker.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.ComponentData.Generated.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.ComponentData.Generated.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.ComponentData.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.ComponentData.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.ConfigUI.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.ConfigUI.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Entities.Core.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Entities.Core.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Entities.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Entities.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Interfaces.ConfigUI.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Interfaces.ConfigUI.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Interfaces.Prefabs.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Interfaces.Prefabs.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Interfaces.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Interfaces.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Prefabs.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Prefabs.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Properties.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Properties.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Protobufs.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Protobufs.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.RendererV1.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.RendererV1.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Services.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Services.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Systems.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Systems.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Telemetry.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Telemetry.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Transmission.PUN.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Transmission.PUN.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectModel.Transmission.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectModel.Transmission.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ObjectPool.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ObjectPool.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Persistence.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Persistence.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.PlatformNotifications.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.PlatformNotifications.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Preferences.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Preferences.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.PrefParsers.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.PrefParsers.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ProgressionEvents.Interfaces">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ProgressionEvents.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ProgressionEvents.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ProgressionEvents.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Promises.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Promises.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Protobuf.Debugging.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Protobuf.Debugging.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Protobuf.Extensions.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Protobuf.Extensions.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Protobuf.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Protobuf.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Protobuf.UnityExtensions.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Protobuf.UnityExtensions.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Rbex.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Rbex.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ResourceManagement.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ResourceManagement.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.RoomLoading.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.RoomLoading.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.RoomPermissions.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.RoomPermissions.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Rranticheat.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Rranticheat.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.RRUI.Core.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.RRUI.Core.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.RRUI.Data.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.RRUI.Data.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.RRUI.Navigation.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.RRUI.Navigation.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.RRUI.Theme.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.RRUI.Theme.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Scheduling.Interface.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Scheduling.Interface.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Scheduling.Scheduler.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Scheduling.Scheduler.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Scheduling.Schedules.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Scheduling.Schedules.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.ShapeRendering.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.ShapeRendering.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Streamingaudio.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Streamingaudio.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Studio.Common.LocalTesting">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Studio.Common.LocalTesting.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Studio.Common.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Studio.Common.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.TagsAndLayers.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.TagsAndLayers.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Time.Interfaces">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Time.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Time.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Time.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Tweening.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Tweening.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.UIInteraction.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.UIInteraction.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Unityextensions.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Unityextensions.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.UrlHandler.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.UrlHandler.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Utf8json.Interfaces">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Utf8json.Interfaces.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Utf8json.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Utf8json.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="RecRoom.Versioning.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\RecRoom.Versioning.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SA.Foundation">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\SA.Foundation.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SA.Foundation.Network">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\SA.Foundation.Network.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SA.iOS">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\SA.iOS.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SA.iOS.XCode">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\SA.iOS.XCode.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Singular">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Singular.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="StansAssets.Foundation">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\StansAssets.Foundation.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="StansAssets.Plugins">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\StansAssets.Plugins.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="StatsigUnity.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\StatsigUnity.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SteamVR">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\SteamVR.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="SteamVR_Actions">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\SteamVR_Actions.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="TextureTool.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\TextureTool.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="ToxMod">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\ToxMod.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UJect.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UJect.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UJect.UnityExtensions">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UJect.UnityExtensions.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Addressables">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Addressables.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Burst">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Burst.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Burst.Unsafe">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Burst.Unsafe.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Collections">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Collections.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Entities">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Entities.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.InputSystem">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.InputSystem.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Jobs">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Jobs.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Localization">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Localization.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Mathematics">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Mathematics.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.ProBuilder">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.ProBuilder.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Properties">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Properties.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.RenderPipeline.Universal.ShaderLibrary">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.RenderPipeline.Universal.ShaderLibrary.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.RenderPipelines.Core.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.RenderPipelines.Core.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.RenderPipelines.Universal.Runtime">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.RenderPipelines.Universal.Runtime.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.ResourceManager">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.ResourceManager.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.Serialization">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.Serialization.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.TextMeshPro">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.TextMeshPro.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.XR.ARFoundation">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.XR.ARFoundation.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.XR.ARSubsystems">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.XR.ARSubsystems.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.XR.Management">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.XR.Management.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.XR.Oculus">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.XR.Oculus.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.XR.OpenVR">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.XR.OpenVR.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Unity.XR.PSVR">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Unity.XR.PSVR.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.AccessibilityModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.AccessibilityModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.AIModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.AIModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.AndroidJNIModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.AndroidJNIModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.AnimationModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.AnimationModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.AssetBundleModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.AssetBundleModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.AudioModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.AudioModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ClothModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.ClothModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ClusterInputModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.ClusterInputModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ClusterRendererModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.ClusterRendererModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.CoreModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CrashReportingModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.CrashReportingModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.DirectorModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.DirectorModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.DSPGraphModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.DSPGraphModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.GameCenterModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.GameCenterModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.GIModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.GIModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.GridModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.GridModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.HotReloadModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.HotReloadModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ImageConversionModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.ImageConversionModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.IMGUIModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.IMGUIModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.InputLegacyModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.InputLegacyModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.InputModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.InputModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.JSONSerializeModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.JSONSerializeModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.LocalizationModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.LocalizationModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ParticleSystemModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.ParticleSystemModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.PerformanceReportingModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.PerformanceReportingModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.Physics2DModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.Physics2DModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.PhysicsModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.PhysicsModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ProfilerModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.ProfilerModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.RuntimeInitializeOnLoadManagerInitializerModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.RuntimeInitializeOnLoadManagerInitializerModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.ScreenCaptureModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.ScreenCaptureModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.SharedInternalsModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.SharedInternalsModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.SpatialTracking">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.SpatialTracking.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.SpriteMaskModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.SpriteMaskModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.SpriteShapeModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.SpriteShapeModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.StreamingModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.StreamingModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.SubstanceModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.SubstanceModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.SubsystemsModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.SubsystemsModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TerrainModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.TerrainModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TerrainPhysicsModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.TerrainPhysicsModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TextCoreModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.TextCoreModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TextRenderingModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.TextRenderingModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TilemapModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.TilemapModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.TLSModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.TLSModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UI">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UI.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UIElementsModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UIElementsModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UIElementsNativeModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UIElementsNativeModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UIModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UIModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UmbraModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UmbraModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UNETModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UNETModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityAnalyticsModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityAnalyticsModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityConnectModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityConnectModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityCurlModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityCurlModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityTestProtocolModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityTestProtocolModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityWebRequestAssetBundleModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityWebRequestAssetBundleModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityWebRequestAudioModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityWebRequestAudioModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityWebRequestModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityWebRequestModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityWebRequestTextureModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityWebRequestTextureModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UnityWebRequestWWWModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.UnityWebRequestWWWModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.VehiclesModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.VehiclesModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.VFXModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.VFXModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.VideoModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.VideoModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.VirtualTexturingModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.VirtualTexturingModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.VRModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.VRModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.WindModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.WindModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.XRModule">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\UnityEngine.XRModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="Valve.Newtonsoft.Json">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\Valve.Newtonsoft.Json.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="XboxUWP">
|
||||
<HintPath>$(GamePath)\BepInEx\interop\XboxUWP.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="DeployPlugin" AfterTargets="Build">
|
||||
<Copy SourceFiles="$(OutputPath)$(AssemblyName).dll" DestinationFolder="$(GamePath)\BepInEx\plugins\" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
void LogAPIRequest(
|
||||
const char *method,
|
||||
const char *url
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define _CRT_SECURE_NO_WARNINGS
|
||||
|
||||
#include <windows.h>
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <dbghelp.h>
|
||||
#include <psapi.h>
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdarg.h>
|
||||
|
||||
#pragma comment(lib,"ws2_32.lib")
|
||||
#pragma comment(lib,"dbghelp.lib")
|
||||
#pragma comment(lib,"psapi.lib")
|
||||
|
||||
#ifndef ARRAYSIZE
|
||||
#define ARRAYSIZE(x) (sizeof(x)/sizeof((x)[0]))
|
||||
#endif
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
#define CONFIG_FILE "redirector.json"
|
||||
#define MAX_REDIRECTS 64
|
||||
#define MAX_REWRITES 32
|
||||
#define DEFAULT_IP "127.0.0.1"
|
||||
#define DEFAULT_PORT 443
|
||||
|
||||
// Static-IP redirect (used only by the disabled connect hook; DNS uses host rewrite below).
|
||||
extern char redirect_ip[16];
|
||||
extern int redirect_port;
|
||||
|
||||
extern char *redirect_domains[MAX_REDIRECTS];
|
||||
extern int redirect_count_config;
|
||||
|
||||
// Host rewrite pairs: a DNS lookup for exactly <from> is resolved as <to> instead, so real DNS
|
||||
// returns the target's current (possibly dynamic) IP.
|
||||
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,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef int (WSAAPI *connect_t)(
|
||||
SOCKET,
|
||||
const struct sockaddr *,
|
||||
int
|
||||
);
|
||||
|
||||
extern connect_t real_connect;
|
||||
extern connect_t original_connect;
|
||||
|
||||
extern BYTE backup_connect[14];
|
||||
|
||||
int WSAAPI hook_connect(
|
||||
SOCKET,
|
||||
const struct sockaddr *,
|
||||
int
|
||||
);
|
||||
@@ -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,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
int InstallDetour(
|
||||
LPVOID target,
|
||||
LPVOID hook,
|
||||
BYTE *backup,
|
||||
LPVOID *outTrampoline
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
typedef int (WSAAPI *getaddrinfo_t)(
|
||||
PCSTR,
|
||||
PCSTR,
|
||||
const ADDRINFOA *,
|
||||
PADDRINFOA *
|
||||
);
|
||||
|
||||
typedef struct hostent *(WSAAPI *gethostbyname_t)(
|
||||
const char *
|
||||
);
|
||||
|
||||
extern getaddrinfo_t real_getaddrinfo;
|
||||
extern getaddrinfo_t original_getaddrinfo;
|
||||
|
||||
extern gethostbyname_t real_gethostbyname;
|
||||
|
||||
extern BYTE backup_getaddrinfo[32]; // holds whole stolen instructions (>= 14 bytes)
|
||||
|
||||
int WSAAPI hook_getaddrinfo(
|
||||
PCSTR,
|
||||
PCSTR,
|
||||
const ADDRINFOA *,
|
||||
PADDRINFOA *
|
||||
);
|
||||
|
||||
struct hostent *WSAAPI hook_gethostbyname(
|
||||
const char *
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
// Neutralizes EasyAntiCheat integration so the client runs against the self-hosted server. Ports the
|
||||
// managed EACPatches: (1) force EACManager's readiness check true, (2) make GenerateChallengeResponse
|
||||
// return base64(challenge). Resolves EACManager by its (unobfuscated) name and the readiness method by
|
||||
// signature (its obfuscated name rotates per build). Waits for the il2cpp runtime; safe on its own thread.
|
||||
void PatchEAC(void);
|
||||
@@ -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,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
DWORD WINAPI HookThread(LPVOID);
|
||||
|
||||
BOOL InstallHooks(void);
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
// Installs the HTTP-layer host rewrite: hooks BestHTTP.HTTPManager.SendRequest(HTTPRequest) and
|
||||
// rewrites the request's Uri (ns.rec.net -> ns.recflare.net) so the URL, TLS SNI and Host header all
|
||||
// carry the target host -- a genuine request to the alternate backend, not just a redirected IP.
|
||||
// Waits for the il2cpp runtime; safe to call on its own thread. No-op if no rewrite pairs configured.
|
||||
void PatchHttpHostRewrite(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,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void InitConsole(void);
|
||||
void InitLogger(void);
|
||||
|
||||
void Log(const char *fmt, ...);
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
// Neutralizes the client's native memory-integrity scan, which otherwise fails boot with
|
||||
// "Launch validation failed. Is Rec Room installed correctly?" because it hashes GameAssembly.dll's
|
||||
// executable memory and our inline hooks (SendRequest, NotifyServerCertificate) change it.
|
||||
//
|
||||
// Mirrors the managed MemoryIntegrityPatch: find the scanner by signature (a class with both a
|
||||
// Thread and a CancellationTokenSource field), detour its public 0-param scan-start method to return
|
||||
// an already-resolved promise, so the boot step never sees a mismatch. All resolution is by
|
||||
// signature at runtime -- no obfuscated names, survives per-build name rotation. Waits for the
|
||||
// il2cpp runtime; safe on its own thread.
|
||||
void PatchMemoryIntegrityCheck(void);
|
||||
@@ -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,5 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void WatchModules(void);
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
void LogPacket(
|
||||
const char *direction,
|
||||
const void *data,
|
||||
size_t size
|
||||
);
|
||||
@@ -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,13 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
BOOL IsGameProcess(void);
|
||||
|
||||
void LogProcessInfo(void);
|
||||
|
||||
void DumpLoadedModules(void);
|
||||
|
||||
void LogStack(void);
|
||||
|
||||
LONG WINAPI MyExceptionHandler(EXCEPTION_POINTERS *e);
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
void PatchBestHTTPSSL(void);
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
|
||||
int ShouldRedirect(const char *host);
|
||||
|
||||
// If host exactly matches a configured rewrite pair's <from>, writes <to> into out (up to outlen)
|
||||
// and returns 1; otherwise returns 0 and leaves out untouched.
|
||||
// Example: host "ns.rec.net", from "ns.rec.net", to "ns.recflare.net" -> "ns.recflare.net".
|
||||
int RewriteHost(const char *host, char *out, size_t outlen);
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
void LogTLS(
|
||||
const char *event
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"_comment": "Copy to redirector.json next to RecRoom.exe. Parsed by src/core/config.c (simple key scan, not a real JSON parser -- keep it flat, one object per rewrite).",
|
||||
|
||||
"_rewrite_comment": "DNS lookups for an exact 'from' host are resolved as 'to' instead, so real DNS returns the target's current IP (survives dynamic IPs). Matching is EXACT -- list each host you want redirected.",
|
||||
"rewrite": [
|
||||
{ "from": "ns.rec.net", "to": "ns.recflare.net" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "config.h"
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
char redirect_ip[16] = DEFAULT_IP;
|
||||
|
||||
int redirect_port = DEFAULT_PORT;
|
||||
|
||||
|
||||
char *redirect_domains[MAX_REDIRECTS];
|
||||
|
||||
int redirect_count_config = 0;
|
||||
|
||||
|
||||
char *rewrite_from[MAX_REWRITES];
|
||||
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()
|
||||
{
|
||||
HANDLE hFile =
|
||||
CreateFileA(
|
||||
CONFIG_FILE,
|
||||
GENERIC_READ,
|
||||
FILE_SHARE_READ,
|
||||
NULL,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(hFile == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
Log(
|
||||
"[CONFIG] No config found, using defaults %s:%d",
|
||||
redirect_ip,
|
||||
redirect_port
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
DWORD size =
|
||||
GetFileSize(
|
||||
hFile,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(size == INVALID_FILE_SIZE)
|
||||
{
|
||||
CloseHandle(hFile);
|
||||
|
||||
Log(
|
||||
"[CONFIG] Failed reading file size"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
char *buffer =
|
||||
calloc(
|
||||
1,
|
||||
size + 1
|
||||
);
|
||||
|
||||
|
||||
if(!buffer)
|
||||
{
|
||||
CloseHandle(hFile);
|
||||
|
||||
Log(
|
||||
"[CONFIG] Memory allocation failed"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
DWORD read = 0;
|
||||
|
||||
|
||||
ReadFile(
|
||||
hFile,
|
||||
buffer,
|
||||
size,
|
||||
&read,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
CloseHandle(hFile);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// IP
|
||||
//
|
||||
|
||||
char *ip =
|
||||
strstr(
|
||||
buffer,
|
||||
"\"ip\""
|
||||
);
|
||||
|
||||
|
||||
if(ip)
|
||||
{
|
||||
char *colon =
|
||||
strchr(
|
||||
ip,
|
||||
':'
|
||||
);
|
||||
|
||||
|
||||
if(colon)
|
||||
{
|
||||
char *start =
|
||||
strchr(
|
||||
colon,
|
||||
'"'
|
||||
);
|
||||
|
||||
|
||||
if(start)
|
||||
{
|
||||
start++;
|
||||
|
||||
|
||||
char *end =
|
||||
strchr(
|
||||
start,
|
||||
'"'
|
||||
);
|
||||
|
||||
|
||||
if(end)
|
||||
{
|
||||
size_t len =
|
||||
end - start;
|
||||
|
||||
|
||||
if(len < sizeof(redirect_ip))
|
||||
{
|
||||
memcpy(
|
||||
redirect_ip,
|
||||
start,
|
||||
len
|
||||
);
|
||||
|
||||
|
||||
redirect_ip[len] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Port
|
||||
//
|
||||
|
||||
char *port =
|
||||
strstr(
|
||||
buffer,
|
||||
"\"port\""
|
||||
);
|
||||
|
||||
|
||||
if(port)
|
||||
{
|
||||
char *colon =
|
||||
strchr(
|
||||
port,
|
||||
':'
|
||||
);
|
||||
|
||||
|
||||
if(colon)
|
||||
{
|
||||
int p =
|
||||
atoi(
|
||||
colon + 1
|
||||
);
|
||||
|
||||
|
||||
if(
|
||||
p > 0 &&
|
||||
p < 65536
|
||||
)
|
||||
{
|
||||
redirect_port = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Redirect domains
|
||||
//
|
||||
|
||||
char *redirect =
|
||||
strstr(
|
||||
buffer,
|
||||
"\"redirect\""
|
||||
);
|
||||
|
||||
|
||||
if(redirect)
|
||||
{
|
||||
char *array =
|
||||
strchr(
|
||||
redirect,
|
||||
'['
|
||||
);
|
||||
|
||||
|
||||
if(array)
|
||||
{
|
||||
char *current =
|
||||
array;
|
||||
|
||||
|
||||
while(
|
||||
redirect_count_config < MAX_REDIRECTS
|
||||
)
|
||||
{
|
||||
char *q1 =
|
||||
strchr(
|
||||
current,
|
||||
'"'
|
||||
);
|
||||
|
||||
|
||||
if(!q1)
|
||||
break;
|
||||
|
||||
|
||||
q1++;
|
||||
|
||||
|
||||
char *q2 =
|
||||
strchr(
|
||||
q1,
|
||||
'"'
|
||||
);
|
||||
|
||||
|
||||
if(!q2)
|
||||
break;
|
||||
|
||||
|
||||
|
||||
size_t len =
|
||||
q2 - q1;
|
||||
|
||||
|
||||
|
||||
redirect_domains[
|
||||
redirect_count_config
|
||||
] =
|
||||
calloc(
|
||||
1,
|
||||
len + 1
|
||||
);
|
||||
|
||||
|
||||
if(
|
||||
redirect_domains[
|
||||
redirect_count_config
|
||||
]
|
||||
)
|
||||
{
|
||||
memcpy(
|
||||
redirect_domains[
|
||||
redirect_count_config
|
||||
],
|
||||
q1,
|
||||
len
|
||||
);
|
||||
|
||||
|
||||
redirect_count_config++;
|
||||
}
|
||||
|
||||
|
||||
current =
|
||||
q2 + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Host rewrite pairs: "rewrite": [ { "from": "rec.net", "to": "recflare.net" }, ... ]
|
||||
// Scanned sequentially -- within each object "from" precedes "to".
|
||||
//
|
||||
|
||||
char *rw =
|
||||
strstr(
|
||||
buffer,
|
||||
"\"rewrite\""
|
||||
);
|
||||
|
||||
|
||||
if(rw)
|
||||
{
|
||||
char *current = rw;
|
||||
|
||||
|
||||
while(rewrite_count < MAX_REWRITES)
|
||||
{
|
||||
//
|
||||
// "from" value
|
||||
//
|
||||
|
||||
char *fk =
|
||||
strstr(current, "\"from\"");
|
||||
|
||||
if(!fk)
|
||||
break;
|
||||
|
||||
|
||||
char *fv1 = strchr(fk + 6, '"');
|
||||
if(!fv1) break;
|
||||
fv1++;
|
||||
|
||||
char *fv2 = strchr(fv1, '"');
|
||||
if(!fv2) break;
|
||||
|
||||
|
||||
//
|
||||
// "to" value (must follow this object's "from")
|
||||
//
|
||||
|
||||
char *tk =
|
||||
strstr(fv2, "\"to\"");
|
||||
|
||||
if(!tk)
|
||||
break;
|
||||
|
||||
|
||||
char *tv1 = strchr(tk + 4, '"');
|
||||
if(!tv1) break;
|
||||
tv1++;
|
||||
|
||||
char *tv2 = strchr(tv1, '"');
|
||||
if(!tv2) break;
|
||||
|
||||
|
||||
size_t flen = fv2 - fv1;
|
||||
size_t tlen = tv2 - tv1;
|
||||
|
||||
|
||||
char *fbuf = calloc(1, flen + 1);
|
||||
char *tbuf = calloc(1, tlen + 1);
|
||||
|
||||
|
||||
if(fbuf && tbuf)
|
||||
{
|
||||
memcpy(fbuf, fv1, flen);
|
||||
memcpy(tbuf, tv1, tlen);
|
||||
|
||||
rewrite_from[rewrite_count] = fbuf;
|
||||
rewrite_to[rewrite_count] = tbuf;
|
||||
|
||||
rewrite_count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
free(fbuf);
|
||||
free(tbuf);
|
||||
}
|
||||
|
||||
|
||||
current = tv2 + 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// 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
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[CONFIG] Loaded %d host rewrites",
|
||||
rewrite_count
|
||||
);
|
||||
|
||||
|
||||
for(
|
||||
int i = 0;
|
||||
i < rewrite_count;
|
||||
i++
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[CONFIG] rewrite %s -> %s",
|
||||
rewrite_from[i],
|
||||
rewrite_to[i]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Log(
|
||||
"[CONFIG] Redirect IP %s:%d",
|
||||
redirect_ip,
|
||||
redirect_port
|
||||
);
|
||||
|
||||
|
||||
|
||||
for(
|
||||
int i = 0;
|
||||
i < redirect_count_config;
|
||||
i++
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[CONFIG] %s",
|
||||
redirect_domains[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
static FILE *logFile = NULL;
|
||||
|
||||
|
||||
void InitConsole()
|
||||
{
|
||||
AllocConsole();
|
||||
|
||||
FILE *fp;
|
||||
|
||||
freopen_s(
|
||||
&fp,
|
||||
"CONOUT$",
|
||||
"w",
|
||||
stdout
|
||||
);
|
||||
|
||||
|
||||
printf("\n");
|
||||
printf("==============================\n");
|
||||
printf(" RR Redirector Loaded\n");
|
||||
printf("==============================\n\n");
|
||||
}
|
||||
|
||||
|
||||
void InitLogger()
|
||||
{
|
||||
char path[MAX_PATH];
|
||||
|
||||
GetModuleFileNameA(
|
||||
NULL,
|
||||
path,
|
||||
sizeof(path)
|
||||
);
|
||||
|
||||
|
||||
char *slash = strrchr(
|
||||
path,
|
||||
'\\'
|
||||
);
|
||||
|
||||
|
||||
if(slash)
|
||||
*(slash + 1) = 0;
|
||||
|
||||
|
||||
//
|
||||
// Per-PID filename. version.dll is loaded into several processes (the game, the EAC
|
||||
// launcher/bootstrap, the crash handler); a shared redirector.log means they truncate each
|
||||
// other's output with "w". One file per process keeps the game's diagnostics intact.
|
||||
//
|
||||
char name[64];
|
||||
|
||||
sprintf_s(
|
||||
name,
|
||||
sizeof(name),
|
||||
"redirector_%lu.log",
|
||||
GetCurrentProcessId()
|
||||
);
|
||||
|
||||
|
||||
strcat_s(
|
||||
path,
|
||||
sizeof(path),
|
||||
name
|
||||
);
|
||||
|
||||
|
||||
logFile = fopen(
|
||||
path,
|
||||
"w"
|
||||
);
|
||||
|
||||
|
||||
if(logFile)
|
||||
{
|
||||
fprintf(
|
||||
logFile,
|
||||
"==============================\n"
|
||||
);
|
||||
|
||||
fprintf(
|
||||
logFile,
|
||||
" RR Redirector Loaded\n"
|
||||
);
|
||||
|
||||
fprintf(
|
||||
logFile,
|
||||
"==============================\n\n"
|
||||
);
|
||||
|
||||
|
||||
fflush(logFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
void Log(
|
||||
const char *fmt,
|
||||
...
|
||||
)
|
||||
{
|
||||
SYSTEMTIME st;
|
||||
|
||||
GetLocalTime(
|
||||
&st
|
||||
);
|
||||
|
||||
|
||||
DWORD tid =
|
||||
GetCurrentThreadId();
|
||||
|
||||
|
||||
char buffer[4096];
|
||||
|
||||
|
||||
va_list args;
|
||||
|
||||
va_start(
|
||||
args,
|
||||
fmt
|
||||
);
|
||||
|
||||
|
||||
vsprintf_s(
|
||||
buffer,
|
||||
sizeof(buffer),
|
||||
fmt,
|
||||
args
|
||||
);
|
||||
|
||||
|
||||
va_end(args);
|
||||
|
||||
|
||||
|
||||
printf(
|
||||
"[%02d:%02d:%02d.%03d][TID %lu] %s\n",
|
||||
st.wHour,
|
||||
st.wMinute,
|
||||
st.wSecond,
|
||||
st.wMilliseconds,
|
||||
tid,
|
||||
buffer
|
||||
);
|
||||
|
||||
|
||||
if(logFile)
|
||||
{
|
||||
fprintf(
|
||||
logFile,
|
||||
"[%02d:%02d:%02d.%03d][TID %lu] %s\n",
|
||||
st.wHour,
|
||||
st.wMinute,
|
||||
st.wSecond,
|
||||
st.wMilliseconds,
|
||||
tid,
|
||||
buffer
|
||||
);
|
||||
|
||||
|
||||
fflush(logFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "process.h"
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
|
||||
//
|
||||
// version.dll gets loaded by every process launched from the game folder -- RecRoom.exe itself and
|
||||
// UnityCrashHandler64.exe, which RecRoom spawns from the same directory. Only the game is worth
|
||||
// hooking (and worth a console window: two AllocConsole calls = two debug windows on every launch).
|
||||
//
|
||||
BOOL IsGameProcess()
|
||||
{
|
||||
char path[MAX_PATH];
|
||||
|
||||
if(
|
||||
!GetModuleFileNameA(
|
||||
NULL,
|
||||
path,
|
||||
sizeof(path)
|
||||
)
|
||||
)
|
||||
{
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
|
||||
char *slash = strrchr(
|
||||
path,
|
||||
'\\'
|
||||
);
|
||||
|
||||
|
||||
const char *exe =
|
||||
slash ? slash + 1 : path;
|
||||
|
||||
|
||||
return _stricmp(
|
||||
exe,
|
||||
"RecRoom.exe"
|
||||
) == 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
void LogProcessInfo()
|
||||
{
|
||||
char path[MAX_PATH];
|
||||
|
||||
|
||||
GetModuleFileNameA(
|
||||
NULL,
|
||||
path,
|
||||
sizeof(path)
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[PROCESS] %s",
|
||||
path
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[PID] %lu",
|
||||
GetCurrentProcessId()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
void DumpLoadedModules()
|
||||
{
|
||||
Log(
|
||||
"========== MODULE LIST =========="
|
||||
);
|
||||
|
||||
|
||||
HANDLE snap =
|
||||
CreateToolhelp32Snapshot(
|
||||
TH32CS_SNAPMODULE,
|
||||
GetCurrentProcessId()
|
||||
);
|
||||
|
||||
|
||||
if(snap == INVALID_HANDLE_VALUE)
|
||||
{
|
||||
Log(
|
||||
"[MODULE] Failed"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
MODULEENTRY32 me;
|
||||
|
||||
me.dwSize =
|
||||
sizeof(me);
|
||||
|
||||
|
||||
|
||||
if(Module32First(
|
||||
snap,
|
||||
&me
|
||||
))
|
||||
{
|
||||
do
|
||||
{
|
||||
MODULEINFO info;
|
||||
|
||||
|
||||
if(GetModuleInformation(
|
||||
GetCurrentProcess(),
|
||||
me.hModule,
|
||||
&info,
|
||||
sizeof(info)
|
||||
))
|
||||
{
|
||||
Log(
|
||||
"[MODULE] %s Base=%p Size=%lu",
|
||||
me.szModule,
|
||||
info.lpBaseOfDll,
|
||||
info.SizeOfImage
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log(
|
||||
"[MODULE] %s",
|
||||
me.szModule
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
} while(Module32Next(
|
||||
snap,
|
||||
&me
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
CloseHandle(
|
||||
snap
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"================================"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
void LogStack()
|
||||
{
|
||||
void *frames[32];
|
||||
|
||||
|
||||
USHORT count =
|
||||
CaptureStackBackTrace(
|
||||
1,
|
||||
32,
|
||||
frames,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[STACK] Frames=%d",
|
||||
count
|
||||
);
|
||||
|
||||
|
||||
for(
|
||||
int i = 0;
|
||||
i < count;
|
||||
i++
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[STACK] %p",
|
||||
frames[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
LONG WINAPI MyExceptionHandler(
|
||||
EXCEPTION_POINTERS *e
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"========== DLL CRASH =========="
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[EXCEPTION] 0x%08X",
|
||||
e->ExceptionRecord->ExceptionCode
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[ADDRESS] %p",
|
||||
e->ExceptionRecord->ExceptionAddress
|
||||
);
|
||||
|
||||
|
||||
LogStack();
|
||||
|
||||
|
||||
Log(
|
||||
"=============================="
|
||||
);
|
||||
|
||||
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
void LogAPIRequest(
|
||||
const char *method,
|
||||
const char *url
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[API] %s %s",
|
||||
method ? method : "UNKNOWN",
|
||||
url ? url : "NULL"
|
||||
);
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "common.h"
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
void LogPacket(
|
||||
const char *direction,
|
||||
const void *data,
|
||||
size_t size
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[PACKET] %s %zu bytes",
|
||||
direction ? direction : "UNKNOWN",
|
||||
size
|
||||
);
|
||||
|
||||
|
||||
//
|
||||
// Hex dumping can be added later
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// SEND:
|
||||
// 16 03 01 00 5A ...
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
void LogTLS(
|
||||
const char *event
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[TLS] %s",
|
||||
event ? event : "NULL"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "logger.h"
|
||||
#include "process.h"
|
||||
#include "hook_manager.h"
|
||||
|
||||
|
||||
BOOL WINAPI DllMain(
|
||||
HINSTANCE hinst,
|
||||
DWORD reason,
|
||||
LPVOID reserved
|
||||
)
|
||||
{
|
||||
if(reason == DLL_PROCESS_ATTACH)
|
||||
{
|
||||
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,
|
||||
(LPVOID)hinst,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
if(thread)
|
||||
CloseHandle(thread);
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "connect_hook.h"
|
||||
|
||||
#include "logger.h"
|
||||
#include "config.h"
|
||||
|
||||
|
||||
|
||||
connect_t real_connect = NULL;
|
||||
|
||||
connect_t original_connect = NULL;
|
||||
|
||||
|
||||
BYTE backup_connect[14];
|
||||
|
||||
|
||||
|
||||
|
||||
int WSAAPI hook_connect(
|
||||
SOCKET s,
|
||||
const struct sockaddr *name,
|
||||
int namelen
|
||||
)
|
||||
{
|
||||
if(!name)
|
||||
{
|
||||
if(original_connect)
|
||||
{
|
||||
return original_connect(
|
||||
s,
|
||||
name,
|
||||
namelen
|
||||
);
|
||||
}
|
||||
|
||||
return SOCKET_ERROR;
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct sockaddr_in redirect_addr;
|
||||
|
||||
memcpy(
|
||||
&redirect_addr,
|
||||
name,
|
||||
sizeof(struct sockaddr_in)
|
||||
);
|
||||
|
||||
|
||||
|
||||
char ip[INET_ADDRSTRLEN] = {0};
|
||||
|
||||
|
||||
|
||||
if(name->sa_family == AF_INET)
|
||||
{
|
||||
SOCKADDR_IN *addr =
|
||||
(SOCKADDR_IN*)name;
|
||||
|
||||
|
||||
|
||||
inet_ntop(
|
||||
AF_INET,
|
||||
&addr->sin_addr,
|
||||
ip,
|
||||
sizeof(ip)
|
||||
);
|
||||
|
||||
|
||||
|
||||
Log(
|
||||
"[CONNECT] Socket %d attempting connection to %s:%d",
|
||||
s,
|
||||
ip,
|
||||
ntohs(addr->sin_port)
|
||||
);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Redirect HTTPS traffic
|
||||
//
|
||||
|
||||
if(
|
||||
addr->sin_port == htons(443)
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[CONNECT REDIRECT] %s:%d -> %s:%d",
|
||||
ip,
|
||||
ntohs(addr->sin_port),
|
||||
redirect_ip,
|
||||
redirect_port
|
||||
);
|
||||
|
||||
|
||||
|
||||
redirect_addr.sin_addr.s_addr =
|
||||
inet_addr(
|
||||
redirect_ip
|
||||
);
|
||||
|
||||
|
||||
redirect_addr.sin_port =
|
||||
htons(
|
||||
redirect_port
|
||||
);
|
||||
|
||||
|
||||
|
||||
name =
|
||||
(struct sockaddr*)
|
||||
&redirect_addr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if(original_connect)
|
||||
{
|
||||
int ret =
|
||||
original_connect(
|
||||
s,
|
||||
name,
|
||||
namelen
|
||||
);
|
||||
|
||||
|
||||
|
||||
if(
|
||||
ret == 0 ||
|
||||
(
|
||||
ret == SOCKET_ERROR &&
|
||||
WSAGetLastError() ==
|
||||
WSAEWOULDBLOCK
|
||||
)
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[CONNECT] Socket %d connection established",
|
||||
s
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log(
|
||||
"[CONNECT] Socket %d connection FAILED. Error: %d",
|
||||
s,
|
||||
WSAGetLastError()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return SOCKET_ERROR;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "dns_hook.h"
|
||||
|
||||
#include "config.h"
|
||||
#include "logger.h"
|
||||
#include "strings.h"
|
||||
#include "process.h"
|
||||
|
||||
|
||||
|
||||
getaddrinfo_t real_getaddrinfo = NULL;
|
||||
|
||||
getaddrinfo_t original_getaddrinfo = NULL;
|
||||
|
||||
|
||||
gethostbyname_t real_gethostbyname = NULL;
|
||||
|
||||
|
||||
BYTE backup_getaddrinfo[32];
|
||||
|
||||
|
||||
|
||||
static int redirect_count = 0;
|
||||
|
||||
|
||||
|
||||
struct hostent *WSAAPI hook_gethostbyname(
|
||||
const char *name
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[HOSTBYNAME] %s",
|
||||
name ? name : "NULL"
|
||||
);
|
||||
|
||||
|
||||
if(real_gethostbyname)
|
||||
return real_gethostbyname(name);
|
||||
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
int WSAAPI hook_getaddrinfo(
|
||||
PCSTR node,
|
||||
PCSTR service,
|
||||
const ADDRINFOA *hints,
|
||||
PADDRINFOA *result
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"=============================="
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[DNS REQUEST]"
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[THREAD] %lu",
|
||||
GetCurrentThreadId()
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[DNS HOST] %s",
|
||||
node ? node : "NULL"
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[DNS SERVICE] %s",
|
||||
service ? service : "NULL"
|
||||
);
|
||||
|
||||
|
||||
LogStack();
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Host rewrite: swap the hostname (e.g. ns.rec.net -> ns.recflare.net) and let real DNS
|
||||
// resolve the target's current IP. We don't synthesize a static address, so the redirect
|
||||
// survives the target's IP changing.
|
||||
//
|
||||
|
||||
char rewritten[256];
|
||||
|
||||
|
||||
if(
|
||||
RewriteHost(
|
||||
node,
|
||||
rewritten,
|
||||
sizeof(rewritten)
|
||||
)
|
||||
)
|
||||
{
|
||||
redirect_count++;
|
||||
|
||||
|
||||
Log(
|
||||
"[REDIRECT #%d] %s -> %s (resolving)",
|
||||
redirect_count,
|
||||
node,
|
||||
rewritten
|
||||
);
|
||||
|
||||
|
||||
if(original_getaddrinfo)
|
||||
{
|
||||
int ret =
|
||||
original_getaddrinfo(
|
||||
rewritten,
|
||||
service,
|
||||
hints,
|
||||
result
|
||||
);
|
||||
|
||||
|
||||
if(ret != 0)
|
||||
Log(
|
||||
"[DNS FAIL] %s (rewritten, %d)",
|
||||
rewritten,
|
||||
ret
|
||||
);
|
||||
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
return EAI_FAIL;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if(original_getaddrinfo)
|
||||
{
|
||||
int ret =
|
||||
original_getaddrinfo(
|
||||
node,
|
||||
service,
|
||||
hints,
|
||||
result
|
||||
);
|
||||
|
||||
|
||||
|
||||
if(
|
||||
ret == 0 &&
|
||||
result &&
|
||||
*result
|
||||
)
|
||||
{
|
||||
SOCKADDR_IN *addr =
|
||||
(SOCKADDR_IN*)
|
||||
(*result)->ai_addr;
|
||||
|
||||
|
||||
|
||||
char ip[INET_ADDRSTRLEN];
|
||||
|
||||
|
||||
inet_ntop(
|
||||
AF_INET,
|
||||
&addr->sin_addr,
|
||||
ip,
|
||||
sizeof(ip)
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[DNS RESULT] %s -> %s",
|
||||
node,
|
||||
ip
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log(
|
||||
"[DNS FAIL] %s (%d)",
|
||||
node,
|
||||
ret
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return EAI_FAIL;
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "hook_manager.h"
|
||||
|
||||
#include "logger.h"
|
||||
#include "config.h"
|
||||
#include "process.h"
|
||||
|
||||
#include "dns_hook.h"
|
||||
#include "connect_hook.h"
|
||||
|
||||
#include "detour.h"
|
||||
|
||||
#include "module_watch.h"
|
||||
#include "ssl_patch.h"
|
||||
#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"
|
||||
|
||||
|
||||
|
||||
static BOOL unity_loaded = FALSE;
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Wait until UnityPlayer.dll exists. Bounded: our version.dll also loads into non-game processes
|
||||
// (EAC launcher/bootstrap, crash handler) where UnityPlayer never appears -- those must give up and
|
||||
// let the thread exit instead of spinning forever. Returns FALSE if Unity never showed up.
|
||||
//
|
||||
|
||||
#define UNITY_WAIT_MS 60000
|
||||
|
||||
static BOOL WaitForUnity()
|
||||
{
|
||||
Log(
|
||||
"[UNITY] Waiting for UnityPlayer.dll..."
|
||||
);
|
||||
|
||||
|
||||
for(int waited = 0; waited < UNITY_WAIT_MS; waited += 100)
|
||||
{
|
||||
if(GetModuleHandleA("UnityPlayer.dll"))
|
||||
{
|
||||
unity_loaded = TRUE;
|
||||
|
||||
Log(
|
||||
"[UNITY] UnityPlayer.dll detected"
|
||||
);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
Sleep(100);
|
||||
}
|
||||
|
||||
|
||||
Log(
|
||||
"[UNITY] UnityPlayer.dll not found after %d ms -- not a game process, exiting hook thread",
|
||||
UNITY_WAIT_MS
|
||||
);
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Install all hooks
|
||||
//
|
||||
|
||||
BOOL InstallHooks()
|
||||
{
|
||||
HMODULE ws2 =
|
||||
GetModuleHandleA(
|
||||
"ws2_32.dll"
|
||||
);
|
||||
|
||||
|
||||
if(!ws2)
|
||||
{
|
||||
Log(
|
||||
"[HOOK] ws2_32.dll missing"
|
||||
);
|
||||
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
|
||||
Log(
|
||||
"[HOOK] ws2_32.dll loaded"
|
||||
);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Resolve functions
|
||||
//
|
||||
|
||||
real_getaddrinfo =
|
||||
(getaddrinfo_t)GetProcAddress(
|
||||
ws2,
|
||||
"getaddrinfo"
|
||||
);
|
||||
|
||||
|
||||
real_gethostbyname =
|
||||
(gethostbyname_t)GetProcAddress(
|
||||
ws2,
|
||||
"gethostbyname"
|
||||
);
|
||||
|
||||
|
||||
real_connect =
|
||||
(connect_t)GetProcAddress(
|
||||
ws2,
|
||||
"connect"
|
||||
);
|
||||
|
||||
|
||||
|
||||
Log(
|
||||
"[ADDR] getaddrinfo=%p",
|
||||
real_getaddrinfo
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[ADDR] gethostbyname=%p",
|
||||
real_gethostbyname
|
||||
);
|
||||
|
||||
|
||||
Log(
|
||||
"[ADDR] connect=%p",
|
||||
real_connect
|
||||
);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Install DNS hook
|
||||
//
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
|
||||
if(
|
||||
InstallDetour(
|
||||
real_getaddrinfo,
|
||||
hook_getaddrinfo,
|
||||
backup_getaddrinfo,
|
||||
(LPVOID*)&original_getaddrinfo
|
||||
)
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[HOOK] getaddrinfo installed"
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log(
|
||||
"[HOOK] getaddrinfo failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log(
|
||||
"[HOOK] getaddrinfo missing"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Connect hook will be enabled later
|
||||
//
|
||||
// Currently disabled exactly like
|
||||
// your original test build.
|
||||
//
|
||||
|
||||
|
||||
|
||||
Log(
|
||||
"===================================="
|
||||
);
|
||||
|
||||
Log(
|
||||
"[STATUS] DNS REDIRECT ACTIVE"
|
||||
);
|
||||
|
||||
Log(
|
||||
"===================================="
|
||||
);
|
||||
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Main redirector thread
|
||||
//
|
||||
|
||||
DWORD WINAPI HookThread(
|
||||
LPVOID param
|
||||
)
|
||||
{
|
||||
//
|
||||
// Bail before AllocConsole/InitLogger in non-game processes. RecRoom.exe spawns
|
||||
// UnityCrashHandler64.exe out of the same folder, so our version.dll loads there too; without
|
||||
// this check every launch opened two debug consoles and left the crash handler spinning in
|
||||
// WaitForUnity for a minute. Nothing here belongs in that process anyway.
|
||||
//
|
||||
|
||||
if(!IsGameProcess())
|
||||
return 0;
|
||||
|
||||
|
||||
InitConsole();
|
||||
|
||||
InitLogger();
|
||||
|
||||
|
||||
SetUnhandledExceptionFilter(
|
||||
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"
|
||||
);
|
||||
|
||||
|
||||
// Identify which process we're in (game vs EAC launcher vs crash handler).
|
||||
LogProcessInfo();
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Start module watcher
|
||||
//
|
||||
|
||||
HANDLE moduleThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
WatchModules,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(moduleThread)
|
||||
CloseHandle(moduleThread);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Load redirect config
|
||||
//
|
||||
|
||||
LoadConfig();
|
||||
|
||||
|
||||
|
||||
//
|
||||
// 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;
|
||||
|
||||
|
||||
|
||||
Sleep(2000);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Install hooks
|
||||
//
|
||||
|
||||
if(!InstallHooks())
|
||||
{
|
||||
Log(
|
||||
"[HOOK] Installation failed"
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// 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
|
||||
// scan-start detour is in place before boot calls it. Without this, boot fails "Launch
|
||||
// validation failed" once our other hooks perturb GameAssembly memory.
|
||||
//
|
||||
|
||||
HANDLE memThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchMemoryIntegrityCheck,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(memThread)
|
||||
CloseHandle(memThread);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// TLS pinning bypass. Runs on its own thread because it waits for the il2cpp runtime to
|
||||
// finish init (GameAssembly.dll + il2cpp_domain_get) before it can resolve+detour the
|
||||
// BouncyCastle NotifyServerCertificate method. Without this, HTTPS to the redirected server
|
||||
// fails the handshake (mismatched/pinned cert).
|
||||
//
|
||||
|
||||
if(enable_ssl)
|
||||
{
|
||||
HANDLE sslThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchBestHTTPSSL,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(sslThread)
|
||||
CloseHandle(sslThread);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[HOOK] SSL bypass disabled via config -- skipping");
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// HTTP-layer host rewrite (ns.rec.net -> ns.recflare.net in the request Uri). Own thread: like
|
||||
// the SSL patch it waits for the il2cpp runtime before resolving+hooking SendRequest.
|
||||
//
|
||||
|
||||
if(enable_http)
|
||||
{
|
||||
HANDLE httpThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchHttpHostRewrite,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(httpThread)
|
||||
CloseHandle(httpThread);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[HOOK] HTTP host rewrite disabled via config -- skipping");
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// EAC neutralizer (force readiness true + base64 challenge response). Own thread; waits for the
|
||||
// il2cpp runtime. Safe now that the memory-integrity scan is neutralized.
|
||||
//
|
||||
|
||||
HANDLE eacThread =
|
||||
CreateThread(
|
||||
NULL,
|
||||
0,
|
||||
(LPTHREAD_START_ROUTINE)
|
||||
PatchEAC,
|
||||
NULL,
|
||||
0,
|
||||
NULL
|
||||
);
|
||||
|
||||
|
||||
if(eacThread)
|
||||
CloseHandle(eacThread);
|
||||
|
||||
|
||||
|
||||
//
|
||||
// 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"
|
||||
);
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "detour.h"
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
//
|
||||
// 14-byte absolute indirect jump: FF 25 00000000 <8-byte target>. rip-relative disp=0 means the
|
||||
// 64-bit pointer sits immediately after the 6-byte opcode, so this needs no register and can reach
|
||||
// anywhere in the address space.
|
||||
//
|
||||
#define JMP_PATCH_LEN 14
|
||||
|
||||
// Trampoline capacity: stolen bytes (<= ~24) + the 14-byte jump back.
|
||||
#define TRAMP_SIZE 128
|
||||
|
||||
|
||||
//
|
||||
// How a copied instruction must be fixed up once relocated to the trampoline.
|
||||
//
|
||||
typedef enum
|
||||
{
|
||||
RK_NONE, // position-independent, copy verbatim
|
||||
RK_RIPREL, // has a rip-relative disp32 operand (mod=00,rm=101)
|
||||
RK_REL32, // CALL/JMP rel32 (E8/E9)
|
||||
RK_UNSUPPORTED // rel8 branch etc. -- we won't relocate it, refuse the hook
|
||||
} reloc_kind;
|
||||
|
||||
typedef struct
|
||||
{
|
||||
size_t len; // total instruction length
|
||||
reloc_kind kind;
|
||||
size_t disp_off; // offset of the disp32/rel32 field within the instruction
|
||||
} insn_t;
|
||||
|
||||
|
||||
//
|
||||
// Minimal x86-64 length decoder + relocation classifier.
|
||||
//
|
||||
// Measures whole-instruction boundaries so a trampoline never copies a torn instruction, and flags
|
||||
// the two relocation cases we can fix (rip-relative disp32, rel32 branch). Returns 1 on success with
|
||||
// *out filled; 0 if it hits an opcode we don't model (caller then refuses the hook rather than
|
||||
// corrupt code). rel8 branches are modelled (so length is known) but flagged RK_UNSUPPORTED.
|
||||
//
|
||||
static int decode(const uint8_t *p, insn_t *out)
|
||||
{
|
||||
size_t n = 0;
|
||||
int opsize = 0; // 0x66 present
|
||||
int rexW = 0;
|
||||
out->kind = RK_NONE;
|
||||
out->disp_off = 0;
|
||||
|
||||
// Legacy prefixes.
|
||||
for (;;)
|
||||
{
|
||||
uint8_t c = p[n];
|
||||
if (c == 0x66) { opsize = 1; n++; continue; }
|
||||
if (c == 0x67 || c == 0xF0 || c == 0xF2 || c == 0xF3 ||
|
||||
c == 0x2E || c == 0x36 || c == 0x3E || c == 0x26 ||
|
||||
c == 0x64 || c == 0x65) { n++; continue; }
|
||||
break;
|
||||
}
|
||||
|
||||
// REX prefix (0x40-0x4F).
|
||||
if ((p[n] & 0xF0) == 0x40) { rexW = (p[n] & 0x08) != 0; n++; }
|
||||
|
||||
uint8_t op = p[n++];
|
||||
|
||||
if (op == 0x0F)
|
||||
return 0; // two-byte opcodes: unsupported here, bail
|
||||
|
||||
// Relative branches. rel32 forms we can relocate; rel8 we model (length) but won't move.
|
||||
if (op == 0xE8 || op == 0xE9) // CALL/JMP rel32
|
||||
{
|
||||
out->kind = RK_REL32;
|
||||
out->disp_off = n;
|
||||
n += 4;
|
||||
out->len = n;
|
||||
return 1;
|
||||
}
|
||||
if ((op >= 0x70 && op <= 0x7F) || // Jcc rel8
|
||||
(op >= 0xE0 && op <= 0xE3) || // LOOP/JrCXZ
|
||||
op == 0xEB) // JMP rel8
|
||||
{
|
||||
out->kind = RK_UNSUPPORTED;
|
||||
n += 1;
|
||||
out->len = n;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Does this opcode carry a ModR/M byte?
|
||||
int hasModRM =
|
||||
( op <= 0x3F && (op & 0x07) < 0x04 ) || // arith r/m forms
|
||||
op == 0x62 || op == 0x63 || op == 0x69 || op == 0x6B ||
|
||||
( op >= 0x80 && op <= 0x8F ) || // grp1/test/xchg/mov/lea/pop
|
||||
op == 0xC0 || op == 0xC1 || op == 0xC6 || op == 0xC7 ||
|
||||
( op >= 0xD0 && op <= 0xD3 ) ||
|
||||
( op >= 0xD8 && op <= 0xDF ) || // x87
|
||||
op == 0xF6 || op == 0xF7 || op == 0xFE || op == 0xFF;
|
||||
|
||||
uint8_t modrm_reg = 0;
|
||||
|
||||
if (hasModRM)
|
||||
{
|
||||
uint8_t modrm = p[n++];
|
||||
uint8_t mod = modrm >> 6;
|
||||
uint8_t rm = modrm & 0x07;
|
||||
modrm_reg = (modrm >> 3) & 0x07;
|
||||
|
||||
if (mod != 0x03)
|
||||
{
|
||||
if (rm == 0x04) // SIB
|
||||
{
|
||||
uint8_t sib = p[n++];
|
||||
uint8_t base = sib & 0x07;
|
||||
if (mod == 0x00 && base == 0x05) n += 4; // disp32, no base
|
||||
else if (mod == 0x01) n += 1;
|
||||
else if (mod == 0x02) n += 4;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (mod == 0x00)
|
||||
{
|
||||
if (rm == 0x05) // rip-relative disp32
|
||||
{
|
||||
out->kind = RK_RIPREL;
|
||||
out->disp_off = n;
|
||||
n += 4;
|
||||
}
|
||||
}
|
||||
else if (mod == 0x01) n += 1;
|
||||
else if (mod == 0x02) n += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Immediate size (comes AFTER any disp -- matters for rip-relative rip = end of whole insn).
|
||||
size_t imm = 0;
|
||||
switch (op)
|
||||
{
|
||||
case 0x04: case 0x0C: case 0x14: case 0x1C:
|
||||
case 0x24: case 0x2C: case 0x34: case 0x3C:
|
||||
case 0x6A: case 0x6B: case 0x80: case 0x82: case 0x83:
|
||||
case 0xA8: case 0xC0: case 0xC1: case 0xC6:
|
||||
case 0xB0: case 0xB1: case 0xB2: case 0xB3:
|
||||
case 0xB4: case 0xB5: case 0xB6: case 0xB7:
|
||||
imm = 1; break;
|
||||
|
||||
case 0xC2: case 0xCA: // ret imm16
|
||||
imm = 2; break;
|
||||
|
||||
case 0x05: case 0x0D: case 0x15: case 0x1D:
|
||||
case 0x25: case 0x2D: case 0x35: case 0x3D:
|
||||
case 0x68: case 0x69: case 0x81: case 0xA9:
|
||||
case 0xC7:
|
||||
imm = opsize ? 2 : 4; break;
|
||||
|
||||
case 0xB8: case 0xB9: case 0xBA: case 0xBB:
|
||||
case 0xBC: case 0xBD: case 0xBE: case 0xBF: // mov r,imm (imm64 if REX.W)
|
||||
imm = rexW ? 8 : (opsize ? 2 : 4); break;
|
||||
|
||||
case 0xF6: // grp3: imm8 only for TEST (reg 0/1)
|
||||
if (modrm_reg <= 1) imm = 1; break;
|
||||
case 0xF7: // grp3: imm16/32 for TEST (reg 0/1)
|
||||
if (modrm_reg <= 1) imm = opsize ? 2 : 4; break;
|
||||
|
||||
default: break;
|
||||
}
|
||||
n += imm;
|
||||
|
||||
out->len = n;
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Sum whole instructions until we have at least JMP_PATCH_LEN bytes to overwrite. 0 => a decode
|
||||
// failed (unknown opcode) and the hook must be refused.
|
||||
//
|
||||
static size_t steal_len(const uint8_t *target)
|
||||
{
|
||||
size_t total = 0;
|
||||
insn_t insn;
|
||||
while (total < JMP_PATCH_LEN)
|
||||
{
|
||||
if (!decode(target + total, &insn)) return 0;
|
||||
total += insn.len;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
|
||||
static void WriteAbsJump(BYTE *at, LPVOID dest)
|
||||
{
|
||||
at[0] = 0xFF; at[1] = 0x25;
|
||||
at[2] = at[3] = at[4] = at[5] = 0x00;
|
||||
*(void **)&at[6] = dest;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Allocate executable memory within +-2GB of target, so relocated rip-relative disp32 / rel32
|
||||
// fields (which reference addresses near the original code) still encode in 32 bits. Falls back to
|
||||
// anywhere; the per-field range check in InstallDetour is the safety net if that isn't close enough.
|
||||
//
|
||||
static LPVOID AllocNear(void *target, size_t size)
|
||||
{
|
||||
SYSTEM_INFO si;
|
||||
GetSystemInfo(&si);
|
||||
uintptr_t gran = si.dwAllocationGranularity;
|
||||
|
||||
uintptr_t t = (uintptr_t)target;
|
||||
uintptr_t base = t & ~(gran - 1);
|
||||
|
||||
const uintptr_t MAXDIST = 0x70000000ULL; // ~1.87GB, margin under the 2GB limit
|
||||
|
||||
for (uintptr_t off = gran; off < MAXDIST; off += gran)
|
||||
{
|
||||
uintptr_t lo = base - off;
|
||||
if (lo < base) // no underflow
|
||||
{
|
||||
LPVOID p = VirtualAlloc((LPVOID)lo, size, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
|
||||
if (p) return p;
|
||||
}
|
||||
|
||||
uintptr_t hi = base + off;
|
||||
if (hi > base) // no overflow
|
||||
{
|
||||
LPVOID p = VirtualAlloc((LPVOID)hi, size, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
|
||||
if (p) return p;
|
||||
}
|
||||
}
|
||||
|
||||
return VirtualAlloc(NULL, size, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Copy [target, target+stolen) into the trampoline, fixing rip-relative and rel32 operands for the
|
||||
// new location. Returns 1 on success, 0 if an instruction can't be relocated (rel8, or a fixup that
|
||||
// no longer fits in int32).
|
||||
//
|
||||
static int RelocateInto(BYTE *tramp, const uint8_t *target, size_t stolen)
|
||||
{
|
||||
size_t off = 0;
|
||||
insn_t insn;
|
||||
|
||||
while (off < stolen)
|
||||
{
|
||||
if (!decode(target + off, &insn))
|
||||
return 0;
|
||||
|
||||
memcpy(tramp + off, target + off, insn.len);
|
||||
|
||||
if (insn.kind == RK_RIPREL || insn.kind == RK_REL32)
|
||||
{
|
||||
const uint8_t *src = target + off;
|
||||
BYTE *dst = tramp + off;
|
||||
|
||||
// rip is relative to the END of the whole instruction (past any trailing immediate),
|
||||
// so use insn.len, not disp_off+4.
|
||||
int32_t oldDisp = *(int32_t *)(src + insn.disp_off);
|
||||
uintptr_t absTarget = (uintptr_t)src + insn.len + (intptr_t)oldDisp;
|
||||
|
||||
int64_t newDisp = (int64_t)absTarget - (int64_t)((uintptr_t)dst + insn.len);
|
||||
if (newDisp > INT32_MAX || newDisp < INT32_MIN)
|
||||
{
|
||||
Log("[DETOUR] relocation out of int32 range at +%zu -- refusing", off);
|
||||
return 0;
|
||||
}
|
||||
|
||||
*(int32_t *)(dst + insn.disp_off) = (int32_t)newDisp;
|
||||
}
|
||||
else if (insn.kind == RK_UNSUPPORTED)
|
||||
{
|
||||
Log("[DETOUR] rel8 branch in stolen prologue at +%zu -- refusing (needs rel8->rel32 rewrite)", off);
|
||||
return 0;
|
||||
}
|
||||
|
||||
off += insn.len;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
int InstallDetour(
|
||||
LPVOID target,
|
||||
LPVOID hook,
|
||||
BYTE *backup,
|
||||
LPVOID *outTrampoline
|
||||
)
|
||||
{
|
||||
if (!target || !hook)
|
||||
{
|
||||
Log("[DETOUR] Invalid target/hook");
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// How many bytes we overwrite at the entry. For a call-through hook (outTrampoline != NULL) we
|
||||
// steal whole instructions, relocate them into a trampoline, and chain back to the original.
|
||||
// For a replace-only hook (outTrampoline == NULL, e.g. the SSL accept-all that never calls the
|
||||
// original) 14 bytes is enough -- we jump away immediately, so a torn trailing instruction is
|
||||
// never executed.
|
||||
//
|
||||
size_t stolen = JMP_PATCH_LEN;
|
||||
|
||||
if (outTrampoline)
|
||||
{
|
||||
stolen = steal_len((const uint8_t *)target);
|
||||
if (stolen == 0)
|
||||
{
|
||||
Log("[DETOUR] %p: undecodable prologue -- refusing hook", target);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Preserve the original bytes for the caller.
|
||||
memcpy(backup, target, stolen);
|
||||
|
||||
|
||||
//
|
||||
// Build the trampoline (call-through hooks only): relocated stolen instructions + jump back.
|
||||
//
|
||||
LPVOID trampoline = NULL;
|
||||
|
||||
if (outTrampoline)
|
||||
{
|
||||
trampoline = AllocNear(target, TRAMP_SIZE);
|
||||
if (!trampoline)
|
||||
{
|
||||
Log("[DETOUR] trampoline allocation failed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!RelocateInto((BYTE *)trampoline, (const uint8_t *)target, stolen))
|
||||
{
|
||||
VirtualFree(trampoline, 0, MEM_RELEASE);
|
||||
return 0;
|
||||
}
|
||||
|
||||
WriteAbsJump((BYTE *)trampoline + stolen, (BYTE *)target + stolen); // resume original
|
||||
FlushInstructionCache(GetCurrentProcess(), trampoline, stolen + JMP_PATCH_LEN);
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Overwrite the entry: 14-byte jump to hook, NOP-pad any remaining stolen bytes so no torn
|
||||
// instruction is left in the live code stream.
|
||||
//
|
||||
DWORD oldProtect;
|
||||
if (!VirtualProtect(target, stolen, PAGE_EXECUTE_READWRITE, &oldProtect))
|
||||
{
|
||||
Log("[DETOUR] VirtualProtect failed %lu", GetLastError());
|
||||
if (trampoline) VirtualFree(trampoline, 0, MEM_RELEASE);
|
||||
return 0;
|
||||
}
|
||||
|
||||
WriteAbsJump((BYTE *)target, hook);
|
||||
for (size_t i = JMP_PATCH_LEN; i < stolen; i++)
|
||||
((BYTE *)target)[i] = 0x90; // NOP pad
|
||||
|
||||
DWORD tmp;
|
||||
VirtualProtect(target, stolen, oldProtect, &tmp);
|
||||
FlushInstructionCache(GetCurrentProcess(), target, stolen);
|
||||
|
||||
|
||||
if (outTrampoline)
|
||||
*outTrampoline = trampoline;
|
||||
|
||||
|
||||
Log("[DETOUR] %p -> %p (stole %zu bytes%s)", target, hook, stolen,
|
||||
outTrampoline ? ", relocated trampoline" : "");
|
||||
return 1;
|
||||
}
|
||||
@@ -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,193 @@
|
||||
#include "common.h"
|
||||
|
||||
//
|
||||
// version.dll proxy (runtime-forwarding, self-contained).
|
||||
//
|
||||
// RecRoom.exe/UnityPlayer.dll import VERSION.dll by name, and the loader searches the app dir before
|
||||
// System32, so our version.dll in the game root is loaded in their place -- our injection vector
|
||||
// (DllMain in dllmain.c starts the hook thread). To keep being a working version.dll we must still
|
||||
// satisfy every export the game asks for.
|
||||
//
|
||||
// Rather than ship a renamed copy of the system DLL (version_orig.dll) and use static PE forwarders,
|
||||
// each export here is a thin wrapper that lazily loads the REAL system version.dll -- by FULL PATH,
|
||||
// so it never recurses back into us -- and calls through. Result: a single self-contained version.dll
|
||||
// with nothing extra to ship, which is less confusing for users.
|
||||
//
|
||||
// Only UnityPlayer.dll imports version.dll here, and only GetFileVersionInfoA/SizeA + VerQueryValueA,
|
||||
// but we export the full set faithfully.
|
||||
//
|
||||
// We can't name these functions the same as the Win32 APIs (the SDK headers declare them dllimport,
|
||||
// which conflicts with dllexport), so they get my_ names and are exported under the real names via
|
||||
// linker /export aliases below. Aliases (no dot) reference our local symbols; they are NOT forwarders.
|
||||
|
||||
static volatile HMODULE g_real;
|
||||
|
||||
// Load (once) the genuine system version.dll by absolute path so app-dir search can't loop to us.
|
||||
static HMODULE RealVersion(void)
|
||||
{
|
||||
HMODULE h = g_real;
|
||||
if (h) return h;
|
||||
|
||||
wchar_t path[MAX_PATH];
|
||||
UINT n = GetSystemDirectoryW(path, MAX_PATH);
|
||||
if (n == 0 || n > MAX_PATH - 16) return NULL;
|
||||
lstrcatW(path, L"\\version.dll");
|
||||
|
||||
HMODULE loaded = LoadLibraryW(path);
|
||||
HMODULE prev = (HMODULE)InterlockedCompareExchangePointer((volatile PVOID *)&g_real, loaded, NULL);
|
||||
if (prev) { if (loaded) FreeLibrary(loaded); return prev; } // lost the race
|
||||
return loaded;
|
||||
}
|
||||
|
||||
static FARPROC Proc(const char *name)
|
||||
{
|
||||
HMODULE h = RealVersion();
|
||||
return h ? GetProcAddress(h, name) : NULL;
|
||||
}
|
||||
|
||||
// One wrapper per export. The static function-pointer cache is a benign race (GetProcAddress is
|
||||
// idempotent). A NULL real proc (system DLL missing) degrades to a harmless zero/FALSE return.
|
||||
|
||||
BOOL WINAPI my_GetFileVersionInfoA(LPCSTR f, DWORD h, DWORD len, LPVOID data)
|
||||
{
|
||||
typedef BOOL (WINAPI *F)(LPCSTR, DWORD, DWORD, LPVOID);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoA");
|
||||
return fn ? fn(f, h, len, data) : FALSE;
|
||||
}
|
||||
|
||||
BOOL WINAPI my_GetFileVersionInfoW(LPCWSTR f, DWORD h, DWORD len, LPVOID data)
|
||||
{
|
||||
typedef BOOL (WINAPI *F)(LPCWSTR, DWORD, DWORD, LPVOID);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoW");
|
||||
return fn ? fn(f, h, len, data) : FALSE;
|
||||
}
|
||||
|
||||
BOOL WINAPI my_GetFileVersionInfoExA(DWORD flags, LPCSTR f, DWORD h, DWORD len, LPVOID data)
|
||||
{
|
||||
typedef BOOL (WINAPI *F)(DWORD, LPCSTR, DWORD, DWORD, LPVOID);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoExA");
|
||||
return fn ? fn(flags, f, h, len, data) : FALSE;
|
||||
}
|
||||
|
||||
BOOL WINAPI my_GetFileVersionInfoExW(DWORD flags, LPCWSTR f, DWORD h, DWORD len, LPVOID data)
|
||||
{
|
||||
typedef BOOL (WINAPI *F)(DWORD, LPCWSTR, DWORD, DWORD, LPVOID);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoExW");
|
||||
return fn ? fn(flags, f, h, len, data) : FALSE;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_GetFileVersionInfoSizeA(LPCSTR f, LPDWORD h)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(LPCSTR, LPDWORD);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoSizeA");
|
||||
return fn ? fn(f, h) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_GetFileVersionInfoSizeW(LPCWSTR f, LPDWORD h)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(LPCWSTR, LPDWORD);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoSizeW");
|
||||
return fn ? fn(f, h) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_GetFileVersionInfoSizeExA(DWORD flags, LPCSTR f, LPDWORD h)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPCSTR, LPDWORD);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoSizeExA");
|
||||
return fn ? fn(flags, f, h) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_GetFileVersionInfoSizeExW(DWORD flags, LPCWSTR f, LPDWORD h)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPCWSTR, LPDWORD);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoSizeExW");
|
||||
return fn ? fn(flags, f, h) : 0;
|
||||
}
|
||||
|
||||
BOOL WINAPI my_VerQueryValueA(LPCVOID block, LPCSTR sub, LPVOID *buf, PUINT len)
|
||||
{
|
||||
typedef BOOL (WINAPI *F)(LPCVOID, LPCSTR, LPVOID *, PUINT);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerQueryValueA");
|
||||
return fn ? fn(block, sub, buf, len) : FALSE;
|
||||
}
|
||||
|
||||
BOOL WINAPI my_VerQueryValueW(LPCVOID block, LPCWSTR sub, LPVOID *buf, PUINT len)
|
||||
{
|
||||
typedef BOOL (WINAPI *F)(LPCVOID, LPCWSTR, LPVOID *, PUINT);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerQueryValueW");
|
||||
return fn ? fn(block, sub, buf, len) : FALSE;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_VerFindFileA(DWORD flags, LPCSTR file, LPCSTR win, LPCSTR app,
|
||||
LPSTR cur, PUINT curLen, LPSTR dest, PUINT destLen)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPCSTR, LPCSTR, LPCSTR, LPSTR, PUINT, LPSTR, PUINT);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerFindFileA");
|
||||
return fn ? fn(flags, file, win, app, cur, curLen, dest, destLen) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_VerFindFileW(DWORD flags, LPCWSTR file, LPCWSTR win, LPCWSTR app,
|
||||
LPWSTR cur, PUINT curLen, LPWSTR dest, PUINT destLen)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPCWSTR, LPCWSTR, LPCWSTR, LPWSTR, PUINT, LPWSTR, PUINT);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerFindFileW");
|
||||
return fn ? fn(flags, file, win, app, cur, curLen, dest, destLen) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_VerInstallFileA(DWORD flags, LPCSTR src, LPCSTR dst, LPCSTR srcDir,
|
||||
LPCSTR dstDir, LPCSTR curDir, LPSTR tmp, PUINT tmpLen)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPCSTR, LPCSTR, LPCSTR, LPCSTR, LPCSTR, LPSTR, PUINT);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerInstallFileA");
|
||||
return fn ? fn(flags, src, dst, srcDir, dstDir, curDir, tmp, tmpLen) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_VerInstallFileW(DWORD flags, LPCWSTR src, LPCWSTR dst, LPCWSTR srcDir,
|
||||
LPCWSTR dstDir, LPCWSTR curDir, LPWSTR tmp, PUINT tmpLen)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPWSTR, PUINT);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerInstallFileW");
|
||||
return fn ? fn(flags, src, dst, srcDir, dstDir, curDir, tmp, tmpLen) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_VerLanguageNameA(DWORD lang, LPSTR buf, DWORD size)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPSTR, DWORD);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerLanguageNameA");
|
||||
return fn ? fn(lang, buf, size) : 0;
|
||||
}
|
||||
|
||||
DWORD WINAPI my_VerLanguageNameW(DWORD lang, LPWSTR buf, DWORD size)
|
||||
{
|
||||
typedef DWORD (WINAPI *F)(DWORD, LPWSTR, DWORD);
|
||||
static F fn; if (!fn) fn = (F)Proc("VerLanguageNameW");
|
||||
return fn ? fn(lang, buf, size) : 0;
|
||||
}
|
||||
|
||||
// Undocumented; not imported by anything in this game. Faithful passthrough with a best-effort
|
||||
// signature (never actually called here).
|
||||
BOOL WINAPI my_GetFileVersionInfoByHandle(int a, HANDLE b, DWORD c, LPVOID d)
|
||||
{
|
||||
typedef BOOL (WINAPI *F)(int, HANDLE, DWORD, LPVOID);
|
||||
static F fn; if (!fn) fn = (F)Proc("GetFileVersionInfoByHandle");
|
||||
return fn ? fn(a, b, c, d) : FALSE;
|
||||
}
|
||||
|
||||
// Export each under its real name (alias to our local my_ symbol; not a forwarder).
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoA=my_GetFileVersionInfoA")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoW=my_GetFileVersionInfoW")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoExA=my_GetFileVersionInfoExA")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoExW=my_GetFileVersionInfoExW")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoSizeA=my_GetFileVersionInfoSizeA")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoSizeW=my_GetFileVersionInfoSizeW")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoSizeExA=my_GetFileVersionInfoSizeExA")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoSizeExW=my_GetFileVersionInfoSizeExW")
|
||||
#pragma comment(linker, "/export:GetFileVersionInfoByHandle=my_GetFileVersionInfoByHandle")
|
||||
#pragma comment(linker, "/export:VerQueryValueA=my_VerQueryValueA")
|
||||
#pragma comment(linker, "/export:VerQueryValueW=my_VerQueryValueW")
|
||||
#pragma comment(linker, "/export:VerFindFileA=my_VerFindFileA")
|
||||
#pragma comment(linker, "/export:VerFindFileW=my_VerFindFileW")
|
||||
#pragma comment(linker, "/export:VerInstallFileA=my_VerInstallFileA")
|
||||
#pragma comment(linker, "/export:VerInstallFileW=my_VerInstallFileW")
|
||||
#pragma comment(linker, "/export:VerLanguageNameA=my_VerLanguageNameA")
|
||||
#pragma comment(linker, "/export:VerLanguageNameW=my_VerLanguageNameW")
|
||||
@@ -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,251 @@
|
||||
#include "common.h"
|
||||
#include "eac_patch.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
|
||||
//
|
||||
// Native port of the managed EACPatches (RecNetPlugin). Two hooks on RecRoom.AntiCheat.EACManager:
|
||||
//
|
||||
// 1. Readiness check -> true. The client won't proceed unless EAC reports "ready"; the real check
|
||||
// depends on the EasyAntiCheat runtime talking to live services that no longer exist. It's the
|
||||
// only static, 0-param, bool-returning, non-property-getter method on EACManager (obfuscated
|
||||
// name rotates every build -- resolved by that signature).
|
||||
//
|
||||
// 2. GenerateChallengeResponse(string) -> base64(challenge). Unobfuscated name. The server-side
|
||||
// handshake expects base64 of the challenge (empty/null -> base64("nothing")), matching what the
|
||||
// managed build supplied.
|
||||
//
|
||||
// Both are replace-only detours (we never call the originals). Safe to modify EACManager code now that
|
||||
// the native memory-integrity scan is neutralized (see memcheck_patch.c) -- otherwise this would trip
|
||||
// the hash mismatch.
|
||||
|
||||
#define METHOD_ATTRIBUTE_MEMBER_ACCESS_MASK 0x0007
|
||||
#define METHOD_ATTRIBUTE_STATIC 0x0010
|
||||
#define METHOD_ATTRIBUTE_SPECIAL_NAME 0x0800
|
||||
#define IL2CPP_TYPE_BOOLEAN 0x02
|
||||
|
||||
typedef void* (*il2cpp_domain_get_t)(void);
|
||||
typedef int (*il2cpp_thread_attach_t)(void*);
|
||||
typedef void** (*il2cpp_domain_get_assemblies_t)(void*, size_t*);
|
||||
typedef void* (*il2cpp_assembly_get_image_t)(void*);
|
||||
typedef void* (*il2cpp_class_from_name_t)(void*, const char*, const char*);
|
||||
typedef void* (*il2cpp_class_get_method_from_name_t)(void*, const char*, int);
|
||||
typedef void* (*il2cpp_class_get_methods_t)(void*, void**);
|
||||
typedef const char* (*il2cpp_method_get_name_t)(void*);
|
||||
typedef uint32_t (*il2cpp_method_get_flags_t)(void*, uint32_t*);
|
||||
typedef uint32_t (*il2cpp_method_get_param_count_t)(void*);
|
||||
typedef void* (*il2cpp_method_get_return_type_t)(void*);
|
||||
typedef int (*il2cpp_type_get_type_t)(void*);
|
||||
typedef void* (*il2cpp_string_new_t)(const char*);
|
||||
typedef uint16_t*(*il2cpp_string_chars_t)(void*);
|
||||
typedef int (*il2cpp_string_length_t)(void*);
|
||||
|
||||
static il2cpp_domain_get_t p_domain_get;
|
||||
static il2cpp_thread_attach_t p_thread_attach;
|
||||
static il2cpp_domain_get_assemblies_t p_get_assemblies;
|
||||
static il2cpp_assembly_get_image_t p_get_image;
|
||||
static il2cpp_class_from_name_t p_class_from_name;
|
||||
static il2cpp_class_get_method_from_name_t p_get_method;
|
||||
static il2cpp_class_get_methods_t p_get_methods;
|
||||
static il2cpp_method_get_name_t p_method_name;
|
||||
static il2cpp_method_get_flags_t p_method_flags;
|
||||
static il2cpp_method_get_param_count_t p_param_count;
|
||||
static il2cpp_method_get_return_type_t p_return_type;
|
||||
static il2cpp_type_get_type_t p_type_kind;
|
||||
static il2cpp_string_new_t p_string_new;
|
||||
static il2cpp_string_chars_t p_string_chars;
|
||||
static il2cpp_string_length_t p_string_length;
|
||||
|
||||
static int g_gcr_static; // is GenerateChallengeResponse a static method?
|
||||
static BYTE backup_isready[32];
|
||||
static BYTE backup_gcr[32];
|
||||
|
||||
|
||||
// ---- base64 of a UTF-8 buffer ----
|
||||
static void base64(const unsigned char *in, size_t len, char *out)
|
||||
{
|
||||
static const char tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
size_t o = 0;
|
||||
for (size_t i = 0; i < len; i += 3)
|
||||
{
|
||||
unsigned v = in[i] << 16;
|
||||
int n = 1;
|
||||
if (i + 1 < len) { v |= in[i + 1] << 8; n = 2; }
|
||||
if (i + 2 < len) { v |= in[i + 2]; n = 3; }
|
||||
out[o++] = tbl[(v >> 18) & 0x3F];
|
||||
out[o++] = tbl[(v >> 12) & 0x3F];
|
||||
out[o++] = (n >= 2) ? tbl[(v >> 6) & 0x3F] : '=';
|
||||
out[o++] = (n >= 3) ? tbl[v & 0x3F] : '=';
|
||||
}
|
||||
out[o] = 0;
|
||||
}
|
||||
|
||||
// UTF-16 (il2cpp string) -> UTF-8. Returns byte count written (excl NUL). BMP only; ample buffer assumed.
|
||||
static size_t utf16_to_utf8(const uint16_t *w, int wlen, unsigned char *out, size_t outcap)
|
||||
{
|
||||
size_t o = 0;
|
||||
for (int i = 0; i < wlen && o + 4 < outcap; i++)
|
||||
{
|
||||
uint32_t c = w[i];
|
||||
if (c < 0x80) out[o++] = (unsigned char)c;
|
||||
else if (c < 0x800)
|
||||
{
|
||||
out[o++] = (unsigned char)(0xC0 | (c >> 6));
|
||||
out[o++] = (unsigned char)(0x80 | (c & 0x3F));
|
||||
}
|
||||
else
|
||||
{
|
||||
out[o++] = (unsigned char)(0xE0 | (c >> 12));
|
||||
out[o++] = (unsigned char)(0x80 | ((c >> 6) & 0x3F));
|
||||
out[o++] = (unsigned char)(0x80 | (c & 0x3F));
|
||||
}
|
||||
}
|
||||
out[o] = 0;
|
||||
return o;
|
||||
}
|
||||
|
||||
|
||||
// ---- hooks ----
|
||||
|
||||
// Readiness check: force true. Static 0-param bool -> native (RCX=MethodInfo*); return in AL.
|
||||
static int32_t IsReadyHook(void *methodInfo)
|
||||
{
|
||||
(void)methodInfo;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// GenerateChallengeResponse(string) -> base64(challenge). Register order is RCX,RDX,R8 regardless of
|
||||
// static-ness; for an instance method a=this,b=challenge,c=MethodInfo, for static a=challenge,b=MethodInfo.
|
||||
static void* GcrHook(void *a, void *b, void *c)
|
||||
{
|
||||
(void)c;
|
||||
void *challenge = g_gcr_static ? a : b;
|
||||
|
||||
unsigned char utf8[1024];
|
||||
const char *src;
|
||||
|
||||
if (challenge)
|
||||
{
|
||||
int len = p_string_length(challenge);
|
||||
if (len > 0 && len < 300)
|
||||
{
|
||||
uint16_t *w = p_string_chars(challenge);
|
||||
utf16_to_utf8(w, len, utf8, sizeof(utf8));
|
||||
src = (const char *)utf8;
|
||||
}
|
||||
else src = "nothing";
|
||||
}
|
||||
else src = "nothing";
|
||||
|
||||
char b64[1600];
|
||||
base64((const unsigned char *)src, strlen(src), b64);
|
||||
return p_string_new(b64);
|
||||
}
|
||||
|
||||
|
||||
static BOOL ResolveApi(HMODULE ga)
|
||||
{
|
||||
p_domain_get = (il2cpp_domain_get_t) GetProcAddress(ga, "il2cpp_domain_get");
|
||||
p_thread_attach = (il2cpp_thread_attach_t) GetProcAddress(ga, "il2cpp_thread_attach");
|
||||
p_get_assemblies = (il2cpp_domain_get_assemblies_t) GetProcAddress(ga, "il2cpp_domain_get_assemblies");
|
||||
p_get_image = (il2cpp_assembly_get_image_t) GetProcAddress(ga, "il2cpp_assembly_get_image");
|
||||
p_class_from_name= (il2cpp_class_from_name_t) GetProcAddress(ga, "il2cpp_class_from_name");
|
||||
p_get_method = (il2cpp_class_get_method_from_name_t) GetProcAddress(ga, "il2cpp_class_get_method_from_name");
|
||||
p_get_methods = (il2cpp_class_get_methods_t) GetProcAddress(ga, "il2cpp_class_get_methods");
|
||||
p_method_name = (il2cpp_method_get_name_t) GetProcAddress(ga, "il2cpp_method_get_name");
|
||||
p_method_flags = (il2cpp_method_get_flags_t) GetProcAddress(ga, "il2cpp_method_get_flags");
|
||||
p_param_count = (il2cpp_method_get_param_count_t) GetProcAddress(ga, "il2cpp_method_get_param_count");
|
||||
p_return_type = (il2cpp_method_get_return_type_t) GetProcAddress(ga, "il2cpp_method_get_return_type");
|
||||
p_type_kind = (il2cpp_type_get_type_t) GetProcAddress(ga, "il2cpp_type_get_type");
|
||||
p_string_new = (il2cpp_string_new_t) GetProcAddress(ga, "il2cpp_string_new");
|
||||
p_string_chars = (il2cpp_string_chars_t) GetProcAddress(ga, "il2cpp_string_chars");
|
||||
p_string_length = (il2cpp_string_length_t) GetProcAddress(ga, "il2cpp_string_length");
|
||||
|
||||
return p_domain_get && p_get_assemblies && p_get_image && p_class_from_name && p_get_method &&
|
||||
p_get_methods && p_method_name && p_method_flags && p_param_count && p_return_type &&
|
||||
p_type_kind && p_string_new && p_string_chars && p_string_length;
|
||||
}
|
||||
|
||||
static void* FindClass(void *domain, const char *ns, const char *name)
|
||||
{
|
||||
size_t n = 0;
|
||||
void **asms = p_get_assemblies(domain, &n);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
{
|
||||
void *img = p_get_image(asms[i]);
|
||||
if (!img) continue;
|
||||
void *k = p_class_from_name(img, ns, name);
|
||||
if (k) return k;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void PatchEAC(void)
|
||||
{
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
if (!ResolveApi(ga)) { Log("[EAC] missing il2cpp exports -- aborting"); return; }
|
||||
|
||||
void *domain = NULL;
|
||||
for (int i = 0; i < 600 && !domain; i++) { domain = p_domain_get(); if (!domain) Sleep(100); }
|
||||
if (!domain) { Log("[EAC] il2cpp domain never came up"); return; }
|
||||
if (p_thread_attach) p_thread_attach(domain);
|
||||
|
||||
void *cls = NULL;
|
||||
for (int i = 0; i < 100 && !cls; i++) { cls = FindClass(domain, "RecRoom.AntiCheat", "EACManager"); if (!cls) Sleep(100); }
|
||||
if (!cls) { Log("[EAC] RecRoom.AntiCheat.EACManager not found"); return; }
|
||||
|
||||
//
|
||||
// Readiness check: the sole static, 0-param, bool, non-property-getter method.
|
||||
//
|
||||
void *isReady = NULL;
|
||||
int readyCandidates = 0;
|
||||
void *iter = NULL, *m;
|
||||
while ((m = p_get_methods(cls, &iter)) != NULL)
|
||||
{
|
||||
uint32_t iflags = 0;
|
||||
uint32_t f = p_method_flags(m, &iflags);
|
||||
if (!(f & METHOD_ATTRIBUTE_STATIC)) continue;
|
||||
if (f & METHOD_ATTRIBUTE_SPECIAL_NAME) continue; // exclude property getters
|
||||
if (p_param_count(m) != 0) continue;
|
||||
void *rt = p_return_type(m);
|
||||
if (!rt || p_type_kind(rt) != IL2CPP_TYPE_BOOLEAN) continue;
|
||||
|
||||
const char *mn = p_method_name(m);
|
||||
Log("[EAC] readiness candidate: %s", mn ? mn : "?");
|
||||
isReady = m;
|
||||
readyCandidates++;
|
||||
}
|
||||
|
||||
if (!isReady)
|
||||
Log("[EAC] no static bool() readiness method -- readiness NOT forced");
|
||||
else
|
||||
{
|
||||
if (readyCandidates > 1)
|
||||
Log("[EAC] WARNING %d readiness candidates; using the last", readyCandidates);
|
||||
void *code = *(void **)isReady;
|
||||
if (code && InstallDetour(code, IsReadyHook, backup_isready, NULL))
|
||||
Log("[EAC] readiness check forced true");
|
||||
else
|
||||
Log("[EAC] failed to hook readiness check");
|
||||
}
|
||||
|
||||
//
|
||||
// GenerateChallengeResponse(string) -> base64(challenge).
|
||||
//
|
||||
void *gcr = p_get_method(cls, "GenerateChallengeResponse", 1);
|
||||
if (!gcr)
|
||||
Log("[EAC] GenerateChallengeResponse(argc=1) not found -- challenge NOT patched");
|
||||
else
|
||||
{
|
||||
uint32_t iflags = 0;
|
||||
uint32_t f = p_method_flags(gcr, &iflags);
|
||||
g_gcr_static = (f & METHOD_ATTRIBUTE_STATIC) != 0;
|
||||
void *code = *(void **)gcr;
|
||||
if (code && InstallDetour(code, GcrHook, backup_gcr, NULL))
|
||||
Log("[EAC] GenerateChallengeResponse -> base64(challenge) (static=%d)", g_gcr_static);
|
||||
else
|
||||
Log("[EAC] failed to hook GenerateChallengeResponse");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
#include "common.h"
|
||||
#include "http_rewrite.h"
|
||||
#include "logger.h"
|
||||
#include "config.h"
|
||||
#include "strings.h"
|
||||
#include "detour.h"
|
||||
#include "retspoof.h"
|
||||
#include "hwbp.h"
|
||||
#include "crash_handler.h"
|
||||
|
||||
//
|
||||
// HTTP-layer host rewrite.
|
||||
//
|
||||
// DNS-name rewrite alone can't make a request "belong" to ns.recflare.net: the client keeps the
|
||||
// original URL, so TLS SNI and the Host header still say ns.rec.net. recflare serves ns.recflare.net
|
||||
// (its own vhost/cert), so the request must carry that host end-to-end. We do what the managed
|
||||
// SendRequestPatch did: hook the concrete, static BestHTTP.HTTPManager.SendRequest(HTTPRequest),
|
||||
// read request.Uri's absolute URL, swap the host, and set a fresh Uri back before the send proceeds.
|
||||
//
|
||||
// This is a call-through hook (we must let the real SendRequest run), so it relies on the
|
||||
// length-aware trampoline in detour.c.
|
||||
|
||||
typedef void* (*il2cpp_domain_get_t)(void);
|
||||
typedef int (*il2cpp_thread_attach_t)(void*);
|
||||
typedef void** (*il2cpp_domain_get_assemblies_t)(void*, size_t*);
|
||||
typedef void* (*il2cpp_assembly_get_image_t)(void*);
|
||||
typedef void* (*il2cpp_class_from_name_t)(void*, const char*, const char*);
|
||||
typedef void* (*il2cpp_class_get_method_from_name_t)(void*, const char*, int);
|
||||
typedef void* (*il2cpp_runtime_invoke_t)(void* method, void* obj, void** params, void** exc);
|
||||
typedef void* (*il2cpp_object_new_t)(void* klass);
|
||||
typedef void* (*il2cpp_string_new_t)(const char* str);
|
||||
typedef uint16_t* (*il2cpp_string_chars_t)(void* str);
|
||||
typedef int (*il2cpp_string_length_t)(void* str);
|
||||
|
||||
static il2cpp_domain_get_t p_domain_get;
|
||||
static il2cpp_thread_attach_t p_thread_attach;
|
||||
static il2cpp_domain_get_assemblies_t p_domain_get_assemblies;
|
||||
static il2cpp_assembly_get_image_t p_assembly_get_image;
|
||||
static il2cpp_class_from_name_t p_class_from_name;
|
||||
static il2cpp_class_get_method_from_name_t p_get_method;
|
||||
static il2cpp_runtime_invoke_t p_runtime_invoke;
|
||||
static il2cpp_object_new_t p_object_new;
|
||||
static il2cpp_string_new_t p_string_new;
|
||||
static il2cpp_string_chars_t p_string_chars;
|
||||
static il2cpp_string_length_t p_string_length;
|
||||
|
||||
// Resolved il2cpp targets.
|
||||
static void *cls_Uri;
|
||||
static void *m_get_Uri; // HTTPRequest.get_Uri() -> Uri
|
||||
static void *m_set_Uri; // HTTPRequest.set_Uri(Uri)
|
||||
static void *m_get_AbsoluteUri; // Uri.get_AbsoluteUri() -> string
|
||||
static void *m_Uri_ctor; // Uri..ctor(string)
|
||||
|
||||
// Trampoline to the real SendRequest(HTTPRequest req, MethodInfo* method) -> HTTPRequest*.
|
||||
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)
|
||||
{
|
||||
size_t count = 0;
|
||||
void **assemblies = p_domain_get_assemblies(domain, &count);
|
||||
for (size_t i = 0; i < count; i++)
|
||||
{
|
||||
void *image = p_assembly_get_image(assemblies[i]);
|
||||
if (!image) continue;
|
||||
void *k = p_class_from_name(image, ns, name);
|
||||
if (k) return k;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
//
|
||||
// Rewrite the host inside an absolute URL using the configured exact-match pairs.
|
||||
// e.g. "https://ns.rec.net/api/1" -> "https://ns.recflare.net/api/1". Returns 1 if changed.
|
||||
//
|
||||
static int RewriteUrlHost(const char *url, char *out, size_t outlen)
|
||||
{
|
||||
// Find "://"
|
||||
const char *p = strstr(url, "://");
|
||||
if (!p) return 0;
|
||||
p += 3;
|
||||
|
||||
// Host runs until '/', ':', or end.
|
||||
const char *hostEnd = p;
|
||||
while (*hostEnd && *hostEnd != '/' && *hostEnd != ':') hostEnd++;
|
||||
|
||||
size_t hostLen = (size_t)(hostEnd - p);
|
||||
if (hostLen == 0 || hostLen >= 256) return 0;
|
||||
|
||||
char host[256];
|
||||
memcpy(host, p, hostLen);
|
||||
host[hostLen] = 0;
|
||||
|
||||
char newHost[256];
|
||||
if (!RewriteHost(host, newHost, sizeof(newHost)))
|
||||
return 0; // host not in the rewrite list
|
||||
|
||||
// Reassemble: [scheme://][newHost][rest]
|
||||
size_t prefixLen = (size_t)(p - url); // through "://"
|
||||
size_t newHostLen = strlen(newHost);
|
||||
size_t restLen = strlen(hostEnd);
|
||||
if (prefixLen + newHostLen + restLen + 1 > outlen) return 0;
|
||||
|
||||
memcpy(out, url, prefixLen);
|
||||
memcpy(out + prefixLen, newHost, newHostLen);
|
||||
memcpy(out + prefixLen + newHostLen, hostEnd, restLen + 1); // include NUL
|
||||
return 1;
|
||||
}
|
||||
|
||||
//
|
||||
// Our replacement for the static SendRequest(HTTPRequest). Rewrites req.Uri then forwards.
|
||||
//
|
||||
static void* SendRequestHook(void *req, void *method)
|
||||
{
|
||||
if (!req)
|
||||
return original_SendRequest(req, method);
|
||||
|
||||
void *exc = NULL;
|
||||
|
||||
// Uri uri = req.get_Uri();
|
||||
void *uri = p_runtime_invoke(m_get_Uri, req, NULL, &exc);
|
||||
if (!uri || exc)
|
||||
return original_SendRequest(req, method);
|
||||
|
||||
// string url = uri.get_AbsoluteUri();
|
||||
void *urlStr = p_runtime_invoke(m_get_AbsoluteUri, uri, NULL, &exc);
|
||||
if (!urlStr || exc)
|
||||
return original_SendRequest(req, method);
|
||||
|
||||
// Copy the (ASCII) URL out of the il2cpp string.
|
||||
int len = p_string_length(urlStr);
|
||||
if (len <= 0 || len >= 1024)
|
||||
return original_SendRequest(req, method);
|
||||
|
||||
uint16_t *wchars = p_string_chars(urlStr);
|
||||
char url[1024];
|
||||
for (int i = 0; i < len; i++)
|
||||
url[i] = (wchars[i] < 0x80) ? (char)wchars[i] : '?';
|
||||
url[len] = 0;
|
||||
|
||||
char newUrl[1100];
|
||||
if (RewriteUrlHost(url, newUrl, sizeof(newUrl)))
|
||||
{
|
||||
// Uri newUri = new Uri(newUrl); req.set_Uri(newUri);
|
||||
void *newStr = p_string_new(newUrl);
|
||||
void *newUri = p_object_new(cls_Uri);
|
||||
void *ctorArgs[1] = { newStr };
|
||||
exc = NULL;
|
||||
p_runtime_invoke(m_Uri_ctor, newUri, ctorArgs, &exc);
|
||||
if (!exc)
|
||||
{
|
||||
void *setArgs[1] = { newUri };
|
||||
exc = NULL;
|
||||
p_runtime_invoke(m_set_Uri, req, setArgs, &exc);
|
||||
if (!exc)
|
||||
Log("[HTTP] %s -> %s", url, newUrl);
|
||||
else
|
||||
Log("[HTTP] set_Uri threw, left original");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log("[HTTP] new Uri(%s) threw, left original", newUrl);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
p_thread_attach = (il2cpp_thread_attach_t) GetProcAddress(ga, "il2cpp_thread_attach");
|
||||
p_domain_get_assemblies = (il2cpp_domain_get_assemblies_t) GetProcAddress(ga, "il2cpp_domain_get_assemblies");
|
||||
p_assembly_get_image = (il2cpp_assembly_get_image_t) GetProcAddress(ga, "il2cpp_assembly_get_image");
|
||||
p_class_from_name = (il2cpp_class_from_name_t) GetProcAddress(ga, "il2cpp_class_from_name");
|
||||
p_get_method = (il2cpp_class_get_method_from_name_t) GetProcAddress(ga, "il2cpp_class_get_method_from_name");
|
||||
p_runtime_invoke = (il2cpp_runtime_invoke_t) GetProcAddress(ga, "il2cpp_runtime_invoke");
|
||||
p_object_new = (il2cpp_object_new_t) GetProcAddress(ga, "il2cpp_object_new");
|
||||
p_string_new = (il2cpp_string_new_t) GetProcAddress(ga, "il2cpp_string_new");
|
||||
p_string_chars = (il2cpp_string_chars_t) GetProcAddress(ga, "il2cpp_string_chars");
|
||||
p_string_length = (il2cpp_string_length_t) GetProcAddress(ga, "il2cpp_string_length");
|
||||
|
||||
return p_domain_get && p_domain_get_assemblies && p_assembly_get_image && p_class_from_name &&
|
||||
p_get_method && p_runtime_invoke && p_object_new && p_string_new && p_string_chars &&
|
||||
p_string_length;
|
||||
}
|
||||
|
||||
void PatchHttpHostRewrite(void)
|
||||
{
|
||||
if (rewrite_count == 0)
|
||||
{
|
||||
Log("[HTTP] no rewrite pairs configured -- host rewrite disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
HMODULE ga = NULL;
|
||||
while (!ga) { ga = GetModuleHandleA("GameAssembly.dll"); if (!ga) Sleep(100); }
|
||||
|
||||
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); }
|
||||
if (!domain) { Log("[HTTP] il2cpp domain never came up"); return; }
|
||||
if (p_thread_attach) p_thread_attach(domain);
|
||||
|
||||
// Resolve classes (retry through early init).
|
||||
void *cls_Manager = NULL, *cls_Request = NULL;
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
if (!cls_Manager) cls_Manager = FindClass(domain, "BestHTTP", "HTTPManager");
|
||||
if (!cls_Request) cls_Request = FindClass(domain, "BestHTTP", "HTTPRequest");
|
||||
if (!cls_Uri) cls_Uri = FindClass(domain, "System", "Uri");
|
||||
if (cls_Manager && cls_Request && cls_Uri) break;
|
||||
Sleep(100);
|
||||
}
|
||||
if (!cls_Manager || !cls_Request || !cls_Uri)
|
||||
{
|
||||
Log("[HTTP] class resolve failed (mgr=%p req=%p uri=%p)", cls_Manager, cls_Request, cls_Uri);
|
||||
return;
|
||||
}
|
||||
|
||||
void *m_send = p_get_method(cls_Manager, "SendRequest", 1); // SendRequest(HTTPRequest)
|
||||
m_get_Uri = p_get_method(cls_Request, "get_Uri", 0);
|
||||
m_set_Uri = p_get_method(cls_Request, "set_Uri", 1);
|
||||
m_get_AbsoluteUri = p_get_method(cls_Uri, "get_AbsoluteUri", 0);
|
||||
m_Uri_ctor = p_get_method(cls_Uri, ".ctor", 1);
|
||||
|
||||
if (!m_send || !m_get_Uri || !m_set_Uri || !m_get_AbsoluteUri || !m_Uri_ctor)
|
||||
{
|
||||
Log("[HTTP] method resolve failed (send=%p getUri=%p setUri=%p absUri=%p ctor=%p)",
|
||||
m_send, m_get_Uri, m_set_Uri, m_get_AbsoluteUri, m_Uri_ctor);
|
||||
return;
|
||||
}
|
||||
|
||||
void *code = *(void **)m_send; // MethodInfo.methodPointer (compiled entry)
|
||||
Log("[HTTP] SendRequest MethodInfo=%p code=%p", m_send, code);
|
||||
if (!code) { Log("[HTTP] SendRequest has no compiled body"); return; }
|
||||
|
||||
if (InstallDetour(code, SendRequestHook, backup_sendrequest, (LPVOID*)&original_SendRequest))
|
||||
Log("[HTTP] host rewrite installed on SendRequest");
|
||||
else
|
||||
Log("[HTTP] SendRequest detour refused (prologue not relocatable) -- host rewrite NOT active");
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
#include "common.h"
|
||||
#include "memcheck_patch.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
|
||||
//
|
||||
// Native port of the managed MemoryIntegrityPatch (see the RecNetPlugin project). The client runs a
|
||||
// background native memory-integrity scan that hashes GameAssembly.dll code and compares against
|
||||
// baked-in hashes; our inline hooks change that memory, so the scan mismatches and boot dies with
|
||||
// "Launch validation failed. Is Rec Room installed correctly?". The scanner is identified NOT by its
|
||||
// obfuscated name (which rotates every build) but by its signature: a class holding both a
|
||||
// System.Threading.Thread and a System.Threading.CancellationTokenSource field (the background
|
||||
// scanner + its cancellation source). Its public, instance, parameterless, non-void method is the
|
||||
// scan-start entry point; the type it returns is the promise the boot step awaits. We detour that
|
||||
// entry (replace-only -- we never call the original) to instead return an already-resolved promise,
|
||||
// obtained from the promise type's static parameterless "Resolved" property getter. Boot then sees
|
||||
// an instantly-satisfied promise and proceeds.
|
||||
//
|
||||
// Everything here is resolved through the il2cpp reflection API at runtime; there are no hardcoded
|
||||
// obfuscated names. The first run logs generously so an ambiguous match can be diagnosed.
|
||||
|
||||
// --- il2cpp method attribute flags / type enum (stable il2cpp-api constants) ---
|
||||
#define METHOD_ATTRIBUTE_MEMBER_ACCESS_MASK 0x0007
|
||||
#define METHOD_ATTRIBUTE_PUBLIC 0x0006
|
||||
#define METHOD_ATTRIBUTE_STATIC 0x0010
|
||||
#define METHOD_ATTRIBUTE_SPECIAL_NAME 0x0800
|
||||
#define IL2CPP_TYPE_VOID 0x01
|
||||
|
||||
typedef void* (*il2cpp_domain_get_t)(void);
|
||||
typedef int (*il2cpp_thread_attach_t)(void*);
|
||||
typedef void** (*il2cpp_domain_get_assemblies_t)(void*, size_t*);
|
||||
typedef void* (*il2cpp_assembly_get_image_t)(void*);
|
||||
typedef const char* (*il2cpp_image_get_name_t)(void*);
|
||||
typedef size_t (*il2cpp_image_get_class_count_t)(void*);
|
||||
typedef void* (*il2cpp_image_get_class_t)(void*, size_t);
|
||||
typedef const char* (*il2cpp_class_get_name_t)(void*);
|
||||
typedef void* (*il2cpp_class_get_fields_t)(void*, void**);
|
||||
typedef void* (*il2cpp_field_get_type_t)(void*);
|
||||
typedef char* (*il2cpp_type_get_name_t)(void*);
|
||||
typedef void* (*il2cpp_class_get_methods_t)(void*, void**);
|
||||
typedef const char* (*il2cpp_method_get_name_t)(void*);
|
||||
typedef uint32_t (*il2cpp_method_get_flags_t)(void*, uint32_t*);
|
||||
typedef uint32_t (*il2cpp_method_get_param_count_t)(void*);
|
||||
typedef void* (*il2cpp_method_get_return_type_t)(void*);
|
||||
typedef int (*il2cpp_type_get_type_t)(void*);
|
||||
typedef void* (*il2cpp_class_from_type_t)(void*);
|
||||
typedef void* (*il2cpp_runtime_invoke_t)(void*, void*, void**, void**);
|
||||
typedef void (*il2cpp_free_t)(void*);
|
||||
|
||||
static il2cpp_domain_get_t p_domain_get;
|
||||
static il2cpp_thread_attach_t p_thread_attach;
|
||||
static il2cpp_domain_get_assemblies_t p_get_assemblies;
|
||||
static il2cpp_assembly_get_image_t p_get_image;
|
||||
static il2cpp_image_get_name_t p_image_name;
|
||||
static il2cpp_image_get_class_count_t p_class_count;
|
||||
static il2cpp_image_get_class_t p_get_class;
|
||||
static il2cpp_class_get_name_t p_class_name;
|
||||
static il2cpp_class_get_fields_t p_get_fields;
|
||||
static il2cpp_field_get_type_t p_field_type;
|
||||
static il2cpp_type_get_name_t p_type_name;
|
||||
static il2cpp_class_get_methods_t p_get_methods;
|
||||
static il2cpp_method_get_name_t p_method_name;
|
||||
static il2cpp_method_get_flags_t p_method_flags;
|
||||
static il2cpp_method_get_param_count_t p_param_count;
|
||||
static il2cpp_method_get_return_type_t p_return_type;
|
||||
static il2cpp_type_get_type_t p_type_kind;
|
||||
static il2cpp_class_from_type_t p_class_from_type;
|
||||
static il2cpp_runtime_invoke_t p_invoke;
|
||||
static il2cpp_free_t p_free;
|
||||
|
||||
static void *g_resolvedGetter; // MethodInfo* for the promise's static Resolved getter
|
||||
static BYTE backup_scan[32];
|
||||
|
||||
|
||||
static int ends_with(const char *s, const char *suf)
|
||||
{
|
||||
size_t ls = strlen(s), lf = strlen(suf);
|
||||
return ls >= lf && strcmp(s + (ls - lf), suf) == 0;
|
||||
}
|
||||
|
||||
static char *type_name_dup(void *type)
|
||||
{
|
||||
// il2cpp_type_get_name returns a heap string; copy into a small static-free buffer via strdup.
|
||||
char *n = p_type_name(type);
|
||||
if (!n) return NULL;
|
||||
char *copy = _strdup(n);
|
||||
if (p_free) p_free(n);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// True if klass has a field whose type name equals `full` or ends with `.suffix`.
|
||||
static int class_has_field_type(void *klass, const char *full, const char *dotsuffix)
|
||||
{
|
||||
void *iter = NULL, *field;
|
||||
int found = 0;
|
||||
while ((field = p_get_fields(klass, &iter)) != NULL)
|
||||
{
|
||||
void *ft = p_field_type(field);
|
||||
if (!ft) continue;
|
||||
char *tn = type_name_dup(ft);
|
||||
if (!tn) continue;
|
||||
if (strcmp(tn, full) == 0 || ends_with(tn, dotsuffix)) found = 1;
|
||||
free(tn);
|
||||
if (found) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
static BOOL ResolveApi(HMODULE ga)
|
||||
{
|
||||
p_domain_get = (il2cpp_domain_get_t) GetProcAddress(ga, "il2cpp_domain_get");
|
||||
p_thread_attach = (il2cpp_thread_attach_t) GetProcAddress(ga, "il2cpp_thread_attach");
|
||||
p_get_assemblies = (il2cpp_domain_get_assemblies_t) GetProcAddress(ga, "il2cpp_domain_get_assemblies");
|
||||
p_get_image = (il2cpp_assembly_get_image_t) GetProcAddress(ga, "il2cpp_assembly_get_image");
|
||||
p_image_name = (il2cpp_image_get_name_t) GetProcAddress(ga, "il2cpp_image_get_name");
|
||||
p_class_count = (il2cpp_image_get_class_count_t) GetProcAddress(ga, "il2cpp_image_get_class_count");
|
||||
p_get_class = (il2cpp_image_get_class_t) GetProcAddress(ga, "il2cpp_image_get_class");
|
||||
p_class_name = (il2cpp_class_get_name_t) GetProcAddress(ga, "il2cpp_class_get_name");
|
||||
p_get_fields = (il2cpp_class_get_fields_t) GetProcAddress(ga, "il2cpp_class_get_fields");
|
||||
p_field_type = (il2cpp_field_get_type_t) GetProcAddress(ga, "il2cpp_field_get_type");
|
||||
p_type_name = (il2cpp_type_get_name_t) GetProcAddress(ga, "il2cpp_type_get_name");
|
||||
p_get_methods = (il2cpp_class_get_methods_t) GetProcAddress(ga, "il2cpp_class_get_methods");
|
||||
p_method_name = (il2cpp_method_get_name_t) GetProcAddress(ga, "il2cpp_method_get_name");
|
||||
p_method_flags = (il2cpp_method_get_flags_t) GetProcAddress(ga, "il2cpp_method_get_flags");
|
||||
p_param_count = (il2cpp_method_get_param_count_t)GetProcAddress(ga, "il2cpp_method_get_param_count");
|
||||
p_return_type = (il2cpp_method_get_return_type_t)GetProcAddress(ga, "il2cpp_method_get_return_type");
|
||||
p_type_kind = (il2cpp_type_get_type_t) GetProcAddress(ga, "il2cpp_type_get_type");
|
||||
p_class_from_type = (il2cpp_class_from_type_t) GetProcAddress(ga, "il2cpp_class_from_type");
|
||||
p_invoke = (il2cpp_runtime_invoke_t) GetProcAddress(ga, "il2cpp_runtime_invoke");
|
||||
p_free = (il2cpp_free_t) GetProcAddress(ga, "il2cpp_free");
|
||||
|
||||
return p_domain_get && p_get_assemblies && p_get_image && p_image_name && p_class_count &&
|
||||
p_get_class && p_get_fields && p_field_type && p_type_name && p_get_methods &&
|
||||
p_method_name && p_method_flags && p_param_count && p_return_type && p_type_kind &&
|
||||
p_class_from_type && p_invoke;
|
||||
}
|
||||
|
||||
static void* FindImage(void *domain, const char *wantName)
|
||||
{
|
||||
size_t n = 0;
|
||||
void **asms = p_get_assemblies(domain, &n);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
{
|
||||
void *img = p_get_image(asms[i]);
|
||||
if (!img) continue;
|
||||
const char *nm = p_image_name(img);
|
||||
if (nm && strcmp(nm, wantName) == 0) return img;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
//
|
||||
// The detour: return a freshly-resolved promise instead of running the scan. Instance method ABI is
|
||||
// (RCX=this, RDX=MethodInfo*); we ignore both. Re-invoking the getter each call avoids holding a GC
|
||||
// reference. If the getter ever throws/returns null we return null -- the managed patch's fallback
|
||||
// was to let the original run, but by the time we're detoured that's not an option, so null it is
|
||||
// (boot's .Then on a null promise is still better than a guaranteed hash-mismatch rejection).
|
||||
//
|
||||
static void* ScanHook(void *self, void *methodInfo)
|
||||
{
|
||||
(void)self; (void)methodInfo;
|
||||
if (!g_resolvedGetter) return NULL;
|
||||
void *exc = NULL;
|
||||
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)) { PatchMemcheckByRVA(ga); return; }
|
||||
|
||||
void *domain = NULL;
|
||||
for (int i = 0; i < 600 && !domain; i++) { domain = p_domain_get(); if (!domain) Sleep(100); }
|
||||
if (!domain) { Log("[MEMCHECK] il2cpp domain never came up"); return; }
|
||||
if (p_thread_attach) p_thread_attach(domain);
|
||||
|
||||
// Assembly-CSharp holds the scanner. Retry through early init.
|
||||
void *img = NULL;
|
||||
for (int i = 0; i < 100 && !img; i++) { img = FindImage(domain, "Assembly-CSharp.dll"); if (!img) Sleep(100); }
|
||||
if (!img) { Log("[MEMCHECK] Assembly-CSharp.dll image not found"); return; }
|
||||
|
||||
//
|
||||
// Find the scanner class: has BOTH a Thread field and a CancellationTokenSource field.
|
||||
//
|
||||
size_t ccount = p_class_count(img);
|
||||
void *scanner = NULL;
|
||||
|
||||
for (size_t i = 0; i < ccount; i++)
|
||||
{
|
||||
void *c = p_get_class(img, i);
|
||||
if (!c) continue;
|
||||
|
||||
if (class_has_field_type(c, "System.Threading.Thread", ".Thread") &&
|
||||
class_has_field_type(c, "System.Threading.CancellationTokenSource", ".CancellationTokenSource"))
|
||||
{
|
||||
const char *cn = p_class_name(c);
|
||||
Log("[MEMCHECK] scanner candidate: %s", cn ? cn : "?");
|
||||
scanner = c; // keep last; log all so ambiguity is visible
|
||||
}
|
||||
}
|
||||
|
||||
if (!scanner)
|
||||
{
|
||||
Log("[MEMCHECK] no class with Thread+CancellationTokenSource found -- scanner not identified");
|
||||
return;
|
||||
}
|
||||
|
||||
//
|
||||
// Scan-start method: public, instance, 0-param, non-void. Log every candidate; pick the sole one.
|
||||
//
|
||||
void *scanMethod = NULL;
|
||||
void *promiseClass = NULL;
|
||||
int candidates = 0;
|
||||
|
||||
void *iter = NULL, *m;
|
||||
while ((m = p_get_methods(scanner, &iter)) != NULL)
|
||||
{
|
||||
uint32_t iflags = 0;
|
||||
uint32_t f = p_method_flags(m, &iflags);
|
||||
if (f & METHOD_ATTRIBUTE_STATIC) continue;
|
||||
if ((f & METHOD_ATTRIBUTE_MEMBER_ACCESS_MASK) != METHOD_ATTRIBUTE_PUBLIC) continue;
|
||||
if (p_param_count(m) != 0) continue;
|
||||
|
||||
void *rt = p_return_type(m);
|
||||
if (!rt || p_type_kind(rt) == IL2CPP_TYPE_VOID) continue;
|
||||
|
||||
const char *mn = p_method_name(m);
|
||||
char *rtn = type_name_dup(rt);
|
||||
Log("[MEMCHECK] scan-start candidate: %s() -> %s", mn ? mn : "?", rtn ? rtn : "?");
|
||||
if (rtn) free(rtn);
|
||||
|
||||
scanMethod = m;
|
||||
promiseClass = p_class_from_type(rt);
|
||||
candidates++;
|
||||
}
|
||||
|
||||
if (!scanMethod)
|
||||
{
|
||||
Log("[MEMCHECK] no public instance 0-param non-void method on scanner -- cannot hook");
|
||||
return;
|
||||
}
|
||||
if (candidates > 1)
|
||||
Log("[MEMCHECK] WARNING %d scan-start candidates; using the last -- may be wrong", candidates);
|
||||
|
||||
//
|
||||
// Resolved-promise getter: any image, static, special-name (property getter), 0-param, returns
|
||||
// the promise class, name not ending _k__BackingField. (Managed found exactly one.)
|
||||
//
|
||||
size_t na = 0;
|
||||
void **asms = p_get_assemblies(domain, &na);
|
||||
int getters = 0;
|
||||
|
||||
for (size_t ai = 0; ai < na && getters < 1; ai++)
|
||||
{
|
||||
void *im = p_get_image(asms[ai]);
|
||||
if (!im) continue;
|
||||
size_t cc = p_class_count(im);
|
||||
for (size_t ci = 0; ci < cc && getters < 1; ci++)
|
||||
{
|
||||
void *c = p_get_class(im, ci);
|
||||
if (!c) continue;
|
||||
void *it = NULL, *mm;
|
||||
while ((mm = p_get_methods(c, &it)) != NULL)
|
||||
{
|
||||
uint32_t iflags = 0;
|
||||
uint32_t f = p_method_flags(mm, &iflags);
|
||||
if (!(f & METHOD_ATTRIBUTE_STATIC)) continue;
|
||||
if (!(f & METHOD_ATTRIBUTE_SPECIAL_NAME)) continue;
|
||||
if (p_param_count(mm) != 0) continue;
|
||||
|
||||
void *rt = p_return_type(mm);
|
||||
if (!rt || p_class_from_type(rt) != promiseClass) continue;
|
||||
|
||||
const char *mn = p_method_name(mm);
|
||||
if (mn && ends_with(mn, "_k__BackingField")) continue;
|
||||
|
||||
Log("[MEMCHECK] resolved-promise getter: %s.%s", p_class_name(c), mn ? mn : "?");
|
||||
g_resolvedGetter = mm;
|
||||
getters++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!g_resolvedGetter)
|
||||
{
|
||||
Log("[MEMCHECK] no static Resolved getter returning the promise type -- cannot build a resolved promise");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanity: make sure invoking the getter yields a non-null object before we commit the detour.
|
||||
void *exc = NULL;
|
||||
void *test = p_invoke(g_resolvedGetter, NULL, NULL, &exc);
|
||||
if (!test || exc)
|
||||
{
|
||||
Log("[MEMCHECK] Resolved getter returned null/threw -- not hooking (would hand boot a null promise)");
|
||||
return;
|
||||
}
|
||||
|
||||
void *code = *(void **)scanMethod; // MethodInfo.methodPointer
|
||||
Log("[MEMCHECK] scan-start MethodInfo=%p code=%p", scanMethod, code);
|
||||
if (!code) { Log("[MEMCHECK] scan-start has no compiled body"); return; }
|
||||
|
||||
// Replace-only (we never call the original), so a blind 14-byte overwrite is safe.
|
||||
if (InstallDetour(code, ScanHook, backup_scan, NULL))
|
||||
Log("[MEMCHECK] native memory integrity scan skipped (scan-start -> resolved promise)");
|
||||
else
|
||||
Log("[MEMCHECK] failed to install scan-start detour");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "module_watch.h"
|
||||
|
||||
#include "logger.h"
|
||||
#include "process.h"
|
||||
|
||||
|
||||
|
||||
void WatchModules()
|
||||
{
|
||||
Log(
|
||||
"[MODULE WATCH] Started"
|
||||
);
|
||||
|
||||
|
||||
// One full dump for reference, then only announce the two modules we care about as they appear
|
||||
// and stop -- the old every-10s full dump buried the actual hook diagnostics.
|
||||
DumpLoadedModules();
|
||||
|
||||
|
||||
BOOL sawGame = FALSE;
|
||||
BOOL sawUnity = FALSE;
|
||||
|
||||
|
||||
while(!sawGame || !sawUnity)
|
||||
{
|
||||
if(!sawGame && GetModuleHandleA("GameAssembly.dll"))
|
||||
{
|
||||
sawGame = TRUE;
|
||||
Log("[MODULE WATCH] GameAssembly.dll loaded");
|
||||
}
|
||||
|
||||
if(!sawUnity && GetModuleHandleA("UnityPlayer.dll"))
|
||||
{
|
||||
sawUnity = TRUE;
|
||||
Log("[MODULE WATCH] UnityPlayer.dll loaded");
|
||||
}
|
||||
|
||||
Sleep(500);
|
||||
}
|
||||
|
||||
|
||||
Log(
|
||||
"[MODULE WATCH] game modules present, watcher done"
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#include "common.h"
|
||||
#include "ssl_patch.h"
|
||||
#include "logger.h"
|
||||
#include "detour.h"
|
||||
#include "hwbp.h"
|
||||
#include "config.h"
|
||||
|
||||
//
|
||||
// Native TLS pinning bypass.
|
||||
//
|
||||
// Redirecting DNS to a self-hosted server means the TLS handshake presents a certificate the
|
||||
// client's pinning will reject, so the connection dies before any HTTP is sent. The managed build
|
||||
// solved this by Harmony-patching the CONCRETE BouncyCastle class
|
||||
// Org.BouncyCastle.Crypto.Tls.LegacyTlsAuthentication.NotifyServerCertificate (see CLAUDE.md
|
||||
// gotcha 3 -- the interface method never dispatches, you must hit the concrete impl). We do the
|
||||
// same here, natively, by detouring that method's compiled il2cpp body to a no-op that returns.
|
||||
//
|
||||
// "NotifyServerCertificate" is an unobfuscated framework name, stable across Rec Room builds --
|
||||
// this is why the managed build targeted it and why we can resolve it by literal name here.
|
||||
//
|
||||
// We never call the original, so the trampoline InstallDetour builds is never executed -- the
|
||||
// blind 14-byte copy that would mis-handle a rip-relative prologue is therefore harmless for this
|
||||
// target (we only care that the 14-byte jmp overwrite at the entry is valid, which it always is).
|
||||
|
||||
typedef void* (*il2cpp_domain_get_t)(void);
|
||||
typedef int (*il2cpp_thread_attach_t)(void* domain);
|
||||
typedef void** (*il2cpp_domain_get_assemblies_t)(void* domain, size_t* size);
|
||||
typedef void* (*il2cpp_assembly_get_image_t)(void* assembly);
|
||||
typedef void* (*il2cpp_class_from_name_t)(void* image, const char* ns, const char* name);
|
||||
typedef void* (*il2cpp_class_get_method_from_name_t)(void* klass, const char* name, int argc);
|
||||
|
||||
static il2cpp_domain_get_t p_domain_get;
|
||||
static il2cpp_thread_attach_t p_thread_attach;
|
||||
static il2cpp_domain_get_assemblies_t p_domain_get_assemblies;
|
||||
static il2cpp_assembly_get_image_t p_assembly_get_image;
|
||||
static il2cpp_class_from_name_t p_class_from_name;
|
||||
static il2cpp_class_get_method_from_name_t p_class_get_method_from_name;
|
||||
|
||||
static BYTE backup_notify[14];
|
||||
void *original_notify = NULL; // unused (we never call through); kept for symmetry/logging
|
||||
|
||||
//
|
||||
// Replacement for LegacyTlsAuthentication.NotifyServerCertificate(this, cert, MethodInfo*).
|
||||
// il2cpp passes args in the standard x64 convention (RCX=this, RDX=cert, R8=MethodInfo*) and the
|
||||
// method returns void, so simply returning accepts every server certificate. Because the entry was
|
||||
// reached via jmp (not call), our return goes straight back to the game's caller.
|
||||
//
|
||||
static void ReplNotifyServerCertificate(void *thisptr, void *cert, void *method)
|
||||
{
|
||||
(void)thisptr; (void)cert; (void)method;
|
||||
// Accept unconditionally -- no pinning, no validation.
|
||||
}
|
||||
|
||||
//
|
||||
// Resolve a class by namespace+name across every loaded il2cpp assembly. il2cpp_class_from_name
|
||||
// only searches the image it's given, so we sweep them (we don't hard-code which assembly the type
|
||||
// lives in -- it's RecNet.Runtime today, but that's incidental).
|
||||
//
|
||||
static void* FindClass(void *domain, const char *ns, const char *name)
|
||||
{
|
||||
size_t count = 0;
|
||||
void **assemblies = p_domain_get_assemblies(domain, &count);
|
||||
if (!assemblies || count == 0)
|
||||
return NULL;
|
||||
|
||||
for (size_t i = 0; i < count; i++)
|
||||
{
|
||||
void *image = p_assembly_get_image(assemblies[i]);
|
||||
if (!image) continue;
|
||||
|
||||
void *klass = p_class_from_name(image, ns, name);
|
||||
if (klass) return klass;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static BOOL ResolveIl2CppApi(HMODULE ga)
|
||||
{
|
||||
p_domain_get = (il2cpp_domain_get_t) GetProcAddress(ga, "il2cpp_domain_get");
|
||||
p_thread_attach = (il2cpp_thread_attach_t) GetProcAddress(ga, "il2cpp_thread_attach");
|
||||
p_domain_get_assemblies = (il2cpp_domain_get_assemblies_t) GetProcAddress(ga, "il2cpp_domain_get_assemblies");
|
||||
p_assembly_get_image = (il2cpp_assembly_get_image_t) GetProcAddress(ga, "il2cpp_assembly_get_image");
|
||||
p_class_from_name = (il2cpp_class_from_name_t) GetProcAddress(ga, "il2cpp_class_from_name");
|
||||
p_class_get_method_from_name = (il2cpp_class_get_method_from_name_t)GetProcAddress(ga, "il2cpp_class_get_method_from_name");
|
||||
|
||||
return p_domain_get && p_domain_get_assemblies && p_assembly_get_image &&
|
||||
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)
|
||||
{
|
||||
//
|
||||
// Wait for GameAssembly.dll to be mapped.
|
||||
//
|
||||
HMODULE ga = NULL;
|
||||
while (!ga)
|
||||
{
|
||||
ga = GetModuleHandleA("GameAssembly.dll");
|
||||
if (!ga) Sleep(100);
|
||||
}
|
||||
Log("[SSL] GameAssembly.dll at %p", ga);
|
||||
|
||||
if (!ResolveIl2CppApi(ga))
|
||||
{
|
||||
//
|
||||
// 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");
|
||||
|
||||
//
|
||||
// Wait for the il2cpp runtime to finish init: il2cpp_domain_get() returns NULL until then.
|
||||
//
|
||||
void *domain = NULL;
|
||||
for (int i = 0; i < 600 && !domain; i++) // up to ~60s
|
||||
{
|
||||
domain = p_domain_get();
|
||||
if (!domain) Sleep(100);
|
||||
}
|
||||
if (!domain)
|
||||
{
|
||||
Log("[SSL] il2cpp domain never came up -- aborting TLS patch");
|
||||
return;
|
||||
}
|
||||
|
||||
// Our thread is native; attach it so il2cpp metadata calls are safe.
|
||||
if (p_thread_attach) p_thread_attach(domain);
|
||||
|
||||
//
|
||||
// Resolve the concrete class + method. Metadata is present immediately after init, but classes
|
||||
// can briefly not resolve during early init, so retry a few times.
|
||||
//
|
||||
void *klass = NULL;
|
||||
for (int i = 0; i < 100 && !klass; i++) // up to ~10s
|
||||
{
|
||||
klass = FindClass(domain, "Org.BouncyCastle.Crypto.Tls", "LegacyTlsAuthentication");
|
||||
if (!klass) Sleep(100);
|
||||
}
|
||||
if (!klass)
|
||||
{
|
||||
Log("[SSL] LegacyTlsAuthentication not found -- TLS pinning NOT bypassed");
|
||||
return;
|
||||
}
|
||||
Log("[SSL] LegacyTlsAuthentication klass=%p", klass);
|
||||
|
||||
// argc counts only declared params: NotifyServerCertificate(Certificate) -> 1.
|
||||
void *method = p_class_get_method_from_name(klass, "NotifyServerCertificate", 1);
|
||||
if (!method)
|
||||
{
|
||||
Log("[SSL] NotifyServerCertificate(argc=1) not found -- TLS pinning NOT bypassed");
|
||||
return;
|
||||
}
|
||||
|
||||
// MethodInfo.methodPointer is the first field of the struct: the compiled native entry.
|
||||
void *code = *(void **)method;
|
||||
Log("[SSL] NotifyServerCertificate MethodInfo=%p code=%p", method, code);
|
||||
|
||||
if (!code)
|
||||
{
|
||||
Log("[SSL] method has no compiled body -- aborting");
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace-only hook: we never call the original, so pass NULL trampoline -- InstallDetour then
|
||||
// does a plain 14-byte overwrite and won't refuse a complex il2cpp prologue.
|
||||
if (InstallDetour(code, ReplNotifyServerCertificate, backup_notify, NULL))
|
||||
Log("[SSL] TLS pinning bypassed (NotifyServerCertificate -> accept-all)");
|
||||
else
|
||||
Log("[SSL] failed to install NotifyServerCertificate detour");
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
#include "common.h"
|
||||
|
||||
#include "strings.h"
|
||||
#include "config.h"
|
||||
#include "logger.h"
|
||||
|
||||
|
||||
|
||||
int ShouldRedirect(const char *host)
|
||||
{
|
||||
if(!host)
|
||||
return 0;
|
||||
|
||||
|
||||
Log(
|
||||
"[CHECK] %s",
|
||||
host
|
||||
);
|
||||
|
||||
|
||||
for(
|
||||
int i = 0;
|
||||
i < redirect_count_config;
|
||||
i++
|
||||
)
|
||||
{
|
||||
//
|
||||
// Exact match
|
||||
//
|
||||
|
||||
if(
|
||||
_stricmp(
|
||||
host,
|
||||
redirect_domains[i]
|
||||
) == 0
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[MATCH] %s",
|
||||
host
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
//
|
||||
// Subdomain match
|
||||
//
|
||||
|
||||
size_t len =
|
||||
strlen(
|
||||
redirect_domains[i]
|
||||
);
|
||||
|
||||
|
||||
size_t hostLen =
|
||||
strlen(
|
||||
host
|
||||
);
|
||||
|
||||
|
||||
if(
|
||||
hostLen > len &&
|
||||
host[hostLen - len - 1] == '.' &&
|
||||
_stricmp(
|
||||
host + (hostLen - len),
|
||||
redirect_domains[i]
|
||||
) == 0
|
||||
)
|
||||
{
|
||||
Log(
|
||||
"[SUBDOMAIN MATCH] %s",
|
||||
host
|
||||
);
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
int RewriteHost(const char *host, char *out, size_t outlen)
|
||||
{
|
||||
if(!host || !out || outlen == 0)
|
||||
return 0;
|
||||
|
||||
|
||||
for(int i = 0; i < rewrite_count; i++)
|
||||
{
|
||||
//
|
||||
// Exact match only: whole host -> to
|
||||
//
|
||||
|
||||
if(_stricmp(host, rewrite_from[i]) == 0)
|
||||
{
|
||||
const char *to = rewrite_to[i];
|
||||
|
||||
if(strlen(to) + 1 > outlen)
|
||||
return 0;
|
||||
|
||||
strcpy_s(out, outlen, to);
|
||||
|
||||
Log("[REWRITE] %s -> %s", host, out);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user