DEV Community

Cover image for My parser read past the input. AddressSanitizer stayed quiet.
Shivam Dwivedi
Shivam Dwivedi

Posted on

My parser read past the input. AddressSanitizer stayed quiet.

I gave a deliberately broken parser three bytes. It read byte four. AddressSanitizer said nothing.

Then I changed the allocation around the input. Same parser, same three bytes, same compiler flags: heap-buffer-overflow. There it was. ๐Ÿงช

The bug hadn't appeared between runs. My first test had given it somewhere comfortable to hide.

What I Built

I made a small C reproducer for a fuzz-harness trap: the length of a test case can be smaller than the memory holding it.

Chris Wellons describes this in his AFL++ guide, which is circulating again in the fuzzing discussion. It's an older guide, not a new AFL++ release. The useful detail is the warning about passing a reusable input buffer straight into a target.[1]

A fuzzer's harness is the bit of code that hands generated inputs to the program. If it hands over a short input inside a larger allocation, an invalid read relative to the input can still touch allocated memory.

AddressSanitizer, or ASan, instruments memory accesses to catch errors such as heap overflows.[2] It doesn't automatically know the meaning of every length argument in your API.

The same three-byte input occupies either a 64-byte allocation or an exact three-byte allocation. Reading index three stays inside the first allocation but reaches the poisoned boundary of the second.

Original diagram of the executed example. The wider buffer is a synthetic stand-in for reusable backing storage.

Demo

Here's the complete intentionally broken program. Save it as boundary.c:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static volatile unsigned char observed;
__attribute__((noinline))
static void parse(const unsigned char *data, size_t size) {
    observed = data[size]; /* Deliberate bug: first byte past logical input. */
}
int main(int argc, char **argv) {
    if (argc != 2) return 2;
    size_t size = 3;
    size_t capacity = strcmp(argv[1], "exact") == 0 ? size : 64;
    unsigned char *data = malloc(capacity);
    if (!data) return 2;
    memset(data, 0, capacity);
    memcpy(data, "ABC", size);
    parse(data, size);
    free(data);
    puts("returned without an ASan report");
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

The volatile destination keeps the read observable. The parser is deliberately wrong: for a nonempty input, the last valid index is size - 1.

Compile it with Clang and its ASan runtime available:

clang -O1 -g -fsanitize=address -fno-omit-frame-pointer boundary.c -o boundary
./boundary shared
./boundary exact
Enter fullscreen mode Exit fullscreen mode

The first command to run the program printed:

returned without an ASan report
Enter fullscreen mode Exit fullscreen mode

The exact-size version exited with an ASan heap-buffer-overflow report. โœ…

Both inputs contain ABC, with logical length three. The first allocation has room for 64 bytes; the second has room for three. Reading data[3] violates the parser's input contract in both runs, but only crosses the allocated region in the second.

Zero-filling the larger buffer makes this example repeatable. The extra capacity hides the boundary violation; the zeros aren't a safety check.

Change the harness and fix the parser

For this kind of harness, a useful check is to copy the current input into an allocation sized to that input before calling the target. Handle allocation failure and the target's empty-input contract explicitly. If the API expects a terminating zero, its contract also needs space for that byte.

That makes the allocation boundary match the boundary you want ASan to observe. It doesn't repair the target.

The actual fix in this example is:

if (size != 0) observed = data[size - 1];
Enter fullscreen mode Exit fullscreen mode

I compiled that version with the same sanitizer flags. The shared-buffer, exact-size and empty-input cases all returned successfully.

โš ๏ธ A quiet sanitizer run is evidence about the accesses it could check. It isn't proof that the program respected your logical input length.

A second harness check is worth borrowing from a DEV postmortem by @malwaredante: verify that generated inputs reach the intended parser at all. Their mutator selected a different parser while coverage continued growing.[3] Buffer boundaries and target selection are separate ways for a busy test run to miss its purpose. ๐Ÿ”

Partner Technologies

This demo uses C, Clang and AddressSanitizer. The AFL++ documentation supplies context for reusable buffers and persistent-mode harnesses; this reproduction runs directly, without an AFL++ campaign.[4]

The important limit: exact-size input copies can expose reads past the allocation. They don't catch every semantic mistake within an otherwise valid allocation, and a clean result isn't a safety guarantee.

Hackathon Experience

This is a standalone local demo. It wasn't built at an MLH event and isn't a competition entry.

References

  1. Chris Wellons โ€” Tips for more effective fuzz testing with AFL++
  2. Clang โ€” AddressSanitizer documentation
  3. @malwaredante โ€” The Illusion of Progress: how a typo in my custom mutator sent my fuzzer into the wrong parser
  4. AFL++ โ€” Persistent-mode documentation

Asset credits

Original memory-layout diagram, based on the checked reproducer.

AI disclosure

AI was used to draft the article.

Top comments (0)