Implement core diagnostic memory layer, execution helpers, and high-level facade slices
Implemented: - Core: UTF-16 ReadString boundary/alignment fix, target bitness and process id on MemoryBase - function interception: PatchManager, DetourManager, InstructionAnalyzer, MainThreadDispatcher - Execution: BackgroundTaskExecutor, InProcessInvoker - High-level: Magic facade, RemotePointer, async wrappers - Discovery/external code loading/Window groundwork (PEB/TEB, pattern scanning, raw allocations, DLL external code loading, window/input) Tests: 180 passing, 4 integration/interactive tests skipped.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
using System.ComponentModel;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Injection;
|
||||
using WhiteMagic.Memory;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest.Injection;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CodeInjector"/>.
|
||||
/// </summary>
|
||||
public class CodeInjectorTests
|
||||
{
|
||||
private static InProcessReader CreateReader()
|
||||
{
|
||||
return new InProcessReader();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_writes_code_to_specified_address()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Allocate a buffer to write to
|
||||
byte[] buffer = new byte[32];
|
||||
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
|
||||
buffer,
|
||||
System.Runtime.InteropServices.GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = handle.AddrOfPinnedObject();
|
||||
|
||||
// Simple x64 payload: mov eax, 42; ret
|
||||
// B8 2A 00 00 00 C3
|
||||
byte[] code = { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
|
||||
|
||||
if (Environment.Is64BitProcess)
|
||||
{
|
||||
// 64-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3)
|
||||
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
|
||||
}
|
||||
else
|
||||
{
|
||||
// 32-bit: mov eax, 42 (B8 2A 00 00 00) + ret (C3) - same encoding
|
||||
code = new byte[] { 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };
|
||||
}
|
||||
|
||||
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
|
||||
|
||||
Assert.Equal(addr, result);
|
||||
Assert.Equal(code, buffer.Take(code.Length).ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_throws_on_empty_code()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = Array.Empty<byte>();
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
|
||||
|
||||
Assert.Contains("Code cannot be empty", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_throws_on_zero_address()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0x90, 0x90, 0xC3 }; // nop; nop; ret
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
CodeInjector.InjectAtAddress(reader, IntPtr.Zero, code));
|
||||
|
||||
Assert.Contains("Address cannot be zero", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_allocates_and_writes_code()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Simple x64 payload: ret (C3)
|
||||
byte[] code = { 0xC3 };
|
||||
|
||||
using var allocated = CodeInjector.Inject(reader, code);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
Assert.Equal(code.Length, allocated.Size);
|
||||
|
||||
// Verify the code was written
|
||||
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
|
||||
Assert.Equal(code, readBack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_with_execute_read_write_protection()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0xC3 }; // ret
|
||||
|
||||
using var allocated = CodeInjector.Inject(
|
||||
reader,
|
||||
code,
|
||||
MemoryProtectionType.ExecuteReadWrite);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_with_read_only_protection()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0xC3 }; // ret
|
||||
|
||||
using var allocated = CodeInjector.Inject(
|
||||
reader,
|
||||
code,
|
||||
MemoryProtectionType.ExecuteRead);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_throws_on_empty_code()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = Array.Empty<byte>();
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
CodeInjector.Inject(reader, code));
|
||||
|
||||
Assert.Contains("Code cannot be empty", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_returns_allocated_memory_that_can_be_freed()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
byte[] code = { 0xC3 }; // ret
|
||||
|
||||
var allocated = CodeInjector.Inject(reader, code);
|
||||
|
||||
Assert.NotNull(allocated);
|
||||
|
||||
// Dispose should free the memory
|
||||
allocated.Dispose();
|
||||
|
||||
// No exception should be thrown during disposal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectAtAddress_writes_all_bytes()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// Allocate a buffer
|
||||
byte[] buffer = new byte[128];
|
||||
var handle = System.Runtime.InteropServices.GCHandle.Alloc(
|
||||
buffer,
|
||||
System.Runtime.InteropServices.GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = handle.AddrOfPinnedObject();
|
||||
|
||||
// Create a larger payload
|
||||
byte[] code = new byte[64];
|
||||
for (int i = 0; i < code.Length; i++)
|
||||
code[i] = (byte)(i & 0xFF);
|
||||
|
||||
IntPtr result = CodeInjector.InjectAtAddress(reader, addr, code);
|
||||
|
||||
Assert.Equal(addr, result);
|
||||
|
||||
// Verify all bytes were written
|
||||
byte[] readBack = reader.ReadBytes(addr, code.Length);
|
||||
Assert.Equal(code, readBack);
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inject_with_complex_payload()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
// mov eax, 12345678h; ret
|
||||
// x64: B8 78 56 34 12 C3
|
||||
// x86: B8 78 56 34 12 C3 (same)
|
||||
byte[] code = { 0xB8, 0x78, 0x56, 0x34, 0x12, 0xC3 };
|
||||
|
||||
using var allocated = CodeInjector.Inject(reader, code);
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
|
||||
|
||||
// Verify the exact payload was written
|
||||
byte[] readBack = reader.ReadBytes(allocated.BaseAddress, code.Length);
|
||||
Assert.Equal(code, readBack);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Injection;
|
||||
using WhiteMagic.Native;
|
||||
|
||||
namespace WhiteMagicTest.Injection;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DllInjector"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Real injection tests exercise the current process, because it is always available
|
||||
/// and the injected DLLs are ordinary system modules that are already loaded.
|
||||
/// </remarks>
|
||||
public class DllInjectorTests
|
||||
{
|
||||
private static string GetExistingSystemDll()
|
||||
{
|
||||
// user32.dll exists on every Windows system and matches the host bitness.
|
||||
string path = Path.Combine(Environment.SystemDirectory, "user32.dll");
|
||||
Assert.True(File.Exists(path), $"{path} must exist for the test.");
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
|
||||
public void InjectWithRemoteThread_loads_system_dll_in_current_process()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var injector = new DllInjector(reader);
|
||||
|
||||
IntPtr moduleBase = injector.InjectWithRemoteThread(GetExistingSystemDll());
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, moduleBase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectWithRemoteThread_throws_for_missing_dll()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var injector = new DllInjector(reader);
|
||||
string missingPath = Path.Combine(Path.GetTempPath(), $"wm-missing-{Guid.NewGuid()}.dll");
|
||||
|
||||
Assert.False(File.Exists(missingPath));
|
||||
Assert.Throws<FileNotFoundException>(() => injector.InjectWithRemoteThread(missingPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InjectWithRemoteThread_rejects_bitness_mismatch()
|
||||
{
|
||||
// A fake reader that reports the opposite bitness from the current process.
|
||||
using var reader = new FakeBitnessMemoryBase(!Environment.Is64BitProcess);
|
||||
var injector = new DllInjector(reader);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => injector.InjectWithRemoteThread(GetExistingSystemDll()));
|
||||
|
||||
Assert.Contains("bitness", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Integration injection test - run against a dedicated target process")]
|
||||
public void InjectWithThreadHijack_loads_system_dll_and_restores_context()
|
||||
{
|
||||
using var reader = new InProcessReader();
|
||||
var injector = new DllInjector(reader);
|
||||
|
||||
using var stopEvent = new ManualResetEventSlim(false);
|
||||
using var startedEvent = new ManualResetEventSlim(false);
|
||||
|
||||
int osThreadId = 0;
|
||||
Exception? threadError = null;
|
||||
|
||||
var helper = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
osThreadId = (int)NativeMethods.GetCurrentThreadId();
|
||||
startedEvent.Set();
|
||||
|
||||
// Loop with short sleeps so the thread can be hijacked safely and can
|
||||
// also be stopped once its original context is restored.
|
||||
while (!stopEvent.IsSet)
|
||||
{
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
threadError = ex;
|
||||
}
|
||||
});
|
||||
helper.IsBackground = true;
|
||||
helper.Start();
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True(startedEvent.Wait(TimeSpan.FromSeconds(5)), "Helper thread did not start.");
|
||||
Assert.NotEqual(0, osThreadId);
|
||||
|
||||
IntPtr moduleBase = injector.InjectWithThreadHijack(osThreadId, GetExistingSystemDll());
|
||||
Assert.NotEqual(IntPtr.Zero, moduleBase);
|
||||
|
||||
// Tell the helper thread to exit. If the original context was restored correctly,
|
||||
// the thread will return to its loop and observe the stop event.
|
||||
stopEvent.Set();
|
||||
Assert.True(helper.Join(TimeSpan.FromSeconds(5)), "Helper thread did not exit after context restore.");
|
||||
Assert.Null(threadError);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopEvent.Set();
|
||||
helper.Join(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="MemoryBase"/> whose only job is to report a chosen bitness.
|
||||
/// Reads and writes are not expected to be called by the rejection path.
|
||||
/// </summary>
|
||||
private sealed class FakeBitnessMemoryBase : MemoryBase
|
||||
{
|
||||
public FakeBitnessMemoryBase(bool is64Bit)
|
||||
{
|
||||
Is64Bit = is64Bit;
|
||||
}
|
||||
|
||||
public override IntPtr ImageBase => IntPtr.Zero;
|
||||
|
||||
public override SafeMemoryHandle Handle => new(IntPtr.Zero);
|
||||
|
||||
public override bool Is64Bit { get; }
|
||||
|
||||
public override int ProcessId => Environment.ProcessId;
|
||||
|
||||
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override int WriteBytes(IntPtr address, ReadOnlySpan<byte> bytes, bool isRelative = false)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user