Skip to content

fix: hooks can resolve into packer/obfuscator RWX stubs, producing a delayed fatal AccessViolationException - #281

Open
MagicPro1994 wants to merge 2 commits into
BepInEx:masterfrom
MagicPro1994:fix/skip-hooks-resolving-into-rwx-stubs
Open

fix: hooks can resolve into packer/obfuscator RWX stubs, producing a delayed fatal AccessViolationException#281
MagicPro1994 wants to merge 2 commits into
BepInEx:masterfrom
MagicPro1994:fix/skip-hooks-resolving-into-rwx-stubs

Conversation

@MagicPro1994

Copy link
Copy Markdown

Summary

On packed/obfuscated IL2CPP builds, the native-code scan that locates injection hooks can land on
an obfuscator trampoline rather than a real function entry. Detouring one is unsound — the stub may
rewrite its own arguments and tail-jump elsewhere, so Original(...) no longer honours the delegate
signature. The process survives thousands of ordinary calls and then hard-faults far from the actual
mistake, which makes this very hard to attribute.

I have a concrete diagnosis and a small general fix; happy to open a PR if the approach looks right.

Environment

  • Il2CppInterop 1.5.3.0 (also reproduced on current master, and with fix: harden hook and signature resolution for optimized IL2CPP builds #262 cherry-picked)
  • BepInEx 6.0.0-be.785
  • Unity 2022.3.62f2, IL2CPP metadata v29.1, Windows x64
  • Game: Rise of Eros (Erolabs build) — GameAssembly.dll is packed; global-metadata.dat is
    encrypted at rest and decrypted natively at startup

Symptom

Fatal error. System.AccessViolationException: Attempted to read or write protected memory.
This is often an indication that other memory is corrupt.
   at Il2CppInterop.Runtime.Injection.Hooks.MetadataCache_GetTypeInfoFromTypeDefinitionIndex_Hook.Hook(Int32)

100% reproducible on one specific content path (a stage using "borrowed" characters), absent
everywhere else, and reproduces with zero plugins loaded — so it is not mod code. Everything up
to that point works: the game runs normally, and the same hook services thousands of successful
calls first.

Root cause

MetadataCache::GetTypeInfoFromTypeDefinitionIndex resolves to GameAssembly.dll+0x51830B0, which
is in the .data section. This binary marks that section 0xE0000020
(CNT_CODE | MEM_EXECUTE | MEM_READ | MEM_WRITE) — writable and executable. The hooks that
resolve correctly in the same run (il2cpp_image_get_class, Image::GetType) land in .xpdata,
marked 0x60000020 — executable, not writable, like a normal PE code section.

Disassembling the resolved target shows it is not a function entry:

sub  rsp,28h
call 1802F4530        ; result in rax
push rcx
not  rcx
and  [rsp],rcx        ; saved rcx becomes 0
pop  rcx              ; rcx = 0
or   rcx,rax          ; rcx (arg1!) replaced by the call's result
add  rsp,28h
push rbx
lea  rbx,[rel 1814DB052h]
lea  rbx,[rbx-11E3F02h]
xchg rbx,[rsp]
ret                   ; obfuscated indirect jump -> 0x1802F7150

It replaces its own first argument and tail-jumps via a ret trick. The hook is declared as
Il2CppClass* MethodDelegate(int index) and calls Original(index); against this stub that
contract does not hold.

Proposed fix

Compilers never emit ordinary function entries into writable+executable memory — PE code sections
map read+execute — so W+X is a reliable marker for packer/obfuscator stub regions.

In Hook<T>.ApplyHook(), check the resolved target with VirtualQuery before detouring. If it is
W+X, log a warning and skip that hook, so the feature degrades gracefully instead of corrupting the
process. Hooks whose absence would be fatal can opt back in via a virtual AllowUnsafeTarget.
On unpacked games no code section is W+X, so this is a no-op there.

This reuses the MemoryUtils/TerraFX VirtualQuery infrastructure already added in #267.

There is precedent for skipping this specific hook when the environment does not fit its
assumptions: #260 skips it under HybridCLR.

Result

With the guard in place: no crash, and ClassInjector.RegisterTypeInIl2Cpp still works — my
injected MonoBehaviour registers and functions normally on this build, so at least here the
skipped hook costs nothing observable.

[Warning:Il2CppInterop] MetadataCache::GetTypeInfoFromTypeDefinitionIndex resolved to 0x..., which
lies in writable+executable memory - this is a packer/obfuscator stub rather than a real function
entry, so the hook is being skipped to avoid corrupting the process.

Possibly related

#230 (same hook, Unity 2022.3.52f1, AV immediately after the hook springboard), #214, #226, #138
all AV reports on this hook family without an identified root cause. I cannot confirm they share
this cause, but the W+X check is cheap to test against them.

hazre and others added 2 commits August 20, 2026 22:05
Il2CppInterop locates its injection hooks by scanning native code. On
packed/obfuscated IL2CPP builds that scan can land on an obfuscator
trampoline instead of a real function entry, and detouring one is
unsound: the stub may rewrite its own arguments and tail-jump elsewhere,
so Original(...) no longer honours the delegate signature. The process
then survives thousands of ordinary calls and hard-faults later, far from
the actual mistake.

Observed on Rise of Eros (Unity 2022.3.62f2, metadata v29.1), where
MetadataCache::GetTypeInfoFromTypeDefinitionIndex resolved into a .data
section marked CODE|EXECUTE|WRITE. The bytes there zero the saved rcx,
replace it via `or rcx,rax`, and tail-jump through a push/lea/xchg/ret
sequence. Result: a hard AccessViolationException inside the hook,
reproducible 100% on one content path and absent everywhere else.

Compilers never emit ordinary function entries into writable+executable
memory -- PE code sections map read+execute -- so W+X is a reliable
marker for packer/obfuscator stub regions. Check the resolved target with
VirtualQuery before detouring; when it is W+X, log a warning and skip that
hook so the feature degrades instead of corrupting the process. Hooks
whose absence is fatal can override AllowUnsafeTarget.

On unpacked games no code section is W+X, so this is a no-op there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants