From 80b813f50747f10b118bc04186191cc9f32dd326 Mon Sep 17 00:00:00 2001 From: Acts1631 Date: Mon, 12 Oct 2026 22:15:01 -0400 Subject: [PATCH 1/3] Validate GE replay commands before execution Crafted ppdmp command ranges can extend past the decompressed push buffer. Frame buffer playback can then copy adjacent host heap data into emulated VRAM, exposing process memory through rendered output. Validate each command's type, range, and minimum payload size before constructing the replay executor. --- GPU/Debugger/Playback.cpp | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/GPU/Debugger/Playback.cpp b/GPU/Debugger/Playback.cpp index fdae560098..ebaddf7ef6 100644 --- a/GPU/Debugger/Playback.cpp +++ b/GPU/Debugger/Playback.cpp @@ -1222,6 +1222,70 @@ static bool ReadCompressed(u32 fp, void *dest, size_t sz, uint32_t version) { return real_size == sz; } +static bool ValidateReplayCommands(const std::vector &commands, const std::vector &pushbuf) { + for (size_t i = 0; i < commands.size(); ++i) { + const Command &cmd = commands[i]; + u32 minSize; + bool wordData = false; + switch (cmd.type) { + case CommandType::INIT: + minSize = 512 * sizeof(u32); + break; + case CommandType::REGISTERS: + minSize = sizeof(u32); + wordData = true; + break; + case CommandType::CLUTADDR: + minSize = 2 * sizeof(u32); + break; + case CommandType::MEMSET: + case CommandType::DISPLAY: + minSize = 3 * sizeof(u32); + break; + case CommandType::MEMCPYDEST: + case CommandType::EDRAMTRANS: + minSize = sizeof(u32); + break; + case CommandType::FRAMEBUF0: + case CommandType::FRAMEBUF1: + case CommandType::FRAMEBUF2: + case CommandType::FRAMEBUF3: + case CommandType::FRAMEBUF4: + case CommandType::FRAMEBUF5: + case CommandType::FRAMEBUF6: + case CommandType::FRAMEBUF7: + minSize = 4 * sizeof(u32); + break; + case CommandType::VERTICES: + case CommandType::INDICES: + case CommandType::CLUT: + case CommandType::TRANSFERSRC: + case CommandType::MEMCPYDATA: + case CommandType::TEXTURE0: + case CommandType::TEXTURE1: + case CommandType::TEXTURE2: + case CommandType::TEXTURE3: + case CommandType::TEXTURE4: + case CommandType::TEXTURE5: + case CommandType::TEXTURE6: + case CommandType::TEXTURE7: + minSize = 1; + break; + default: + ERROR_LOG(Log::GeDebugger, "Unsupported GE dump command: %d", (int)cmd.type); + return false; + } + + if (cmd.ptr > pushbuf.size() || cmd.sz > pushbuf.size() - cmd.ptr || + cmd.sz < minSize || (wordData && (cmd.sz & 3) != 0)) { + ERROR_LOG(Log::GeDebugger, "Invalid GE dump command %d: type %d, offset %u, size %u", + (int)i, (int)cmd.type, cmd.ptr, cmd.sz); + return false; + } + } + return true; +} + static u32 LoadReplay(const std::string &filename) { PROFILE_THIS_SCOPE("ReplayLoad"); @@ -1279,6 +1343,10 @@ static u32 LoadReplay(const std::string &filename) { ERROR_LOG(Log::GeDebugger, "Truncated GE dump detected - can't replay"); return 0; } + if (!ValidateReplayCommands(lastExecCommands, lastExecPushbuf)) { + ERROR_LOG(Log::GeDebugger, "Invalid GE dump commands detected - can't replay"); + return 0; + } lastExecFilename = filename; lastExecVersion = version; From 9c2cf98b841b962113425d420cb1154cf3979b4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 13 Oct 2026 10:01:29 +0200 Subject: [PATCH 2/3] GE dump playback: Clamp the remaining writes, bound the loader's allocations Follows up the command range validation: - Don't require anything of the data commands' sizes or alignment. Version 2 dumps have empty VERTICES and CLUT commands, and old recorders didn't align anything. Registers() drops a trailing partial word instead. - Split register blocks bigger than the list buffer, which they overran. - Clamp MEMCPYDATA and MEMSET to valid memory. Only their start was checked. - Check the compressed sizes against the file, and the decompressed sizes against what the frame declares, before allocating. Some version 5 dumps leave the size out of the zstd frame; those are checked after decompressing. - Don't let a large FRAMEBUF size wrap around in MarkNeededTargets. Co-Authored-By: Claude Opus 5.5 --- GPU/Debugger/Playback.cpp | 99 ++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 32 deletions(-) diff --git a/GPU/Debugger/Playback.cpp b/GPU/Debugger/Playback.cpp index ebaddf7ef6..7b681fc27f 100644 --- a/GPU/Debugger/Playback.cpp +++ b/GPU/Debugger/Playback.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -476,6 +477,15 @@ void DumpExecute::SyncStall() { } void DumpExecute::Registers(u32 ptr, u32 sz) { + // Whole commands only, and a block too big for the list buffer goes in pieces. + sz &= ~3; + const u32 maxChunk = LIST_BUF_SIZE / 2; + while (sz > maxChunk) { + Registers(ptr, maxChunk); + ptr += maxChunk; + sz -= maxChunk; + } + initialClut_ = false; if (execListBuf == 0) { u32 allocSize = LIST_BUF_SIZE; @@ -498,6 +508,11 @@ void DumpExecute::Registers(u32 ptr, u32 sz) { gpu->EnableInterrupts(true); } + // Only a crafted dump queues this much between two register blocks. + if (execListQueue.size() * sizeof(u32) + sz + 12 > (size_t)LIST_BUF_SIZE) { + ERROR_LOG(Log::GeDebugger, "Too many queued commands (%d), dropping them", (int)execListQueue.size()); + execListQueue.clear(); + } u32 pendingSize = (u32)execListQueue.size() * sizeof(u32); // Validate space for jump. u32 allocSize = pendingSize + sz + 8; @@ -874,14 +889,15 @@ void DumpExecute::Memset(u32 ptr, u32 sz) { const MemsetCommand *data = (const MemsetCommand *)(pushbuf_.data() + ptr); - if (Memory::IsVRAMAddress(data->dest)) { + const u32 size = Memory::ClampValidSizeAt(data->dest, data->sz); + if (Memory::IsVRAMAddress(data->dest) && size != 0) { SyncStall(); // Draws the software renderer still has queued come before the write. gpu->Flush(); // TODO: should probably do this as an operation. // The software renderer leaves the memset to the caller, like sceKernelMemset does. - if (!gpu->PerformMemorySet(data->dest, (u8)data->value, data->sz)) - SetMemory(data->dest, (u8)data->value, data->sz); + if (!gpu->PerformMemorySet(data->dest, (u8)data->value, size)) + SetMemory(data->dest, (u8)data->value, size); } } @@ -891,7 +907,8 @@ void DumpExecute::MemcpyDest(u32 ptr, u32 sz) { void DumpExecute::Memcpy(u32 ptr, u32 sz) { PROFILE_THIS_SCOPE("ReplayMemcpy"); - if (Memory::IsVRAMAddress(execMemcpyDest)) { + sz = Memory::ClampValidSizeAt(execMemcpyDest, sz); + if (Memory::IsVRAMAddress(execMemcpyDest) && sz != 0) { SyncStall(); gpu->Flush(); CopyToMemory(execMemcpyDest, pushbuf_.data() + ptr, sz); @@ -978,7 +995,7 @@ void DumpExecute::MarkNeededTargets() { const bool isTarget = (header[2] & 1) != 0; const bool unchangedVRAM = (header[2] & 2) != 0; const u32 start = header[0] & (VRAM_SIZE - 1); - const u32 end = std::min(start + (cmd.sz - 16), VRAM_SIZE); + const u32 end = start + std::min(cmd.sz - 16, VRAM_SIZE - start); for (u32 b = start >> BLOCK_SHIFT; b < (end + (1 << BLOCK_SHIFT) - 1) >> BLOCK_SHIFT; b++) { if (unchangedVRAM) { if (lastSource[b] >= 0 && lastIsTarget[b]) { @@ -1200,25 +1217,48 @@ ReplayResult DumpExecute::Run() { return ReplayResult::Done; } -static bool ReadCompressed(u32 fp, void *dest, size_t sz, uint32_t version) { +// Reads count items, checking the sizes the file declares against the file and each other before +// allocating for them. +template +static bool ReadCompressed(u32 fp, u64 fileSize, std::vector &dest, u32 count, uint32_t version) { u32 compressed_size = 0; if (pspFileSystem.ReadFile(fp, (u8 *)&compressed_size, sizeof(compressed_size)) != sizeof(compressed_size)) { return false; } - - u8 *compressed = new u8[compressed_size]; - if (pspFileSystem.ReadFile(fp, compressed, compressed_size) != compressed_size) { - delete[] compressed; + const u64 pos = pspFileSystem.GetSeekPos(fp); + if (pos > fileSize || compressed_size > fileSize - pos) { return false; } - size_t real_size = sz; - if (version < 5) - snappy_uncompress((const char *)compressed, compressed_size, (char *)dest, &real_size); - else - real_size = ZSTD_decompress(dest, real_size, compressed, compressed_size); - delete[] compressed; + std::vector compressed(compressed_size); + if (pspFileSystem.ReadFile(fp, compressed.data(), compressed_size) != compressed_size) { + return false; + } + const u64 sz = (u64)count * sizeof(T); + if (sz > SIZE_MAX) { + return false; + } else if (version < 5) { + size_t declared = 0; + if (snappy_uncompressed_length((const char *)compressed.data(), compressed_size, &declared) != SNAPPY_OK || declared != sz) { + return false; + } + } else { + // Some older recorders left the size out of the frame. + const unsigned long long declared = ZSTD_getFrameContentSize(compressed.data(), compressed_size); + if (declared != ZSTD_CONTENTSIZE_UNKNOWN && declared != sz) { + return false; + } + } + + dest.resize(count); + size_t real_size = (size_t)sz; + if (version < 5) { + if (snappy_uncompress((const char *)compressed.data(), compressed_size, (char *)dest.data(), &real_size) != SNAPPY_OK) + return false; + } else { + real_size = ZSTD_decompress(dest.data(), real_size, compressed.data(), compressed_size); + } return real_size == sz; } @@ -1226,15 +1266,10 @@ static bool ValidateReplayCommands(const std::vector &commands, const s for (size_t i = 0; i < commands.size(); ++i) { const Command &cmd = commands[i]; u32 minSize; - bool wordData = false; switch (cmd.type) { case CommandType::INIT: minSize = 512 * sizeof(u32); break; - case CommandType::REGISTERS: - minSize = sizeof(u32); - wordData = true; - break; case CommandType::CLUTADDR: minSize = 2 * sizeof(u32); break; @@ -1256,6 +1291,8 @@ static bool ValidateReplayCommands(const std::vector &commands, const s case CommandType::FRAMEBUF7: minSize = 4 * sizeof(u32); break; + // Any size, including none (version 2 dumps have empty VERTICES and CLUT commands). + case CommandType::REGISTERS: case CommandType::VERTICES: case CommandType::INDICES: case CommandType::CLUT: @@ -1269,15 +1306,15 @@ static bool ValidateReplayCommands(const std::vector &commands, const s case CommandType::TEXTURE5: case CommandType::TEXTURE6: case CommandType::TEXTURE7: - minSize = 1; + minSize = 0; break; default: ERROR_LOG(Log::GeDebugger, "Unsupported GE dump command: %d", (int)cmd.type); return false; } - if (cmd.ptr > pushbuf.size() || cmd.sz > pushbuf.size() - cmd.ptr || - cmd.sz < minSize || (wordData && (cmd.sz & 3) != 0)) { + // Old recorders didn't align anything, so only the range and size are checked. + if (cmd.ptr > pushbuf.size() || cmd.sz > pushbuf.size() - cmd.ptr || cmd.sz < minSize) { ERROR_LOG(Log::GeDebugger, "Invalid GE dump command %d: type %d, offset %u, size %u", (int)i, (int)cmd.type, cmd.ptr, cmd.sz); return false; @@ -1294,11 +1331,12 @@ static u32 LoadReplay(const std::string &filename) { g_cancelled = false; u32 fp = pspFileSystem.OpenFile(filename, FILEACCESS_READ); - Header header; - pspFileSystem.ReadFile(fp, (u8 *)&header, sizeof(header)); + const u64 fileSize = pspFileSystem.GetFileInfoByHandle(fp).size; + Header header{}; + const size_t headerRead = pspFileSystem.ReadFile(fp, (u8 *)&header, sizeof(header)); u32 version = header.version; - if (memcmp(header.magic, HEADER_MAGIC, sizeof(header.magic)) != 0 || header.version > VERSION || header.version < MIN_VERSION) { + if (headerRead != sizeof(header) || memcmp(header.magic, HEADER_MAGIC, sizeof(header.magic)) != 0 || header.version > VERSION || header.version < MIN_VERSION) { ERROR_LOG(Log::GeDebugger, "Invalid GE dump or unsupported version"); pspFileSystem.CloseFile(fp); return 0; @@ -1330,12 +1368,9 @@ static u32 LoadReplay(const std::string &filename) { u32 bufsz = 0; pspFileSystem.ReadFile(fp, (u8 *)&bufsz, sizeof(bufsz)); - lastExecCommands.resize(sz); - lastExecPushbuf.resize(bufsz); - bool truncated = false; - truncated = truncated || !ReadCompressed(fp, lastExecCommands.data(), sizeof(Command) * sz, header.version); - truncated = truncated || !ReadCompressed(fp, lastExecPushbuf.data(), bufsz, header.version); + truncated = truncated || !ReadCompressed(fp, fileSize, lastExecCommands, sz, header.version); + truncated = truncated || !ReadCompressed(fp, fileSize, lastExecPushbuf, bufsz, header.version); pspFileSystem.CloseFile(fp); From ec76a4d56e3f83f7687e7d76c413c4dfb2d917ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 13 Oct 2026 10:05:35 +0200 Subject: [PATCH 3/3] GE dump playback: Refuse sections over 1 GB A zstd frame doesn't always declare its size, and a small one can expand to gigabytes, so this is the limit that keeps a crafted dump from exhausting memory. The largest section in the 1042-dump test set is 82 MB. Co-Authored-By: Claude Opus 5.5 --- GPU/Debugger/Playback.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/GPU/Debugger/Playback.cpp b/GPU/Debugger/Playback.cpp index 7b681fc27f..71b63a77bd 100644 --- a/GPU/Debugger/Playback.cpp +++ b/GPU/Debugger/Playback.cpp @@ -17,7 +17,6 @@ #include #include -#include #include #include #include @@ -1235,8 +1234,12 @@ static bool ReadCompressed(u32 fp, u64 fileSize, std::vector &dest, u32 count return false; } + // Far beyond any real dump. A zstd frame doesn't always say how big it is, and a small one can + // expand to gigabytes, so this is what keeps a crafted one from exhausting memory. + const u64 MAX_SIZE = 1024 * 1024 * 1024; const u64 sz = (u64)count * sizeof(T); - if (sz > SIZE_MAX) { + if (sz > MAX_SIZE) { + ERROR_LOG(Log::GeDebugger, "GE dump section too large: %llu bytes", (unsigned long long)sz); return false; } else if (version < 5) { size_t declared = 0;