Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Il2CppInterop.Common/XrefScans/XrefScanner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Reflection;
using System.Runtime.InteropServices;
using Iced.Intel;
using Il2CppInterop.Common.Attributes;
using Microsoft.Extensions.Logging;
Expand All @@ -13,7 +14,7 @@
?.GetValue(null);
if (fieldValue == null) return Enumerable.Empty<XrefInstance>();

CachedScanResultsAttribute? cachedAttribute = null;

Check warning on line 17 in Il2CppInterop.Common/XrefScans/XrefScanner.cs

View workflow job for this annotation

GitHub Actions / build

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
try
{
cachedAttribute = methodBase.GetCustomAttribute<CachedScanResultsAttribute>(false);
Expand All @@ -25,6 +26,7 @@

if (cachedAttribute == null)
{
EnsureNativeCodeArchitectureSupported();
XrefScanUtil.CallMetadataInitForMethod(methodBase);

return XrefScanImpl(DecoderForAddress(*(IntPtr*)(IntPtr)fieldValue));
Expand All @@ -48,6 +50,17 @@
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));
Expand Down
2 changes: 2 additions & 0 deletions Il2CppInterop.Common/XrefScans/XrefScannerLowLevel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public static class XrefScannerLowLevel
{
public static IEnumerable<IntPtr> JumpTargets(IntPtr codeStart, bool ignoreRetn = false)
{
XrefScanner.EnsureNativeCodeArchitectureSupported();
return JumpTargetsImpl(XrefScanner.DecoderForAddress(codeStart), ignoreRetn);
}

Expand Down Expand Up @@ -45,6 +46,7 @@ private static IEnumerable<IntPtr> JumpTargetsImpl(Decoder myDecoder, bool ignor

public static IEnumerable<IntPtr> CallAndIndirectTargets(IntPtr pointer)
{
XrefScanner.EnsureNativeCodeArchitectureSupported();
return CallAndIndirectTargetsImpl(XrefScanner.DecoderForAddress(pointer, 1024 * 1024));
}

Expand Down
8 changes: 5 additions & 3 deletions Il2CppInterop.Runtime/Injection/InjectorHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
12 changes: 12 additions & 0 deletions tests/NativeXrefRegression/NativeXrefRegression.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<InteropRoot Condition="'$(InteropRoot)' == ''">../..</InteropRoot>
</PropertyGroup>
<ItemGroup>
<Compile Remove="class-init-fixture.cs" />
<ProjectReference Include="$(InteropRoot)/Il2CppInterop.Common/Il2CppInterop.Common.csproj" />
</ItemGroup>
</Project>
64 changes: 64 additions & 0 deletions tests/NativeXrefRegression/Program.cs
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 45 additions & 0 deletions tests/NativeXrefRegression/README.md
Original file line number Diff line number Diff line change
@@ -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.
93 changes: 93 additions & 0 deletions tests/NativeXrefRegression/class-init-fixture.cs
Original file line number Diff line number Diff line change
@@ -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<string, nint> Exports = new();
internal static readonly List<string> 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<string>() : 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<string>(), 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) { }
}
37 changes: 37 additions & 0 deletions tests/NativeXrefRegression/class-init-regression.py
Original file line number Diff line number Diff line change
@@ -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(
'<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup>'
'<TargetFramework>net9.0</TargetFramework><OutputType>Exe</OutputType>'
'<AllowUnsafeBlocks>true</AllowUnsafeBlocks><UseAppHost>false</UseAppHost>'
'</PropertyGroup></Project>')
(scratch / "NuGet.config").write_text(
'<configuration><packageSources><clear /></packageSources></configuration>')
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)
Loading