From bc51cc2c4dd1bfcb8923f1f0db09442fff918732 Mon Sep 17 00:00:00 2001 From: StealUrKill <35749471+StealUrKill@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:27:17 -0500 Subject: [PATCH] Prevent invalid SaveData writes from damaging guest memory (#444) Add an optional write monitor so the team can find future memory damage on each supported desktop system. --- docs/guest-write-watch.md | 57 +++++ .../Memory/PhysicalVirtualMemory.cs | 11 + src/SharpEmu.Core/Memory/VirtualMemory.cs | 9 +- src/SharpEmu.HLE/GuestWriteWatch.cs | 203 ++++++++++++++++++ .../Kernel/KernelMemoryCompatExports.cs | 1 + src/SharpEmu.Libs/SaveData/SaveDataExports.cs | 37 ++-- .../HLE/GuestWriteWatchTests.cs | 77 +++++++ .../SaveData/SaveDataExportsTests.cs | 63 ++++++ 8 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 docs/guest-write-watch.md create mode 100644 src/SharpEmu.HLE/GuestWriteWatch.cs create mode 100644 tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs diff --git a/docs/guest-write-watch.md b/docs/guest-write-watch.md new file mode 100644 index 00000000..5ae326f3 --- /dev/null +++ b/docs/guest-write-watch.md @@ -0,0 +1,57 @@ + + +# Guest write watch + +`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed +code and HLE code that damage guest memory. The tool starts only if you set one +or more `SHARPEMU_WATCH_*` environment variables. + +The tool monitors writes through the SharpEmu managed virtual-memory APIs. It +does not monitor stores that native guest code makes directly. Use a platform +debugger or a hardware watchpoint to monitor these stores. + +## Watch modes + +- `SHARPEMU_WATCH_WRITE=0x
` logs a write that overlaps the eight-byte + block at the specified guest address. +- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It + monitors the first 64 direct mappings that have a size of 64 KiB and + protection value `0xF2`. +- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits + are `1`. The upper 32 bits must look like a small guest-pointer prefix. +- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest + memory range. The tool logs a maximum of 128 entries for each process. +- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It + finds damaged pointer patterns and byte-shifted pointer patterns. The tool + logs a maximum of 64 entries for each process. +- `SHARPEMU_WATCH_BULK_DEST_HI=0x` scans only writes that have the + specified upper 32 bits in the destination address. + +For each match, the tool logs the destination address, the data pattern, and the +managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning +tag. + +Use these variables together to scan bulk writes in the +`0x00000080xxxxxxxx` region. + +macOS and Linux: + +```sh +SHARPEMU_WATCH_BULK_TORN=1 \ +SHARPEMU_WATCH_BULK_DEST_HI=0x80 \ +SharpEmu /path/to/eboot.bin +``` + +Windows PowerShell: + +```powershell +$env:SHARPEMU_WATCH_BULK_TORN = "1" +$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80" +& .\SharpEmu.exe C:\path\to\game\eboot.bin +``` + +To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE` +address from a crash dump. diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs index 2eb2f84b..54f39e45 100644 --- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs +++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs @@ -919,6 +919,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length); } + NotifyGuestWriteWatch(virtualAddress, source); return true; } } @@ -944,6 +945,14 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA } } + private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan source) + { + if (GuestWriteWatch.Armed) + { + GuestWriteWatch.Check(virtualAddress, source); + } + } + private bool TryReadExclusive(ulong virtualAddress, Span destination) { var region = FindRegion(virtualAddress, (ulong)destination.Length); @@ -1016,6 +1025,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length); } + NotifyGuestWriteWatch(virtualAddress, source); return true; } @@ -1040,6 +1050,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA } } + NotifyGuestWriteWatch(virtualAddress, source); return true; } diff --git a/src/SharpEmu.Core/Memory/VirtualMemory.cs b/src/SharpEmu.Core/Memory/VirtualMemory.cs index cb2ff4e0..bd4f2880 100644 --- a/src/SharpEmu.Core/Memory/VirtualMemory.cs +++ b/src/SharpEmu.Core/Memory/VirtualMemory.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.Core.Loader; +using SharpEmu.HLE; namespace SharpEmu.Core.Memory; @@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory } CopyToRegions(virtualAddress, source, regionIndex); - return true; } + + if (GuestWriteWatch.Armed) + { + GuestWriteWatch.Check(virtualAddress, source); + } + + return true; } private bool TryValidateRange( diff --git a/src/SharpEmu.HLE/GuestWriteWatch.cs b/src/SharpEmu.HLE/GuestWriteWatch.cs new file mode 100644 index 00000000..082067d9 --- /dev/null +++ b/src/SharpEmu.HLE/GuestWriteWatch.cs @@ -0,0 +1,203 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers.Binary; +using System.Globalization; +using System.Threading; + +namespace SharpEmu.HLE; + +// This tool monitors guest-memory writes only when a watch mode is active. +public static class GuestWriteWatch +{ + private const ulong WatchBytes = 8; + private const int MaxBulkReports = 64; + + private static readonly ulong WatchBase = Parse( + Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE")); + + private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER"); + + private static readonly ulong[] PoolSlots = new ulong[64]; + private static int _poolSlotCount; + + private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN"); + + private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1"); + + private const ulong DirectBandLow = 0x100_0000_0000; + private const ulong DirectBandHigh = 0x1000_0000_0000; + private static int _value1Reports; + + private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN"); + + private static readonly ulong BulkDestHigh = Parse( + Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI")); + private static int _bulkTornReports; + private static int _bulkShiftReports; + + public static bool Armed => + WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn; + + public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection) + { + if (!WatchPoolHeaders || !IsPoolMapping(length, protection)) + { + return; + } + + var index = Interlocked.Increment(ref _poolSlotCount) - 1; + if (index < PoolSlots.Length) + { + Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40); + Console.Error.WriteLine( + $"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}"); + } + } + + public static void Check(ulong address, ReadOnlySpan data) + { + if (WatchBulkTorn && + data.Length >= 8 && + (BulkDestHigh != 0 + ? (address >> 32) == BulkDestHigh + : address >= DirectBandLow && address < DirectBandHigh)) + { + for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8) + { + var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8)); + var kind = ClassifyBulkValue(qword); + if (kind is not null && ReserveBulkReport(kind)) + { + Console.Error.WriteLine( + $"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " + + $"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " + + $"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}"); + Console.Error.Flush(); + return; + } + } + } + + if (WatchValue1 && + address >= DirectBandLow && address < DirectBandHigh && + data.Length is >= 1 and <= 8 && + LittleEndianValue(data) == 1 && + Interlocked.Increment(ref _value1Reports) <= 128) + { + Report(address, data); + return; + } + + if (WatchValuePattern && data.Length == 8) + { + var value = BinaryPrimitives.ReadUInt64LittleEndian(data); + if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF) + { + Report(address, data); + return; + } + } + + if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase)) + { + Report(address, data); + return; + } + + var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length); + for (var i = 0; i < slots; i++) + { + var slot = Volatile.Read(ref PoolSlots[i]); + if (slot != 0 && Overlaps(address, data.Length, slot)) + { + Report(address, data); + return; + } + } + } + + internal static string? ClassifyBulkValue(ulong qword) + { + var low32 = qword & 0xFFFFFFFF; + var high32 = qword >> 32; + if (low32 == 1 && high32 is > 0 and <= 0xFFFF) + { + return "torn"; + } + + var prefix = low32 & 0xFF00_0000; + var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000; + return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0 + ? "shift" + : null; + } + + internal static int FirstAlignedOffset(ulong address) => + (int)((8 - (address & 7)) & 7); + + internal static bool IsPoolMapping(ulong length, int protection) => + length == 0x10000 && protection == 0xF2; + + internal static bool Overlaps(ulong address, int length, ulong slot) + { + if (length <= 0) + { + return false; + } + + var writeLength = (ulong)length - 1; + var writeEnd = address > ulong.MaxValue - writeLength + ? ulong.MaxValue + : address + writeLength; + var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1) + ? ulong.MaxValue + : slot + WatchBytes - 1; + return address <= slotEnd && slot <= writeEnd; + } + + private static ulong LittleEndianValue(ReadOnlySpan data) + { + ulong value = 0; + for (var i = 0; i < data.Length; i++) + { + value |= (ulong)data[i] << (i * 8); + } + + return value; + } + + private static void Report(ulong address, ReadOnlySpan data) + { + Console.Error.WriteLine( + $"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " + + $"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}"); + Console.Error.Flush(); + } + + private static bool IsEnabled(string name) => + string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal); + + private static bool ReserveBulkReport(string kind) => + kind == "torn" + ? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports + : Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports; + + internal static ulong Parse(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return 0; + } + + text = text.Trim(); + if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + text = text[2..]; + } + + return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value) + ? value + : 0; + } +} diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index e9b179ea..467ae4e4 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -2984,6 +2984,7 @@ public static partial class KernelMemoryCompatExports return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; } + GuestWriteWatch.OnDirectMapping(mappedAddress, length, protection); return (int)OrbisGen2Result.ORBIS_GEN2_OK; } diff --git a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs index 41ef98a7..0496a36e 100644 --- a/src/SharpEmu.Libs/SaveData/SaveDataExports.cs +++ b/src/SharpEmu.Libs/SaveData/SaveDataExports.cs @@ -792,28 +792,15 @@ public static class SaveDataExports var id = (uint)Interlocked.Increment(ref _nextTransactionResource); - // The resource-out pointer's argument slot varies by SDK revision: some - // callers pass it in rdx, others in rcx (a 4-arg form where rdx holds a - // count/flag). Void Terrarium passes rdx=0x1 (not a pointer) and the - // real out-pointer in rcx. Probe the plausible candidates and write the - // handle to the first writable one instead of faulting on a bad rdx. - // This is a stub-level create (matches shadPS4's return-OK semantics); - // never return MEMORY_FAULT for it, or the guest treats savedata init as - // failed and never advances. + // A small RDX value is a flag, and RCX contains the output address. + // A larger RDX value is the output address for the older ABI. var resourceAddress = 0UL; - foreach (var candidate in new[] - { - ctx[CpuRegister.Rdx], - ctx[CpuRegister.Rcx], - ctx[CpuRegister.R8], - ctx[CpuRegister.R9], - }) + var selectedAddress = SelectTransactionResourceAddress( + ctx[CpuRegister.Rdx], + ctx[CpuRegister.Rcx]); + if (selectedAddress != 0 && TryWriteUInt32(ctx, selectedAddress, id)) { - if (candidate != 0 && TryWriteUInt32(ctx, candidate, id)) - { - resourceAddress = candidate; - break; - } + resourceAddress = selectedAddress; } TraceSaveData( @@ -822,6 +809,16 @@ public static class SaveDataExports return SetReturn(ctx, 0); } + internal static ulong SelectTransactionResourceAddress(ulong rdx, ulong rcx) + { + if (rdx == 0) + { + return 0; + } + + return rdx <= ushort.MaxValue ? rcx : rdx; + } + [SysAbiExport( Nid = "lJUQuaKqoKY", ExportName = "sceSaveDataDeleteTransactionResource", diff --git a/tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs b/tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs new file mode 100644 index 00000000..446e27d2 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/HLE/GuestWriteWatchTests.cs @@ -0,0 +1,77 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE; +using Xunit; + +namespace SharpEmu.Libs.Tests.HLE; + +public sealed class GuestWriteWatchTests +{ + [Theory] + [InlineData(0x0000001000000001, "torn")] + [InlineData(0x0000FFFF00000001, "torn")] + [InlineData(0x0000000008000000, "shift")] + [InlineData(0x0000000080015F00, "shift")] + [InlineData(0x0000000007FFFFFF, null)] + [InlineData(0x0000000009000000, null)] + [InlineData(0x000000003F800000, null)] + [InlineData(0x0000000080015F01, null)] + [InlineData(0x0001000000000001, null)] + public void ClassifyBulkValue_RecognizesCorruptionSignatures(ulong value, string? expected) + { + Assert.Equal(expected, GuestWriteWatch.ClassifyBulkValue(value)); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 7)] + [InlineData(3, 5)] + [InlineData(7, 1)] + [InlineData(8, 0)] + public void FirstAlignedOffset_ReturnsTheNextEightByteBoundary(ulong address, int expected) + { + Assert.Equal(expected, GuestWriteWatch.FirstAlignedOffset(address)); + } + + [Theory] + [InlineData(0x1000, 8, 0x1000, true)] + [InlineData(0x0FFF, 1, 0x1000, false)] + [InlineData(0x0FFF, 2, 0x1000, true)] + [InlineData(0x1008, 1, 0x1000, false)] + [InlineData(ulong.MaxValue - 3, 4, ulong.MaxValue - 1, true)] + [InlineData(ulong.MaxValue, 1, ulong.MaxValue, true)] + [InlineData(0x1000, 0, 0x1000, false)] + public void Overlaps_HandlesBoundariesWithoutOverflow( + ulong address, + int length, + ulong slot, + bool expected) + { + Assert.Equal(expected, GuestWriteWatch.Overlaps(address, length, slot)); + } + + [Theory] + [InlineData(0x10000, 0xF2, true)] + [InlineData(0x10001, 0xF2, false)] + [InlineData(0x10000, 0xF1, false)] + public void IsPoolMapping_RequiresTheExpectedSizeAndProtection( + ulong length, + int protection, + bool expected) + { + Assert.Equal(expected, GuestWriteWatch.IsPoolMapping(length, protection)); + } + + [Theory] + [InlineData(null, 0)] + [InlineData("", 0)] + [InlineData("not-hex", 0)] + [InlineData("80", 0x80)] + [InlineData("0x80", 0x80)] + [InlineData(" 0X801DB3BBB ", 0x801DB3BBB)] + public void Parse_HandlesHexadecimalWatchValues(string? text, ulong expected) + { + Assert.Equal(expected, GuestWriteWatch.Parse(text)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs index 4c755f1d..4c655ab2 100644 --- a/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/SaveData/SaveDataExportsTests.cs @@ -31,6 +31,9 @@ public sealed class SaveDataExportsTests : IDisposable private const ulong SyncParam = Base + 0xC80; private const ulong SetupParam = Base + 0xD00; private const ulong SetupResult = Base + 0xD80; + private const ulong TransactionOut = Base + 0xE00; + private const ulong StaleR8 = Base + 0xE08; + private const ulong StaleR9 = Base + 0xE10; private const int NoEvent = unchecked((int)0x809F0008); private const int ParameterError = unchecked((int)0x809F0000); @@ -225,4 +228,64 @@ public sealed class SaveDataExportsTests : IDisposable unchecked((int)0x809F0008), SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam))); } + + [Fact] + public void CreateTransactionResource_WithoutOutPointer_DoesNotProbeStaleRegisters() + { + const uint sentinel = 0xA5A5A5A5; + Assert.True(_ctx.TryWriteUInt32(TransactionOut, sentinel)); + Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel)); + Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel)); + + var ctx = Reg(rdi: UserId, rdx: 0, rcx: TransactionOut); + ctx[CpuRegister.R8] = StaleR8; + ctx[CpuRegister.R9] = StaleR9; + + Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx)); + Assert.True(_ctx.TryReadUInt32(TransactionOut, out var rcxValue)); + Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value)); + Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value)); + Assert.Equal(sentinel, rcxValue); + Assert.Equal(sentinel, r8Value); + Assert.Equal(sentinel, r9Value); + } + + [Fact] + public void CreateTransactionResource_WithOutPointerFlag_WritesOnlyRcx() + { + const uint sentinel = 0xA5A5A5A5; + Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0)); + Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel)); + Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel)); + + var ctx = Reg(rdi: UserId, rdx: 1, rcx: TransactionOut); + ctx[CpuRegister.R8] = StaleR8; + ctx[CpuRegister.R9] = StaleR9; + + Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx)); + Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource)); + Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value)); + Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value)); + Assert.NotEqual(0u, resource); + Assert.Equal(sentinel, r8Value); + Assert.Equal(sentinel, r9Value); + } + + [Fact] + public void CreateTransactionResource_WithLegacyOutPointer_WritesOnlyRdx() + { + const uint sentinel = 0xA5A5A5A5; + Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0)); + Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel)); + + Assert.Equal( + 0, + SaveDataExports.SaveDataCreateTransactionResource( + Reg(rdi: UserId, rdx: TransactionOut, rcx: StaleR8))); + + Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource)); + Assert.True(_ctx.TryReadUInt32(StaleR8, out var rcxValue)); + Assert.NotEqual(0u, resource); + Assert.Equal(sentinel, rcxValue); + } }