a3467f6af9d44a1c6de51202b35524f31cc27a8e
Pass 10 audit of the savestate path against malformed input under
AddressSanitizer + UndefinedBehaviorSanitizer. retro_unserialize is
attacker-controlled in any frontend that loads .state files from disk,
so any out-of-bounds read/write or DoS in the parser is a real attack
surface.
Real OOB-read crash caught by ASAN savestate fuzzing
====================================================
Random-byte mutation of a valid savestate hits a SEGV in
RDoTriangleNoisePCMLQ at sound.c. The LQ Tri/Noise/PCM mixer indexes
wlookup2[scaled_tcout + noiseout + scaled_dmc]
with all three operands derived from state fields that are loaded
straight from the savestate (lq_tcout as raw uint32_t,
EnvUnits[2].decvolume as uint8_t, RawDALatch as uint8_t). The static
table is only 203 entries; under normal play the channel-write paths
keep the operands inside [0, 202] (lq_tcout ≤ 45, noiseout ≤ 30,
RawDALatch ≤ 127), but a malformed state can drive the index to any
value, producing a heap-buffer-overflow read in the audio inner loop.
Same class of bug at four more wlookup2 sites in the same function,
plus the HQ wlookup2[(b >> 16) & 255] in WriteHi (mask is 8 bits, but
the table is only 203 entries so the upper 53 indices are OOB), and
wlookup1[b >> 24] (mask 8 bits, table only 32 entries).
A separate but related issue at the LQ Squares mixer: wlookup1 is
indexed by ttable[0][RectDutyCount[0]] + ttable[1][RectDutyCount[1]],
where RectDutyCount[] is loaded from savestate as int32_t and may be
> 7. ttable is int[2][8], so the indirection itself reads adjacent
memory before feeding it into wlookup1.
Crashes also caught by targeted-input testing on master: an all-zero
state, an all-0xFF state, or any state with a corrupted magic header
all crash because the chunk loop enters with totalsize parsed as
INT_MIN and never exits cleanly.
Changes
=======
src/state.c:
* ReadStateChunk: 4-byte 'toa' read now requires exactly 4 bytes
returned (was <= 0, allowing 1- to 3-byte short reads to leave
toa[] partially uninitialised before strncmp consumed it). Same
for the per-chunk payload memstream_read into tmp->v. A failed
memstream_seek on an unknown chunk is now a parse error - the
loop previously kept spinning at the same offset on out-of-range
seeks because the return was ignored.
* ReadStateChunks: chunk size + 5 housekeeping is now overflow-
checked before being subtracted from totalsize. The previous
'totalsize -= size + 5' computed size + 5 as uint32_t, so a
chunk size of e.g. 0xFFFFFFFB wrapped to 0 and the outer loop
failed to make progress.
* FCEUSS_Load_Mem: zero-initialise the 16-byte stack header so
short reads see deterministic zeros. memstream_read return now
checked. NULL-check memstream_open. Read totalsize as uint32_t
and reject any value > INT32_MAX rather than silently casting
to a negative signed int that makes the chunk loop never enter.
src/sound.c:
* New helper wl2(idx) clamps the index to wlookup2[]'s size before
reading. Used at all five LQ Tri/Noise/PCM call sites and the HQ
WriteHi pass.
* HQ wlookup1[b >> 24] in WriteHi now masks index by & 31 to match
the 32-entry table.
* LQ Squares mixer: RectDutyCount[x] masked & 7 at every
ttable[x][RectDutyCount[x]] call site. The inner-loop increments
already mask the value but the first call after savestate load
did not.
Verification
============
Random-byte fuzz: 50,000 iterations of a baseline savestate with 1-32
random byte mutations each, run through retro_unserialize +
retro_run. Master crashes within ~50 iterations (sound.c SEGV in
RDoTriangleNoisePCMLQ); pass 10 completes 50,000 / 50,000 iterations
without any ASAN-reported issue.
Targeted edge cases (wrong magic, totalsize overflow, chunk-size
+ 5 wrap, all-zero state, all-0xFF state): every input that
crashed master now rejects cleanly on pass 10.
Bit-exact regression: master vs pass 10 on 9 valid test ROMs (silent,
idle, active, 5-channel max-volume stress, plus the per-APU-channel
ROMs from the earlier APU pass) at 1500 audio frames each - byte-
identical audio output. Video output identical at 60 frames each.
Cross-version compatibility: states saved on master load on pass 10
and vice versa; no format change.
Pre-existing issue noted but not fixed in this pass: retro_unserialize
unconditionally returns true if the size matches, regardless of
whether FCEUSS_Load_Mem actually accepted the state. The frontend has
no way to know a malformed savestate was rejected. Worth a follow-up
to plumb a return code through FCEUSS_Load_Mem to retro_unserialize.
FCE Ultra mappers modified
FCEU "mappers modified" is an unofficial build of FCEU Ultra by CaH4e3, which supports a lot of new mappers including some obscure mappers such as one for unlicensed NES ROM's.
Sequential targets Light Guns support added
Support for Sequential targets Light Guns has been added. "Gun Aux A" serves as light sensor logic input.
Description
Languages
C
97.7%
Python
2%
C++
0.2%