mirror of
https://github.com/libretro/RetroArch.git
synced 2026-11-13 16:31:29 -10:00
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.
197 lines
8.3 KiB
YAML
197 lines
8.3 KiB
YAML
name: CI Linux samples/tasks
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- master
|
|
pull_request:
|
|
branches:
|
|
- master
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true
|
|
|
|
jobs:
|
|
samples-tasks:
|
|
name: Build and run samples/tasks
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
|
|
steps:
|
|
- name: Install dependencies
|
|
run: |
|
|
sudo apt-get update -y
|
|
sudo apt-get install -y build-essential zlib1g-dev
|
|
|
|
- name: Checkout
|
|
uses: actions/checkout@v3
|
|
|
|
- name: Build database_task
|
|
shell: bash
|
|
working-directory: samples/tasks/database
|
|
run: |
|
|
set -eu
|
|
make clean all
|
|
test -x database_task
|
|
|
|
- name: Smoke-test database_task argv parsing
|
|
shell: bash
|
|
working-directory: samples/tasks/database
|
|
run: |
|
|
set -u
|
|
# database_task with no args should print the usage line and
|
|
# exit non-zero. This catches build regressions in the argv
|
|
# parsing / main() entry path without depending on the task
|
|
# queue actually running to completion -- task_push_dbscan
|
|
# delegates to task_push_manual_content_scan which does not
|
|
# plumb through the caller's completion callback, so the
|
|
# loop can only be terminated externally (by timeout or
|
|
# signal). The full loop is exercised in local testing; in
|
|
# CI we settle for the argv smoke.
|
|
#
|
|
# We capture the exit status via "|| rc=$?" so that the
|
|
# expected non-zero exit does NOT trip the default "set -e"
|
|
# that GitHub Actions' bash shell injects (it wraps with
|
|
# "bash --noprofile --norc -eo pipefail"). Without this
|
|
# guard the step exits at the binary invocation before our
|
|
# assertion runs.
|
|
rc=0
|
|
./database_task > /dev/null 2>&1 || rc=$?
|
|
if [[ $rc -eq 0 ]]; then
|
|
echo "::error title=Test failed::database_task with no args exited 0; expected non-zero (usage)"
|
|
exit 1
|
|
fi
|
|
echo "[pass] database_task no-args exit=$rc (expected non-zero)"
|
|
|
|
- name: Build and run archive_name_safety_test
|
|
shell: bash
|
|
working-directory: samples/tasks/decompress
|
|
run: |
|
|
set -eu
|
|
make clean all
|
|
test -x archive_name_safety_test
|
|
# Regression test for the Zip Slip / absolute-path defences
|
|
# in tasks/task_decompress.c::archive_name_is_safe(). The
|
|
# test keeps a verbatim copy of the predicate and runs it
|
|
# against 23 safe and unsafe inputs. If task_decompress.c
|
|
# ever amends the predicate, the copy here must follow.
|
|
timeout 60 ./archive_name_safety_test
|
|
echo "[pass] archive_name_safety_test"
|
|
|
|
- name: Build and run http_method_match_test (ASan)
|
|
shell: bash
|
|
working-directory: samples/tasks/http
|
|
run: |
|
|
set -eu
|
|
# Regression test for the heap-buffer-overflow fix in
|
|
# tasks/task_http.c::task_push_http_transfer_generic().
|
|
# Pre-fix, an unsafe memcmp(method, "GET", 3) read past
|
|
# the strdup'd buffer when method was NULL or shorter
|
|
# than 3 bytes. Build under AddressSanitizer so a
|
|
# regression to the unsafe expression is caught at the
|
|
# bounds level, not just the truth-table level. If
|
|
# task_http.c amends the GET-dispatch predicate, the
|
|
# copy in http_method_match_test.c must follow.
|
|
make clean all SANITIZER=address
|
|
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"
|
|
|
|
- name: Build and run input_remap_bounds_test (ASan)
|
|
shell: bash
|
|
working-directory: samples/tasks/input_remap
|
|
run: |
|
|
set -eu
|
|
# Regression test for the input-remap analog-axis OOB-
|
|
# write fixes in configuration.c::input_remapping_load_file()
|
|
# and input/input_driver.c. Pre-fix a malformed .rmp
|
|
# could supply a remap target outside [0, RARCH_ANALOG_
|
|
# BIND_LIST_END), which the use sites in input_driver.c
|
|
# then indexed into a fixed-size analog_value[][8] array
|
|
# without bounds (button -> analog branch) or with a
|
|
# broken bound that compared elements against bytes
|
|
# (analog -> analog branch using sizeof instead of
|
|
# ARRAY_SIZE). Build under AddressSanitizer so any
|
|
# reintroduction of either primitive is caught at the
|
|
# bounds level. If configuration.c or input_driver.c
|
|
# amends the relevant predicates, the verbatim copies
|
|
# in input_remap_bounds_test.c must follow.
|
|
make clean all SANITIZER=address
|
|
test -x input_remap_bounds_test
|
|
timeout 60 ./input_remap_bounds_test
|
|
echo "[pass] input_remap_bounds_test"
|
|
|
|
- name: Build and run bsv_replay_bounds_test (ASan)
|
|
shell: bash
|
|
working-directory: samples/tasks/bsv_replay
|
|
run: |
|
|
set -eu
|
|
# Regression test for the .bsv replay-file event-count
|
|
# bounds in input/bsv/bsvmovie.c. Pre-fix a malformed
|
|
# replay file with key_event_count > 128 or
|
|
# input_event_count > 512 caused a heap-buffer-overflow
|
|
# write into bsv_movie_t (a calloc'd struct) with
|
|
# attacker-chosen 8/12-byte payloads at attacker-chosen
|
|
# offsets up to ~500KB. Build under AddressSanitizer
|
|
# so any reintroduction of the unbounded count is caught
|
|
# at the bounds level. If input/bsv/bsvmovie.c amends
|
|
# the predicate, the verbatim copy in
|
|
# bsv_replay_bounds_test.c must follow.
|
|
make clean all SANITIZER=address
|
|
test -x bsv_replay_bounds_test
|
|
timeout 60 ./bsv_replay_bounds_test
|
|
echo "[pass] bsv_replay_bounds_test"
|
|
|
|
- name: Build and run bps_patch_bounds_test (ASan)
|
|
shell: bash
|
|
working-directory: samples/tasks/bps_patch
|
|
run: |
|
|
set -eu
|
|
# Regression test for the .bps patch parser bounds in
|
|
# tasks/task_patch.c::bps_apply_patch. Pre-fix a
|
|
# malicious .bps could write attacker-chosen bytes
|
|
# past the malloc'd target_data buffer (heap-buffer-
|
|
# overflow WRITE), read past source_data (info leak),
|
|
# and read past modify_data via TARGET_READ. The
|
|
# size-prelude was also unbounded, allowing 32-bit
|
|
# truncation of modify_target_size to drive a
|
|
# smaller-than-expected allocation. Build under
|
|
# AddressSanitizer so any reintroduction is caught
|
|
# at the bounds level. If task_patch.c amends the
|
|
# action-loop predicates, the verbatim copy in
|
|
# bps_patch_bounds_test.c must follow.
|
|
make clean all SANITIZER=address
|
|
test -x bps_patch_bounds_test
|
|
timeout 60 ./bps_patch_bounds_test
|
|
echo "[pass] bps_patch_bounds_test"
|