Skip to content

[pull] master from libretro:master#973

Merged
pull[bot] merged 4 commits intoAlexandre1er:masterfrom
libretro:master
Apr 28, 2026
Merged

[pull] master from libretro:master#973
pull[bot] merged 4 commits intoAlexandre1er:masterfrom
libretro:master

Conversation

@pull
Copy link
Copy Markdown

@pull pull Bot commented Apr 28, 2026

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

…t attacker-controlled inputs

bps_apply_patch (tasks/task_patch.c) implemented BPS patch
application without bounds checks on any of the per-command
reads or writes.  The action loop:

  while (bps.modify_offset < bps.modify_length - 12)
  {
     size_t _len = bps_decode(&bps);    /* attacker-chosen */
     unsigned mode = _len & 3;
     _len = (_len >> 2) + 1;

     switch (mode)
     {
        case TARGET_READ:
           while (_len--) {
              uint8_t data = bps_read(&bps);
              bps.target_data[bps.output_offset++] = data;   /* OOB write */
           }
        case SOURCE_READ:
           while (_len--) {
              uint8_t data = bps.source_data[bps.output_offset];   /* OOB read */
              bps.target_data[bps.output_offset++] = data;
           }
        case SOURCE_COPY:
           bps.source_offset += offset;       /* unbounded signed offset */
           while (_len--) {
              uint8_t data = bps.source_data[bps.source_offset++];   /* OOB read */
              bps.target_data[bps.output_offset++] = data;
           }
        case TARGET_COPY:
           bps.target_offset += offset;
           while (_len--) {
              uint8_t data = bps.target_data[bps.target_offset++];
              bps.target_data[bps.output_offset++] = data;
           }
     }
  }

A malicious .bps could:
  - Write attacker-chosen bytes far past the malloc'd
    target_data buffer (heap-buffer-overflow WRITE) via any
    of the four modes.
  - Read past source_data via SOURCE_COPY's unbounded signed
    offset.  Negative offsets underflow source_offset to a
    huge size_t; positive offsets push it past
    source_length.  The leaked bytes flow into target_data
    and the patch checksum, exposing heap contents.
  - Read past modify_data via TARGET_READ when _len exceeds
    the remaining patch bytes.  The action-loop guard
    confirms only "at least one command's worth of bytes",
    not the full read range.

The size prelude (modify_source_size / modify_target_size /
modify_markup_size, decoded by bps_decode immediately after
the "BPS1" header) was also unchecked:

  - modify_target_size declared as size_t silently truncated
    the uint64 bps_decode result on 32-bit hosts.  A value of
    0x100000000 wraps to 0; the subsequent malloc(0) returns
    a tiny allocation while bps.target_length is set to the
    raw uint64.  The next target_data[output_offset++] write
    OOB by the truncated delta.
  - modify_markup_size was used as a loop bound consuming
    bytes from modify_data via bps_read.  A value above the
    remaining patch bytes ran the read loop off the end of
    modify_data.

Reachability: user has soft-patching enabled (the default
when a .bps file with the same basename as the loaded ROM is
present) and loads a ROM whose .bps was supplied by an
attacker.  Same threat class as CDFS / CHD / BSV file-load
bugs.

Fix:

  1. Capture the three size-prelude values as raw uint64,
     reject anything above SIZE_MAX (so the size_t cast is
     guaranteed lossless), and reject markup_size that would
     drive bps_read past modify_length.

  2. At the top of every action-loop iteration, bound _len
     against (target_length - output_offset).  This single
     bound covers the target_data write in all four modes.

  3. In each mode, bound the source-side read range:
     - SOURCE_READ: output_offset + _len <= source_length
     - TARGET_READ: modify_offset + _len <= modify_length - 12
     - SOURCE_COPY: source_offset (post-offset-add) + _len
                    <= source_length
     - TARGET_COPY: target_offset (post-offset-add) + _len
                    <= target_length

Adds samples/tasks/bps_patch/bps_patch_bounds_test as a
regression test, registered in
.github/workflows/Linux-samples-tasks.yml as an ASan-enabled
step.  Five cases covering OOB-write rejection, OOB-read
rejection, legitimate operations, and the exact-capacity
boundary.

Test follows the existing samples/tasks pattern (verbatim
copy of the post-fix predicates with maintenance-contract
comment, ASan as the discriminator).  Verified to fire
under ASan when the bounds are removed: heap-buffer-overflow
WRITE of size 1 at offset 4 of a 4-byte allocation in
apply_action_loop.  With the bounds the test passes clean.
…se_info_build_query_enum

