diff --git a/WhiteMagic/InProcessReader.cs b/WhiteMagic/InProcessReader.cs
index 518275d..2e2f947 100644
--- a/WhiteMagic/InProcessReader.cs
+++ b/WhiteMagic/InProcessReader.cs
@@ -6,8 +6,11 @@ namespace WhiteMagic;
///
/// In-process memory reader that accesses the owning process's memory through
-/// direct pointer dereference (unsafe). Use this reader from within a
-/// managed DLL injected into the target process.
+/// and
+/// on a handle to the current
+/// process. Unlike the unsafe-deref approach, this fails softly (returns
+/// empty / zero bytes) on invalid or protected addresses instead of crashing
+/// the host process with an .
///
public sealed class InProcessReader : MemoryBase
{
@@ -25,6 +28,12 @@ public sealed class InProcessReader : MemoryBase
ProcessAccess.VmRead | ProcessAccess.VmWrite | ProcessAccess.VmOperation | ProcessAccess.QueryInformation,
false,
current.Id);
+ if (_handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ throw new InvalidOperationException(
+ $"OpenProcess failed for PID {current.Id}: error {error}");
+ }
_imageBase = current.MainModule?.BaseAddress ?? IntPtr.Zero;
}
@@ -36,30 +45,37 @@ public sealed class InProcessReader : MemoryBase
public override SafeMemoryHandle Handle => _handle;
///
- public override unsafe byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
+ public override byte[] ReadBytes(IntPtr address, int count, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
byte[] buffer = new byte[count];
- fixed (byte* ptr = buffer)
+ if (!NativeMethods.ReadProcessMemory(_handle, address, buffer, count, out nint bytesRead))
{
- Buffer.MemoryCopy((void*)address, ptr, count, count);
+ return [];
}
+
+ if ((int)bytesRead != count)
+ {
+ Array.Resize(ref buffer, (int)bytesRead);
+ }
+
return buffer;
}
///
- public override unsafe int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false)
+ public override int WriteBytes(IntPtr address, ReadOnlySpan bytes, bool isRelative = false)
{
if (isRelative)
address = GetAbsolute(address);
- fixed (byte* ptr = bytes)
+ if (!NativeMethods.WriteProcessMemory(_handle, address, bytes, bytes.Length, out nint written))
{
- Buffer.MemoryCopy(ptr, (void*)address, bytes.Length, bytes.Length);
+ return 0;
}
- return bytes.Length;
+
+ return (int)written;
}
///
diff --git a/WhiteMagic/MemoryBase.cs b/WhiteMagic/MemoryBase.cs
index 931e8da..88f59d6 100644
--- a/WhiteMagic/MemoryBase.cs
+++ b/WhiteMagic/MemoryBase.cs
@@ -30,6 +30,8 @@ public abstract class MemoryBase : IDisposable
// ── Typed IO ───────────────────────────────────────────────────────────
/// Reads a value of type from the target address.
+ /// The value, or default(T) when the read fails or returns fewer bytes than
+ /// .
public T Read(IntPtr address, bool isRelative = false) where T : struct
{
if (isRelative)
@@ -38,10 +40,11 @@ public abstract class MemoryBase : IDisposable
int size = MarshalCache.Size;
byte[] raw = ReadBytes(address, size);
+ if (raw.Length < size)
+ return default;
+
if (MarshalCache.TypeRequiresMarshal)
- {
return MarshalByteArrayToStructure(raw);
- }
return MemoryMarshal.Read(raw.AsSpan());
}
@@ -57,9 +60,7 @@ public abstract class MemoryBase : IDisposable
byte[] raw;
if (MarshalCache.TypeRequiresMarshal)
- {
raw = StructureToByteArray(value, size);
- }
else
{
raw = new byte[size];
@@ -71,6 +72,8 @@ public abstract class MemoryBase : IDisposable
}
/// Reads an array of values of type from the target address.
+ /// An array of at most elements. May be shorter when the read
+ /// returns fewer bytes than expected.
public T[] Read(IntPtr address, int count, bool isRelative = false) where T : struct
{
if (isRelative)
@@ -79,24 +82,32 @@ public abstract class MemoryBase : IDisposable
int elementSize = MarshalCache.Size;
int totalSize = elementSize * count;
byte[] raw = ReadBytes(address, totalSize);
+ int actualCount = Math.Min(count, raw.Length / elementSize);
- var result = new T[count];
+ var result = new T[actualCount];
+
+ if (actualCount == 0)
+ return result;
if (MarshalCache.TypeRequiresMarshal)
{
- for (int i = 0; i < count; i++)
+ GCHandle pin = GCHandle.Alloc(raw, GCHandleType.Pinned);
+ try
{
- var elementBytes = new ReadOnlySpan(raw, i * elementSize, elementSize);
- result[i] = MarshalByteArrayToStructure(elementBytes.ToArray());
+ IntPtr basePtr = pin.AddrOfPinnedObject();
+ for (int i = 0; i < actualCount; i++)
+ result[i] = Marshal.PtrToStructure(basePtr + (i * elementSize));
+ }
+ finally
+ {
+ pin.Free();
}
}
else
{
ReadOnlySpan span = raw;
- for (int i = 0; i < count; i++)
- {
+ for (int i = 0; i < actualCount; i++)
result[i] = MemoryMarshal.Read(span.Slice(i * elementSize, elementSize));
- }
}
return result;
@@ -121,13 +132,9 @@ public abstract class MemoryBase : IDisposable
{
Span slice = span.Slice(i * elementSize, elementSize);
if (MarshalCache.TypeRequiresMarshal)
- {
StructureToByteArray(values[i], slice, elementSize);
- }
else
- {
MemoryMarshal.Write(slice, in values[i]);
- }
}
int written = WriteBytes(address, raw, false);
@@ -136,17 +143,61 @@ public abstract class MemoryBase : IDisposable
// ── String IO ──────────────────────────────────────────────────────────
- /// Reads a null-terminated string from the target address.
+ /// Reads a null-terminated string from the target address by scanning in small
+ /// chunks. Stops at the null terminator, the maximum length, or the first page boundary
+ /// that fails to read (avoids an atomic failure when a 512-byte window crosses an unmapped
+ /// region).
+ /// The address to read from.
+ /// The text encoding.
+ /// The maximum number of bytes to read.
+ /// If , is relative
+ /// to .
public virtual string ReadString(IntPtr address, Encoding encoding, int maxLength = 512, bool relative = false)
{
- byte[] buffer = ReadBytes(address, maxLength, relative);
- string decoded = encoding.GetString(buffer);
- int nullIndex = decoded.IndexOf('\0');
- if (nullIndex >= 0)
+ if (relative)
+ address = GetAbsolute(address);
+
+ // The encoded null terminator. For ASCII/UTF-8 this is a single 0x00 byte;
+ // for UTF-16 it is two zero bytes (0x00 0x00); for UTF-32 it is four.
+ byte[] nullTerminator = encoding.GetBytes("\0");
+
+ const int chunkSize = 64;
+ int remaining = maxLength;
+ var accumulated = new System.Collections.Generic.List();
+
+ while (remaining > 0)
{
- return decoded[..nullIndex];
+ int take = Math.Min(chunkSize, remaining);
+ byte[] chunk = ReadBytes(address, take);
+ if (chunk.Length == 0)
+ break;
+
+ int nullPos = IndexOfPattern(chunk, nullTerminator);
+ if (nullPos >= 0)
+ {
+ if (nullPos > 0)
+ accumulated.Add(chunk[..nullPos]);
+ break;
+ }
+
+ accumulated.Add(chunk);
+ address += take;
+ remaining -= take;
}
- return decoded;
+
+ int totalLength = 0;
+ foreach (byte[] part in accumulated)
+ totalLength += part.Length;
+
+ byte[] combined = new byte[totalLength];
+ int offset = 0;
+ foreach (byte[] part in accumulated)
+ {
+ part.CopyTo(combined, offset);
+ offset += part.Length;
+ }
+
+ return encoding.GetString(combined);
}
/// Writes a null-terminated string to the target address.
@@ -168,10 +219,11 @@ public abstract class MemoryBase : IDisposable
return ImageBase + (nint)relative;
}
- /// Converts an absolute address to a relative offset from .
+ /// Converts an absolute address to a relative offset from .
+ /// This is the inverse of : GetAbsolute(GetRelative(a)) == a.
public IntPtr GetRelative(IntPtr absolute)
{
- return (IntPtr)((nint)ImageBase - (nint)absolute);
+ return (IntPtr)((nint)absolute - (nint)ImageBase);
}
// ── Lifecycle ──────────────────────────────────────────────────────────
@@ -214,16 +266,27 @@ public abstract class MemoryBase : IDisposable
private static void StructureToByteArray(T value, Span destination, int size) where T : struct
{
- byte[] temp = destination.ToArray();
- GCHandle pin = GCHandle.Alloc(temp, GCHandleType.Pinned);
- try
+ byte[] bytes = StructureToByteArray(value, size);
+ bytes.CopyTo(destination);
+ }
+
+ private static int IndexOfPattern(byte[] data, byte[] pattern)
+ {
+ int lastStart = data.Length - pattern.Length;
+ for (int i = 0; i <= lastStart; i++)
{
- Marshal.StructureToPtr(value, pin.AddrOfPinnedObject(), false);
- temp.CopyTo(destination);
- }
- finally
- {
- pin.Free();
+ bool match = true;
+ for (int j = 0; j < pattern.Length; j++)
+ {
+ if (data[i + j] != pattern[j])
+ {
+ match = false;
+ break;
+ }
+ }
+ if (match)
+ return i;
}
+ return -1;
}
}
diff --git a/WhiteMagicTest/AddressingTests.cs b/WhiteMagicTest/AddressingTests.cs
index 3245817..77ea5c2 100644
--- a/WhiteMagicTest/AddressingTests.cs
+++ b/WhiteMagicTest/AddressingTests.cs
@@ -7,6 +7,8 @@ namespace WhiteMagicTest;
///
/// Tests for relative/absolute addressing in .
+/// GetAbsolute(relative) = ImageBase + relative.
+/// GetRelative(absolute) = absolute - ImageBase (inverse of GetAbsolute).
///
public class AddressingTests
{
@@ -27,31 +29,45 @@ public class AddressingTests
}
[Fact]
- public void GetRelative_computes_offset_from_image_base()
+ public void GetRelative_returns_absolute_minus_image_base()
{
using var reader = OpenSelf();
IntPtr imageBase = reader.ImageBase;
IntPtr absolute = imageBase + 0x2000;
IntPtr relative = reader.GetRelative(absolute);
- // GetRelative returns ImageBase - absolute (GreyMagic convention)
- Assert.Equal((IntPtr)((int)imageBase - (int)absolute), relative);
+ Assert.Equal((IntPtr)((nint)absolute - (nint)imageBase), relative);
}
[Fact]
- public void GetAbsolute_after_GetRelative_at_image_base_returns_to_base()
+ public void GetAbsolute_and_GetRelative_are_inverses()
{
using var reader = OpenSelf();
- IntPtr atBase = reader.ImageBase;
- IntPtr relative = reader.GetRelative(atBase);
- IntPtr back = reader.GetAbsolute(relative);
- Assert.Equal(atBase, back);
+ IntPtr offset = (IntPtr)0x3000;
+
+ // Round-trip: offset -> absolute -> back to offset
+ IntPtr absolute = reader.GetAbsolute(offset);
+ IntPtr back = reader.GetRelative(absolute);
+ Assert.Equal(offset, back);
+
+ // Reverse round-trip: absolute -> offset -> back to absolute
+ IntPtr relative = reader.GetRelative(absolute);
+ IntPtr absoluteAgain = reader.GetAbsolute(relative);
+ Assert.Equal(absolute, absoluteAgain);
+ }
+
+ [Fact]
+ public void GetRelative_on_ImageBase_returns_zero()
+ {
+ using var reader = OpenSelf();
+ IntPtr relative = reader.GetRelative(reader.ImageBase);
+ Assert.Equal(IntPtr.Zero, relative);
}
[Fact]
public void Read_with_isRelative_true_uses_image_base()
{
using var reader = OpenSelf();
- // Read the first byte at ImageBase (should be MZ header: 0x4D = 'M')
+ // DOS header 'MZ' at the image base
byte firstByte = reader.Read(IntPtr.Zero, isRelative: true);
Assert.Equal(0x4D, firstByte);
}
@@ -65,10 +81,9 @@ public class AddressingTests
try
{
IntPtr absolute = pin.AddrOfPinnedObject();
- nint relative = (nint)absolute - (nint)reader.ImageBase;
- IntPtr relativePtr = (IntPtr)relative;
+ IntPtr relative = reader.GetRelative(absolute);
- Assert.True(reader.Write(relativePtr, 42, isRelative: true));
+ Assert.True(reader.Write(relative, 42, isRelative: true));
Assert.Equal(42, reader.Read(absolute));
}
finally
@@ -81,7 +96,6 @@ public class AddressingTests
public void ReadBytes_with_isRelative_true_resolves_correctly()
{
using var reader = OpenSelf();
- // DOS header 'MZ' at the image base
byte[] data = reader.ReadBytes(IntPtr.Zero, 2, isRelative: true);
Assert.Equal(0x4D, data[0]);
Assert.Equal(0x5A, data[1]);
diff --git a/WhiteMagicTest/MemoryBaseTests.cs b/WhiteMagicTest/MemoryBaseTests.cs
index 223ba5a..afc177f 100644
--- a/WhiteMagicTest/MemoryBaseTests.cs
+++ b/WhiteMagicTest/MemoryBaseTests.cs
@@ -1,8 +1,8 @@
-using WhiteMagic.Native;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
using WhiteMagic;
+using WhiteMagic.Native;
namespace WhiteMagicTest;
@@ -31,7 +31,6 @@ public class MemoryBaseTests
{
using var reader = OpenSelf();
- // Pin a local int to use as our "remote" address
int slot = 0;
GCHandle pin = GCHandle.Alloc(slot, GCHandleType.Pinned);
try
@@ -91,10 +90,7 @@ public class MemoryBaseTests
try
{
IntPtr addr = pin.AddrOfPinnedObject();
-
- // Write a new value
Assert.True(reader.Write(addr, new TestStruct { X = 100, Y = 200 }));
-
var result = reader.Read(addr);
Assert.Equal(100, result.X);
Assert.Equal(200, result.Y);
@@ -140,7 +136,6 @@ public class MemoryBaseTests
int[] expected = [10, 20, 30, 40];
Assert.True(reader.Write(addr, expected));
-
int[] actual = reader.Read(addr, 4);
Assert.Equal(expected, actual);
}
@@ -168,7 +163,6 @@ public class MemoryBaseTests
};
Assert.True(reader.Write(addr, expected));
-
var actual = reader.Read(addr, 4);
Assert.Equal(expected, actual);
}
@@ -199,7 +193,39 @@ public class MemoryBaseTests
{
var reader = OpenSelf();
reader.Dispose();
- reader.Dispose(); // Should not throw
+ reader.Dispose();
+ }
+
+ // ── Graceful failure on invalid addresses ───────────────────────────────
+
+ [Fact]
+ public void Read_int_on_invalid_address_returns_default()
+ {
+ using var reader = OpenSelf();
+ Assert.Equal(0, reader.Read(IntPtr.Zero));
+ }
+
+ [Fact]
+ public void Read_struct_on_invalid_address_returns_default()
+ {
+ using var reader = OpenSelf();
+ var result = reader.Read(IntPtr.Zero);
+ Assert.Equal(0, result.X);
+ Assert.Equal(0, result.Y);
+ }
+
+ [Fact]
+ public void Read_int_array_on_invalid_address_returns_empty()
+ {
+ using var reader = OpenSelf();
+ Assert.Empty(reader.Read(IntPtr.Zero, 10));
+ }
+
+ [Fact]
+ public void Read_bytes_on_invalid_address_returns_empty()
+ {
+ using var reader = OpenSelf();
+ Assert.Empty(reader.ReadBytes(IntPtr.Zero, 10));
}
}