Lab 3 — Reproduce the Use-After-Free¶
This lab walks you through the exact memory bug that was found and fixed in src/parser.c. You will trigger it with AddressSanitizer, read the report, study the two-line fix, verify that the fixed code exits cleanly, and then run the project's fuzzer to confirm no similar bugs remain. When you finish, you will have a concrete mental model of what "heap-use-after-free" means and why NULL-ing a pointer after free is non-negotiable.
Background¶
A heap-use-after-free occurs when code frees a block of heap memory and then reads or writes through a pointer that still points to that block. The memory manager may have already reused the block for something else — or left it zeroed — so the read returns garbage. Worse, with AddressSanitizer enabled, the allocator poisons freed memory and the program aborts the moment a stale pointer is dereferenced.
Here is the sequence that caused the bug in CargoForge-C:
parse_cargo_listencounters a bad weight field (safe_atofreturns NaN).- The error path calls
free(ship->cargo)— correct. - But it does not set
ship->cargo = NULLorship->cargo_count = 0. - The caller eventually invokes
ship_cleanup, which iteratesfor (int i = 0; i < ship->cargo_count; i++)and touchesship->cargo[i].dg. ship->cargo_countis still non-zero, so the loop runs — over the freed block.
ASan detects the access in step 5 and kills the process with exit code 134 (SIGABRT ≥ 128).
Prerequisites¶
- You have completed Lab 1 and Lab 2 (the repo builds cleanly with
make). - You are at the repo root:
CargoForge-C/. gcc(orclang) supports-fsanitize=address,undefined.
Step 1 — Verify the ordinary build works¶
Expected output (version string may differ):
A clean ordinary build confirms you have a working baseline before adding sanitizers.
Step 2 — Build the ASan binary¶
The fuzzer builds its own ASan binary at build/cargoforge-asan. You can also build it manually, which is exactly what scripts/fuzz.sh does on first run:
cc -O1 -g -std=c99 -D_POSIX_C_SOURCE=200809L -Iinclude \
-fsanitize=address,undefined -fno-omit-frame-pointer \
src/*.c -lm -o build/cargoforge-asan
Note
-O1 (not -O3) keeps the compiler from optimizing away the stale pointer
access before ASan gets a chance to catch it. -g adds debug symbols so
the ASan report shows file names and line numbers.
When the compilation finishes you will see the binary at build/cargoforge-asan. No output means success.
Step 3 — Create a minimal ship config¶
The validate subcommand parses the ship config and the cargo manifest, then reports errors without running the full analysis. It still calls ship_cleanup on exit, which is what triggers the dangling-pointer access.
cat > /tmp/lab_ship.cfg << 'EOF'
length_m=150
width_m=25
max_weight_tonnes=50000
lightship_weight_tonnes=2000
lightship_kg_m=8.0
EOF
Step 4 — Create a manifest with a bad weight¶
The bug fires when safe_atof rejects the weight field. The value abc is not a number, so it will return NaN and trigger the error path:
cat > /tmp/lab_cargo_bad.txt << 'EOF'
# One valid item, one item with a bad weight
BoxA 100 10x5x3 standard
BoxB abc 10x5x3 standard
EOF
BoxA parses successfully — ship->cargo_count becomes 1 and ship->cargo is a live pointer. When the parser hits BoxB, it detects the bad weight, frees ship->cargo, but (in the buggy version) leaves ship->cargo_count = 1. ship_cleanup then walks the freed array.
Step 5 — Trigger the bug with ASan¶
Set the ASan options so the sanitizer aborts immediately on the first error, then run validate:
export ASAN_OPTIONS="abort_on_error=1"
export UBSAN_OPTIONS="halt_on_error=1:abort_on_error=1:print_stacktrace=1"
build/cargoforge-asan validate /tmp/lab_ship.cfg /tmp/lab_cargo_bad.txt
echo "Exit code: $?"
What you are looking at — the current (fixed) code
The bug was already fixed before this repo was published. The fix is in
src/parser.c lines 336–337 and 364–365. So the command above will exit
cleanly with a non-zero error code (parse failure) but no ASan report.
The steps below show you the exact two lines responsible for the fix, and the
Solution section walks you through reverting them to reproduce the original crash.
Expected output from the fixed code:
No AddressSanitizer banner. No SIGABRT. Exit code 1 means "input rejected" — a clean error, not a crash.
Step 6 — Examine the fix in src/parser.c¶
Open src/parser.c and find the weight-parse error path around line 333:
float weight_t = safe_atof(w_str, 0.1f, 1e6f, "weight");
if (isnan(weight_t)) {
for (int j = 0; j < ship->cargo_count; j++) free(ship->cargo[j].dg);
free(ship->cargo);
ship->cargo = NULL; // avoid a dangling pointer -> use-after-free in ship_cleanup
ship->cargo_count = 0; // avoid iterating over freed memory
/* ... cleanup stdin buffer or fclose ... */
return -1;
}
The two lines marked are the entire fix. Without them:
ship->cargostill holds the address of freed memory.ship->cargo_countis still 1.ship_cleanupiteratescargo[0].dg— reads from a freed block — and ASan kills the process.
The identical pattern appears again at the dimension-parse error path (around line 362) for the same reason: any error path that calls free(ship->cargo) must immediately NULL the pointer and zero the count.
The general rule
After free(ptr), always write ptr = NULL. After freeing an array whose
length is tracked separately, zero the length field immediately after. These
two lines together make every downstream code path safe, regardless of what
it checks or does not check.
Step 7 — Run the full test suite under ASan¶
make test-asan rebuilds everything with -fsanitize=address,undefined and then runs all 8 test binaries:
Expected final lines:
All 8 test binaries pass without any ASan report. This confirms that no other code path in the engine has the same class of bug.
Step 8 — Run the fuzzer¶
The fuzzer (scripts/fuzz.sh) automates exactly what you did manually in Steps 2–5 — but at scale. It generates 300 randomly malformed ship configs and cargo manifests (including bad weights, bad dimensions, invalid DG fields, empty fields, overflow values) and feeds them to build/cargoforge-asan. Any exit code ≥ 128 or any line in stderr matching AddressSanitizer, runtime error:, or UndefinedBehavior is a FAIL.
The script builds the ASan binary if it does not already exist (you built it in Step 2, so it will skip the compile step). Each of the 300 iterations prints nothing on success. A passing run ends with:
The fuzzer is how this bug was originally discovered: one of the adversarial VALS entries (abc, 0.0, -5, etc.) triggered the bad-weight path, and ASan reported the use-after-free on the subsequent ship_cleanup call.
Solution¶
To reproduce the original crash you need to temporarily remove the two fix lines, rebuild the ASan binary, and re-run Step 5. Here is the exact procedure:
1. Remove the NULL-out lines from both error paths in src/parser.c.
In the weight-parse error path (≈ line 336–337), delete:
In the dimension-parse error path (≈ line 364–365), delete the same two lines.
2. Rebuild the ASan binary (force a rebuild by removing the old one):
rm build/cargoforge-asan
cc -O1 -g -std=c99 -D_POSIX_C_SOURCE=200809L -Iinclude \
-fsanitize=address,undefined -fno-omit-frame-pointer \
src/*.c -lm -o build/cargoforge-asan
3. Run Step 5 again:
You will now see an ASan report similar to:
=================================================================
==XXXXX==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 8 at 0x... thread T0
#0 0x... in ship_cleanup src/analysis.c:...
#1 0x... in parse_cargo_list src/parser.c:...
...
Exit code: 134
Exit code 134 = SIGABRT (128 + 6). The fuzzer's rc >= 128 check catches exactly this.
4. Restore the fix:
Apply to both error paths, rebuild, and confirm Step 5 exits with code 1 and no ASan output.
Recap¶
- A heap-use-after-free happens when freed memory is later accessed through a stale pointer. C gives you no runtime protection — without a tool like ASan, the access silently reads garbage.
- The bug in
parse_cargo_listleftship->cargopointing to freed memory andship->cargo_countnon-zero. The cleanup function trusted the count and dereferenced the stale pointer. - The fix is two lines:
ship->cargo = NULL; ship->cargo_count = 0;— applied immediately after everyfree(ship->cargo)on an error path. make test-asanrebuilds the whole codebase with AddressSanitizer and UBSan, then runs the test suite. A clean run is a strong signal the codebase is free of this class of bug.make fuzz(viascripts/fuzz.sh) generated 300 adversarial inputs includingabcas a weight value, which is what triggered the original bug report. A passing fuzz run means the parser rejects all malformed input without crashing.
Next: Make and CMake.