gfx/video_shader_parse: clamp replace_text length and prefix+_len in wildcard replacement

video_shader_replace_wildcards_impl built each replacement
value into a 256-byte stack buffer:

  char replace_text[256];
  _len = strlcpy(replace_text, source, sizeof(replace_text));

then unbounded:

  memcpy(dst + prefix, replace_text, _len);
  strlcpy(dst + prefix + _len, found + token_len,
          PATH_MAX_LENGTH - prefix - _len);

Two related primitives were exposed:

 1. strlcpy returns strlen(source) regardless of destination
    capacity.  When source resolved to a $CONTENT-DIR$ /
    $PRESET-DIR$ / $CORE$ / $GAME$ value longer than 256
    bytes (DIR_MAX_LENGTH is 4096, library_name and
    path_basename are platform-dependent and routinely
    exceed 256), _len was strlen(source).  The memcpy then
    read past the end of the 256-byte replace_text array
    into adjacent stack memory.

 2. The same _len fed (size_t)(PATH_MAX_LENGTH - prefix -
    _len) on the next line.  When prefix + _len exceeded
    PATH_MAX_LENGTH the subtraction underflowed size_t to a
    huge value and the strlcpy walked off the end of dst.
    Even with the _len clamp from (1), a wildcard token
    placed near the end of the input path (legitimate for
    an attacker-supplied .slangp/.glslp) lets prefix +
    REPLACE_BUF_SZ exceed PATH_MAX_LENGTH on its own.

The snprintf-chained branches (CORE_REQUESTED_ROTATION,
VIDEO_USER_ROTATION, VIDEO_FINAL_ROTATION,
SCREEN_ORIENTATION) had a sibling problem: snprintf returns
the would-have-written length, so _len += snprintf(...) on
a saturated buffer pushes _len past sizeof(replace_text)
even when the destination ate the entire size budget.

Reachable via shader presets the user opens.  These come
from the online updater (typically slang-shaders or
common-shaders), the user's own preset directory, and from
content-bundled presets distributed alongside ROMs.

Fix: clamp at the use site so all the upstream branches are
covered in one place.

  if (_len >= sizeof(replace_text))
     _len = sizeof(replace_text) - 1;
  if (prefix >= PATH_MAX_LENGTH || _len >= PATH_MAX_LENGTH - prefix)
     break;

Adds samples/tasks/video_shader_parse/video_shader_wildcard_test
as a regression test, registered in
.github/workflows/Linux-samples-tasks.yml as an ASan-enabled
step.  Four cases:

  - short replacement produces correct output
  - long source (600 chars) clamped without OOB read
  - huge source (4000 chars) clamped without strlcpy underflow
  - prefix near PATH_MAX_LENGTH correctly bails out

Test follows the existing samples/tasks pattern (verbatim
copy of the post-fix arithmetic block, ASan as the
discriminator) used by archive_name_safety_test and
http_method_match_test.  Verified to fire under ASan when
the clamps are removed: stack-buffer-overflow READ of size
600 at offset 288 in replace_text (a 256-byte buffer).
With the clamps the test passes clean.

