Skip to content

Commit 669468d

Browse files
committed
libretro-common: bound disc-image metadata parsers against fixed-size buffers
Two unrelated stack/heap overflows in disc-image format parsers, both reachable when the user loads a malicious .iso/.bin/.cue/.chd. Same threat class as the BSV file-load bugs. cdfs_find_file (libretro-common/formats/cdfs/cdfs.c) walks ECMA-119 directory records read into a 2048-byte stack buffer. The loop guard only checked that the record header byte was in bounds; tmp[33 + path_length], tmp[2..4], and tmp[10..13] could read past the buffer end, leaking adjacent stack bytes into ';' / '\0' comparisons and feeding attacker-influenced data into `sector` (redirecting subsequent reads) and `file->size`. The advance tmp += tmp[0] with attacker-controlled tmp[0] compounded this by jumping the iterator forward arbitrarily when records claimed lengths shorter than the legal minimum (33 + filename + 1). Fix: require the entire record window to fit inside the buffer in the loop guard, and reject records whose claimed length is below the filename-comparison minimum. chdstream_get_meta (libretro-common/streams/chd_stream.c) parsed GDROM metadata KEY:VALUE pairs read into a 256-byte stack buffer. The TYPE: / SUBTYPE: / PGTYPE: / PGSUB: branches all did: while (p[len] && p[len] != ' ') len++; memcpy(md->FIELD, p, len); with no upper bound on len -- the only terminator was the meta-buffer NUL. A malicious .chd with "TYPE:" + 200 non-space bytes stack-overflowed md->type[64] by 136 bytes, clobbering subtype/pgtype/pgsub and (depending on layout) the saved frame pointer. Fix: apply the same "len >= sizeof(md->FIELD)" reject already used by the SCD-format parser earlier in the same function, to all four GDROM fields. Adds two regression tests under libretro-common/samples, registered in .github/workflows/Linux-libretro-common- samples.yml's RUN_TARGETS, with ASan + UBSan made the default for that workflow (MAKE_ARGS env var) since both tests rely on ASan as the bound-check discriminator. Verified locally that all 22 existing RUN_TARGETS still pass under the new sanitizer build. Tests follow the existing samples pattern (verbatim copy of the post-fix predicate with maintenance-contract comment). Verified to fire under ASan when the bounds are removed: heap-buffer-overflow READ at offset 2048 of a 2048-byte allocation (cdfs); heap-buffer-overflow WRITE of size 200 at offset 180 of a 180-byte allocation (chd). Both pass clean with the bounds in place.
1 parent 003a1ff commit 669468d

7 files changed

Lines changed: 737 additions & 2 deletions

File tree

.github/workflows/Linux-libretro-common-samples.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ jobs:
3333
- name: Build and run samples
3434
shell: bash
3535
working-directory: libretro-common/samples
36+
env:
37+
# Build samples under AddressSanitizer + UndefinedBehaviorSanitizer.
38+
# Most overflow tests under libretro-common/samples (rpng_chunk_
39+
# overflow_test, rbmp_test, vfs_read_overflow_test, cdrom_cuesheet
40+
# _overflow_test, cdfs_dir_record_test, chd_meta_overflow_test, ...)
41+
# use ASan as the regression discriminator for the bound-check
42+
# they verify, so make ASan the default for this workflow. Each
43+
# test's Makefile honours SANITIZER= via the conventional opt-in
44+
# block (matching samples/tasks/http/Makefile). Test sources
45+
# that don't have the opt-in block treat the variable as
46+
# ignored.
47+
MAKE_ARGS: "SANITIZER=address,undefined"
3648
run: |
3749
set -u
3850
set -o pipefail
@@ -55,6 +67,8 @@ jobs:
5567
net_ifinfo
5668
vfs_read_overflow_test
5769
cdrom_cuesheet_overflow_test
70+
cdfs_dir_record_test
71+
chd_meta_overflow_test
5872
http_parse_test
5973
rjson_test
6074
rtga_test
@@ -115,7 +129,7 @@ jobs:
115129
fi
116130
117131
# Build
118-
if ! ( cd "$d" && make clean all ); then
132+
if ! ( cd "$d" && make clean all $MAKE_ARGS ); then
119133
printf '\n::error title=Build failed::%s failed to build\n' "$rel"
120134
fails=$((fails+1))
121135
continue

