Fuzzing for Security Vulnerabilities: A Practical Guide

Fuzzing for Security Vulnerabilities: A Practical Guide

Fuzzing has found more real-world security vulnerabilities than almost any other technique. The Heartbleed bug, dozens of Chrome V8 exploits, hundreds of ImageMagick CVEs — all found by fuzzers. This isn't coincidence. Fuzzing excels precisely at the class of bugs that matter most in security: edge cases in input parsing, integer arithmetic errors, and state machine violations that developers never think to test.

This guide covers how to set up fuzzing specifically for security research, not just quality assurance.

Why Fuzzing Finds Security Bugs

Security vulnerabilities cluster in predictable places:

  • Input parsing: Every byte of untrusted data that your program reads is an attack surface
  • Integer arithmetic: Off-by-one errors, overflow/underflow, sign confusion
  • Memory management: Buffer overflows, use-after-free, double-free
  • State machines: Unexpected transitions, race conditions between requests
  • Format string handling: Anywhere user data could reach printf-family functions

Fuzzing is uniquely effective here because these bugs are triggered by specific input values or sequences that no human would think to include in a test case. A fuzzer tries millions of variations; a human writes dozens.

Sanitizers: From Crash to Vulnerability

A program can have a buffer overflow without immediately crashing — it might just silently corrupt memory and continue running. Without sanitizers, you'd miss it. With sanitizers, it becomes an explicit error.

AddressSanitizer (ASan)

ASan catches:

  • Heap buffer overflow
  • Stack buffer overflow
  • Use-after-free
  • Use-after-return
  • Double-free
clang -fsanitize=address -fno-omit-frame-pointer -g \
    -O1 target.c -o target_asan

# Or with libFuzzer
clang -fsanitize=fuzzer,address -fno-omit-frame-pointer -g \
    -O1 harness.c target.c -o fuzz_asan

ASan adds ~2x memory overhead and slows execution by 2x. Worth it.

Example: Finding a Heap Overflow

// vulnerable.c
#include <stdlib.h>
#include <string.h>

typedef struct {
    int type;
    int length;
    char *data;
} Packet;

Packet *parse_packet(const uint8_t *buf, size_t buf_len) {
    if (buf_len < 8) return NULL;
    
    Packet *p = malloc(sizeof(Packet));
    p->type = *(int *)buf;
    p->length = *(int *)(buf + 4);
    
    // BUG: p->length is attacker-controlled, no bounds check
    p->data = malloc(p->length);
    memcpy(p->data, buf + 8, p->length);  // heap overflow if length > buf_len - 8
    
    return p;
}
// fuzz_harness.c
#include <stdint.h>
#include <stddef.h>
#include <stdlib.h>

extern Packet *parse_packet(const uint8_t *buf, size_t buf_len);

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    Packet *p = parse_packet(data, size);
    if (p) {
        free(p->data);
        free(p);
    }
    return 0;
}

Compile and run:

clang -fsanitize=fuzzer,address -g -O1 \
    fuzz_harness.c vulnerable.c -o fuzz_packet

./fuzz_packet -max_total_time=60

ASan will report the heap overflow with a full stack trace, the allocation site, and the write that went out of bounds.

UndefinedBehaviorSanitizer (UBSan)

UBSan catches undefined behavior that compilers can legally exploit:

clang -fsanitize=fuzzer,address,undefined \
    -fsanitize=integer \
    harness.c target.c -o fuzz_ubsan

Particularly useful flags:

  • -fsanitize=signed-integer-overflow: Catches signed integer overflow (undefined in C)
  • -fsanitize=unsigned-integer-overflow: Catches unsigned overflow (defined but often a bug)
  • -fsanitize=shift: Catches invalid shift operations
  • -fsanitize=null: Catches null pointer dereference before it crashes

MemorySanitizer (MSan)

MSan catches reads of uninitialized memory — a class of vulnerability that ASan misses entirely:

// Uninitialized read: may leak stack/heap data to attacker
char response[512];
int len = build_response(input, response);  // May not initialize all of response
send(sock, response, len);  // Sending uninitialized bytes
# MSan requires clang, not GCC
clang -fsanitize=fuzzer,memory -fno-omit-frame-pointer -g \
    harness.c target.c -o fuzz_msan

MSan is expensive (~3x slowdown) but finds the bugs that lead to information disclosure vulnerabilities.

Targeting Security-Relevant Code

Not all code is equally worth fuzzing from a security perspective. Prioritize:

Network Protocol Parsers

Any code that parses data received from the network is highest priority:

// fuzz_tls_record.c — fuzzing TLS record parsing
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    // Wrap raw bytes in a fake SSL struct
    BIO *bio = BIO_new_mem_buf(data, size);
    SSL *ssl = create_test_ssl_context();
    SSL_set_bio(ssl, bio, bio);
    
    // Attempt to read a record — this exercises the TLS record layer parser
    uint8_t buf[16384];
    SSL_read(ssl, buf, sizeof(buf));
    
    SSL_free(ssl);
    return 0;
}

Authentication and Authorization Code

Auth bugs are high-severity. Fuzz token parsers, signature verification, and permission checks:

# Fuzz JWT parsing with Atheris (Python fuzzer backed by libFuzzer)
import atheris
import sys
import jwt

