Skip to content

Commit d78caed

Browse files
brentjohnsonclaude
andcommitted
Fuzz the GDL reader with libFuzzer; fix 5 reader UB/DoS bugs
Add an opt-in libFuzzer harness (test/fuzz/, -DXCONQ_FUZZ=ON, Clang only) for the GDL lisp reader -- the parser that untrusted downloaded modules, save files, and network transfer-protocol messages all feed through. fuzz_gdl.cc drives read_form_from_string() (the lowest reader entry) over the whole input buffer; it links a fuzz-instrumented copy of the low-level reader objects plus fuzz_stubs.cc (fake high-level callbacks, mirroring imf2imf.cc's standalone-reader link closure). Interpreting parsed forms (interp_form in read.cc) is not fuzzed: it needs full world state and does file I/O, so a harness over it would surface init-order artifacts. Corpus seeds regression cases + small real GDL modules; gdl.dict is ~1000 GDL tokens extracted from kernel/*.def by gen_dict.sh. A non-optional CI `fuzz` leg builds the harness, replays the corpus (the fuzz-labelled CTest), and runs ~5 min of live fuzzing under ASan+UBSan. Longer runs / OSS-Fuzz documented in test/fuzz/README.md. Two campaigns (a shallow-bug burst, then a post-fix run to coverage saturation) surfaced five memory-safety / UB bugs, all in kernel/lisp.cc, each reachable from a truncated or hostile module/save/network message: - Infinite allocation loop (DoS) on an unclosed list ending in a symbol (e.g. "(foo"): strmgetc returns EOF at a string's terminating NUL without consuming, but strmungetc decremented the position unconditionally, so pushing that EOF back re-exposed the previous char and read_list re-read it forever, consing without bound. Fixed: strmungetc(EOF) is now a no-op, as in stdio's ungetc. - Signed overflow accumulating a long digit run (num*10 + digit); saturates at INT_MAX, with (ch - '0') parenthesized so precedence can't overflow the intermediate near INT_MAX. - Signed overflow in the decimal factor*num step; saturates. - Left shift of a negative value in dice bit-packing ((dice-2)<<7 etc.) for out-of-range dice like "1d"/"1d1" (already warned, then computed anyway); fields packed as unsigned now, bit-identical for valid dice. - Signed overflow accumulating a long octal escape (8*octch + digit); only the low byte is kept, so it accumulates as unsigned now. All behavior-preserving for valid input: the quick ctest lane (555 tests, incl. check-save's save/restore fidelity comparison) stays green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3ed7b9b commit d78caed

25 files changed

Lines changed: 1773 additions & 61 deletions

.github/workflows/c-cpp.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,52 @@ jobs:
9292
- name: test
9393
run: ctest --test-dir build --output-on-failure --label-exclude long -j$(nproc)
9494

95+
fuzz:
96+
# Smoke-fuzz the GDL reader: build the opt-in libFuzzer harness
97+
# (test/fuzz/, -DXCONQ_FUZZ=ON, Clang only) and run it for a few minutes
98+
# over the checked-in corpus under ASan+UBSan. This is a regression guard
99+
# for the reader bugs the fuzzer found (see test/fuzz/README.md) -- the
100+
# corpus-replay CTest re-parses every seed, and a short live run keeps
101+
# mutating from them. Longer campaigns are a local/OSS-Fuzz job, not CI.
102+
runs-on: ubuntu-latest
103+
env:
104+
CC: clang
105+
CXX: clang++
106+
# Same rationale as the sanitizers leg: the reader never frees, so leak
107+
# detection is deferred; any ASan/UBSan finding must abort so libFuzzer
108+
# reports it as a crash.
109+
ASAN_OPTIONS: detect_leaks=0:abort_on_error=1
110+
UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
111+
112+
steps:
113+
- uses: actions/checkout@v4
114+
- name: install dependencies
115+
run: |
116+
sudo apt update
117+
sudo apt install -y clang
118+
- name: configure
119+
run: >
120+
cmake -B build
121+
-DCMAKE_BUILD_TYPE=RelWithDebInfo
122+
-DXCONQ_FUZZ=ON
123+
-DXCONQ_UI_CURSES=OFF -DXCONQ_UI_SDL=OFF
124+
- name: build
125+
run: cmake --build build -j$(nproc) --target fuzz_gdl
126+
- name: corpus regression replay
127+
# -runs=0 executes every checked-in seed once and exits nonzero on any
128+
# crash; this is the fuzz-labelled CTest.
129+
run: ctest --test-dir build --output-on-failure -L fuzz
130+
- name: smoke fuzz
131+
# A short live run (~5 min) mutating from the checked-in corpus with the
132+
# GDL keyword dictionary; -runs caps it so a fast machine still stops.
133+
run: >
134+
./build/test/fuzz/fuzz_gdl
135+
-dict=test/fuzz/gdl.dict
136+
-rss_limit_mb=4096
137+
-max_total_time=300
138+
-runs=20000000
139+
test/fuzz/corpus
140+
95141
macos:
96142
runs-on: macos-latest
97143
# sdlconq also links directly against Xlib/Xmu/Xext (see sdl/CMakeLists.txt),

CMakeLists.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ set(XCONQ_SANITIZE "" CACHE STRING
3939
set(XCONQ_TEST_TIMEOUT_SCALE 1 CACHE STRING
4040
"Integer multiplier for test timeouts (raise for slow sanitized runs)")
4141

42+
# Opt-in libFuzzer harness for the GDL reader (Clang only). When ON, builds a
43+
# dedicated fuzz_gdl target under test/fuzz/ with -fsanitize=fuzzer,address,
44+
# undefined on the reader objects it links. Kept out of default builds; see
45+
# test/fuzz/README.md. A short CI smoke-fuzz leg turns this on.
46+
option(XCONQ_FUZZ "Build the libFuzzer GDL-reader harness (Clang only)" OFF)
47+
4248
# ---------------------------------------------------------------------------
4349
# Dependency discovery. Each UI is skipped (with a warning) when its
4450
# dependencies are missing, so a bare kernel build always works.

MODERNIZATION-PLAN.md

Lines changed: 45 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -389,59 +389,51 @@ and the BWidget vendor-audit are resolved by this removal (see §4 below).
389389
Also fixed two RelWithDebInfo-only `-Wmaybe-uninitialized` errors (that build
390390
type is new to CI): `economy.cc` `newtype` and `move.cc` `acpcost`, both
391391
false positives value-inited with a comment.
392-
- **[M] Fuzz the GDL reader** (`kernel/read.c`, `kernel/lisp.c`) with
393-
libFuzzer/AFL. It parses untrusted input: downloaded game modules, network
394-
messages, and save files all go through it.
395-
396-
**⚙ PROMPT 3.4 — recommended model: Opus.** *(Harness design against a
397-
global-state kernel plus crash triage; a naive harness either won't reach
398-
the interesting code or will "find" init-order artifacts that aren't real.)*
399-
400-
```text
401-
Task: build a libFuzzer harness for Xconq's GDL reader, run it locally
402-
until quiet, fix what it finds, and wire a short CI smoke-fuzz job.
403-
This hardens a defensive surface: the reader parses downloaded modules,
404-
save files, and network-fed data.
405-
406-
PREREQUISITE: builds with Clang (§1 prompt 2.1 done). The §3 sanitizer
407-
task (3.3) is complementary but not required.
408-
Method:
409-
1. Harness (new test/fuzz/fuzz_gdl.cc): LLVMFuzzerTestOneInput feeds one
410-
input buffer through the lisp-object reader. Study kernel/lisp.c and
411-
read.c for the entry point that parses a stream/string into objects
412-
(the same machinery module loading uses); prefer the lowest entry that
413-
still exercises real parsing (strings, escapes, numbers/dice notation,
414-
symbols, nested lists) WITHOUT needing a full game world. Mind global
415-
state: the lisp package/obstack allocators persist across iterations —
416-
do one-time init in LLVMFuzzerInitialize, and confirm iteration N's
417-
parse cannot be corrupted by N-1's leftovers (interned symbols
418-
accumulate — that is acceptable memory growth, not corruption; use
419-
-rss_limit_mb generously). If interpretation of *forms* (interp_form
420-
and friends) is reachable without a full game, add a second harness
421-
for it; do not force it in one.
422-
2. Build wiring: an opt-in CMake target (XCONQ_FUZZ=ON, Clang-only,
423-
-fsanitize=fuzzer,address,undefined on the kernel objects it links).
424-
Keep it out of default builds.
425-
3. Corpus: seed from test/*.g and a selection of lib/*.g (small ones),
426-
plus a dictionary generated from kernel/keyword.def's GDL symbols
427-
(a small script can extract them — keywords steer the fuzzer into
428-
real syntax fast).
429-
4. Run locally >= a few CPU-hours (parallel jobs fine). Triage every
430-
crash/OOM: minimize (llvm's -minimize_crash), identify root cause in
431-
the reader, fix with bounds/validation matching surrounding idiom
432-
(history: the reader already had a BIGBUF off-by-one — expect
433-
relatives). Every fixed crash's minimized input goes into
434-
test/fuzz/corpus/ as a regression seed.
435-
5. CI: a short job (Clang, ~5 minutes of fuzzing with the checked-in
436-
corpus + -runs bound) as a smoke test, non-optional once green.
437-
Document longer local runs in test/fuzz/README.md. Note OSS-Fuzz as a
438-
future option on the plan item, don't apply.
439-
Verify: harness builds and runs clean over the full checked-in corpus;
440-
normal build + quick ctest unaffected and green (reader fixes are on the
441-
hot path for every game load — the lib sweep is the regression test).
442-
Commit(s); mark this item done (strikethrough + date) in
443-
MODERNIZATION-PLAN.md, recording bugs found/fixed.
444-
```
392+
- ~~**[M] Fuzz the GDL reader** (`kernel/read.cc`, `kernel/lisp.cc`) with
393+
libFuzzer/AFL.~~ *(done 7/2026)*: added a libFuzzer harness
394+
(`test/fuzz/fuzz_gdl.cc`) driving `read_form_from_string()` — the lowest
395+
reader entry, the same tokenizer/parser that module loads, save restore, and
396+
the network transfer protocol all feed untrusted bytes through — over the
397+
whole input buffer one form at a time. It links a dedicated copy of the
398+
low-level reader objects with `-fsanitize=fuzzer,address,undefined` plus a
399+
stub file mirroring `imf2imf.cc`'s standalone-reader link closure; the target
400+
is opt-in (`-DXCONQ_FUZZ=ON`, Clang only, `test/fuzz/CMakeLists.txt`) and out
401+
of default builds. Interpreting parsed forms (`interp_form` in `read.cc`) is
402+
deliberately *not* fuzzed — it needs full world state and does file I/O
403+
(include/image/default-game loading), so a harness over it would surface
404+
init-order artifacts, not parser bugs; the untrusted-bytes-to-parser surface
405+
is `lisp.cc`. Corpus (`test/fuzz/corpus/`) seeds from the reader's own
406+
regression cases plus representative GDL; `gdl.dict` is ~1000 GDL tokens
407+
extracted from `kernel/*.def` by `gen_dict.sh`. A `fuzz` CI leg (Clang) builds
408+
the harness, replays the corpus (the `fuzz`-labelled CTest), and runs ~5 min
409+
of live fuzzing under ASan+UBSan; longer runs and OSS-Fuzz are noted as future
410+
options in `test/fuzz/README.md`. Two rounds of fuzzing (a shallow-bug burst,
411+
then a ~5 CPU-hour post-fix campaign that found nothing new; coverage
412+
saturated at ~325 edges) surfaced **five memory-safety / UB bugs, all fixed in
413+
`kernel/lisp.cc`**, every one reachable from a truncated or hostile module /
414+
save / network message and every one a real BIGBUF-relative the prompt
415+
predicted:
416+
- **Infinite allocation loop (DoS)** on an unclosed list ending in a symbol
417+
(`(foo`): `strmgetc` returns EOF at a string's terminating NUL *without*
418+
consuming, but `strmungetc` decremented the position unconditionally, so
419+
pushing that EOF back re-exposed the previous char and `read_list` re-read
420+
it forever, consing without bound. Fixed by making `strmungetc(EOF, …)` a
421+
no-op, as in stdio's `ungetc`.
422+
- **Signed overflow accumulating a long digit run** (`num*10 + digit`) — UB on
423+
`999999999999`; saturates at `INT_MAX` now, with `(ch - '0')` parenthesized
424+
so operator precedence can't overflow the intermediate near `INT_MAX`.
425+
- **Signed overflow in the decimal `factor * num` step** — large value times
426+
the ×100 decimal factor; saturates now.
427+
- **Left shift of a negative value in dice bit-packing** (`(dice - 2) << 7`,
428+
`(numdice - 1) << 11`) — UB for out-of-range dice like `1d`/`1d1` (already
429+
warned about, then computed anyway); packs the fields as unsigned now,
430+
bit-identical for valid dice.
431+
- **Signed overflow accumulating a long octal escape** (`8*octch + digit`) —
432+
UB on `"\04444444444"`; only the low byte is kept, so it accumulates as
433+
unsigned (defined wrap, same result) now.
434+
All fixes are behavior-preserving for valid input: the full quick ctest lane
435+
(555 tests, including `check-save`'s save/restore fidelity comparison) stays
436+
green in the normal build.
445437
- **[S] Port the `test/*-diff.sh` / `*-uses.sh` consistency checks into CTest**
446438
(docs ↔ `.def` symbols ↔ library usage) so they can't silently rot.
447439

kernel/lisp.cc

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ strmgetc(Strm *strm)
173173
static void
174174
strmungetc(int ch, Strm *strm)
175175
{
176+
/* Pushing EOF back is a no-op, as in stdio's ungetc(EOF, ...). strmgetc
177+
returns EOF at a string's terminating NUL (or a file's end) WITHOUT
178+
consuming anything, so moving the position back here would re-expose
179+
the previous character. Several callers ungetc unconditionally after a
180+
read that may have hit EOF (e.g. the symbol/number readers), so an
181+
unclosed list ending in a symbol like "(foo" would otherwise loop
182+
forever re-reading that character and allocating without bound. */
183+
if (ch == EOF)
184+
return;
176185
if (strm->type == stringstrm) {
177186
--strm->ptr.sp;
178187
} else {
@@ -373,7 +382,12 @@ read_form_aux(Strm *strm)
373382
while ((ch = strmgetc(strm)) != EOF) {
374383
if (isdigit(ch)) {
375384
/* should ignore decimal digits past second one */
376-
num = num * 10 + ch - '0';
385+
/* Saturate instead of overflowing int (UB) on an absurdly
386+
long digit run from hostile input. */
387+
if (num > (INT_MAX - 9) / 10)
388+
num = INT_MAX;
389+
else
390+
num = num * 10 + (ch - '0');
377391
if (factor > 1)
378392
factor /= 10;
379393
} else if (ch == 'd') {
@@ -430,16 +444,27 @@ read_form_aux(Strm *strm)
430444
numdice, dice, num, num);
431445
if (!minus && minus2)
432446
num = num - 1;
433-
num =
447+
/* Pack the fields as unsigned: out-of-range dice/addon values
448+
(already warned about just above) can make (dice - 2) etc.
449+
negative, and left-shifting a negative int is UB. */
450+
num =
434451
(short)(
435-
((minus | minus2) << 15)
436-
| (((minus | minus2) ? 0 : 1) << 14)
437-
| ((numdice - 1) << 11) | ((dice - 2) << 7)
438-
| (num & 0x7f));
452+
(((unsigned) (minus | minus2)) << 15)
453+
| (((minus | minus2) ? 0u : 1u) << 14)
454+
| (((unsigned) (numdice - 1)) << 11)
455+
| (((unsigned) (dice - 2)) << 7)
456+
| ((unsigned) num & 0x7f));
439457
}
440458
// "Integerize" decimal spec, if neccesary.
441459
else {
442-
num = factor * num;
460+
/* Saturate instead of overflowing int (UB): a huge accumulated
461+
value times factor (up to 100 for a decimal) can exceed int. */
462+
if (factor > 1 && num > INT_MAX / factor)
463+
num = INT_MAX;
464+
else if (factor > 1 && num < INT_MIN / factor)
465+
num = INT_MIN;
466+
else
467+
num = factor * num;
443468
}
444469
if (!actually_read_lisp)
445470
return lispnil;
@@ -534,7 +559,10 @@ read_delimited_text(Strm *strm, const char *delim, int spacedelimits,
534559
/* Soak up numeric digits (don't complain about 8 or 9,
535560
sloppy but traditional). */
536561
while ((ch = strmgetc(strm)) != EOF && isdigit(ch)) {
537-
octch = 8 * octch + ch - '0';
562+
/* Unsigned accumulation: only the low byte is kept
563+
(ch = octch below), but a long digit run from hostile
564+
input overflows a signed int (UB). */
565+
octch = (int) (8u * (unsigned) octch + (ch - '0'));
538566
}
539567
/* The non-digit char is actually next one in the string. */
540568
strmungetc(ch, strm);

test/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,6 @@ set_tests_properties(check-ai check-long check-run PROPERTIES LABELS "long")
8888
# matching that same per-run bound; check-auto is a single fixed run.
8989
set_tests_properties(check-auto PROPERTIES TIMEOUT ${XCONQ_AUTO_TIMEOUT})
9090
set_tests_properties(check-ai check-run check-long PROPERTIES TIMEOUT ${XCONQ_LONG_TIMEOUT})
91+
92+
# The opt-in libFuzzer GDL-reader harness (only active under -DXCONQ_FUZZ=ON).
93+
add_subdirectory(fuzz)

test/fuzz/CMakeLists.txt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# libFuzzer harness for the GDL reader. Opt-in (-DXCONQ_FUZZ=ON), Clang only,
2+
# kept out of default builds. See test/fuzz/README.md.
3+
#
4+
# Xconq is free software; you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation; either version 2, or (at your option)
7+
# any later version.
8+
9+
if(NOT XCONQ_FUZZ)
10+
return()
11+
endif()
12+
13+
if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang")
14+
message(FATAL_ERROR
15+
"XCONQ_FUZZ needs Clang for libFuzzer "
16+
"(-fsanitize=fuzzer); configure with CXX=clang++.")
17+
endif()
18+
19+
# fuzzer = coverage instrumentation + libFuzzer main (linked into the exe);
20+
# address,undefined catch the memory-safety / UB bugs we are hunting. Applied
21+
# to compile *and* link of every object in the fuzz target so instrumentation
22+
# is uniform.
23+
set(XCONQ_FUZZ_FLAGS -fsanitize=fuzzer,address,undefined
24+
-fno-omit-frame-pointer -g)
25+
26+
# The low-level reader objects (kernel/conqlow set: lisp/util + platform glue),
27+
# built here with fuzz instrumentation rather than reusing the plain `conqlow`
28+
# library. Only the objects the harness actually references get pulled into
29+
# the executable from this static lib (imf/gif/socket stay out), exactly as
30+
# imf2imf links the same low-level layer standalone. Keep in sync with
31+
# CONQLOW_SOURCES in kernel/CMakeLists.txt.
32+
set(_reader_srcs
33+
${CMAKE_SOURCE_DIR}/kernel/lisp.cc
34+
${CMAKE_SOURCE_DIR}/kernel/util.cc
35+
${CMAKE_SOURCE_DIR}/kernel/unix.cc
36+
${CMAKE_SOURCE_DIR}/kernel/socket.cc
37+
${CMAKE_SOURCE_DIR}/kernel/imf.cc
38+
${CMAKE_SOURCE_DIR}/kernel/gif.cc)
39+
40+
add_library(conqfuzz_reader STATIC ${_reader_srcs})
41+
target_link_libraries(conqfuzz_reader PUBLIC xconq_common)
42+
target_compile_definitions(conqfuzz_reader PRIVATE
43+
XCONQDATA="${XCONQ_DATA_DIR}"
44+
XCONQSCORES="${XCONQ_SCORES_DIR}")
45+
target_compile_options(conqfuzz_reader PRIVATE ${XCONQ_FUZZ_FLAGS})
46+
47+
add_executable(fuzz_gdl fuzz_gdl.cc fuzz_stubs.cc)
48+
target_link_libraries(fuzz_gdl PRIVATE conqfuzz_reader xconq_common)
49+
target_compile_options(fuzz_gdl PRIVATE ${XCONQ_FUZZ_FLAGS})
50+
target_link_options(fuzz_gdl PRIVATE ${XCONQ_FUZZ_FLAGS})
51+
52+
# CTest smoke test: replay the whole checked-in corpus under the sanitizers
53+
# (no mutation, -runs=0 just executes each seed and exits nonzero on any
54+
# crash). This is the regression guard for every reader bug the fuzzer found.
55+
# The corpus dir is passed as a directory argument; libFuzzer runs each file.
56+
add_test(NAME fuzz-gdl-corpus
57+
COMMAND fuzz_gdl -runs=0 ${CMAKE_CURRENT_SOURCE_DIR}/corpus)
58+
set_tests_properties(fuzz-gdl-corpus PROPERTIES
59+
LABELS "fuzz"
60+
TIMEOUT 120
61+
# detect_leaks=0: the reader interns/allocates and frees nothing by design
62+
# (same rationale as the sanitizers CI leg), so LSan would flag the corpus.
63+
# halt_on_error makes any UB finding fail the test.
64+
ENVIRONMENT "ASAN_OPTIONS=detect_leaks=0:abort_on_error=1;UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1")

0 commit comments

Comments
 (0)