Skip to content

Commit cf73f1a

Browse files
committed
libretro-common/vfs: fix three real bugs + regression test
Three independent bugs in vfs_implementation.c, bundled with a regression test (true discriminator for bug 1) and the CI hook to run it. vfs: fix mmap read integer overflow in retro_vfs_file_read_impl Pre-patch: if (stream->mappos + len > stream->mapsize) len = stream->mapsize - stream->mappos; memcpy(s, &stream->mapped[stream->mappos], len); mappos and len are uint64_t. When len is attacker-controlled (the VFS read interface is exposed to cores), picking len such that (mappos + len) overflows uint64_t past zero defeats the bound check: the wrapped small value is not > mapsize, the clamp is skipped, and memcpy reads the full unclamped len bytes off the end of the mapped region. Concrete repro on a 16-byte file with mappos seeked to 10: pick len = UINT64_MAX - 9. Sum = UINT64_MAX + 1 = 0 in uint64_t. 0 > 16 is false, clamp bypassed, memcpy reads (UINT64_MAX - 9) bytes from mapped[10]. Crash / OOB. ASan reports "negative-size-param: (size=-10)" in memcpy. Fix: clamp len via unsigned subtraction against the remaining bytes (`remaining = mapsize - mappos; if (len > remaining) len = remaining;`) before computing the sum. This cannot wrap. Also tighten the at-EOF predicate from > to >= and return 0 (successful read at EOF) rather than -1. Reachability: requires HAVE_MMAP + the frequent-access hint. Linux builds, and any other build with mmap support, are affected. Windows (VC6 and every other MSVC) is NOT affected since HAVE_MMAP is not defined there. vfs: saturate retro_vfs_stat_impl size on files > 2 GiB Pre-patch *size = (int32_t)size64 silently truncated files > 2 GiB. Worse, for files in (INT32_MAX, UINT32_MAX] the high bit propagated and callers saw a negative size, which many interpret as an error. Post-patch saturates to INT_MAX before the cast. INT_MAX from <limits.h> is used instead of INT32_MAX from <stdint.h> for C89 / VC6 portability: INT32_MAX is a C99 addition and not available in VC6's stdint shim. int is 32-bit on all MSVC targets including VC6, so the two macros are equal everywhere this code runs. vfs: fix retro_vfs_file_truncate_impl silent truncation on Windows _chsize takes a long (32-bit on Windows) and silently truncates lengths > LONG_MAX. This affects every Windows CRT including VC6. The Secure CRT (VS 2005+, _MSC_VER >= 1400) added _chsize_s which takes __int64. Post-patch: _MSC_VER >= 1400: use _chsize_s with the full 64-bit length _MSC_VER < 1400 (including VC6) and non-MSVC Windows: reject lengths > LONG_MAX and fall back to _chsize for shorter values. Returning an error for the unsupported case is strictly better than silently truncating the file. The _MSC_VER >= 1400 gate preserves VC6 (_MSC_VER = 1200) buildability while still giving modern MSVC the correct semantics. Regression test: libretro-common/samples/file/vfs/ A true discriminator for bug #1. Creates a 16-byte mmap'd file, seeks to offset 10, then calls the read function with len = UINT64_MAX - 9. unpatched: SEGV (or under ASan: "negative-size-param: (size=-10)" in memcpy) patched: both reads return sane clamped values; ASan clean; exit 0 Bugs #2 and #3 have no targeted regression test -- both require > 2 GiB input files, impractical for a self-contained sample that runs in CI. The existing nbio_test and config_file_test exercise non-huge-file paths through vfs, so a regression that breaks the happy path would still surface. CI: libretro-common samples workflow updated vfs_read_overflow_test added to RUN_TARGETS. Full local dry-run under the GHA shell contract reports: Built: 13 Ran: 12 Failed: 0
1 parent 53a26bc commit cf73f1a

4 files changed