def fuzz_jwt(data):
    fdp = atheris.FuzzedDataProvider(data)
    token = fdp.ConsumeUnicodeNoSurrogates(200)
    secret = fdp.ConsumeBytes(32)
    algorithms = ["HS256", "HS384", "HS512", "RS256", "none"]
    
    try:
        # Should never crash, only raise exceptions
        jwt.decode(token, secret, algorithms=algorithms)
    except Exception:
        pass  # Expected for malformed tokens

atheris.Setup(sys.argv, fuzz_jwt)
atheris.Fuzz()

Run with:

pip install atheris jwt
python fuzz_jwt.py -max_total_time=300

Deserialization

Deserialization vulnerabilities are notoriously dangerous. Fuzz any code that deserializes untrusted data:

import atheris
import sys
import pickle
import json
import yaml

def fuzz_deserializers(data):
    fdp = atheris.FuzzedDataProvider(data)
    choice = fdp.ConsumeIntInRange(0, 2)
    payload = fdp.ConsumeBytes(len(data))
    
    try:
        if choice == 0:
            json.loads(payload)
        elif choice == 1:
            yaml.safe_load(payload)
        # Note: never fuzz pickle with untrusted data in production!
        # elif choice == 2:
        #     pickle.loads(payload)  # Intentionally dangerous
    except Exception:
        pass

atheris.Setup(sys.argv, fuzz_deserializers)
atheris.Fuzz()

Finding Injection Vulnerabilities

Injection bugs (SQL, command, path traversal) require the fuzzer to generate strings that break out of expected contexts. Use a dictionary with known injection payloads:

# injection_dict.txt
kw1="' OR '1'='1"
kw2="'; DROP TABLE users;--"
kw3="../../../etc/passwd"
kw4="$(id)"
kw5="`id`"
kw6="${IFS}id"
kw7="%00"
kw8="<script>alert(1)</script>"
kw9="{{7*7}}"
kw10="%27"
# AFL++ with injection dictionary
afl-fuzz -i seeds/ -o findings/ -x injection_dict.txt ./web_handler @@

For REST APIs, Schemathesis automatically tries injection strings in string fields. You can extend its default set:

from hypothesis import strategies as st
import schemathesis

# Add custom injection strings to Schemathesis's generation strategy
injection_strings = [
    "' OR '1'='1",
    "../../../etc/passwd",
    "${7*7}",
    "'; exec xp_cmdshell('whoami')--",
]

@schemathesis.hook
def filter_query(context, value):
    return value

schema = schemathesis.from_uri("http://localhost:8000/openapi.json")

@schema.parametrize()
def test_injection(case):
    # Inject our strings into string parameters
    for param in case.formatted_parameters.values():
        if isinstance(param, str):
            for payload in injection_strings:
                case_copy = case.evolve(formatted_parameters={**case.formatted_parameters})
                response = case_copy.call()
                # A 500 on injection payload is suspicious
                assert response.status_code != 500, \
                    f"Server error on injection payload: {payload}"

Triaging and Reproducing Crashes

Finding a crash is step one. Turning it into a confirmed vulnerability requires triage.

Step 1: Minimize the Input

Large crash inputs are hard to analyze:

# libFuzzer crash minimization
./fuzz_target -minimize_crash=1 -max_total_time=30 crash-abc123

# AFL++ minimization
afl-tmin -i crash-abc123 -o minimized_crash ./target @@

Step 2: Determine Exploitability

Not all crashes are equally severe. Tools to help:

# exploitable: GDB plugin that classifies crashes
pip install exploitable
gdb -batch -ex "source /path/to/exploitable.py" \
    -ex "run < minimized_crash" \
    -ex "exploitable" ./target

Output categories:

  • EXPLOITABLE: High confidence this is exploitable (control flow hijack, write-what-where)
  • PROBABLY_EXPLOITABLE: Likely exploitable with more effort
  • PROBABLY_NOT_EXPLOITABLE: Crash but limited impact (null deref in non-critical path)
  • NOT_EXPLOITABLE: Crash with no exploitability

Step 3: Write a Regression Test

Every confirmed vulnerability needs a regression test — a minimal input that triggers the bug, to be run as a unit test forever:

// test_cve_2024_XXXX.c — regression test for the heap overflow
#include <assert.h>
#include "vulnerable.h"

void test_length_overflow() {
    // Minimized input that triggers the heap overflow
    uint8_t evil[] = {
        0x01, 0x00, 0x00, 0x00,  // type = 1
        0xFF, 0xFF, 0xFF, 0x7F,  // length = INT_MAX (overflow!)
        0x41                      // one byte of data
    };
    
    // After the fix, this must not crash
    Packet *p = parse_packet(evil, sizeof(evil));
    // Should return NULL or a safe packet, not crash
    if (p) {
        free(p->data);
        free(p);
    }
}

Add this to your test suite. It costs nothing to run and prevents regression.

Continuous Security Fuzzing

One-off fuzzing runs miss bugs found by longer campaigns. For security-critical code, run fuzzing continuously:

  • OSS-Fuzz: Free for open-source projects. Submit your fuzz targets at github.com/google/oss-fuzz.
  • ClusterFuzz: Deploy your own fuzzing infrastructure using the same codebase.
  • GitHub Actions with libFuzzer: Run a short fuzz campaign on every PR to catch regressions.

For application-level security testing — checking that authentication flows work, that access controls hold in the browser, that sensitive data isn't exposed in the UI — HelpMeTest automates browser-based security testing alongside your fuzzing infrastructure. Fuzzing finds low-level memory bugs; browser automation finds higher-level auth and access control issues.

Security testing isn't one tool. It's a stack. Fuzzing is the foundation.

Read more

Start now free