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,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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user