From 69301ebfe0d1a36bfe4a2a90539627b0733307e5 Mon Sep 17 00:00:00 2001 From: jweivy Date: Fri, 2 Oct 2026 17:40:28 -0500 Subject: [PATCH] Guard native xref and Class::Init scanning by process architecture --- Il2CppInterop.Common/XrefScans/XrefScanner.cs | 13 +++ .../XrefScans/XrefScannerLowLevel.cs | 2 + .../Injection/InjectorHelpers.cs | 8 +- .../NativeXrefRegression.csproj | 12 +++ tests/NativeXrefRegression/Program.cs | 64 +++++++++++++ tests/NativeXrefRegression/README.md | 45 +++++++++ .../class-init-fixture.cs | 93 +++++++++++++++++++ .../class-init-regression.py | 37 ++++++++ 8 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/NativeXrefRegression/NativeXrefRegression.csproj create mode 100644 tests/NativeXrefRegression/Program.cs create mode 100644 tests/NativeXrefRegression/README.md create mode 100644 tests/NativeXrefRegression/class-init-fixture.cs create mode 100644 tests/NativeXrefRegression/class-init-regression.py diff --git a/Il2CppInterop.Common/XrefScans/XrefScanner.cs b/Il2CppInterop.Common/XrefScans/XrefScanner.cs index e16c22d7..7932205b 100644 --- a/Il2CppInterop.Common/XrefScans/XrefScanner.cs +++ b/Il2CppInterop.Common/XrefScans/XrefScanner.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.InteropServices; using Iced.Intel; using Il2CppInterop.Common.Attributes; using Microsoft.Extensions.Logging; @@ -25,6 +26,7 @@ public static unsafe IEnumerable XrefScan(MethodBase methodBase) if (cachedAttribute == null) { + EnsureNativeCodeArchitectureSupported(); XrefScanUtil.CallMetadataInitForMethod(methodBase); return XrefScanImpl(DecoderForAddress(*(IntPtr*)(IntPtr)fieldValue)); @@ -48,6 +50,17 @@ public static IEnumerable UsedBy(MethodBase methodBase) return XrefScanMethodDb.ListUsers(cachedAttribute); } + // Runtime entry points scan code from this process. The generator calls + // DecoderForAddress separately for an explicitly selected input image. + internal static void EnsureNativeCodeArchitectureSupported() + { + var architecture = RuntimeInformation.ProcessArchitecture; + if (architecture != Architecture.X86 && architecture != Architecture.X64) + throw new PlatformNotSupportedException( + $"Uncached native cross-reference scanning is not supported on {architecture}. " + + "The built-in decoder only supports x86/x64 code."); + } + internal static unsafe Decoder DecoderForAddress(IntPtr codeStart, int lengthLimit = 1000) { if (codeStart == IntPtr.Zero) throw new NullReferenceException(nameof(codeStart)); diff --git a/Il2CppInterop.Common/XrefScans/XrefScannerLowLevel.cs b/Il2CppInterop.Common/XrefScans/XrefScannerLowLevel.cs index a8d44d7c..ba9a8fa0 100644 --- a/Il2CppInterop.Common/XrefScans/XrefScannerLowLevel.cs +++ b/Il2CppInterop.Common/XrefScans/XrefScannerLowLevel.cs @@ -6,6 +6,7 @@ public static class XrefScannerLowLevel { public static IEnumerable JumpTargets(IntPtr codeStart, bool ignoreRetn = false) { + XrefScanner.EnsureNativeCodeArchitectureSupported(); return JumpTargetsImpl(XrefScanner.DecoderForAddress(codeStart), ignoreRetn); } @@ -45,6 +46,7 @@ private static IEnumerable JumpTargetsImpl(Decoder myDecoder, bool ignor public static IEnumerable CallAndIndirectTargets(IntPtr pointer) { + XrefScanner.EnsureNativeCodeArchitectureSupported(); return CallAndIndirectTargetsImpl(XrefScanner.DecoderForAddress(pointer, 1024 * 1024)); } diff --git a/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs b/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs index 9418c061..26576de7 100644 --- a/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs +++ b/Il2CppInterop.Runtime/Injection/InjectorHelpers.cs @@ -185,9 +185,11 @@ static nint GetClassInitSubstitute() Logger.Instance.LogTrace("GameAssembly.dll: 0x{Il2CppModuleAddress}", Il2CppModule.BaseAddress.ToInt64().ToString("X2")); throw new NotSupportedException("Failed to use signature for Class::Init and a substitute cannot be found, please create an issue and report your unity version & game"); } - nint pClassInit = s_ClassInitSignatures - .Select(s => MemoryUtils.FindSignatureInModule(Il2CppModule, s)) - .FirstOrDefault(p => p != 0); + // These signatures describe x86 instructions. Never match them against ARM code. + nint pClassInit = RuntimeInformation.ProcessArchitecture is Architecture.X86 or Architecture.X64 + ? s_ClassInitSignatures.Select(s => MemoryUtils.FindSignatureInModule(Il2CppModule, s)) + .FirstOrDefault(p => p != 0) + : 0; if (pClassInit == 0) { diff --git a/tests/NativeXrefRegression/NativeXrefRegression.csproj b/tests/NativeXrefRegression/NativeXrefRegression.csproj new file mode 100644 index 00000000..e0c71147 --- /dev/null +++ b/tests/NativeXrefRegression/NativeXrefRegression.csproj @@ -0,0 +1,12 @@ + + + net9.0 + Exe + false + ../.. + + + + + + diff --git a/tests/NativeXrefRegression/Program.cs b/tests/NativeXrefRegression/Program.cs new file mode 100644 index 00000000..9a8a7a70 --- /dev/null +++ b/tests/NativeXrefRegression/Program.cs @@ -0,0 +1,64 @@ +using System.Runtime.InteropServices; +using Il2CppInterop.Common.Attributes; +using Il2CppInterop.Common.XrefScans; + +var arch = RuntimeInformation.ProcessArchitecture; +Console.WriteLine($"Process architecture: {arch}"); +var failures = 0; +void Test(string name, Action test) +{ + try { test(); Console.WriteLine("PASS: " + name); } + catch (Exception e) { failures++; Console.WriteLine($"FAIL: {name}: {e.GetType().Name}: {e.Message}"); } +} +void Unsupported(Action action) +{ + try { action(); } + catch (PlatformNotSupportedException e) when (e.Message.Contains("x86/x64")) { return; } + throw new Exception("Expected explicit unsupported-architecture error"); +} +if (arch is not (Architecture.X86 or Architecture.X64)) +{ + // Zero pointers deliberately avoid reading invalid native memory in the negative control. + Test("JumpTargets refuses unsupported architecture before pointer access", () => + Unsupported(() => XrefScannerLowLevel.JumpTargets(IntPtr.Zero).ToArray())); + Test("CallAndIndirectTargets refuses unsupported architecture before pointer access", () => + Unsupported(() => XrefScannerLowLevel.CallAndIndirectTargets(IntPtr.Zero).ToArray())); + Test("uncached managed method refuses native metadata initialization", () => + Unsupported(() => XrefScanner.XrefScan(typeof(Fixture).GetMethod(nameof(Fixture.Uncached))!).ToArray())); +} +else +{ + // The bytes are read, never executed. x86 and x64 both encode CALL rel32; RET this way. + var code = Marshal.AllocHGlobal(1024 * 1024); + try + { + Marshal.Copy(new byte[] { 0xe8, 0x03, 0, 0, 0, 0xc3, 0, 0, 0xc3 }, 0, code, 9); + Test("x86/x64 direct call scan remains available", () => { + var targets = XrefScannerLowLevel.JumpTargets(code).ToArray(); + if (targets.Length != 1 || targets[0] != code + 8) throw new Exception("Wrong direct target"); + }); + Test("x86/x64 call-and-indirect scan remains available", () => { + var targets = XrefScannerLowLevel.CallAndIndirectTargets(code).ToArray(); + if (targets.Length != 1 || targets[0] != code + 8) throw new Exception("Wrong direct target"); + }); + } + finally { Marshal.FreeHGlobal(code); } +} +Test("empty cached results remain usable without native decoding", () => { + if (XrefScanner.XrefScan(typeof(Fixture).GetMethod(nameof(Fixture.Cached))!).Any()) + throw new Exception("Expected empty cache"); +}); +Test("ordinary managed method still returns no native references", () => { + if (XrefScanner.XrefScan(typeof(Fixture).GetMethod(nameof(Fixture.Managed))!).Any()) + throw new Exception("Expected no native method"); +}); +return failures == 0 ? 0 : 1; + +public static class Fixture +{ + public static IntPtr NativeMethodInfoPtr_Test = IntPtr.Zero; + public static IntPtr Uncached() => NativeMethodInfoPtr_Test; + [CachedScanResults] + public static IntPtr Cached() => NativeMethodInfoPtr_Test; + public static int Managed() => 42; +} diff --git a/tests/NativeXrefRegression/README.md b/tests/NativeXrefRegression/README.md new file mode 100644 index 00000000..6d60bcba --- /dev/null +++ b/tests/NativeXrefRegression/README.md @@ -0,0 +1,45 @@ +# Native xref architecture regressions + +Run commands from a directory **outside this checkout** when using .NET 9; +the repository's `global.json` selects .NET 8. No game files are needed. + +```sh +checkout=/absolute/path/to/Il2CppInterop +dotnet9=/absolute/path/to/dotnet9/dotnet +output=/absolute/path/to/temporary/xref-output +cd /tmp +"$dotnet9" build "$checkout/tests/NativeXrefRegression/NativeXrefRegression.csproj" \ + -p:GeneratePackageOnBuild=false -p:NuGetAudit=false -o "$output" +"$dotnet9" "$output/NativeXrefRegression.dll" +``` + +On ARM64 this runs five checks: both low-level entry points reject native +scanning before pointer access; an uncached proxy method rejects scanning +before metadata initialization; empty cached results and ordinary managed +methods retain their existing behavior. On x86/x64 it runs four checks, +including decoding a synthetic CALL/RET byte buffer without executing it. + +On Apple Silicon with Rosetta and the .NET 9 x64 runtime installed, run the +same assembly in a real x64 process: + +```sh +arch -x86_64 /absolute/path/to/x64-dotnet/dotnet "$output/NativeXrefRegression.dll" +``` + +The Class::Init regression compiles the exact `Class::Init` region extracted +from `InjectorHelpers.cs` in a temporary project. Signature lookup, export +lookup and logging use test doubles; the resolver and fallback logic come +from production source. It checks that ARM skips every x86 signature, x64 +retains signature selection, the existing export fallback order is preserved, +and missing substitutes throw. No returned native delegate is invoked. + +```sh +python3 "$checkout/tests/NativeXrefRegression/class-init-regression.py" \ + --dotnet "$dotnet9" --x64-dotnet /absolute/path/to/x64-dotnet/dotnet +``` + +Omit `--x64-dotnet` to run only the current architecture. This fixture avoids +the game's module-dependent `InjectorHelpers` type initializer; it verifies +resolver control flow, not real IL2CPP class initialization or hook safety. +The xref guard does not add ARM decoding. Generator decoding of explicitly +selected input images is unchanged. diff --git a/tests/NativeXrefRegression/class-init-fixture.cs b/tests/NativeXrefRegression/class-init-fixture.cs new file mode 100644 index 00000000..1d627a66 --- /dev/null +++ b/tests/NativeXrefRegression/class-init-fixture.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; + +// The runner inserts the production Class::Init region inside this fixture. +// Only module/signature/export lookup and logging are replaced: no native code +// is scanned or executed, and the returned delegates remain rooted here. +internal static unsafe partial class InjectorHelpers +{ + internal static readonly Module Il2CppModule = new(); + internal static readonly Dictionary Exports = new(); + internal static readonly List Lookups = new(); + internal static bool TryGetIl2CppExport(string name, out nint address) + { + Lookups.Add(name); + return Exports.TryGetValue(name, out address); + } + + private static readonly d_ClassInit SignatureDelegate = SignatureTarget; + private static readonly d_ClassInit SubstituteDelegate = SubstituteTarget; + private static void SignatureTarget(Il2CppClass* klass) { } + private static void SubstituteTarget(Il2CppClass* klass) { } + + internal static int Main() + { + bool x86 = RuntimeInformation.ProcessArchitecture is Architecture.X86 or Architecture.X64; + nint signature = Marshal.GetFunctionPointerForDelegate(SignatureDelegate); + nint substitute = Marshal.GetFunctionPointerForDelegate(SubstituteDelegate); + Console.WriteLine($"Class::Init resolver architecture: {RuntimeInformation.ProcessArchitecture}"); + + Check("signature selection is architecture guarded", signature, + new[] { "mono_class_instance_size" }, x86 ? signature : substitute, + x86 ? 1 : 0, x86 ? Array.Empty() : new[] { "mono_class_instance_size" }); + Check("second export fallback is preserved", 0, + new[] { "mono_class_setup_vtable" }, substitute, x86 ? 2 : 0, + new[] { "mono_class_instance_size", "mono_class_setup_vtable" }); + Check("third export fallback is preserved", 0, + new[] { "il2cpp_class_has_references" }, substitute, x86 ? 2 : 0, + new[] { "mono_class_instance_size", "mono_class_setup_vtable", "il2cpp_class_has_references" }); + Check("missing substitutes fail explicitly", 0, Array.Empty(), 0, x86 ? 2 : 0, + new[] { "mono_class_instance_size", "mono_class_setup_vtable", "il2cpp_class_has_references" }); + return 0; + + void Check(string name, nint scanResult, string[] exports, nint expected, + int expectedScans, string[] expectedLookups) + { + MemoryUtils.Calls = 0; + MemoryUtils.Result = scanResult; + Exports.Clear(); + Lookups.Clear(); + foreach (string export in exports) Exports.Add(export, substitute); + try + { + var resolved = FindClassInit(); + if (expected == 0 || Marshal.GetFunctionPointerForDelegate(resolved) != expected) + throw new Exception($"{name}: wrong target"); + } + catch (NotSupportedException) when (expected == 0) { } + if (MemoryUtils.Calls != expectedScans || !Lookups.SequenceEqual(expectedLookups)) + throw new Exception($"{name}: unexpected signature/export lookup sequence"); + Console.WriteLine("PASS: " + name); + } + } +} + +internal struct Il2CppClass { } +internal sealed class Module { internal nint BaseAddress => 0; } +internal static class IL2CPP { internal static void il2cpp_class_has_references() { } } +internal static class MemoryUtils +{ + internal sealed class SignatureDefinition + { + internal string pattern; + internal string mask; + internal bool xref; + } + internal static int Calls; + internal static nint Result; + internal static nint FindSignatureInModule(Module module, SignatureDefinition signature) + { + if (RuntimeInformation.ProcessArchitecture is not (Architecture.X86 or Architecture.X64)) + throw new Exception("Attempted x86 signature scanning on unsupported architecture"); + Calls++; + return Result; + } +} +internal sealed class Logger +{ + internal static readonly Logger Instance = new(); + internal void LogTrace(string message, params object[] values) { } + internal void LogWarning(string message) { } +} diff --git a/tests/NativeXrefRegression/class-init-regression.py b/tests/NativeXrefRegression/class-init-regression.py new file mode 100644 index 00000000..0e419ece --- /dev/null +++ b/tests/NativeXrefRegression/class-init-regression.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Compile and test the exact Class::Init production region without game startup.""" +import argparse +from pathlib import Path +import subprocess +import tempfile + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--dotnet", required=True, help=".NET 9 SDK executable") +parser.add_argument("--x64-dotnet", help="Optional macOS x64 .NET 9 runtime for Rosetta checks") +args = parser.parse_args() +test_dir = Path(__file__).resolve().parent +source = test_dir.parents[1] / "Il2CppInterop.Runtime/Injection/InjectorHelpers.cs" +text = source.read_text(encoding="utf-8-sig") +marker = "#region Class::Init" +if text.count(marker) != 1: + raise SystemExit("Expected exactly one production Class::Init region") +region = text.split(marker, 1)[1].split("#endregion", 1)[0] +with tempfile.TemporaryDirectory(prefix="il2cpp-class-init-") as directory: + scratch = Path(directory) + (scratch / "Fixture.cs").write_text((test_dir / "class-init-fixture.cs").read_text()) + (scratch / "Production.cs").write_text( + "using System; using System.Linq; using System.Runtime.InteropServices;\n" + "internal static unsafe partial class InjectorHelpers {\n" + region + "\n}\n") + (scratch / "ClassInitRegression.csproj").write_text( + '' + 'net9.0Exe' + 'truefalse' + '') + (scratch / "NuGet.config").write_text( + '') + subprocess.run([args.dotnet, "build", "--nologo", "-o", str(scratch / "out")], + cwd=scratch, check=True) + assembly = str(scratch / "out/ClassInitRegression.dll") + subprocess.run([args.dotnet, assembly], cwd=scratch, check=True) + if args.x64_dotnet: + subprocess.run(["arch", "-x86_64", args.x64_dotnet, assembly], cwd=scratch, check=True)