Note on test scope: the test exercises a verbatim copy of
the post-replacement arithmetic block from
video_shader_parse.c lines 344-358, not the full
video_shader_replace_wildcards_impl.  The full function
depends on settings_t / runloop_state / video driver
globals that aren't tractable to stub for a unit test --
following the convention used by archive_name_safety_test
("if task_decompress.c amends the predicate, the copy here
must follow"), the test's verbatim block must track the
production block.  A comment in the test points at the
production lines explicitly.
This commit is contained in:
U-DESKTOP-SPFP6AQ\twistedtechre committed 2026-04-28 02:48:49 +02:00
1 parent 854c9f3e95
commit bac68947b0
4 files changed
+423

No files matched your search

+26
View File
@@ -100,3 +100,29 @@ jobs:
test -x http_method_match_test
timeout 60 ./http_method_match_test
echo "[pass] http_method_match_test"
- name: Build and run video_shader_wildcard_test (ASan)
shell: bash
working-directory: samples/tasks/video_shader_parse
run: |
set -eu
# Regression test for the buffer-overflow defences in
# gfx/video_shader_parse.c::video_shader_replace_wildcards_impl().
# Pre-fix, _len = strlcpy(replace_text, source, 256)
# returned strlen(source) regardless of the 256-byte
# cap. When source was a $CONTENT-DIR$ / $PRESET-DIR$
# / $CORE$ value longer than 256 bytes (DIR_MAX_LENGTH
# is 4096, NAME_MAX_LENGTH 256), the subsequent
# memcpy(dst + prefix, replace_text, _len) read off
# the end of replace_text and the strlcpy's size
# argument PATH_MAX_LENGTH - prefix - _len underflowed
# size_t. Build under AddressSanitizer so the OOB
# read on replace_text and the underflow-driven OOB
# write on dst are both caught at the bounds level.
# If video_shader_parse.c amends the post-replacement
# arithmetic, the verbatim copy in
# video_shader_wildcard_test.c must follow.
make clean all SANITIZER=address
test -x video_shader_wildcard_test
timeout 60 ./video_shader_wildcard_test
echo "[pass] video_shader_wildcard_test"
+23
View File
@@ -345,6 +345,29 @@ static void video_shader_replace_wildcards_impl(
{
char *tmp_ptr;
size_t prefix = (size_t)(found - src);
/* _len comes from strlcpy / snprintf return values.
* strlcpy returns strlen(source) regardless of the
* destination capacity, and snprintf returns the
* "would-have-written" length, so on long sources
* (content_dir_name, library_name, path_basename,
* preset_dir_name, all of which can be up to
* DIR_MAX_LENGTH = 4096) _len exceeds
* sizeof(replace_text) = 256. The memcpy below
* would then read past the end of replace_text into
* adjacent stack memory; the strlcpy further down
* would underflow (size_t)(PATH_MAX_LENGTH - prefix
* - _len) and write tens of GiB. Clamp here at the
* use site so every preceding branch is covered. */
if (_len >= sizeof(replace_text))
_len = sizeof(replace_text) - 1;
/* Also bound prefix + _len against the dst buffer
* size so the strlcpy below doesn't underflow its
* size argument and walk off the end of dst. */
if (prefix >= PATH_MAX_LENGTH || _len >= PATH_MAX_LENGTH - prefix)
break;
memcpy(dst, src, prefix);
memcpy(dst + prefix, replace_text, _len);
strlcpy(dst + prefix + _len, found + token_len,
+25
View File
@@ -0,0 +1,25 @@
TARGET := video_shader_wildcard_test
SOURCES := video_shader_wildcard_test.c
OBJS := $(SOURCES:.c=.o)
CFLAGS += -Wall -pedantic -std=gnu99 -g -O0
ifneq ($(SANITIZER),)
CFLAGS := -fsanitize=$(SANITIZER) -fno-omit-frame-pointer $(CFLAGS)
LDFLAGS := -fsanitize=$(SANITIZER) $(LDFLAGS)
endif
all: $(TARGET)
%.o: %.c
$(CC) -c -o $@ $< $(CFLAGS)
$(TARGET): $(OBJS)
$(CC) -o $@ $^ $(LDFLAGS)
clean:
rm -f $(TARGET) $(OBJS)
.PHONY: clean
@@ -0,0 +1,349 @@
/* Copyright (C) 2010-2026 The RetroArch team
*
* ---------------------------------------------------------------------------------------
* The following license statement only applies to this file (video_shader_wildcard_test.c).
* ---------------------------------------------------------------------------------------
*
* Permission is hereby granted, free of charge,
* to any person obtaining a copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
* and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
/* Regression test for the buffer-overflow defences in
* gfx/video_shader_parse.c::video_shader_replace_wildcards_impl().
*
* Pre-fix the function did:
*
* char replace_text[256];
* _len = strlcpy(replace_text, source, sizeof(replace_text));
* ...
* memcpy(dst + prefix, replace_text, _len);
* strlcpy(dst + prefix + _len, found + token_len,
* PATH_MAX_LENGTH - prefix - _len);
*
* strlcpy returns strlen(source) regardless of destination
* capacity. When the source -- $CONTENT-DIR$ resolves to a
* directory name up to DIR_MAX_LENGTH (4096) bytes, $CORE$ to
* a core's library_name, $GAME$ to a basename, $PRESET-DIR$ to
* up to DIR_MAX_LENGTH bytes -- exceeds 256 chars, _len is the
* source's strlen. The subsequent memcpy then reads past the
* 256-byte replace_text buffer (heap- or stack-out-of-bounds
* read), and the strlcpy's size argument
* (size_t)(PATH_MAX_LENGTH - prefix - _len) underflows, asking
* libc to copy SIZE_MAX-ish bytes. ASan flags the read; on a
* stripped binary it manifests as a crash or silent
* corruption.
*
* The same pattern affects the snprintf-chained wildcards
* (CORE_REQUESTED_ROTATION, VIDEO_USER_ROTATION,
* VIDEO_FINAL_ROTATION, SCREEN_ORIENTATION) where _len += the
* snprintf "would-have-written" return. At full _len the
* second snprintf is given size 0 and writes nothing but still
* returns the format-string's length, pushing _len past
* sizeof(replace_text).
*
* Fix: clamp _len at the use site immediately before the
* memcpy/strlcpy block, and bail out of the replacement loop
* if prefix + _len would overflow the dst buffer.
*
* IMPORTANT: this test keeps a verbatim copy of the
* post-replacement memcpy/strlcpy block so any reintroduction
* of the unbounded _len arithmetic in
* gfx/video_shader_parse.c is caught here under
* -fsanitize=address. If video_shader_parse.c amends the
* arithmetic, the copy below must follow.
*/
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
/* Mirror libretro-common's strlcpy semantics:
* returns strlen(in) regardless of len. */
static size_t test_strlcpy(char *s, const char *in, size_t len)
{
size_t _len = strlen(in);
if (len)
{
size_t __len = _len < len - 1 ? _len : len - 1;
memcpy(s, in, __len);
s[__len] = '\0';
}
return _len;
}
/* Production-mirror constants. PATH_MAX_LENGTH is 4096 in
* libretro-common. DIR_MAX_LENGTH is also 4096. */
#define PATH_MAX_LENGTH 4096
#define REPLACE_BUF_SZ 256
static int failures = 0;
/* Mirror of the post-fix replacement block from
* video_shader_replace_wildcards_impl(). Returns 1 if the
* replacement was performed, 0 if it was clamped/bailed.
* Operates on a 2*PATH_MAX_LENGTH workspace `tmp_buf` exactly
* the way the production code does (src = tmp_buf, dst =
* tmp_buf + PATH_MAX_LENGTH). Caller arranges src content
* and the location of the wildcard token within it. */
static int do_one_replacement(char *tmp_buf,
const char *src_content,
const char *token,
const char *replace_source)
{
char *src = tmp_buf;
char *dst = tmp_buf + PATH_MAX_LENGTH;
const char *found;
size_t prefix;
size_t token_len = strlen(token);
char replace_text[REPLACE_BUF_SZ];
size_t _len = 0;
test_strlcpy(src, src_content, PATH_MAX_LENGTH);
found = strstr(src, token);
if (!found)
return 0;
replace_text[0] = '\0';
_len = test_strlcpy(replace_text, replace_source, sizeof(replace_text));
if (_len <= 0)
return 0;
prefix = (size_t)(found - src);
/* === verbatim copy of the post-fix arithmetic from
* gfx/video_shader_parse.c lines 344-358. If the
* production block changes shape, this must follow. === */
if (_len >= sizeof(replace_text))
_len = sizeof(replace_text) - 1;
if (prefix >= PATH_MAX_LENGTH || _len >= PATH_MAX_LENGTH - prefix)
return 0;
memcpy(dst, src, prefix);
memcpy(dst + prefix, replace_text, _len);
test_strlcpy(dst + prefix + _len, found + token_len,
PATH_MAX_LENGTH - prefix - _len);
/* === end verbatim copy === */
return 1;
}
/* ---- test cases ---------------------------------------------------- */
static void test_short_replacement_succeeds(void)
{
/* Sanity: a normal-sized replacement string still works. */
char *tmp_buf = (char*)malloc(2 * PATH_MAX_LENGTH);
int rv;
assert(tmp_buf);
memset(tmp_buf, 0, 2 * PATH_MAX_LENGTH);
rv = do_one_replacement(tmp_buf,
"/path/to/$CORE$/preset.slangp",
"$CORE$",
"snes9x");
if (rv != 1)
{
printf("[ERROR] short replacement returned %d (expected 1)\n", rv);
failures++;
}
else if (strcmp(tmp_buf + PATH_MAX_LENGTH, "/path/to/snes9x/preset.slangp"))
{
printf("[ERROR] short replacement wrong output: %s\n",
tmp_buf + PATH_MAX_LENGTH);
failures++;
}
else
printf("[SUCCESS] short replacement produces correct output\n");
free(tmp_buf);
}
static void test_long_source_does_not_overrun(void)
{
/* Replacement source is 600 chars (exceeds REPLACE_BUF_SZ
* = 256). Pre-fix: the unclamped _len = 600 caused memcpy
* to read 600 bytes from a 256-byte stack buffer (ASan
* flags this as stack-buffer-overflow READ). Post-fix:
* _len is clamped to 255 and the memcpy stays in bounds.
* Under -fsanitize=address this test crashes pre-fix and
* passes post-fix. */
char *tmp_buf = (char*)malloc(2 * PATH_MAX_LENGTH);
char long_source[700];
int rv;
assert(tmp_buf);
memset(tmp_buf, 0, 2 * PATH_MAX_LENGTH);
memset(long_source, 'A', 600);
long_source[600] = '\0';
rv = do_one_replacement(tmp_buf,
"prefix-$CONTENT-DIR$-suffix",
"$CONTENT-DIR$",
long_source);
if (rv != 1)
{
printf("[ERROR] long source replacement returned %d (expected 1)\n", rv);
failures++;
}
else
{
/* After clamping, exactly REPLACE_BUF_SZ - 1 = 255
* 'A's should be in the output between "prefix-" and
* "-suffix". */
const char *out = tmp_buf + PATH_MAX_LENGTH;
size_t i;
int ok = (strncmp(out, "prefix-", 7) == 0);
for (i = 0; ok && i < REPLACE_BUF_SZ - 1; i++)
if (out[7 + i] != 'A')
ok = 0;
if (ok && strncmp(out + 7 + (REPLACE_BUF_SZ - 1), "-suffix", 7) != 0)
ok = 0;
if (!ok)
{
printf("[ERROR] long source produced wrong output\n");
failures++;
}
else
printf("[SUCCESS] long source clamped without OOB read\n");
}
free(tmp_buf);
}
static void test_pathological_long_source_does_not_underflow_strlcpy(void)
{
/* Source string of 4000 chars. Pre-fix the strlcpy at
* line 350 receives size = PATH_MAX_LENGTH - prefix -
* _len which underflows size_t and asks libc to copy
* SIZE_MAX-ish bytes. ASan flags this as
* heap-buffer-overflow WRITE on dst. Post-fix the second
* clamp (prefix + _len >= PATH_MAX_LENGTH check) bails
* out before the bad strlcpy. */
char *tmp_buf = (char*)malloc(2 * PATH_MAX_LENGTH);
char *huge_source;
int rv;
assert(tmp_buf);
memset(tmp_buf, 0, 2 * PATH_MAX_LENGTH);
huge_source = (char*)malloc(4001);
assert(huge_source);
memset(huge_source, 'B', 4000);
huge_source[4000] = '\0';
/* Note: even with the _len clamp to 255, this test
* exercises the second clamp because we set up a prefix
* close to PATH_MAX_LENGTH below. */
rv = do_one_replacement(tmp_buf,
"$CONTENT-DIR$", /* token at offset 0 */
"$CONTENT-DIR$",
huge_source);
if (rv != 1)
{
printf("[ERROR] huge source basic replacement returned %d\n", rv);
failures++;
}
else
{
/* Output should contain exactly REPLACE_BUF_SZ - 1
* 'B's (clamped). */
size_t i;
int ok = 1;
const char *out = tmp_buf + PATH_MAX_LENGTH;
for (i = 0; i < REPLACE_BUF_SZ - 1; i++)
if (out[i] != 'B') { ok = 0; break; }
if (out[REPLACE_BUF_SZ - 1] != '\0')
ok = 0;
if (!ok)
{
printf("[ERROR] huge source produced wrong output\n");
failures++;
}
else
printf("[SUCCESS] huge source clamped without strlcpy underflow\n");
}
free(huge_source);
free(tmp_buf);
}
static void test_prefix_near_path_max_bails(void)
{
/* Prefix near PATH_MAX_LENGTH: the second clamp must bail
* out so that prefix + _len doesn't run off the dst
* buffer. Pre-fix this would write up to ~250 bytes past
* the end of dst (heap-buffer-overflow WRITE under ASan).
* Construct an src that places the token at offset
* PATH_MAX_LENGTH - 20, so prefix = PATH_MAX_LENGTH - 20
* and prefix + (REPLACE_BUF_SZ - 1) = PATH_MAX_LENGTH -
* 20 + 255 > PATH_MAX_LENGTH. The new bound rejects this
* combination. */
char *tmp_buf = (char*)malloc(2 * PATH_MAX_LENGTH);
char *long_path;
int rv;
size_t pad_len = PATH_MAX_LENGTH - 20 - 1; /* room for token + NUL */
assert(tmp_buf);
memset(tmp_buf, 0, 2 * PATH_MAX_LENGTH);
long_path = (char*)malloc(PATH_MAX_LENGTH);
assert(long_path);
memset(long_path, 'X', pad_len);
/* Token "$CORE$" is 6 chars; place it after pad. */
memcpy(long_path + pad_len, "$CORE$", 6);
long_path[pad_len + 6] = '\0';
/* Replacement is 200 chars -- clamped to 199 by _len cap,
* but prefix (PATH_MAX_LENGTH - 20 - 1) + 199 still
* exceeds PATH_MAX_LENGTH, so the prefix+_len bound
* fires. */
{
char src200[201];
memset(src200, 'C', 200);
src200[200] = '\0';
rv = do_one_replacement(tmp_buf, long_path, "$CORE$", src200);
}
/* Bail-out is the correct behavior here. */
if (rv != 0)
{
printf("[ERROR] near-overflow prefix should have bailed (rv=%d)\n", rv);
failures++;
}
else
printf("[SUCCESS] near-overflow prefix correctly bailed\n");
free(long_path);
free(tmp_buf);
}
int main(void)
{
test_short_replacement_succeeds();
test_long_source_does_not_overrun();
test_pathological_long_source_does_not_underflow_strlcpy();
test_prefix_near_path_max_bails();
if (failures)
{
printf("\n%d video_shader wildcard test(s) failed\n", failures);
return 1;
}
printf("\nAll video_shader wildcard regression tests passed.\n");
return 0;
}