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:
kbe
2026-07-21 23:43:14 +02:00
parent a0ca7050a2
commit 3f0bea6bd4
44 changed files with 5595 additions and 84 deletions
@@ -0,0 +1,201 @@
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Discovery;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PatternScannerCache"/>.
/// </summary>
public class PatternScannerCacheTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void FindCached_returns_same_result_on_second_call()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
// Create a buffer with a known pattern
byte[] buffer = new byte[256];
buffer[30] = 0x11;
buffer[31] = 0x22;
buffer[32] = 0x33;
buffer[33] = 0x44;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0x11, 0x22, 0x33, 0x44 };
// First call should scan memory
IntPtr first = cache.FindCached(pattern, null, addr, end);
// Second call should return cached result
IntPtr second = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 30, first);
Assert.Equal(first, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindCached_different_ranges_are_cached_separately()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
// Create two separate buffers
byte[] buffer1 = new byte[128];
buffer1[10] = 0xAA;
buffer1[11] = 0xBB;
byte[] buffer2 = new byte[128];
buffer2[20] = 0xAA;
buffer2[21] = 0xBB;
GCHandle pin1 = GCHandle.Alloc(buffer1, GCHandleType.Pinned);
GCHandle pin2 = GCHandle.Alloc(buffer2, GCHandleType.Pinned);
try
{
IntPtr addr1 = pin1.AddrOfPinnedObject();
IntPtr end1 = addr1 + buffer1.Length;
IntPtr addr2 = pin2.AddrOfPinnedObject();
IntPtr end2 = addr2 + buffer2.Length;
byte[] pattern = { 0xAA, 0xBB };
IntPtr found1 = cache.FindCached(pattern, null, addr1, end1);
IntPtr found2 = cache.FindCached(pattern, null, addr2, end2);
Assert.Equal(addr1 + 10, found1);
Assert.Equal(addr2 + 20, found2);
Assert.NotEqual(found1, found2);
}
finally
{
pin1.Free();
pin2.Free();
}
}
[Fact]
public void FindCached_with_mask_caches_correctly()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
byte[] buffer = new byte[256];
buffer[40] = 0x99;
buffer[41] = 0x88; // This is wildcard
buffer[42] = 0x77;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0x99, 0x00, 0x77 };
string mask = "x?x";
IntPtr first = cache.FindCached(pattern, mask, addr, end);
IntPtr second = cache.FindCached(pattern, mask, addr, end);
Assert.Equal(addr + 40, first);
Assert.Equal(first, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void Clear_clears_cached_results()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
byte[] buffer = new byte[256];
buffer[50] = 0xCC;
buffer[51] = 0xDD;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
byte[] pattern = { 0xCC, 0xDD };
// Cache a result
IntPtr first = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 50, first);
// Clear the cache
cache.Clear();
// This should rescan (not return cached result)
IntPtr second = cache.FindCached(pattern, null, addr, end);
Assert.Equal(addr + 50, second);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindInModuleCached_caches_module_scans()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
// MZ header is always at the start of the main module
byte[] pattern = { 0x4D, 0x5A };
IntPtr first = cache.FindInModuleCached(pattern, null, mainModule);
IntPtr second = cache.FindInModuleCached(pattern, null, mainModule);
Assert.Equal(mainModule.BaseAddress, first);
Assert.Equal(first, second);
}
[Fact]
public void FindInModulesCached_caches_multiple_modules()
{
using var reader = CreateReader();
var cache = new PatternScannerCache(reader);
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var modules = currentProcess.Modules.Cast<System.Diagnostics.ProcessModule>().ToList();
Assert.NotEmpty(modules);
// MZ header should be present in at least one module
byte[] pattern = { 0x4D, 0x5A };
IntPtr first = cache.FindInModulesCached(pattern, null, modules);
IntPtr second = cache.FindInModulesCached(pattern, null, modules);
Assert.NotEqual(IntPtr.Zero, first);
Assert.Equal(first, second);
}
}
@@ -0,0 +1,188 @@
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Discovery;
using WhiteMagicTest;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PatternScanner"/>.
/// </summary>
public class PatternScannerTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void Find_exact_pattern_returns_correct_address()
{
using var reader = CreateReader();
// Create a buffer with known bytes
byte[] buffer = new byte[256];
buffer[10] = 0xDE;
buffer[11] = 0xAD;
buffer[12] = 0xBE;
buffer[13] = 0xEF;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search for the exact pattern
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(addr + 10, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_with_wildcard_mask_ignores_wildcard_bytes()
{
using var reader = CreateReader();
// Create a buffer with known bytes
byte[] buffer = new byte[256];
buffer[20] = 0x12;
buffer[21] = 0x34; // This byte is wildcard
buffer[22] = 0x56;
buffer[23] = 0x78;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search with wildcard mask (x = match, ? = wildcard)
byte[] pattern = { 0x12, 0x00, 0x56, 0x78 };
string mask = "x?xx"; // Second byte is wildcard
IntPtr found = PatternScanner.Find(reader, pattern, mask, addr, end);
Assert.Equal(addr + 20, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_pattern_not_found_returns_zero()
{
using var reader = CreateReader();
// Create a buffer without the target pattern
byte[] buffer = new byte[256];
for (int i = 0; i < buffer.Length; i++)
buffer[i] = 0xAA;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Search for pattern that doesn't exist
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(IntPtr.Zero, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void Find_empty_pattern_throws()
{
using var reader = CreateReader();
byte[] pattern = Array.Empty<byte>();
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, null, IntPtr.Zero, (IntPtr)1000));
Assert.Contains("Pattern cannot be empty", ex.Message);
}
[Fact]
public void Find_mask_length_mismatch_throws()
{
using var reader = CreateReader();
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
string mask = "xxx"; // Wrong length
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
Assert.Contains("Mask length", ex.Message);
}
[Fact]
public void Find_invalid_mask_char_throws()
{
using var reader = CreateReader();
byte[] pattern = { 0xDE, 0xAD, 0xBE, 0xEF };
string mask = "axxx"; // 'a' is invalid
var ex = Assert.Throws<ArgumentException>(() =>
PatternScanner.Find(reader, pattern, mask, IntPtr.Zero, (IntPtr)1000));
}
[Fact]
public void Find_null_mask_treats_all_as_exact()
{
using var reader = CreateReader();
byte[] buffer = new byte[256];
buffer[50] = 0xAB;
buffer[51] = 0xCD;
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
IntPtr end = addr + buffer.Length;
// Null mask should behave like "xx" (exact match)
byte[] pattern = { 0xAB, 0xCD };
IntPtr found = PatternScanner.Find(reader, pattern, null, addr, end);
Assert.Equal(addr + 50, found);
}
finally
{
pin.Free();
}
}
[Fact]
public void FindInModule_scans_current_process_module()
{
using var reader = CreateReader();
// Get the current process's main module
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
// MZ header is always at the start of the main module
byte[] pattern = { 0x4D, 0x5A };
IntPtr found = PatternScanner.FindInModule(reader, pattern, null, mainModule);
Assert.Equal(mainModule.BaseAddress, found);
}
}
@@ -0,0 +1,156 @@
using WhiteMagic;
using WhiteMagic.Discovery;
namespace WhiteMagicTest.Discovery;
/// <summary>
/// Tests for <see cref="PeHeaderParser"/>.
/// </summary>
public class PeHeaderParserTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void EntryPoint_returns_nonzero_for_current_module()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
IntPtr entryPoint = parser.EntryPoint;
// Entry point should be a valid RVA (non-zero for a valid PE)
Assert.NotEqual(IntPtr.Zero, entryPoint);
// Entry point should be less than module size
Assert.True((nint)entryPoint < mainModule.ModuleMemorySize);
}
[Fact]
public void Sections_enumerates_at_least_text_section()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.ToList();
Assert.NotEmpty(sections);
// Every PE file should have a .text section (or similar)
var textSection = sections.FirstOrDefault(s =>
s.Name.Equals(".text", StringComparison.OrdinalIgnoreCase) ||
s.Name.Equals("TEXT", StringComparison.OrdinalIgnoreCase));
// May not find ".text" exactly, but should have at least some sections
Assert.True(sections.Count >= 1);
}
[Fact]
public void Sections_have_valid_properties()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.ToList();
foreach (var section in sections)
{
// Name should not be empty
Assert.False(string.IsNullOrWhiteSpace(section.Name));
// Virtual address should be within module bounds
Assert.True((nint)section.VirtualAddress < mainModule.ModuleMemorySize);
// Virtual size should be positive
Assert.True(section.VirtualSize > 0);
}
}
[Fact]
public void Sections_have_common_names()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var sections = parser.Sections.Select(s => s.Name).ToList();
// At least some common section names should be present
var commonNames = new[] { ".text", ".data", ".rdata", ".bss" };
bool hasCommonSection = commonNames.Any(name =>
sections.Contains(name, StringComparer.OrdinalIgnoreCase));
// This might not always be true, but for managed EXEs it usually is
// We'll just verify sections were enumerated
Assert.NotEmpty(sections);
}
[Fact]
public void EntryPoint_is_consistent_across_calls()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
IntPtr first = parser.EntryPoint;
IntPtr second = parser.EntryPoint;
Assert.Equal(first, second);
}
[Fact]
public void Sections_are_consistent_across_calls()
{
using var reader = CreateReader();
var currentProcess = System.Diagnostics.Process.GetCurrentProcess();
var mainModule = currentProcess.MainModule;
Assert.NotNull(mainModule);
var parser = new PeHeaderParser(reader, mainModule.BaseAddress);
var first = parser.Sections.ToList();
var second = parser.Sections.ToList();
Assert.Equal(first.Count, second.Count);
for (int i = 0; i < first.Count; i++)
{
Assert.Equal(first[i].Name, second[i].Name);
Assert.Equal(first[i].VirtualAddress, second[i].VirtualAddress);
Assert.Equal(first[i].VirtualSize, second[i].VirtualSize);
}
}
[Fact]
public void Constructor_throws_on_zero_base_address()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentException>(() =>
new PeHeaderParser(reader, IntPtr.Zero));
Assert.Contains("Base address cannot be zero", ex.Message);
}
}
@@ -0,0 +1,77 @@
using System;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Execution;
using Xunit;
namespace WhiteMagicTest.Execution;
/// <summary>
/// Tests for <see cref="InProcessInvoker"/> operating in-process.
/// </summary>
public class InProcessInvokerTests
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AddDelegate(int a, int b);
private static int NativeAdd(int a, int b) => a + b;
[Fact]
public void CreateFunction_calls_known_in_process_function()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
var native = new AddDelegate(NativeAdd);
IntPtr functionPointer = Marshal.GetFunctionPointerForDelegate(native);
AddDelegate callable = invoker.CreateFunction<AddDelegate>(functionPointer);
int result = callable(5, 7);
Assert.Equal(12, result);
GC.KeepAlive(native);
}
[Fact]
public void CreateFunction_rejects_zero_address()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
ArgumentException ex = Assert.Throws<ArgumentException>(() => invoker.CreateFunction<AddDelegate>(IntPtr.Zero));
Assert.Equal("address", ex.ParamName);
}
[Fact]
public void Vtable_helper_reads_function_pointer_slot()
{
using var reader = new InProcessReader();
var invoker = new InProcessInvoker(reader);
// Build a tiny fake vtable in a pinned buffer: two slots holding known pointers.
IntPtr slot0 = Marshal.GetFunctionPointerForDelegate(new AddDelegate(NativeAdd));
IntPtr slot1 = new IntPtr(0x12345678);
IntPtr[] vtable;
if (reader.Is64Bit)
{
vtable = [slot0, slot1];
}
else
{
vtable = [slot0, slot1];
}
GCHandle pin = GCHandle.Alloc(vtable, GCHandleType.Pinned);
try
{
IntPtr vTableAddress = pin.AddrOfPinnedObject();
Assert.Equal(slot0, invoker.ReadVTableFunction(vTableAddress, 0));
Assert.Equal(slot1, invoker.ReadVTableFunction(vTableAddress, 1));
}
finally
{
pin.Free();
}
}
}
@@ -0,0 +1,209 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Execution;
using WhiteMagic.Native;
namespace WhiteMagicTest.Execution;
public sealed class RemoteThreadExecutorTests
{
// x64 payloads. Live execution tests run only on x64 because the payloads use the
// Microsoft x64 ABI (integer args in RCX, RDX, R8, R9, then stack at [rsp+0x28]).
// mov eax, ecx
// add eax, edx
// ret
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
// mov eax, ecx
// add eax, edx
// add eax, r8d
// add eax, r9d
// add eax, [rsp+0x28]
// ret
private static readonly byte[] SumFivePayload =
[
0x89, 0xC8,
0x01, 0xD0,
0x44, 0x01, 0xC0,
0x44, 0x01, 0xC8,
0x03, 0x84, 0x24, 0x28, 0x00, 0x00, 0x00,
0xC3
];
// xor eax, eax
// cmp byte ptr [rcx+rax], 0
// je done
// inc eax
// jmp loop
// done: ret
private static readonly byte[] Utf8LengthPayload =
[
0x31, 0xC0,
0x80, 0x3C, 0x01, 0x00,
0x74, 0x04,
0xFF, 0xC0,
0xEB, 0xF6,
0xC3
];
// mov eax, [rcx]
// add eax, [rcx+4]
// ret
private static readonly byte[] PointSumPayload = [0x8B, 0x01, 0x03, 0x41, 0x04, 0xC3];
[StructLayout(LayoutKind.Sequential)]
private struct Point
{
public int X;
public int Y;
}
[Fact]
public void Execute_adds_two_integers()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(AddPayload, CallConvention.Cdecl, 10, 32);
Assert.Equal(42, result);
}
[Fact]
public void Execute_sums_register_and_stack_arguments()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(SumFivePayload, CallConvention.Cdecl, 1, 2, 3, 4, 5);
Assert.Equal(15, result);
}
[Fact]
public void Execute_marshals_string_as_utf8_pointer()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(Utf8LengthPayload, CallConvention.Cdecl, "hello");
Assert.Equal(5, result);
}
[Fact]
public void Execute_marshals_struct_as_pointer()
{
if (!Environment.Is64BitProcess)
{
return;
}
int result = RunPayload(PointSumPayload, CallConvention.Cdecl, new Point { X = 30, Y = 12 });
Assert.Equal(42, result);
}
[Fact]
public void Execute_throws_when_handle_is_invalid()
{
var executor = new RemoteThreadExecutor(new InvalidProcessReader());
InvalidOperationException ex = Assert.Throws<InvalidOperationException>(() =>
{
executor.Execute<int>((IntPtr)0x1234, CallConvention.Cdecl);
});
Assert.Contains("handle", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Execute_throws_when_address_is_zero()
{
using var reader = new InProcessReader();
var executor = new RemoteThreadExecutor(reader);
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
{
executor.Execute<int>(IntPtr.Zero, CallConvention.Cdecl);
});
Assert.Equal("address", ex.ParamName);
}
[Fact]
public void InProcessReader_reports_current_process_bitness()
{
using var reader = new InProcessReader();
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
}
[Fact]
public void ExternalReader_reports_current_process_bitness()
{
using var reader = new ExternalReader(Process.GetCurrentProcess());
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
}
private static int RunPayload(byte[] payload, CallConvention convention, params object?[] args)
{
const nint pageSize = 4096;
const nint blockSize = pageSize * 2;
using var reader = new InProcessReader();
var executor = new RemoteThreadExecutor(reader);
// Allocate a single executable block. The payload lives at the start and the
// generated call stub is written to the second page, guaranteeing that the
// relative CALL instruction stays within its ±2 GiB range.
IntPtr block = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
blockSize,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, block);
IntPtr stubAddress = block + pageSize;
executor.StubAllocator = (_, size) => size <= pageSize ? stubAddress : IntPtr.Zero;
try
{
int written = reader.WriteBytes(block, payload);
Assert.Equal(payload.Length, written);
return executor.Execute<int>(block, convention, args);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, block, 0, MemoryFreeType.Release);
}
}
private sealed class InvalidProcessReader : MemoryBase
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle { get; } = new SafeMemoryHandle(new IntPtr(-1));
public override bool Is64Bit => Environment.Is64BitProcess;
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();
public override void Dispose()
{
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic;
using WhiteMagic.Assembly;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest;
/// <summary>
/// Tests for the high-level facade (<see cref="Magic"/>) and <see cref="RemotePointer"/>.
/// </summary>
public class HighLevelTests
{
private static readonly byte[] AddPayload = [0x89, 0xC8, 0x01, 0xD0, 0xC3];
[Fact]
public void OpenExternal_returns_session_for_current_process()
{
using var magic = Magic.Open(Process.GetCurrentProcess());
Assert.NotNull(magic.Memory);
Assert.False(magic.Memory.Handle.IsInvalid);
Assert.Same(magic.Memory.PatchManager, magic.PatchManager);
Assert.Same(magic.Memory.DetourManager, magic.DetourManager);
}
[Fact]
public void OpenInProcess_returns_session_for_self()
{
using var magic = Magic.OpenInProcess();
Assert.IsType<InProcessReader>(magic.Memory);
Assert.False(magic.Memory.Handle.IsInvalid);
}
[Fact]
public void Indexer_returns_remote_pointer_that_reads_and_writes_relative()
{
using var magic = Magic.OpenInProcess();
byte[] slot = new byte[16];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr baseAddr = pin.AddrOfPinnedObject();
magic[baseAddr + 4].Write(0x12345678);
Assert.Equal(0x12345678, magic[baseAddr].Read<int>(4));
}
finally
{
pin.Free();
}
}
[Fact]
public async Task RemoteThread_ExecuteAsync_runs_payload_and_returns_result()
{
if (!Environment.Is64BitProcess)
{
return;
}
using var magic = Magic.OpenInProcess();
IntPtr payload = NativeMethods.VirtualAllocEx(
magic.Memory.Handle,
IntPtr.Zero,
4096,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, payload);
try
{
magic.Memory.WriteBytes(payload, AddPayload);
int result = await magic.RemoteThread.ExecuteAsync<int>(payload, CallConvention.Cdecl, 10, 32);
Assert.Equal(42, result);
}
finally
{
NativeMethods.VirtualFreeEx(magic.Memory.Handle, payload, 0, MemoryFreeType.Release);
}
}
}
+278
View File
@@ -0,0 +1,278 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using WhiteMagic;
using WhiteMagic.Hooking;
using WhiteMagic.Native;
using Xunit;
namespace WhiteMagicTest.Hooking;
/// <summary>
/// Tests for <see cref="PatchManager"/>, <see cref="DetourManager"/> and
/// <see cref="Execution.MainThreadPump"/> operating in-process.
/// </summary>
public class HookingTests
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int FrameFunc();
private const int FrameResult = 42;
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
/// <summary>
/// Allocates a tiny executable function whose prologue is made entirely of
/// covered instruction shapes, so detours apply cleanly in tests.
/// </summary>
private static IntPtr AllocateFrameStub(MemoryBase reader, out IntPtr allocationBase)
{
// x64: push rbp; push rdi; push rsi; push rbx; sub rsp, 0x28; sub rsp, 0x12345678;
// mov eax, 42; add rsp, 0x12345678; add rsp, 0x28; pop rbx; pop rsi; pop rdi; pop rbp; ret
byte[] code =
[
0x55, // push rbp
0x57, // push rdi
0x56, // push rsi
0x53, // push rbx
0x48, 0x83, 0xEC, 0x28, // sub rsp, 0x28
0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12, // sub rsp, 0x12345678
0xB8, 0x2A, 0x00, 0x00, 0x00, // mov eax, 42
0x48, 0x81, 0xC4, 0x78, 0x56, 0x34, 0x12, // add rsp, 0x12345678
0x48, 0x83, 0xC4, 0x28, // add rsp, 0x28
0x5B, // pop rbx
0x5E, // pop rsi
0x5F, // pop rdi
0x5D, // pop rbp
0xC3 // ret
];
allocationBase = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
code.Length,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, allocationBase);
reader.WriteBytes(allocationBase, code);
return allocationBase;
}
[Fact]
public void Patch_apply_writes_bytes_and_remove_restores_original()
{
using var reader = CreateReader();
byte[] slot = new byte[8];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] original = reader.ReadBytes(addr, 4);
byte[] patchBytes = [0x90, 0x90, 0x90, 0x90];
Patch patch = reader.PatchManager.Create("nop", addr, patchBytes);
Assert.False(patch.IsApplied);
patch.Apply();
Assert.True(patch.IsApplied);
Assert.Equal(patchBytes, reader.ReadBytes(addr, 4));
patch.Remove();
Assert.False(patch.IsApplied);
Assert.Equal(original, reader.ReadBytes(addr, 4));
}
finally
{
pin.Free();
}
}
[Fact]
public void Detour_apply_redirects_callOriginal_remove_restores()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
int hookCalls = 0;
Detour? detour = null;
FrameFunc hook = () =>
{
hookCalls++;
return (int?)detour?.CallOriginal() ?? 0;
};
detour = reader.DetourManager.Create("frame", targetPtr, hook);
detour.Apply();
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
int result = routed();
Assert.True(hookCalls > 0);
Assert.Equal(FrameResult, result);
detour.Remove();
hookCalls = 0;
result = routed();
Assert.Equal(0, hookCalls);
Assert.Equal(FrameResult, result);
GC.KeepAlive(hook);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_named_lookup_returns_existing_detour()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
Detour detour = reader.DetourManager.Create("lookup", targetPtr, (FrameFunc)(() => FrameResult));
Assert.Same(detour, reader.DetourManager["lookup"]);
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Detour_aligned_prologue_applies_and_unknown_prologue_rejects()
{
using var reader = CreateReader();
// A normal JIT-compiled function has a covered prologue shape.
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr goodAllocation);
try
{
Detour good = reader.DetourManager.Create("good", targetPtr, (FrameFunc)(() => FrameResult));
good.Apply();
good.Remove();
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, goodAllocation, 0, MemoryFreeType.Release);
}
// Allocate a small executable region whose first instruction is outside the
// covered set. The decoder must refuse to splice it.
IntPtr code = NativeMethods.VirtualAllocEx(
reader.Handle,
IntPtr.Zero,
32,
MemoryAllocationType.Commit | MemoryAllocationType.Reserve,
MemoryProtectionType.ExecuteReadWrite);
Assert.NotEqual(IntPtr.Zero, code);
try
{
// 0x0F 0x05 = syscall (not covered), followed by padding and a ret.
byte[] unknown = [0x0F, 0x05, 0xC3, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC];
reader.WriteBytes(code, unknown);
Detour bad = reader.DetourManager.Create("bad", code, (FrameFunc)(() => FrameResult));
Assert.Throws<InvalidOperationException>(() => bad.Apply());
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, code, 0, MemoryFreeType.Release);
}
}
[Fact]
public void Dispose_restores_active_patches_and_detours()
{
InProcessReader reader = CreateReader();
byte[] slot = new byte[8];
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
byte[] original = reader.ReadBytes(addr, 2);
Patch patch = reader.PatchManager.Create("dispose-patch", addr, [0x90, 0x90]);
patch.Apply();
reader.Dispose();
// Verify with a fresh reader; the original handle was closed by Dispose.
using var verify = CreateReader();
Assert.Equal(original, verify.ReadBytes(addr, 2));
}
finally
{
pin.Free();
}
}
[Fact]
public async Task MainThreadPump_drains_work_on_frame_call_and_uninstalls_on_dispose()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
Task<int> work = pump.ExecuteAsync(() => 123);
// Drive the frame function manually. The detoured frame runs the pump hook on
// this thread, drains the work queue, then calls the original frame function.
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
int frameResult = routed();
Assert.Equal(FrameResult, frameResult);
Assert.Equal(123, await work);
pump.Dispose();
// After uninstall, calling the frame function should behave like the original.
routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
Assert.Equal(FrameResult, routed());
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
[Fact]
public async Task MainThreadPump_exception_survives_and_does_not_kill_pump()
{
using var reader = CreateReader();
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
try
{
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
pump.Install();
Task<int> bad = pump.ExecuteAsync<int>(() => throw new InvalidOperationException("boom"));
Task<int> good = pump.ExecuteAsync(() => 7);
FrameFunc routed = Marshal.GetDelegateForFunctionPointer<FrameFunc>(targetPtr);
routed();
await Assert.ThrowsAsync<InvalidOperationException>(() => bad);
Assert.Equal(7, await good);
pump.Dispose();
}
finally
{
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
}
}
}
@@ -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();
}
}
@@ -0,0 +1,41 @@
using System.Diagnostics;
using WhiteMagic.Input;
using WhiteMagic.Windows;
using Xunit;
namespace WhiteMagicTest.Input;
public sealed class InputSimulatorTests
{
[Fact(Skip = "Interactive input test - requires a visible window")]
public void SendKeys_to_current_window_does_not_throw()
{
using var process = Process.GetCurrentProcess();
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
? process.MainWindowHandle
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
if (handle == IntPtr.Zero)
return;
var simulator = new InputSimulator();
bool result = simulator.SendKeys(handle, "ab");
Assert.True(result);
}
[Fact(Skip = "Interactive input test - requires a visible window")]
public void SendMouseClick_to_current_window_does_not_throw()
{
using var process = Process.GetCurrentProcess();
IntPtr handle = process.MainWindowHandle != IntPtr.Zero
? process.MainWindowHandle
: WindowFactory.GetWindows().FirstOrDefault()?.Handle ?? IntPtr.Zero;
if (handle == IntPtr.Zero)
return;
var simulator = new InputSimulator();
bool result = simulator.SendMouseClick(handle, 10, 20, MouseButton.Left);
Assert.True(result);
}
}
@@ -0,0 +1,254 @@
using System.ComponentModel;
using WhiteMagic;
using WhiteMagic.Memory;
using WhiteMagic.Native;
namespace WhiteMagicTest.Memory;
/// <summary>
/// Tests for <see cref="AllocatedMemory"/>.
/// </summary>
public class AllocatedMemoryTests
{
private static InProcessReader CreateReader()
{
return new InProcessReader();
}
[Fact]
public void Constructor_allocates_memory_with_execute_read_write_protection()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
Assert.Equal(4096, allocated.Size);
}
[Fact]
public void Constructor_with_custom_protection()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096, MemoryProtectionType.ReadOnly);
Assert.NotEqual(IntPtr.Zero, allocated.BaseAddress);
}
[Fact]
public void Constructor_throws_on_negative_size()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
new AllocatedMemory(reader, -1));
Assert.Equal("size", ex.ParamName);
}
[Fact]
public void Constructor_throws_on_zero_size()
{
using var reader = CreateReader();
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
new AllocatedMemory(reader, 0));
Assert.Equal("size", ex.ParamName);
}
[Fact]
public void AddRegion_adds_named_region()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("test", 100);
Assert.Equal(100, allocated.AddressOf("test") - allocated.BaseAddress);
}
[Fact]
public void AddRegion_throws_on_duplicate_name()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("test", 100);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.AddRegion("test", 200));
Assert.Contains("already exists", ex.Message);
}
[Fact]
public void AddRegion_throws_on_negative_offset()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.AddRegion("test", -1));
Assert.Equal("offset", ex.ParamName);
}
[Fact]
public void AddRegion_throws_on_offset_exceeding_size()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentOutOfRangeException>(() =>
allocated.AddRegion("test", 4096));
Assert.Equal("offset", ex.ParamName);
}
[Fact]
public void AddressOf_returns_correct_address()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("region1", 0);
allocated.AddRegion("region2", 100);
allocated.AddRegion("region3", 200);
Assert.Equal(allocated.BaseAddress, allocated.AddressOf("region1"));
Assert.Equal(allocated.BaseAddress + 100, allocated.AddressOf("region2"));
Assert.Equal(allocated.BaseAddress + 200, allocated.AddressOf("region3"));
}
[Fact]
public void AddressOf_throws_on_unknown_region()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.AddressOf("unknown"));
Assert.Contains("does not exist", ex.Message);
}
[Fact]
public void Write_and_Read_int_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("value", 0);
int original = unchecked((int)0xDEADBEEF);
Assert.True(allocated.Write("value", original));
int read = allocated.Read<int>("value");
Assert.Equal(original, read);
}
[Fact]
public void Write_and_Read_long_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("value", 8);
long original = 0x123456789ABCDEF0;
Assert.True(allocated.Write("value", original));
long read = allocated.Read<long>("value");
Assert.Equal(original, read);
}
[Fact]
public void WriteBytes_and_ReadBytes_roundtrip()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("buffer", 0);
byte[] original = { 0x01, 0x02, 0x03, 0x04, 0x05 };
int written = allocated.WriteBytes("buffer", original);
Assert.Equal(original.Length, written);
byte[] read = allocated.ReadBytes("buffer", original.Length);
Assert.Equal(original, read);
}
[Fact]
public void Dispose_frees_memory()
{
using var reader = CreateReader();
var allocated = new AllocatedMemory(reader, 4096);
IntPtr baseAddr = allocated.BaseAddress;
Assert.NotEqual(IntPtr.Zero, baseAddr);
allocated.Dispose();
// After dispose, accessing properties should throw ObjectDisposedException
Assert.Throws<ObjectDisposedException>(() =>
allocated.AddressOf("any"));
}
[Fact]
public void Multiple_regions_independent_access()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
allocated.AddRegion("a", 0);
allocated.AddRegion("b", 4);
allocated.AddRegion("c", 8);
Assert.True(allocated.Write("a", 0x11111111));
Assert.True(allocated.Write("b", 0x22222222));
Assert.True(allocated.Write("c", 0x33333333));
Assert.Equal(0x11111111, allocated.Read<int>("a"));
Assert.Equal(0x22222222, allocated.Read<int>("b"));
Assert.Equal(0x33333333, allocated.Read<int>("c"));
}
[Fact]
public void Write_to_unknown_region_throws()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.Write("unknown", 42));
Assert.Contains("does not exist", ex.Message);
}
[Fact]
public void Read_from_unknown_region_throws()
{
using var reader = CreateReader();
using var allocated = new AllocatedMemory(reader, 4096);
var ex = Assert.Throws<ArgumentException>(() =>
allocated.Read<int>("unknown"));
Assert.Contains("does not exist", ex.Message);
}
}
+2
View File
@@ -160,6 +160,8 @@ public class MemoryHardeningTests
{
public override IntPtr ImageBase => IntPtr.Zero;
public override SafeMemoryHandle Handle => null!;
public override bool Is64Bit => Environment.Is64BitProcess;
public override int ProcessId => Environment.ProcessId;
public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
@@ -0,0 +1,28 @@
using System.Diagnostics;
using WhiteMagic;
using WhiteMagic.ProcessEnvironment;
using Xunit;
namespace WhiteMagicTest.ProcessEnvironment;
public sealed class ManagedPebTests
{
[Fact]
public void Read_current_process_peb_fields_returns_plausible_values()
{
using var magic = Magic.OpenInProcess();
var peb = new ManagedPeb(magic.Memory);
Assert.NotEqual(IntPtr.Zero, peb.ReadPebAddress());
Assert.NotEqual(IntPtr.Zero, peb.ReadImageBaseAddress());
byte beingDebugged = peb.ReadBeingDebugged();
Assert.True(beingDebugged == 0 || beingDebugged == 1);
Assert.NotEqual(IntPtr.Zero, peb.ReadLdrAddress());
// The in-process test process is native to the host architecture, so
// it is not running under WOW64.
Assert.False(peb.ReadIsWow64Process());
}
}
+55
View File
@@ -157,6 +157,61 @@ public class StringReadWriteTests
}
}
/// <summary>
/// UTF-16 null terminator split across the 64-byte chunk boundary must still be found.
/// The first chunk ends at byte 63, so the null bytes at 64/65 are in the second chunk.
/// </summary>
[Fact]
public void ReadString_utf16_null_across_chunk_boundary_is_found()
{
using var reader = OpenSelf();
byte[] slot = new byte[256];
// 32 'A' UTF-16 chars = 64 bytes, no embedded null.
byte[] text = Encoding.Unicode.GetBytes(new string('A', 32));
Assert.Equal(64, text.Length);
text.CopyTo(slot, 0);
// Null terminator at bytes 64/65.
slot[64] = 0x00;
slot[65] = 0x00;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 256);
Assert.Equal(new string('A', 32), result);
}
finally
{
pin.Free();
}
}
/// <summary>
/// A byte sequence that looks like a null at a misaligned offset must not stop the scan.
/// "A" + U+4200 produces bytes 41 00 00 42 00 00; bytes 1-2 are an aligned-position null
/// only if scanned byte-by-byte. The aligned UTF-16 scan must see the real terminator.
/// </summary>
[Fact]
public void ReadString_utf16_does_not_stop_at_misaligned_null()
{
using var reader = OpenSelf();
byte[] slot = Encoding.Unicode.GetBytes("A\u4200\0");
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
{
IntPtr addr = pin.AddrOfPinnedObject();
string result = reader.ReadString(addr, Encoding.Unicode, maxLength: 64);
Assert.Equal("A\u4200", result);
}
finally
{
pin.Free();
}
}
[Fact]
public void WriteString_empty_string_writes_only_null()
{
@@ -0,0 +1,23 @@
using WhiteMagic;
using WhiteMagic.Native;
using WhiteMagic.ThreadEnvironment;
using Xunit;
namespace WhiteMagicTest.ThreadEnvironment;
public sealed class ManagedTebTests
{
[Fact]
public void Read_current_thread_teb_fields_returns_plausible_values()
{
using var magic = Magic.OpenInProcess();
using var teb = new ManagedTeb(magic.Memory, (int)NativeMethods.GetCurrentThreadId());
Assert.NotEqual(IntPtr.Zero, teb.ReadTebAddress());
Assert.NotEqual(IntPtr.Zero, teb.ReadStackBase());
Assert.NotEqual(IntPtr.Zero, teb.ReadStackLimit());
// The stack grows down, so the base is above the limit on x86/x64.
Assert.True((nuint)teb.ReadStackBase() > (nuint)teb.ReadStackLimit());
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using WhiteMagic.Windows;
using Xunit;
namespace WhiteMagicTest.Windows;
public sealed class WindowTests
{
[Fact]
public void GetWindows_returns_at_least_one_top_level_window()
{
var windows = WindowFactory.GetWindows().ToList();
Assert.NotEmpty(windows);
}
[Fact]
public void GetWindowsByClassName_filters_to_matching_classes()
{
var all = WindowFactory.GetWindows().ToList();
if (all.Count == 0)
return;
string firstClass = all[0].ClassName;
if (string.IsNullOrEmpty(firstClass))
return;
var filtered = WindowFactory.GetWindowsByClassName(firstClass).ToList();
Assert.All(filtered, w => Assert.Equal(firstClass, w.ClassName));
Assert.True(filtered.Count <= all.Count);
}
[Fact]
public void RemoteWindow_can_query_and_manipulate_a_test_window()
{
IntPtr handle = Process.GetCurrentProcess().MainWindowHandle;
if (handle == IntPtr.Zero)
{
// The xUnit runner may not expose a main window; skip destructive
// manipulation but still validate factory enumeration above.
return;
}
var window = new RemoteWindow(handle);
Assert.Equal(handle, window.Handle);
string text = window.Text;
Assert.NotNull(text);
string originalTitle = window.Title;
Assert.Equal(text, originalTitle);
// Move and resize, then restore the original position.
bool moved = window.MoveResize(10, 10, 400, 300);
Assert.True(moved);
bool flashed = window.Flash();
Assert.True(flashed);
// Restore a sensible size without asserting exact title restoration;
// terminal windows often ignore SetWindowText.
bool restored = window.MoveResize(0, 0, 800, 600);
Assert.True(restored);
}
}