libretro-common/formats/cdfs/cdfs.c

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,55 @@ static int cdfs_find_file(cdfs_file_t* file, const char* path)
185185
path_length = strlen(path);
186186
tmp = buffer;
187187

188-
while (tmp < buffer + sizeof(buffer))
188+
/* The directory record layout (ECMA-119 §9.1) is:
189+
* byte 0: record length (1 byte; 0 = end of records)
190+
* byte 1: extended-attr length
191+
* bytes 2-9: location-of-extent (LE+BE) -- we use bytes 2..4
192+
* bytes 10-17: data length (LE+BE) -- we use bytes 10..13
193+
* ...
194+
* byte 32: filename length
195+
* bytes 33..32+filename_length: filename
196+
*
197+
* Pre-this-patch the read at tmp[33 + path_length] and the
198+
* subsequent reads at tmp[2..4]/tmp[10..13] could run off the
199+
* end of the 2048-byte stack buffer when a malformed disc
200+
* image had a record positioned (or chained via the
201+
* tmp += tmp[0] advance) so that tmp + 33 + path_length lay
202+
* past buffer + sizeof(buffer). An attacker who can place a
203+
* crafted sector at the directory offset gets:
204+
* - a 1-byte info-leak from adjacent stack via the
205+
* tmp[33 + path_length] comparison vs ';' / '\0';
206+
* - filename-length bytes leaked via strncasecmp's
207+
* short-circuit timing;
208+
* - up to 4 bytes of attacker-influenced stack data feeding
209+
* `sector` (tmp[2..4]) and `file->size` (tmp[10..13]),
210+
* redirecting the next intfstream_read.
211+
*
212+
* The advance "tmp += tmp[0]" itself can land tmp anywhere
213+
* up to 255 bytes ahead. The guard tmp < buffer + sizeof
214+
* only catches the case where the loop body re-runs; it does
215+
* not protect against a single iteration whose body reads
216+
* past the buffer.
217+
*
218+
* Tighten the guard so the entire record (header through the
219+
* byte at offset 33 + path_length) must fit, and require the
220+
* record's claimed length to be at least large enough to
221+
* cover the filename comparison. */
222+
while ( tmp < buffer + sizeof(buffer)
223+
&& (size_t)(tmp - buffer) + 33 + path_length < sizeof(buffer))
189224
{
190225
/* The first byte of the record is the length of
191226
* the record - if 0, we reached the end of the data */
192227
if (!*tmp)
193228
break;
194229

230+
/* Reject records whose claimed length cannot accommodate
231+
* the filename comparison below. A legitimate ECMA-119
232+
* directory record has length >= 33 + filename_length
233+
* + 1 (the version-separator byte). */
234+
if (tmp[0] < 33 + path_length + 1)
235+
break;
236+
195237
/* filename is 33 bytes into the record and
196238
* the format is "FILENAME;version" or "DIRECTORY" */
197239
if ( (tmp[33 + path_length] == ';'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
TARGET := cdfs_dir_record_test
2+
3+
SOURCES := cdfs_dir_record_test.c
4+
5+
OBJS := $(SOURCES:.c=.o)
6+
7+
CFLAGS += -Wall -pedantic -std=gnu99 -g -O0
8+
9+
ifneq ($(SANITIZER),)
10+
CFLAGS := -fsanitize=$(SANITIZER) -fno-omit-frame-pointer $(CFLAGS)
11+
LDFLAGS := -fsanitize=$(SANITIZER) $(LDFLAGS)
12+
endif
13+
14+
all: $(TARGET)
15+
16+
%.o: %.c
17+
$(CC) -c -o $@ $< $(CFLAGS)
18+
19+
$(TARGET): $(OBJS)
20+
$(CC) -o $@ $^ $(LDFLAGS)
21+
22+
clean:
23+
rm -f $(TARGET) $(OBJS)
24+
25+
.PHONY: clean
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/* Copyright (C) 2010-2026 The RetroArch team
2+
*
3+
* ---------------------------------------------------------------------------------------
4+
* The following license statement only applies to this file (cdfs_dir_record_test.c).
5+
* ---------------------------------------------------------------------------------------
6+
*
7+
* Permission is hereby granted, free of charge,
8+
* to any person obtaining a copy of this software and associated documentation files (the "Software"),
9+
* to deal in the Software without restriction, including without limitation the rights to
10+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
11+
* and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
16+
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18+
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19+
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
*/
22+
23+
/* Regression test for the directory-record iterator bounds in
24+
* libretro-common/formats/cdfs/cdfs.c::cdfs_find_file.
25+
*
26+
* The function reads a 2048-byte ECMA-119 directory sector
27+
* from the disc image into a stack buffer and walks the
28+
* variable-length records:
29+
*
30+
* while (tmp < buffer + sizeof(buffer))
31+
* {
32+
* if (!*tmp) break;
33+
* if (tmp[33 + path_length] == ';' || tmp[33 + path_length] == '\0')
34+
* ...
35+
* tmp += tmp[0];
36+
* }
37+
*
38+
* Pre-this-patch the loop had three composable issues:
39+
*
40+
* 1. The guard tmp < buffer + sizeof(buffer) only confirmed
41+
* the record header byte was in bounds. tmp[33 +
42+
* path_length] could read past the buffer end, leaking
43+
* up to ~32 bytes of adjacent stack into the comparison.
44+
*
45+
* 2. tmp[2..4] and tmp[10..13] were read after a successful
46+
* filename comparison, with the same shape problem -- a
47+
* record positioned near the buffer end produced
48+
* attacker-influenced stack bytes flowing into `sector`
49+
* (which redirects the next intfstream_read) and
50+
* `file->size`.
51+
*
52+
* 3. The advance "tmp += tmp[0]" with tmp[0] attacker-
53+
* controlled could land tmp anywhere up to 255 bytes
54+
* past the safe range. A record that claimed length
55+
* less than 33 + filename_length + 1 (the minimum legal
56+
* ECMA-119 record size) was a primitive for jumping the
57+
* iterator to an unsafe offset.
58+
*
59+
* Reachability: user loads a malicious .iso/.bin/.cue. Same
60+
* threat class as other disc-image bugs (CHD, BSV).
61+
*
62+
* Fix: tighten the loop guard to require the entire record
63+
* (header through tmp[33 + path_length]) to fit, and require
64+
* the record's claimed length to be >= 33 + path_length + 1
65+
* before doing the filename comparison.
66+
*
67+
* IMPORTANT: this test keeps a verbatim copy of the post-fix
68+
* loop predicate. If cdfs.c's loop amends, the copy below
69+
* must follow. Convention used by the other regression
70+
* tests under libretro-common/samples/.
71+
*/
72+
73+
#include <stdio.h>
74+
#include <stdint.h>
75+
#include <stdlib.h>
76+
#include <string.h>
77+
#include <strings.h> /* strncasecmp */
78+
79+
#define SECTOR_SIZE 2048
80+
81+
static int failures = 0;
82+
83+
/* === verbatim copy of the post-fix iterator from
84+
* libretro-common/formats/cdfs/cdfs.c::cdfs_find_file.
85+
* If cdfs.c amends, this copy must follow. ===
86+
*
87+
* Returns the matched record's "sector" value (tmp[2..4])
88+
* on success, -1 if no record matches, -2 if the loop
89+
* would have run off the buffer. */
90+
static int find_dir_record(const uint8_t *buffer,
91+
size_t buffer_size, const char *path)
92+
{
93+
const uint8_t *tmp = buffer;
94+
size_t path_length = strlen(path);
95+
96+
while ( tmp < buffer + buffer_size
97+
&& (size_t)(tmp - buffer) + 33 + path_length < buffer_size)
98+
{
99+
if (!*tmp)
100+
break;
101+
102+
if (tmp[0] < 33 + path_length + 1)
103+
break;
104+
105+
if ( (tmp[33 + path_length] == ';'
106+
|| (tmp[33 + path_length] == '\0'))
107+
&& strncasecmp((const char*)(tmp + 33), path, path_length) == 0)
108+
{
109+
return tmp[2] | (tmp[3] << 8) | (tmp[4] << 16);
110+
}
111+
112+
tmp += tmp[0];
113+
}
114+
115+
return -1;
116+
}
117+
/* === end verbatim copy === */
118+
119+
/* Build a single legitimate directory record into `out`.
120+
* Returns the record's length. */
121+
static size_t build_record(uint8_t *out, const char *filename,
122+
uint32_t sector, uint32_t size)
123+
{
124+
size_t fn_len = strlen(filename);
125+
size_t total = 33 + fn_len + 1; /* +1 for ';' */
126+
memset(out, 0, total);
127+
out[0] = (uint8_t)total;
128+
/* sector at bytes 2..4 (little-endian 24-bit) */
129+
out[2] = (uint8_t)(sector & 0xff);
130+
out[3] = (uint8_t)((sector >> 8) & 0xff);
131+
out[4] = (uint8_t)((sector >> 16) & 0xff);
132+
/* size at bytes 10..13 */
133+
out[10] = (uint8_t)(size & 0xff);
134+
out[11] = (uint8_t)((size >> 8) & 0xff);
135+
out[12] = (uint8_t)((size >> 16) & 0xff);
136+
out[13] = (uint8_t)((size >> 24) & 0xff);
137+
memcpy(out + 33, filename, fn_len);
138+
out[33 + fn_len] = ';';
139+
return total;
140+
}
141+
142+
/* ---- tests ---- */
143+
144+
static void test_finds_legitimate_record(void)
145+
{
146+
uint8_t buffer[SECTOR_SIZE];
147+
size_t off;
148+
int rv;
149+
memset(buffer, 0, sizeof(buffer));
150+
off = build_record(buffer, "FOO", 0x123456, 1024);
151+
/* terminator: a 0-length record marks end-of-records */
152+
buffer[off] = 0;
153+
154+
rv = find_dir_record(buffer, sizeof(buffer), "FOO");
155+
if (rv != 0x123456)
156+
{
157+
printf("[ERROR] legitimate record: rv=0x%x, want 0x123456\n", rv);
158+
failures++;
159+
}
160+
else
161+
printf("[SUCCESS] legitimate record found and decoded correctly\n");
162+
}
163+
164+
static void test_record_at_buffer_end_does_not_oob(void)
165+
{
166+
/* Fill the buffer with single-byte-length records so the
167+
* iterator walks 1 byte at a time, then place a non-zero
168+
* byte close to the end so the guard would have allowed
169+
* the body to run pre-fix. Allocate the buffer as exactly
170+
* SECTOR_SIZE bytes on the heap with no slack so ASan
171+
* flags any read past the end. */
172+
uint8_t *buffer = (uint8_t*)malloc(SECTOR_SIZE);
173+
int rv;
174+
if (!buffer) { printf("[ERROR] alloc\n"); failures++; return; }
175+
176+
/* Records of length 1 from offset 0 to SECTOR_SIZE - 5. */
177+
memset(buffer, 1, SECTOR_SIZE - 4);
178+
/* The last 4 bytes are zero, terminating the chain. */
179+
memset(buffer + SECTOR_SIZE - 4, 0, 4);
180+
181+
/* Search for a 0-length filename: path_length = 0, so the
182+
* pre-fix bug fires when tmp + 33 > buffer + 2048, i.e.
183+
* tmp > buffer + 2015. The iterator walks single-byte
184+
* records right up to the boundary; pre-fix it would have
185+
* read tmp[33] for tmp >= buffer + 2016. */
186+
rv = find_dir_record(buffer, SECTOR_SIZE, "");
187+
/* Behavioural check: no match should be found, and (under
188+
* ASan) no OOB read should have occurred. */
189+
if (rv >= 0)
190+
{
191+
/* "" matches if tmp[33] == ';' or '\0'. Some matches are
192+
* possible from random buffer content -- accept any rv,
193+
* the point of the test is that ASan didn't fire. */
194+
}
195+
printf("[SUCCESS] record-near-buffer-end iteration stayed in bounds\n");
196+
197+
free(buffer);
198+
}
199+
200+
static void test_short_record_length_rejected(void)
201+
{
202+
/* A record claiming length 5 (less than the 33-byte
203+
* minimum) used to advance the iterator only 5 bytes,
204+
* letting subsequent reads of tmp[33+plen] reach into
205+
* undefined territory. The post-fix guard
206+
* if (tmp[0] < 33 + path_length + 1) break;
207+
* stops the iterator. */
208+
uint8_t *buffer = (uint8_t*)malloc(SECTOR_SIZE);
209+
int rv;
210+
if (!buffer) { printf("[ERROR] alloc\n"); failures++; return; }
211+
212+
memset(buffer, 0, SECTOR_SIZE);
213+
buffer[0] = 5; /* claimed length way below 33 */
214+
/* Subsequent bytes are zero so even if the iterator
215+
* advances we hit the !*tmp break. */
216+
217+
rv = find_dir_record(buffer, SECTOR_SIZE, "FOO");
218+
if (rv != -1)
219+
{
220+
printf("[ERROR] short-length record produced rv=%d\n", rv);
221+
failures++;
222+
}
223+
else
224+
printf("[SUCCESS] short-length record rejected without iteration\n");
225+
226+
free(buffer);
227+
}
228+
229+
static void test_pathological_record_at_2046(void)
230+
{
231+
/* Place a record claiming length 1 at buffer[2046]. Pre-fix
232+
* the loop guard tmp < buffer + 2048 admitted this iteration
233+
* but tmp[33 + path_length] = buffer[2079 + plen] read way
234+
* off the end. Heap-alloc with exact SECTOR_SIZE so ASan
235+
* catches the read.
236+
*
237+
* To get there the iterator needs to walk to offset 2046.
238+
* Single-byte records all the way is the simplest path. */
239+
uint8_t *buffer = (uint8_t*)malloc(SECTOR_SIZE);
240+
int rv;
241+
if (!buffer) { printf("[ERROR] alloc\n"); failures++; return; }
242+
243+
memset(buffer, 1, SECTOR_SIZE);
244+
/* Make sure the byte at 2046 is non-zero (it already is from
245+
* the memset). */
246+
buffer[2046] = 1;
247+
buffer[2047] = 0; /* any value */
248+
249+
rv = find_dir_record(buffer, SECTOR_SIZE, "BAR");
250+
/* Behaviour: no match. Under ASan: no OOB. */
251+
(void)rv;
252+
printf("[SUCCESS] iterator at offset 2046 stayed in bounds\n");
253+
254+
free(buffer);
255+
}
256+
257+
int main(void)
258+
{
259+
test_finds_legitimate_record();
260+
test_record_at_buffer_end_does_not_oob();
261+
test_short_record_length_rejected();
262+
test_pathological_record_at_2046();
263+
264+
if (failures)
265+
{
266+
printf("\n%d cdfs_dir_record test(s) failed\n", failures);
267+
return 1;
268+
}
269+
printf("\nAll cdfs_dir_record regression tests passed.\n");
270+
return 0;
271+
}

0 commit comments

Comments
 (0)