From 4630c8360228460b3f74b4e477819eca421d1af2 Mon Sep 17 00:00:00 2001 From: hazre Date: Sat, 2 May 2026 14:54:06 +0200 Subject: [PATCH 1/2] fix(il2cppinterop): harden hook and signature resolution for optimized IL2CPP builds --- ...MetadataCache_GetTypeInfoFromTypeDefinitionIndex_Hook.cs | 6 +++--- Il2CppInterop.Runtime/Injection/InjectorHelpers.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Il2CppInterop.Runtime/Injection/Hooks/MetadataCache_GetTypeInfoFromTypeDefinitionIndex_Hook.cs b/Il2CppInterop.Runtime/Injection/Hooks/MetadataCache_GetTypeInfoFromTypeDefinitionIndex_Hook.cs index 943a9ad0..5094446b 100644 --- a/Il2CppInterop.Runtime/Injection/Hooks/MetadataCache_GetTypeInfoFromTypeDefinitionIndex_Hook.cs +++ b/Il2CppInterop.Runtime/Injection/Hooks/MetadataCache_GetTypeInfoFromTypeDefinitionIndex_Hook.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Runtime.InteropServices; using Il2CppInterop.Common; @@ -75,8 +75,8 @@ private IntPtr FindGetTypeInfoFromTypeDefinitionIndex(bool forceICallMethod = fa else getTypeInfoFromTypeDefinitionIndex = imageGetTypeXrefs[0]; if ((getTypeInfoFromTypeDefinitionIndex.ToInt64() & 0xF) != 0) { - Logger.Instance.LogTrace("Image::GetType xref wasn't aligned, attempting to resolve from icall"); - return FindGetTypeInfoFromTypeDefinitionIndex(true); + Logger.Instance.LogTrace("Image::GetType xref wasn't aligned, GetTypeInfoFromTypeDefinitionIndex is likely inlined into Image::GetType"); + getTypeInfoFromTypeDefinitionIndex = imageGetType; } if (imageGetTypeXrefs.Count() > 1 && UnityVersionHandler.IsMetadataV29OrHigher) { diff --git a/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs b/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs index 9418c061..6d0b44b3 100644 --- a/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs +++ b/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -187,7 +187,7 @@ static nint GetClassInitSubstitute() } nint pClassInit = s_ClassInitSignatures .Select(s => MemoryUtils.FindSignatureInModule(Il2CppModule, s)) - .FirstOrDefault(p => p != 0); + .FirstOrDefault(p => p != 0 && (long)p >= (long)Il2CppModule.BaseAddress && (long)p < (long)Il2CppModule.BaseAddress + Il2CppModule.ModuleMemorySize); if (pClassInit == 0) { From a146bb7b435b3dbe2c0c990971c6a7dbc751ca7a Mon Sep 17 00:00:00 2001 From: Seijin Date: Thu, 20 Aug 2026 23:38:24 +0700 Subject: [PATCH 2/2] fix: skip hooks whose target resolves into RWX packer stubs 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 --- Il2CppInterop.Runtime/Injection/Hook.cs | 26 ++++++++++++++++++++++++- Il2CppInterop.Runtime/MemoryUtils.cs | 25 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Il2CppInterop.Runtime/Injection/Hook.cs b/Il2CppInterop.Runtime/Injection/Hook.cs index 24bb7bd1..1aa27174 100644 --- a/Il2CppInterop.Runtime/Injection/Hook.cs +++ b/Il2CppInterop.Runtime/Injection/Hook.cs @@ -8,6 +8,7 @@ namespace Il2CppInterop.Runtime.Injection internal abstract class Hook where T : Delegate { private bool _isApplied; + private bool _isSkipped; private T _detour; private T _method; private T _original; @@ -23,9 +24,19 @@ public virtual void TargetMethodNotFound() throw new Exception($"Required target method {TargetMethodName} not found"); } + /// + /// Whether a target that resolved into writable+executable memory should be hooked anyway. + /// Such a target is not a normal function entry: packers/obfuscators emit their stubs into + /// RWX regions, and detouring one installs a hook on a trampoline that may rewrite its own + /// arguments and tail-jump elsewhere, so Original(...) no longer honours the delegate + /// signature. That corrupts state and eventually faults far away from here. Hooks whose + /// absence is fatal can opt back in by overriding this. + /// + public virtual bool AllowUnsafeTarget => false; + public void ApplyHook() { - if (_isApplied) return; + if (_isApplied || _isSkipped) return; var methodPtr = FindTargetMethod(); @@ -35,6 +46,19 @@ public void ApplyHook() return; } + if (!AllowUnsafeTarget && MemoryUtils.IsWritableExecutable(methodPtr)) + { + // Degrade gracefully rather than installing a detour that is known to be unsound: + // whatever this hook enables stays unavailable, but the process keeps running. + Logger.Instance.LogWarning( + "{MethodName} resolved to 0x{MethodPtr}, 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.", + TargetMethodName, methodPtr.ToInt64().ToString("X2")); + _isSkipped = true; + return; + } + Logger.Instance.LogTrace("{MethodName} found: 0x{MethodPtr}", TargetMethodName, methodPtr.ToInt64().ToString("X2")); _detour = GetDetour(); diff --git a/Il2CppInterop.Runtime/MemoryUtils.cs b/Il2CppInterop.Runtime/MemoryUtils.cs index 9fd2abd0..a9be8945 100644 --- a/Il2CppInterop.Runtime/MemoryUtils.cs +++ b/Il2CppInterop.Runtime/MemoryUtils.cs @@ -100,6 +100,31 @@ internal static unsafe List GetModuleRegions(ProcessMo return regions; } + /// + /// Whether sits in committed memory that is both writable and executable. + /// Compilers never emit ordinary function entries into such memory - PE code sections map read+execute - + /// so a hit here means the address belongs to a packer/obfuscator stub region instead. Returns false when + /// the answer cannot be established (non-Windows, or VirtualQuery failure), leaving callers on + /// their previous behaviour rather than skipping work on a guess. + /// + internal static unsafe bool IsWritableExecutable(nint address) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(6, 1)) + return false; + + MEMORY_BASIC_INFORMATION memoryInfo = default; + if (Windows.VirtualQuery((void*)address, &memoryInfo, (nuint)sizeof(MEMORY_BASIC_INFORMATION)) == 0) + return false; + + if (memoryInfo.State != MEM.MEM_COMMIT) + return false; + + // The two protections that grant write and execute at once. PAGE_EXECUTE_WRITECOPY counts: the page + // becomes privately writable on first write while staying executable. + const uint pageWritableExecutable = PAGE.PAGE_EXECUTE_READWRITE | PAGE.PAGE_EXECUTE_WRITECOPY; + return (memoryInfo.Protect & pageWritableExecutable) != 0; + } + public struct SignatureDefinition { public string pattern;