Lines changed: 236 additions & 11 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
rpng
5353
rzip_chunk_size_test
5454
net_ifinfo
55+
vfs_read_overflow_test
5556
)
5657
5758
# Per-binary run command (overrides ./<binary> if present).
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
TARGET := vfs_read_overflow_test
2+
3+
LIBRETRO_COMM_DIR := ../../..
4+
5+
SOURCES := \
6+
vfs_read_overflow_test.c \
7+
$(LIBRETRO_COMM_DIR)/compat/fopen_utf8.c \
8+
$(LIBRETRO_COMM_DIR)/compat/compat_strl.c \
9+
$(LIBRETRO_COMM_DIR)/encodings/encoding_utf.c \
10+
$(LIBRETRO_COMM_DIR)/file/file_path.c \
11+
$(LIBRETRO_COMM_DIR)/file/file_path_io.c \
12+
$(LIBRETRO_COMM_DIR)/streams/file_stream.c \
13+
$(LIBRETRO_COMM_DIR)/time/rtime.c \
14+
$(LIBRETRO_COMM_DIR)/vfs/vfs_implementation.c
15+
16+
OBJS := $(SOURCES:.c=.o)
17+
18+
# -DHAVE_MMAP is required for the test to exercise the patched
19+
# code path; without it the implementation falls back to buffered
20+
# fread and the test becomes a smoke test that can't discriminate
21+
# the overflow case.
22+
CFLAGS += -Wall -pedantic -std=gnu99 -g -DHAVE_MMAP -I$(LIBRETRO_COMM_DIR)/include
23+
24+
all: $(TARGET)
25+
26+
%.o: %.c
27+
$(CC) -c -o $@ $< $(CFLAGS)
28+
29+
$(TARGET): $(OBJS)
30+
$(CC) -o $@ $^ $(LDFLAGS)
31+
32+
clean:
33+
rm -f $(TARGET) $(OBJS)
34+
35+
.PHONY: clean
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/* Regression test for mmap-read integer overflow in
2+
* retro_vfs_file_read_impl (libretro-common/vfs/vfs_implementation.c).
3+
*
4+
* Pre-patch, the function contained:
5+
*
6+
* if (stream->mappos + len > stream->mapsize)
7+
* len = stream->mapsize - stream->mappos;
8+
* memcpy(s, &stream->mapped[stream->mappos], len);
9+
*
10+
* mappos and len are both uint64_t. When `len` is attacker-chosen
11+
* and near UINT64_MAX, the addition `mappos + len` wraps past zero
12+
* and the bound check `> mapsize` evaluates FALSE on the small
13+
* wrapped value -- the clamp is skipped and memcpy reads `len`
14+
* bytes off the end of the mapped region.
15+
*
16+
* Post-patch the clamp is done as an unsigned subtraction
17+
* (`remaining = mapsize - mappos; if (len > remaining) len =
18+
* remaining;`) which cannot wrap.
19+
*
20+
* The test mmaps a small file, then directly invokes
21+
* retro_vfs_file_read_impl with a len value engineered to trigger
22+
* the wrap (mappos=10, len=UINT64_MAX-10, sum wraps to 0). Post-
23+
* patch the function returns at most 0 (no bytes left after mappos
24+
* advanced to mapsize) and does not corrupt memory.
25+
*
26+
* ASan gives the strongest signal: pre-patch runs it fires
27+
* "heap-buffer-overflow"; post-patch runs complete cleanly.
28+
*/
29+
30+
#include <stdio.h>
31+
#include <stdlib.h>
32+
#include <string.h>
33+
#include <stdint.h>
34+
#include <unistd.h>
35+
36+
#include <vfs/vfs.h>
37+
#include <vfs/vfs_implementation.h>
38+
39+
/* These constants are defined in libretro.h; redeclare the subset
40+
* we need so the sample doesn't depend on that header being on the
41+
* include path. */
42+
#ifndef RETRO_VFS_FILE_ACCESS_READ
43+
#define RETRO_VFS_FILE_ACCESS_READ (1 << 0)
44+
#endif
45+
#ifndef RETRO_VFS_FILE_ACCESS_HINT_NONE
46+
#define RETRO_VFS_FILE_ACCESS_HINT_NONE 0
47+
#endif
48+
#ifndef RETRO_VFS_FILE_ACCESS_HINT_FREQUENT_ACCESS
49+
#define RETRO_VFS_FILE_ACCESS_HINT_FREQUENT_ACCESS (1 << 0)
50+
#endif
51+
52+
static int failures = 0;
53+
54+
static void test_mmap_read_overflow(void)
55+
{
56+
libretro_vfs_implementation_file *stream;
57+
const char *tmp_path = "vfs_overflow_test.bin";
58+
const char payload[16] = "ABCDEFGHIJKLMNOP";
59+
char buf[64];
60+
int64_t rc;
61+
FILE *fp;
62+
63+
/* Create a 16-byte file. */
64+
fp = fopen(tmp_path, "wb");
65+
if (!fp) { printf("[ERROR] fopen failed\n"); failures++; return; }
66+
fwrite(payload, 1, sizeof(payload), fp);
67+
fclose(fp);
68+
69+
/* Open with frequent-access hint so the implementation uses the
70+
* mmap code path. (If HAVE_MMAP is not compiled in, this
71+
* falls back to buffered reads and the test becomes a smoke
72+
* test rather than a true discriminator.) */
73+
stream = retro_vfs_file_open_impl(tmp_path,
74+
RETRO_VFS_FILE_ACCESS_READ,
75+
RETRO_VFS_FILE_ACCESS_HINT_FREQUENT_ACCESS);
76+
if (!stream)
77+
{
78+
printf("[ERROR] retro_vfs_file_open_impl failed\n");
79+
failures++;
80+
remove(tmp_path);
81+
return;
82+
}
83+
84+
/* Normal read to establish baseline behaviour. */
85+
rc = retro_vfs_file_read_impl(stream, buf, 4);
86+
if (rc != 4 || memcmp(buf, payload, 4) != 0)
87+
{
88+
printf("[ERROR] baseline read: rc=%lld, want 4\n", (long long)rc);
89+
failures++;
90+
}
91+
else
92+
printf("[SUCCESS] baseline read returned 4 bytes\n");
93+
94+
/* Seek to offset 10. mappos is now 10. */
95+
if (retro_vfs_file_seek_impl(stream, 10, 0 /*SEEK_SET*/) != 10)
96+
{
97+
printf("[ERROR] seek to 10 failed\n");
98+
failures++;
99+
}
100+
101+
/* Crafted len chosen so that (mappos + len) wraps uint64_t past 0
102+
* and lands at or below mapsize (=16), bypassing the pre-patch
103+
* bound check and causing an unclamped memcpy off the end.
104+
*
105+
* With mappos=10, mapsize=16:
106+
* naive check: mappos + len > mapsize
107+
* We need (mappos + len) to wrap past zero in uint64_t.
108+
* Wrap happens when sum >= 2^64, i.e. len >= 2^64 - mappos.
109+
* With mappos=10, pick len = UINT64_MAX - 9 so that sum =
110+
* UINT64_MAX + 1 wraps to 0. Check "0 > 16" is FALSE; the
111+
* clamp is SKIPPED. Memcpy then reads UINT64_MAX - 9 bytes
112+
* starting at mapped[10]. Crash / OOB.
113+
*
114+
* Post-patch: remaining = mapsize - mappos = 6. len (very
115+
* large) > remaining, so len clamps to 6. memcpy reads 6 bytes
116+
* from mapped[10..15]. Safe. */
117+
{
118+
uint64_t evil_len = (uint64_t)-1 - 9; /* UINT64_MAX - 9 */
119+
rc = retro_vfs_file_read_impl(stream, buf, evil_len);
120+
121+
/* Post-patch contract: rc is at most mapsize - mappos = 6.
122+
* Pre-patch would either return a huge number or crash. */
123+
if (rc < 0 || rc > 6)
124+
{
125+
printf("[ERROR] overflow read returned rc=%lld (want 0..6)\n",
126+
(long long)rc);
127+
failures++;
128+
}
129+
else
130+
printf("[SUCCESS] overflow read clamped to rc=%lld\n",
131+
(long long)rc);
132+
}
133+
134+
retro_vfs_file_close_impl(stream);
135+
remove(tmp_path);
136+
}
137+
138+
int main(void)
139+
{
140+
test_mmap_read_overflow();
141+
142+
if (failures)
143+
{
144+
printf("\n%d vfs test(s) failed\n", failures);
145+
return 1;
146+
}
147+
printf("\nAll vfs regression tests passed.\n");
148+
return 0;
149+
}

