Reproducible AFL++ fuzzing environment for the CS-412 Software Security
fuzzing lab. The target is libpng 1.6.50, the reference PNG decoder.
The Dockerfile compiles libpng three times and ships four harness binaries so every campaign and measurement required by the report runs from the same container:
| Binary | libpng build | Purpose |
|---|---|---|
png_fuzz |
afl-clang-fast + ASan |
Main white-box campaign, crash triage |
png_fuzz_nosan |
afl-clang-fast, no sanitizer |
Q8 no-ASan exec-speed baseline |
png_fuzz_persistent |
afl-clang-fast + ASan |
Q8 persistent-mode exec-speed measurement |
png_fuzz_qemu |
vanilla gcc -O1 -g |
Q7 binary-only AFL++ QEMU campaign |
The harness drives the standard libpng decoding pipeline
(png_read_info → transforms → png_read_update_info → png_read_image
→ png_read_end) and enables png_set_quantize, which is what makes the
png_do_quantize path (CVE-2025-64505 on libpng < 1.6.51) reachable.
.
|-- Dockerfile # Reproducible AFL++ + libpng build environment
|-- Makefile # build / fuzz* / plot* / sanity / shell / clean
|-- project.pdf # Assignment handout
|-- report.pdf # Final report
|-- Report_tex/ # USENIX-style report sources and figures
|-- q8_data.txt # Q8 edge counts and exec-speed numbers
|-- png.dict # PNG grammar tokens for AFL++ dictionary mutations
|-- patches/
| `-- png_crc_finish-return0.patch # CRC-bypass patch for libpng 1.6.x
|-- src/
| |-- harness.c # Fork-mode harness (also supports SYNTHETIC_BUG_Q5)
| `-- harness_persistent.c # Persistent-mode harness (__AFL_LOOP)
|-- seeds/
| |-- gen_seeds.py # Regenerates the 8 PNG seeds below
| |-- palette_small.png # color_type=3, 4-entry PLTE (reaches png_do_quantize)
| |-- palette_trns.png # palette + tRNS
| |-- rgb.png / rgba.png
| |-- gray.png / gray16.png # gray16.png exercises png_set_strip_16
| |-- interlaced.png # Adam7
| `-- text_gamma.png # tEXt + gAMA ancillary chunks
|-- findings/ # Instrumented + ASan campaign output (gitignored)
|-- findings-nosan/ # No-ASan + fork-mode campaign output (gitignored)
|-- findings-persistent/ # Persistent-mode campaign output (gitignored)
|-- findings-qemu/ # QEMU-mode campaign output (gitignored)
|-- findings-synthetic/ # Q5 synthetic-bug campaign output (gitignored)
|-- plot_output/ # afl-plot graphs, instrumented (gitignored)
`-- plot_output_qemu/ # afl-plot graphs, QEMU (gitignored)
The Dockerfile builds libpng-1.6.50.
AFL++'s bundled libpng_no_checksum/libpng-nocrc.patch only matches the
1.2.x source layout, so this repository ships a custom patch:
patches/png_crc_finish-return0.patch
It short-circuits png_crc_error() in pngrutil.c to always return 0
while still consuming the 4 CRC bytes from the stream, so the parser
stays in sync. png_crc_error() is the single chokepoint reached by
both png_crc_finish() and png_crc_finish_critical() in libpng 1.6.x,
so the patch covers critical and ancillary chunks alike. Without it,
mutated chunks would be rejected at the CRC boundary and coverage would
stall.
src/harness.c (fork-mode) and src/harness_persistent.c (persistent
mode via __AFL_LOOP) share the same fuzz_one(data, size) body:
- Reject inputs shorter than the 8-byte PNG signature.
- Match the signature with
png_sig_cmp. - Create
png_structp/png_infop. - Install a
setjmphandler so libpngpng_error/longjmpreturns cleanly instead of being recorded as an AFL++ crash. - Register a memory-backed read callback (
mem_read_fn) so libpng parses straight from the fuzzer buffer with no file-system detour. - Call
png_read_info. - Enforce
width, height≤ 4096 to keep per-rowmallocs small and stability high. - Enable transformations:
png_set_quantize,png_set_expand,png_set_strip_16,png_set_gray_to_rgb. png_read_update_info, allocate row buffers,png_read_image,png_read_end, free, destroy.
png_set_quantize is what activates the png_do_quantize pipeline,
which is where CVE-2025-64505 lives.
size < 8andpng_sig_cmp(...) != 0: skip obviously-invalid inputs before allocating any libpng state.setjmp(png_jmpbuf(png)): turns normal libpng parse errors into clean harness returns instead of false-positive AFL++ crashes.mem_read_fnbounds check: a truncated input becomes a libpngpng_error/longjmpinstead of a harness out-of-bounds read.MAX_DIM = 4096: rejects mutated IHDRs that claim huge dimensions (e.g. 65535×65535), which would otherwise OOM-kill the fuzzer and drop stability.- Per-row
mallocreturn checks: an allocation failure on rowifrees rows0…i-1so persistent-mode iterations do not leak state. MAX_INPUT_SIZE(fork-modemain): caps testcase size at 16 MiB.
png.dict contains the PNG grammar terminals AFL++ can inject during
mutation: the 8-byte file signature, the critical chunk types (IHDR,
PLTE, IDAT, IEND), and the ancillary chunk types (tRNS, gAMA,
cHRM, sRGB, iCCP, tEXt, zTXt, iTXt, bKGD, pHYs, tIME,
sBIT, hIST, sPLT).
seeds/gen_seeds.py produces eight minimal PNGs, each exercising a
distinct format axis (grayscale, RGB, RGBA, 4-entry palette, palette +
tRNS, 16-bit, interlaced, ancillary chunks). To regenerate them:
python3 seeds/gen_seeds.pyThe Docker build also copies AFL++'s bundled PNG testcases from
/AFLplusplus/testcases/images/png/ into /work/seeds/.
make buildThe Docker build:
- Starts from
aflplusplus/aflplusplus:latest. - Downloads
libpng-1.6.50. - Applies
patches/png_crc_finish-return0.patch. - Builds libpng three times (AFL+ASan, AFL no-ASan, vanilla GCC).
- Compiles
png_fuzz,png_fuzz_nosan,png_fuzz_persistent, andpng_fuzz_qemu.
make sanityRuns ./png_fuzz on the first PNG seed available in the container.
Should print exit: 0 with no ASan report.
All campaigns default to DURATION_SEC=1800 (30 minutes). Override with
DURATION_SEC=<seconds> make ....
| Target | Binary used | Output directory |
|---|---|---|
make fuzz |
png_fuzz |
findings/ |
make fuzz-nosan |
png_fuzz_nosan |
findings-nosan/ |
make fuzz-persistent |
png_fuzz_persistent |
findings-persistent/ |
make fuzz-qemu |
png_fuzz_qemu (-Q) |
findings-qemu/ |
make fuzz-synthetic |
png_fuzz_synthetic (built on demand with -DSYNTHETIC_BUG_Q5) |
findings-synthetic/ |
fuzz-synthetic rebuilds the harness with the off-by-many synthetic
write enabled (gated on data[15] != 'R') and is used in Q5 to prove
the campaign pipeline catches injected memory bugs.
make plot # findings/default -> plot_output/
make plot-qemu # findings-qemu/default -> plot_output_qemu/Each output directory contains index.html, edges.png,
exec_speed.png, high_freq.png, and low_freq.png.
make shellBind-mounts every findings* directory and drops into a bash shell —
useful for afl-tmin, ASan symbolization, and inspecting raw AFL++
output.
make cleanRemoves every findings*/ and plot_output*/ directory (via the
container so root-owned files inside disappear too) and deletes the
softsec-libpng-fuzz Docker image.
If findings/default/crashes/ is populated:
make shell
./png_fuzz findings/default/crashes/<crash-file>
afl-tmin -i findings/default/crashes/<crash-file> -o minimized.png -- ./png_fuzz @@
ASAN_OPTIONS=symbolize=1:abort_on_error=1:detect_leaks=0 ./png_fuzz minimized.pngThe instrumented and QEMU 30-minute campaigns documented in report.pdf
produced no real crashes; the Q5 synthetic-bug variant (make fuzz-synthetic) is the demonstration that the setup detects memory
bugs end-to-end.
The written report is report.pdf (USENIX style, 4 pages + appendix).
LaTeX sources and figures live in Report_tex/. Raw numbers for Q8
(edge counts and exec speed) are in q8_data.txt.