← Writeups

One byte crashes libConfuse: a null deref hiding in comment parsing

20 Jul 2026 fuzzinglibconfusenull-derefcwe-476

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