Add strlcpy_append, a bound-checked replacement for the unsafe
idiom that's widespread in this codebase:

   _len += strlcpy(s + _len, src, len - _len);

That looks like a self-bounding chain but isn't.  strlcpy
returns strlen(source) regardless of how much was actually
written, so on truncation _len overshoots len and the next
(len - _len) underflows to a huge size_t, driving the next
strlcpy into an OOB-write or memcpy of attacker-influenced
size.  This is the same primitive that drove the shader-
wildcard fix (commit bac6894).

strlcpy_append takes (s, len, *pos, src), checks the
truncation condition correctly, and on overflow:
  - leaves s NUL-terminated at len - 1 (strlcpy's contract)
  - clamps *pos to len - 1 so subsequent calls in a chain
    short-circuit (also returning -1)
  - returns -1 to the caller

The clamp is what makes chained use safe with a single check
at the end of the chain.

Apply to database_info_build_query_enum (database_info.c).
That function builds DB query strings of the shape

   {'KEY':"PATH"}      (or numeric variants without the quotes,
                        or {'developer':glob('*PATH*')})

via 22 switch cases, each of which was a hand-unrolled chain
of s[++_len] = '...' character writes followed by a path
strlcpy and another short s[++_len] = '...' chain to close
the brace.  None of those writes was bounded against len.
A caller passing a buffer smaller than the prefix length
got OOB-writes from the prefix; a caller whose buffer was
just barely enough for prefix + path got the trailing
s[_len]='"';s[++_len]='}' writes off the end (up to 3 bytes
OOB).  All callers in tree pass query[4096] from the menu
displaylist, so practical buffers are large -- but the
pattern is wrong and the path component is attacker-
influenced when scanning content metadata.

Side fix in DATABASE_QUERY_ENTRY_BBFC_RATING: pre-rewrite
the case wrote s[++_len] = '"' after the path strlcpy
instead of s[_len] = '"', leaving the strlcpy's NUL
terminator embedded in the query.  BBFC rating searches
have been silently returning nothing since this code was
written.  Fixed while rewriting.

Adds libretro-common/samples/string/strlcpy_append/
strlcpy_append_test as a regression test, registered in
.github/workflows/Linux-libretro-common-samples.yml's
RUN_TARGETS allowlist (which builds with ASan + UBSan
default since the cdfs/chd commit).  Eight cases covering
the contract: basic append, chained append, exact-fit
boundary, wide-margin truncation, chain short-circuiting
after truncation, empty source, NULL/zero-length args, and
*pos already at or past len.

Test follows the existing libretro-common/samples pattern
(heap-allocated destinations sized to exactly the legal
capacity so any reintroduction of the unbounded chain is
flagged by ASan).  Verified to fire under ASan when the
helper is replaced with the unsafe naive idiom: heap-
buffer-overflow WRITE of size 2 at offset 26 of an 8-byte
allocation in test_chain_short_circuits_after_truncation.
With the safe helper all 8 cases pass clean.

Verified database_info_build_query_enum produces identical
output for all 21 enum values at len=4096 (the production
caller's buffer size); also verified that at len=8 the
function returns -1 with no OOB write, which is the
behaviour the unsafe naive replacement would have failed at.
Apply strlcpy_append (commit 78c52ab) to seven sites flagged
in the strlcpy-misuse audit, all sharing the same shape:

   _len = strlcpy(dst, src1, sizeof(dst));
   dst[_len]   = c1;
   dst[++_len] = c2;
   ...
   strlcpy(dst + _len, src2, sizeof(dst) - _len);

None of these had a bound on _len.  When src1 was longer
than sizeof(dst) - 1, strlcpy returned strlen(src1) >=
sizeof(dst) and:
  - the dst[_len], dst[++_len], ... character writes ran
    off the end of dst (stack buffer overflow);
  - the trailing strlcpy got len - _len which underflowed
    size_t, producing an unbounded copy.

Sites:

menu/drivers/materialui.c:8090 - "Draw message box".
   dst is msg[NAME_MAX_LENGTH] which is 128 on Xbox1, 3DS,
   PSP, PS2, GameCube, Wii, WiiU, PS3, Emscripten and 256
   elsewhere.  src1 is menu_st->input_dialog_kb_label[256]
   (always 256 regardless of platform).  On the small-
   NAME_MAX_LENGTH platforms a long label drove _len up to
   ~255 and the chain wrote up to 127 bytes past the stack
   frame.  Reachable from any input dialog.

tasks/task_http.c:495 and tasks/task_http_emscripten.c:312 -
   download status string.  dst is tmp[NAME_MAX_LENGTH];
   src1 is the localized "Downloading" string.  Same
   primitive on small-NAME_MAX_LENGTH platforms when a
   translation pushed the prefix close to the cap.

menu/cbs/menu_cbs_sublabel.c:2164 - core backup CRC sublabel.
   The chain wrote 8 character bytes ("00000000") plus a NUL
   after the localized "Backup CRC: " prefix.  dst is the
   caller's s/len; if a translation pushed the prefix close
   to len, the 9 byte writes ran past the caller's buffer.

steam/steam.c:449/471/493 - rich-presence content strings.
   Three near-identical cases (CONTENT_SYSTEM, CONTENT_CORE,
   CONTENT_SYSTEM_CORE).  Each builds "LABEL (CONTENT)" or
   "LABEL (N/A)" with hand-unrolled writes of " (", "N/A",
   "/", "A", ")", "\0" at offsets _len + N.  dst is
   content[PATH_MAX_LENGTH] (512 on small platforms, 4096
   elsewhere) and the per-character write offsets reached
   _len + 12 on the SYSTEM_CORE variant; long labels OOB-
   wrote up to 12 bytes past content.

Replace each chain with a sequence of strlcpy_append calls
on a single rolling cursor, with the helper handling the
bound check at every step.  No functional change at the
buffer-fits-comfortably end; on small buffers the new code
truncates cleanly instead of stack-overflowing.

No new tests -- the strlcpy_append regression test from
commit 78c52ab covers the helper's contract; these are
just additional users.
Apply strlcpy_append (commit 78c52ab) to three further sites
identified in the wider strlcpy-misuse sweep, all with the
same shape that drove the prior commits c41e955 / 78c52ab /
e446242:

   _len  = strlcpy(buf, src1, sizeof(buf));
   _len += strlcpy(buf + _len, src2, sizeof(buf) - _len);
   ... (more steps) ...

That chain is unsafe because strlcpy returns strlen(source)
regardless of truncation; on overflow _len overshoots
sizeof(buf) and the next sizeof - _len underflows size_t,
producing an unbounded copy that writes past the buffer.

Sites:

gfx/drivers/gl2.c:4549 - GL device-info string assembly.
   gl->device_str is 128 bytes; the chain appends `vendor`,
   ' ', and `renderer` (all from glGetString).  Some drivers
   report long vendor/renderer combinations.  Not attacker-
   reachable in the usual threat model but the pattern is
   still wrong, and the `device_str[_len]=' '; device_str
   [++_len]='\0'` separator chain in the middle was the
   short-chain shape from commit e446242.

menu/menu_displaylist.c:2767 - create_string_list_rdb_entry
   _string.  Builds out_lbl (NAME_MAX_LENGTH bytes) from
   label + "|" + actual_string + "|" + path, plus tmp (128
   bytes) from desc + ": " + actual_string.  The strings
   come from RetroArch database (.rdb) entries; a malicious
   .rdb could supply long values to drive the chain
   underflow.  Modest threat model -- users typically use
   the RetroArch-shipped databases, not custom ones -- but
   the pattern is the same one we've fixed elsewhere.

command.c:1119 - GET_STATUS network reply.  Builds the
   reply for the network command interface from the paused/
   playing state, system_id or library_name, content
   basename, and CRC.  reply is 8192 bytes which fits any
   realistic content, but ROM basenames can legally be any
   length and the chain has 8 steps; a long enough basename
   drives the underflow.

Replace each chain with sequences of strlcpy_append calls
on a single rolling cursor.  Same correctness story as
e446242: helper handles the bound check at every step,
truncation is silent and contained, no behaviour change
for the buffer-fits-comfortably case.

No new tests -- the strlcpy_append regression test from
78c52ab covers the helper's contract; these are additional
users.

Triaged but not fixed in this commit (safe by construction,
flagged for the record):

   network/cloud_sync/webdav.c:401 - 16-call chain, but
   the function pre-calculates the exact required length
   via strlen() over the same inputs and mallocs that
   exact size before the chain runs.  Single-threaded.

   retroarch.c:8769 - 20-call chain for SIMD feature names
   into a 128-byte buffer.  Total max ~120 bytes; tight but
   no real CPU has all SIMD bits set so practical max is
   well under cap.

   gfx/video_shader_parse.c:1252, configuration.c:6961/7167,
   frontend/drivers/platform_emscripten.c:620 - classifier
   false positives (each _len is per-buffer reused, not
   accumulated cross-buffer).
@pull pull Bot locked and limited conversation to collaborators Apr 28, 2026
@pull pull Bot added the ⤵️ pull label Apr 28, 2026
@pull pull Bot merged commit 25ade82 into Alexandre1er:master Apr 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant