Anti-tamper protector
Reverse-engineering note. Establishes whether MinHook / IAT / VEH-style
hooks on the game’s own .text survive once the game is running, or
whether the protector installs a runtime integrity monitor that
invalidates them.
Conclusion
The protector is a one-shot unpacker stub in section .bind. It
runs before the real entry point, validates the binary, optionally
decrypts a small metadata blob, and returns the real entry-point VA.
After it returns, the protector code is not reentered:
- no thread is created from inside the protector (no
CreateThread, noCreateRemoteThread, noNtCreateThreadEx), - no exception filter is installed (no
SetUnhandledExceptionFilter, noAddVectoredExceptionHandler), - no
.textpage-protection change is performed after the unpack (the protector itself does not re-VirtualProtect anything periodically), - the only post-return tear-down zeroes the protector’s own working buffers on stack — pure anti-forensics, not anti-hook.
This means MinHook on functions inside .text of Ravenswatch.exe
works once the loader’s worker thread has settled, which is consistent
with the Phase 3 INTERNALS.md evidence (FUN_1401145b0 and
FUN_140487040 were hooked successfully).
The ROADMAP.md #3 claim that an “anti-tamper integrity check”
crashes every hookpoint is incorrect. That observation was about
two specific hooks both of which had unrelated failure modes:
CreateFileWviaMH_CreateHookonkernel32.dllexports — Wine’sCreateFileWis a thin forwarder layered onNtCreateFileand rewriting its prologue corrupts Wine’s TEB-tracked state during early Vulkan init.hook_io.cppsidesteps this via IAT patching of the game’s import slot and works.IDXGISwapChain::Present-equivalent — Ravenswatch uses Vulkan, not D3D, so there is noIDXGISwapChainfor the engine to use. The hook target was looking at the wrong API surface.
Section layout
.text 0x140001000 size 0xe91ab4 EXEC|READ — game + CRT + engine.rdata 0x140e93000 size 0x429ace READ.data 0x1412bd000 size 0x190540 READ|WRITE.pdata 0x14144e000 size 0xbdaf8 READCPADinfo 0x14150c000 size 0x38 READ|WRITE — CFG cohort_RDATA 0x14150d000 size 0xf4 READ.fptable 0x14150e000 size 0x100 READ|WRITE — CFG fn-pointer table.rsrc 0x14150f000 size 0x1e260 READ.reloc 0x14152e000 size 0x2be78 READ|DISCARDABLE.bind 0x14155a000 size 0x39048 EXEC|READ — protector stubThe PE entry-point 0x14155a310 lands inside .bind. The first
instruction is a call 0x14155a315 to an immediately adjacent
function (the inner-stub) which saves all 15 GPRs, derives the
self-address ([rsp+0x78] - 5) and calls the big protector function
FUN_14155a3d0 (13,788 bytes) with that self-address in RCX.
After FUN_14155a3d0 returns, the inner-stub writes the return
value into [rsp+0x78], restores GPRs, and a ret jumps to that
address. That is the real game entry point. It lives in .text.
TLS callbacks
The exe registers two TLS callbacks via the standard
IMAGE_DIRECTORY_ENTRY_TLS directory:
| VA | Symbol | Purpose |
|---|---|---|
0x140c93e2c | tls_callback_0 | MSVC _Init_thread_callback — walks a fn-ptr table at rip+0x209701..0x209712 on DLL_THREAD_ATTACH (edx==2). |
0x140c9457c | tls_callback_1 | MSVC per-thread destructor walker — runs the chained dtor list at gs:[0x58] + N*8 + 0x20 on DLL_PROCESS_ATTACH or DLL_THREAD_DETACH. |
Both are stock Microsoft Visual C++ runtime. Neither is part of the
protector. No code in either callback touches .text or .bind.
Protector flow (decompiled summary)
FUN_14155a3d0(self_va):
- Reads a small metadata header (
local_378) somewhere relative to the call site (viaparam_1 - 5). - Decrypts the header with a rolling XOR loop:
key = local_378[0]for i in 0..0xec/4:tmp = buf[i]buf[i] ^= keykey = tmp
- Validates a magic at
header[1] == 0xC0DEC0DF(signed-0x3f213f21). On mismatch goes to the error sinkLAB_14155cfdewhich zeroes its scratch buffers and triggersint3— game dies with a debug-trap. - Parses an embedded string table of 0x22 (34) null-terminated strings. These are API names (Win32 kernel32 / kernelbase) that the protector will resolve dynamically.
- Resolves
kernel32.dllitself — but does so without leaving a literal"kernel32.dll"string in.rdata. The string is assembled on stack:then passed to a previously-resolvedlocal_a90='k', uStack_a8e='e', uStack_a8c='r', uStack_a8a='n',uStack_a88='e', uStack_a86='l', uStack_a84='3', uStack_a82='2',uStack_a80='.', uStack_a7e='d',uStack_a7c='l', uStack_a7a='l',uStack_a78=0GetModuleHandleA-style pointer. - Walks the table of API names and stores each function pointer
into
local_5xxslots — this is the protector’s private import table. - Optionally invokes Steam-DRM / packaging integration via
CreateFileMappingA+MapViewOfFile(the(*local_580)(0x100000, 0, name)and(*local_578)(2, 0, name)calls at lines 1155-1157). The mapped region at offsets 0x90..0xb4 is written with a small inter-process struct — likely the same kind of handshake that the Microsoft Store / Steam wrapper uses to sign per-process state. This does not affect.textintegrity. - On the success path (line 1104):
That sum is the real entry-point VA insidereturn local_b70 + local_358;
.text. - On any error path the protector ends in
TerminateProcess(-1, exit_code)at line 1260 (local_5c8is the resolvedTerminateProcess).
There is no follow-on thread spin-up. There is no .text checksum
loop scheduled to run later. The protector vacates the stage.
Implications for the Hook API
We can build rsmm.hook(name_or_va, "sig", callback) on top of
MinHook directly against .text. The existing fn_resolver
(pattern DB) gives us name -> runtime VA at any time after Wine’s ASLR
settles, and MinHook installs a 5- or 14-byte detour at the resolved
VA without triggering any further reaction from the protector.
Constraints to respect:
- Install hooks after the loader’s worker thread has done its
initial
VirtualQueryretry loop (hook_engine.cppalready waits up to 5s for.textto be mapped). The protector finishes before the engine’s firstoCStringallocation, so by the time the loader’sscript_emit_event("ready")fires, the unpacker is gone. - Avoid Wine syscall-forwarder targets:
kernel32!CreateFileW,kernelbase!NtOpenFile, etc. Use IAT patching (already inhook_io.cpp) when you need to filter game I/O. - The protector’s success path returns an
entry_point + offset_constcomputed from a metadata header that is decrypted at boot. A game patch may shift this constant, so do not hardcode the real entry-point VA anywhere — let the protector compute it and resolve hooks by pattern.
Verification plan
- Restore a minimal
MinHooktrampoline targetingFUN_140487040inhook_engine.cppbehind an opt-in env var (RSMM_ENABLE_ENGINE_HOOK=1). - Run the game. The hook should fire on the first resource lookup without any crash. Compare against the Phase 3 INTERNALS evidence (same hookpoint, same captured handles).
- If clean for one minute of normal play, the hypothesis above is
confirmed and we lift the gate;
rsmm.hookproceeds.