EE/MMI: clamp cpuRegs.sa in recQFSRV before host-pointer indexing (AX-03)

Both recQFSRV paths added the raw 32-bit cpuRegs.sa to a host base (fast
path off &GPR.r[Rt], slow path off the 32-byte temp buffer) before the
128-bit load. Our MTSA is deliberately a full 32-bit copy (3c381989e,
matching the interp), so a game doing MTSA with a value >= 16 walks the
load out of bounds — a guest-controlled host OOB read. x86 avoids this
only because recMTSA masks sa & 0xf at store time; we combined interp-
faithful MTSA with x86's raw-index QFSRV.

Fix: And sa, 0xf at consumption in both paths. MTSA/MFSA keep the full
value (round-trip pinned by test); QFSRV behaves as x86's effective
sa & 0xf for oversized values (the interp is shift->=64 UB there, so
memory safety + x86 equivalence wins over matching host-dependent
garbage).

TDD: QfsrvOversizedSaClampedOn{FastPath,TempBufferPath} red on unfixed
code (fast path read bytes of the neighboring guest GPR), green after;
in-range MTSA+QFSRV+MFSA auto-diff test guards the no-op case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Brian Degenhardt
2026-07-02 14:59:38 -07:00
co-authored by Claude Fable 5
parent a067f1c23c
commit 1718a923ed
2 changed files with 67 additions and 1 deletions
+10 -1
View File
@@ -1266,6 +1266,13 @@ void recQFSRV()
if (_Rt_ != 0 && _Rs_ == _Rt_ + 1)
{
armLoadEERegPtr(RWSCRATCH, &cpuRegs.sa);
// Clamp sa to 0..15 before indexing host memory: MTSA is a full
// 32-bit copy (matching the interp), so cpuRegs.sa can hold >= 16 —
// unmasked that walks this 128-bit load out of the two registers'
// 32 bytes (guest-controlled host OOB read). x86 gets the same
// effective clamp by masking in recMTSA; we mask at consumption so
// the MFSA round-trip stays interp-faithful. (AX-03)
armAsm->And(RWSCRATCH, RWSCRATCH, 0xf);
armMoveAddressToReg(RSCRATCHADDR, &cpuRegs.GPR.r[_Rt_]);
armAsm->Add(RSCRATCHADDR, RSCRATCHADDR, RXSCRATCH);
armAsm->Ldr(RQSCRATCH, a64::MemOperand(RSCRATCHADDR));
@@ -1282,8 +1289,10 @@ void recQFSRV()
armMoveAddressToReg(RSCRATCHADDR, &s_qfsrvTemp[16]);
armAsm->Str(RQSCRATCH, a64::MemOperand(RSCRATCHADDR));
// Load sa (byte offset)
// Load sa (byte offset), clamped to 0..15 — see the fast path above; an
// unmasked sa >= 16 reads past the 32-byte temp buffer. (AX-03)
armLoadEERegPtr(RWSCRATCH, &cpuRegs.sa);
armAsm->And(RWSCRATCH, RWSCRATCH, 0xf);
// Unaligned 128-bit load from temp + sa
armMoveAddressToReg(RSCRATCHADDR, &s_qfsrvTemp[0]);
@@ -310,6 +310,63 @@ TEST(EeRecMmi, QfsrvAdjacentRtZeroUsesTempBuffer)
h.ExpectMmiPair(reg::v0, 0x0ull, 0x1122334400000000ull);
}
// ===========================================================================
// QFSRV with an oversized sa (AX-03). MTSA is architecturally a full 32-bit
// copy (3c381989e, matching the interp), so cpuRegs.sa can legitimately hold
// >= 16 when QFSRV consumes it. recQFSRV indexes host memory with sa (fast
// path off &GPR.r[Rt], slow path off the 32-byte temp buffer), so an
// unmasked sa walks the 128-bit load out of bounds — a guest-controlled
// host OOB read. The rec must clamp sa & 0xf at consumption (equivalent to
// x86, which masks in recMTSA). The interp's own sa>=16 behavior is
// shift-by->=64 UB (host-dependent mod-64), so these are JIT-only
// assertions against the sa&0xf funnel result; MTSA itself stays a full
// copy (MFSA round-trip below runs the normal auto-diff).
// ===========================================================================
TEST(EeRecMmi, QfsrvOversizedSaClampedOnFastPath)
{
EeRecTestHarness h;
h.SetMmiPair(reg::a0, 0x1122334455667788ull, 0x99AABBCCDDEEFF00ull); // Rt
h.SetMmiPair(reg::a1, 0xAABBCCDD11223344ull, 0x5566778899AABBCCull); // Rs = Rt+1
h.SetGpr64(reg::a3, 0x13); // MTSA full copy: sa = 19; QFSRV must use 19 & 0xf = 3
h.LoadProgram({ee::MTSA(reg::a3), ee::QFSRV(reg::v0, reg::a1, reg::a0)});
h.RunJitNoDiff();
// Funnel {Rs:Rt} >> 3 bytes. Unclamped, the fast path reads
// &GPR.r[a0] + 19 — bytes of a2 (host OOB relative to the two sources).
EXPECT_EQ(h.JitSnapshot().regs.GPR.r[reg::v0].UD[0], 0xEEFF001122334455ull);
EXPECT_EQ(h.JitSnapshot().regs.GPR.r[reg::v0].UD[1], 0x22334499AABBCCDDull);
}
TEST(EeRecMmi, QfsrvOversizedSaClampedOnTempBufferPath)
{
EeRecTestHarness h;
h.SetMmiPair(reg::a0, 0x1122334455667788ull, 0x99AABBCCDDEEFF00ull); // Rt
h.SetMmiPair(reg::a2, 0xAABBCCDD11223344ull, 0x5566778899AABBCCull); // Rs != Rt+1
h.SetGpr64(reg::a3, 0x17); // sa = 23; QFSRV must use 23 & 0xf = 7
h.LoadProgram({ee::MTSA(reg::a3), ee::QFSRV(reg::v0, reg::a2, reg::a0)});
h.RunJitNoDiff();
// Funnel by 7 bytes (same values as QfsrvNonAdjacentSource). Unclamped,
// the load at temp+23 runs 7 bytes past the 32-byte buffer.
EXPECT_EQ(h.JitSnapshot().regs.GPR.r[reg::v0].UD[0], 0xAABBCCDDEEFF0011ull);
EXPECT_EQ(h.JitSnapshot().regs.GPR.r[reg::v0].UD[1], 0xBBCCDD1122334499ull);
}
TEST(EeRecMmi, QfsrvMtsaInRangeSaAutoDiffsAndMfsaRoundTrips)
{
// In-range MTSA-fed QFSRV must stay byte-identical to the interp (the
// clamp is a no-op for sa < 16), and MFSA must read back the FULL value
// MTSA stored — the clamp lives at QFSRV's consumption, never in SA.
EeRecTestHarness h;
h.SetMmiPair(reg::a0, 0x1122334455667788ull, 0x99AABBCCDDEEFF00ull); // Rt
h.SetMmiPair(reg::a1, 0xAABBCCDD11223344ull, 0x5566778899AABBCCull); // Rs
h.SetGpr64(reg::a3, 0x4);
h.LoadProgram({ee::MTSA(reg::a3), ee::QFSRV(reg::v0, reg::a1, reg::a0),
ee::MFSA(reg::v1)});
h.Run();
h.ExpectMmiPair(reg::v0, 0xDDEEFF0011223344ull, 0x1122334499AABBCCull);
h.ExpectGpr64(reg::v1, 0x4);
}
// ===========================================================================
// Parallel shift immediate — PSLLH / PSRLH / PSRAH (8 × u16) +
// PSLLW / PSRLW / PSRAW (4 × u32)