TL;DR: A short fuzzing-plus-static-review session against
libConfuse — the INI-style
configuration parser used by a long tail of daemons and tools — turned up a
null-pointer dereference (CWE-476) in its comment lexer. When an application
enables CFGF_COMMENTS, a configuration line consisting of nothing but a comment
introducer crashes the parser. The smallest reproducer is a single byte: #.
Reported as libconfuse/libconfuse#187;
fixed upstream in df808867a8cc with regression tests, credited, and scheduled
for release 3.4.
It’s a modest bug — a crash-only denial of service, not memory corruption — but it’s a nice illustration of two things: how far a minimal PoC narrows a root cause, and how a lexer invariant that “can’t” be violated quietly is.
Picking the target
The rule that keeps paying off: parse-untrusted-input, C, actually-deployed, and not already ground on by OSS-Fuzz or a maintainer’s own in-repo fuzzer. libConfuse fits — it’s a small C library that parses attacker-supplyable config text, it’s vendored and packaged widely, it’s not an OSS-Fuzz project, and there’s no fuzz harness in the tree. An active maintainer, too, which matters for actually getting a fix landed.
Two AFL++ persistent-mode harnesses covered the parser: one over the typed
schema/raw/nested paths, one over dynamic CFGF_KEYSTRVAL option creation, both
built with ASan/UBSan. The comment-enabled campaigns surfaced a cluster of
crashes that all deduplicated, by ASan stack, to one fault.
The bug: an empty comment has no value
libConfuse tokenizes comments in a small flex lexer. When it sees a comment line,
qstr() strips the leading comment introducers (#) and qput() accumulates the
remaining bytes into a quoted-string buffer; qend() finalizes the token and sets
cfg_yylval to the collected string.
The unstated assumption is that a comment token always has some value. It
doesn’t. For a line that is only comment introducers — #, or ##, or ### —
qstr() removes every character, qput() is never called, and the buffer pointer
cfg_qstring is still NULL. qend() happily returns a comment token whose
cfg_yylval is that NULL.
Downstream, with CFGF_COMMENTS enabled, cfg_parse_internal() trusts the
invariant and does:
comment = strdup(cfg_yylval); /* cfg_yylval == NULL */
strdup(NULL) dereferences the null pointer. UBSan calls it first, then it’s a
plain SEGV:
src/confuse.c: runtime error: null pointer passed as argument 1
AddressSanitizer: SEGV on address 0x000000000000
The minimal PoC, and why it matters
The smallest input that triggers it in the latest release (v3.3) is the one-byte file:
#
That one byte is doing real work in the diagnosis. It says the crash isn’t about comment contents, or interaction with a key/value, or a particular parser state reached after valid input — it’s the empty comment itself, at parser state 0, before anything else has happened. A minimal PoC is the cheapest root-cause tool there is: every byte you can delete is a hypothesis you can discard.
It also cleared up a red herring. A recent upstream change (2026-07-18) looked
like it might have introduced the bug — it broadened the lexer states from which
an empty comment is reachable (e.g. raw# is another door in). But feeding # to
a stock v3.3 build crashes it just the same. The change widened reachability;
it didn’t create the bug. Worth getting right in the report, so the fix targets
the real cause rather than the most recent diff.
The fix: restore the invariant
The clean fix keeps the lexer’s own contract — that a comment token carries a
valid (possibly empty) C string — rather than patching every consumer to
null-check. Make qend() materialize an empty, NUL-terminated buffer when no
comment bytes were captured:
static int qend(cfg_t *cfg, int trim, int ret)
{
- char *ptr = cfg_qstring;
+ char *ptr;
BEGIN(INITIAL);
if (cfg)
cfg->line++;
+ if (!cfg_qstring)
+ qputc('\0');
+ ptr = cfg_qstring;
+
if (trim)
ptr = trim_whitespace(cfg_qstring, qstring_index);
strdup now receives a valid empty string, the token is well-formed, and the PoC
parses cleanly with no sanitizer finding. One place, one invariant, no behavior
change for any non-empty comment.
Triage discipline
The comment-enabled harnesses saved 31 unique-ish crash files. Replaying every one
of them against its original comment-enabled configuration, they all deduplicate
to this same strdup(NULL) — there was no hidden second bug in the pile. That’s
the boring-but-important step: a fuzzer’s crash count is not a bug count until you
minimize and dedupe by root cause. Reporting “31 crashes” would have been noise;
reporting one bug with a one-byte PoC and a three-line fix is a thing a maintainer
can act on in an afternoon.
Disclosure
Reported publicly as issue #187
on 2026-07-20, since the project directs bug reports to its public tracker and the
impact (a crash-only DoS in a config parser) is proportionate to public handling.
The maintainer fixed it in df808867a8cc, added regression tests for the empty and
whitespace-only comment cases, credited the report, and slated it for release 3.4.
A fresh build at the exact fix commit passes the original PoC under ASan/UBSan.
Takeaways
- Minimal PoCs are diagnostic instruments, not just evidence. One byte located the fault at the lexer, ruled out contents/interaction, and exposed the false “the last commit did it” lead.
- Invariants that hold “in practice” are exactly where parsers break. “A comment always has a value” was true for every comment anyone had tested — until the empty one.
- Crashes aren’t bugs until you dedupe them. Thirty-one files, one root cause, one three-line fix.