Fix dispatcher crash/deadlock, instruction analyzer, cache equality, task leak, and redirection protection
- MainThreadDispatcher: guard DispatchHook with try/catch so exceptions never escape to native caller; drain and fault pending work on Dispose; synchronize Execute/ExecuteAsync/Dispose against race/dispose. - InstructionAnalyzer: require ModRM 0xEC for 0x83/0x81 sub-esp/rsp forms, rejecting unsafe RIP-relative or memory forms. - PatternScannerCache: implement value equality on CacheKey so repeated scans actually hit cache. - BackgroundTaskExecutor: add remote allocations to the free list immediately after VirtualAllocEx, before any write that could fail and leak. - redirect: capture and restore original page protection in Apply/Remove instead of leaving target RWX. - Regression tests for all six fixes. Tests: 198 passing, 4 integration/interactive skipped.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using WhiteMagic;
|
||||
using WhiteMagic.Discovery;
|
||||
@@ -43,6 +45,48 @@ public class PatternScannerCacheTests
|
||||
|
||||
Assert.Equal(addr + 30, first);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
// Value equality must mean the second call reused the cached entry.
|
||||
var cacheField = typeof(PatternScannerCache).GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var cacheDict = cacheField.GetValue(cache)!;
|
||||
int count = (int)cacheDict.GetType().GetProperty("Count")!.GetValue(cacheDict)!;
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pin.Free();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindCached_value_equality_uses_content_not_reference()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
var cache = new PatternScannerCache(reader);
|
||||
|
||||
byte[] buffer = new byte[256];
|
||||
buffer[10] = 0xAA;
|
||||
buffer[11] = 0xBB;
|
||||
|
||||
GCHandle pin = GCHandle.Alloc(buffer, GCHandleType.Pinned);
|
||||
try
|
||||
{
|
||||
IntPtr addr = pin.AddrOfPinnedObject();
|
||||
IntPtr end = addr + buffer.Length;
|
||||
|
||||
byte[] pattern1 = { 0xAA, 0xBB };
|
||||
byte[] pattern2 = { 0xAA, 0xBB };
|
||||
|
||||
IntPtr first = cache.FindCached(pattern1, null, addr, end);
|
||||
IntPtr second = cache.FindCached(pattern2, null, addr, end);
|
||||
|
||||
Assert.Equal(addr + 10, first);
|
||||
Assert.Equal(first, second);
|
||||
|
||||
var cacheField = typeof(PatternScannerCache).GetField("_cache", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var cacheDict = cacheField.GetValue(cache)!;
|
||||
int count = (int)cacheDict.GetType().GetProperty("Count")!.GetValue(cacheDict)!;
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -150,6 +150,19 @@ public sealed class RemoteThreadExecutorTests
|
||||
Assert.Equal(Environment.Is64BitProcess, reader.Is64Bit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Execute_releases_allocated_remote_memory_on_write_failure()
|
||||
{
|
||||
using var reader = new WriteFailingMemoryBase();
|
||||
var executor = new RemoteThreadExecutor(reader);
|
||||
|
||||
// The executor will allocate a remote call stub; our reader then refuses every
|
||||
// WriteBytes call. The allocation made before the failure must still be freed.
|
||||
// The write failure must surface as an InvalidOperationException, not hang or crash.
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
executor.Execute<int>(new IntPtr(0x123456789ABCDEF0L), CallConvention.Stdcall));
|
||||
}
|
||||
|
||||
private static int RunPayload(byte[] payload, CallConvention convention, params object?[] args)
|
||||
{
|
||||
const nint pageSize = 4096;
|
||||
@@ -206,4 +219,26 @@ public sealed class RemoteThreadExecutorTests
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fake reader whose WriteBytes always returns zero, forcing the executor down
|
||||
/// the failure path after it has allocated remote memory.
|
||||
/// </summary>
|
||||
private sealed class WriteFailingMemoryBase : 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)
|
||||
=> 0;
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,4 +275,64 @@ public class HookingTests
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainThreadPump_dispose_faults_pending_work()
|
||||
{
|
||||
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> pending = pump.ExecuteAsync(() => 42);
|
||||
|
||||
pump.Dispose();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<ObjectDisposedException>(() => pending);
|
||||
Assert.Equal(nameof(WhiteMagic.Execution.MainThreadPump), ex.ObjectName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MainThreadPump_dispose_while_execute_blocked_does_not_deadlock()
|
||||
{
|
||||
using var reader = CreateReader();
|
||||
|
||||
IntPtr targetPtr = AllocateFrameStub(reader, out IntPtr allocation);
|
||||
try
|
||||
{
|
||||
var pump = new WhiteMagic.Execution.MainThreadPump(reader.DetourManager, targetPtr);
|
||||
pump.Install();
|
||||
|
||||
// Start Execute on another thread; it will block until Dispose drains the queue.
|
||||
Task executeTask = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
pump.Execute<int>(() => 42);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
});
|
||||
|
||||
// Give Execute time to pass the gate and block on the TCS.
|
||||
await Task.Delay(50);
|
||||
pump.Dispose();
|
||||
|
||||
Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(executeTask, completed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMethods.VirtualFreeEx(reader.Handle, allocation, 0, MemoryFreeType.Release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using WhiteMagic.Hooking;
|
||||
using Xunit;
|
||||
|
||||
namespace WhiteMagicTest.Hooking;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="PrologueDecoder"/> covering the accepted x86/x64 prologue
|
||||
/// shapes and rejection of opcodes outside the covered set.
|
||||
/// </summary>
|
||||
public class PrologueDecoderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false, new byte[] { 0x55 }, 1)] // push rbp
|
||||
[InlineData(false, new byte[] { 0x53 }, 1)] // push rbx
|
||||
[InlineData(false, new byte[] { 0x8B, 0xFF }, 2)] // mov edi, edi
|
||||
[InlineData(false, new byte[] { 0x8B, 0xEC }, 2)] // mov ebp, esp
|
||||
[InlineData(false, new byte[] { 0x83, 0xEC, 0x20 }, 3)] // sub esp, 0x20
|
||||
[InlineData(false, new byte[] { 0x81, 0xEC, 0x00, 0x01, 0x00, 0x00 }, 6)] // sub esp, 0x100
|
||||
[InlineData(true, new byte[] { 0x48, 0x8B, 0xEC }, 3)] // mov rbp, rsp
|
||||
[InlineData(true, new byte[] { 0x48, 0x83, 0xEC, 0x28 }, 4)] // sub rsp, 0x28
|
||||
[InlineData(true, new byte[] { 0x48, 0x81, 0xEC, 0x78, 0x56, 0x34, 0x12 }, 7)] // sub rsp, 0x12345678
|
||||
public void Decodes_covered_prologue_shapes(bool is64Bit, byte[] bytes, int expectedLength)
|
||||
{
|
||||
int length = PrologueDecoder.GetInstructionLength(bytes, is64Bit);
|
||||
Assert.Equal(expectedLength, length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, new byte[] { 0x83, 0x05, 0x39, 0x00, 0x00, 0x00, 0x01 })] // add [rip+0x39], 1 — wrong modrm
|
||||
[InlineData(false, new byte[] { 0x83, 0x3D, 0x00, 0x00, 0x00, 0x00, 0x01 })] // cmp [rip], 1 — wrong modrm
|
||||
[InlineData(false, new byte[] { 0x81, 0x05, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00 })] // add [rip], 1 — wrong modrm
|
||||
[InlineData(false, new byte[] { 0x0F, 0x05 })] // syscall
|
||||
[InlineData(false, new byte[] { 0x90, 0x90 })] // nop
|
||||
public void Rejects_unsafe_or_uncovered_shapes(bool is64Bit, byte[] bytes)
|
||||
{
|
||||
int length = PrologueDecoder.GetInstructionLength(bytes, is64Bit);
|
||||
Assert.Equal(-1, length);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => PrologueDecoder.GetWholeInstructionLength(bytes, requiredBytes: 2, is64Bit));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user