libretro-common/vfs/vfs_implementation.c

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
#include <stdlib.h>
2525
#include <string.h>
2626
#include <errno.h>
27+
#include <limits.h> /* INT_MAX, LONG_MAX -- both C89 */
2728
#include <sys/types.h>
2829

2930
#ifdef HAVE_CONFIG_H
@@ -711,11 +712,28 @@ int64_t retro_vfs_file_size_impl(libretro_vfs_implementation_file *stream)
711712
int64_t retro_vfs_file_truncate_impl(libretro_vfs_implementation_file *stream, int64_t len)
712713
{
713714
#ifdef _WIN32
714-
if (stream && stream->fp && _chsize(_fileno(stream->fp), len) == 0)
715+
/* _chsize takes a long and silently truncates lengths > LONG_MAX
716+
* (2 GiB on Windows) -- present on all Windows CRTs including
717+
* VC6. _chsize_s takes __int64 and was added in the Secure CRT
718+
* (VS 2005, _MSC_VER 1400). Prefer the 64-bit variant when
719+
* available, and on older MSVC / MinGW with legacy msvcrt fall
720+
* back to _chsize only for lengths that fit in long -- return
721+
* an error for larger lengths rather than silently truncating
722+
* the file. */
723+
#if defined(_MSC_VER) && _MSC_VER >= 1400
724+
if (stream && stream->fp && _chsize_s(_fileno(stream->fp), len) == 0)
725+
{
726+
stream->size = len;
727+
return 0;
728+
}
729+
#else
730+
if (stream && stream->fp && len >= 0 && len <= (int64_t)LONG_MAX
731+
&& _chsize(_fileno(stream->fp), (long)len) == 0)
715732
{
716733
stream->size = len;
717734
return 0;
718735
}
736+
#endif
719737
#elif !defined(VITA) && !defined(PSP) && !defined(PS2) && !defined(ORBIS) && (!defined(SWITCH) || defined(HAVE_LIBNX))
720738
if (stream && stream->fp && ftruncate(fileno(stream->fp), (off_t)len) == 0)
721739
{
@@ -792,16 +810,31 @@ int64_t retro_vfs_file_read_impl(libretro_vfs_implementation_file *stream,
792810
#ifdef HAVE_MMAP
793811
if (stream->hints & RETRO_VFS_FILE_ACCESS_HINT_FREQUENT_ACCESS)
794812
{
795-
if (stream->mappos > stream->mapsize)
813+
if (stream->mappos >= stream->mapsize)
814+
{
815+
/* At or past EOF: 0 bytes is the correct return for
816+
* fread-style semantics on a legitimate read that reached
817+
* EOF, -1 if we were already past EOF (corrupt state). */
818+
if (stream->mappos == stream->mapsize)
819+
return 0;
796820
return -1;
821+
}
797822

798-
if (stream->mappos + len > stream->mapsize)
799-
len = stream->mapsize - stream->mappos;
823+
/* Clamp len against the remaining mapped bytes. Done as an
824+
* unsigned subtraction *before* computing mappos+len to avoid
825+
* integer overflow: mappos+len can wrap past mapsize when
826+
* both operands are large uint64_t values, defeating the
827+
* naive "mappos + len > mapsize" bound check. */
828+
{
829+
uint64_t remaining = stream->mapsize - stream->mappos;
830+
if (len > remaining)
831+
len = remaining;
832+
}
800833

801-
memcpy(s, &stream->mapped[stream->mappos], len);
834+
memcpy(s, &stream->mapped[stream->mappos], (size_t)len);
802835
stream->mappos += len;
803836

804-
return len;
837+
return (int64_t)len;
805838
}
806839
#endif
807840

@@ -1186,12 +1219,19 @@ int retro_vfs_stat_impl(const char *path, int32_t *size)
11861219
int64_t size64 = 0;
11871220
int ret = retro_vfs_stat_64_impl(path, size ? &size64 : NULL);
11881221

1189-
/* if a file is larger than 2 GB, size64 will hold the correct value
1190-
* but the cast to int32_t will truncate it.
1191-
* new code should migrate to retro_vfs_stat_64_t
1192-
*/
1222+
/* If a file is larger than 2 GiB, size64 holds the correct value
1223+
* but a naked (int32_t) cast would truncate -- worse, on files in
1224+
* (INT32_MAX, UINT32_MAX] the high bit wraps and callers see a
1225+
* negative size that they may interpret as an error. Saturate to
1226+
* INT_MAX so a caller using the legacy API gets a clamped-large
1227+
* value rather than a corrupted one, and migrate to
1228+
* retro_vfs_stat_64_impl for files that need the real size.
1229+
* INT_MAX is used instead of INT32_MAX for C89 / VC6 portability
1230+
* (stdint.h's INT32_MAX is a C99 addition; INT_MAX is C89). int
1231+
* is 32-bit on all MSVC targets including VC6, so INT_MAX ==
1232+
* INT32_MAX everywhere this code runs. */
11931233
if (size)
1194-
*size = (int32_t)size64;
1234+
*size = (size64 > (int64_t)INT_MAX) ? INT_MAX : (int32_t)size64;
11951235

11961236
return ret;
11971237
}

0 commit comments

Comments
 (0)