In short

Treat every length, index and size that comes from input as hostile. Check a declared length against the bytes that actually arrived, do size arithmetic with overflow checks such as C23 ckd_mul, and give each allocation one owner that clears its pointer when it frees. Build releases with the OpenSSF hardening flags, run separate test builds under AddressSanitizer, UndefinedBehaviorSanitizer and a fuzzer, and write new parsers and network-facing code in a memory-safe language.

  • Seven entries in MITRE's 2025 CWE Top 25 are memory-safety bugs, linked to 39 CVEs in CISA's Known Exploited Vulnerabilities catalog.
  • Compare a declared length with the bytes that arrived, not only with the destination; Heartbleed was that missing comparison.
  • Compute sizes with ckd_mul, __builtin_mul_overflow or calloc and cap them; a wrapped multiplication allocates a buffer that is too small.
  • _FORTIFY_SOURCE=3 caught a heap overflow in testing that level 2 missed, but it also hid a use after free from AddressSanitizer; add -U_FORTIFY_SOURCE to sanitizer builds.
  • Ship the hardened build and check the binary with readelf; sanitizers and fuzzers belong in test builds.
  • CISA and the FBI expect makers of existing C and C++ products to publish a memory-safety roadmap; start with new code and untrusted-input parsers.

Verify it

  1. Run check-hardening.sh, or readelf by hand, on the artifact you ship: PIE, GNU_RELRO with BIND_NOW, a non-executable stack, __stack_chk_fail, __*_chk symbols and the IBT/SHSTK or BTI/PAC property.
  2. On x86_64, link once with -Wl,-z,cet-report=error to find objects, including C runtime start files, that drop the CET marking.
  3. Run the unit tests in an ASan and UBSan build with -U_FORTIFY_SOURCE, and in an MSan build on Linux where every library can be instrumented.
  4. Fuzz each parser of untrusted input with libFuzzer and the sanitizers on every pull request, keep the corpus, and turn each crash into a regression test.
  5. Feed the hostile inputs from this guide (a length longer than the record, dimensions whose product wraps, a call after logout) to your own code and confirm it rejects them.
Applies to
  • GCC 12+ for _FORTIFY_SOURCE=3; 14+ for -fhardened and <stdckdint.h> (tested 15.2 and 16.2)
  • Clang/LLVM 16+ for -fstrict-flex-arrays=3; 18+ for <stdckdint.h> and libc++ hardening modes (tested 22.1 and Apple clang 21)
  • glibc 2.35+ for _FORTIFY_SOURCE=3 with GCC (tested 2.41 and 2.43)
  • OpenSSF Compiler Options Hardening Guide for C and C++ 2026-08-20 edition

C and C++ code built with Clang or GCC. Sanitizers and hardening flags reduce exploitability but do not replace fixing the bug.

Part of
Workflow: Fix a finding
Learning goals and prerequisitesAbout 42 minutes: 12 to read and 30 for the exercise

What you will learn

  • Recognize out-of-bounds access, use after free, NULL dereference and integer overflow in review, and name the input that triggers each.
  • Fix them with checked lengths, overflow-checked size arithmetic and single ownership.
  • Configure the OpenSSF hardening flags and verify them on a release binary.
  • Run sanitizer and fuzzing builds that catch the bugs hardening only mitigates.
  • Outline a memory-safety roadmap for an existing product.

Before you start

C and C++ developers, reviewers and build engineers who ship native code that handles untrusted input

  • Read C code that uses pointers, malloc and memcpy.
  • Build a C or C++ project with GCC or Clang from the command line or a build system.
  • Know what a stack frame and a heap allocation are.
Go to the guided practice

Know what each memory bug gives an attacker

C and C++ leave two rules to the programmer: every access stays inside the object it targets, and every object is used only while it is alive. Break either and the behavior is undefined, so the program may crash, return the wrong data or run code an attacker supplied. Seven entries in MITRE's 2025 CWE Top 25 are bugs of this kind, and MITRE's data links them to 39 CVEs in CISA's Known Exploited Vulnerabilities catalog.

Know what each memory bug gives an attacker
WeaknessWhat goes wrongWhat an attacker getsFix
CWE-787 out-of-bounds write; CWE-120, CWE-121 and CWE-122 buffer overflowsA copy or loop writes past a stack or heap bufferOverwritten return addresses, function pointers, allocator metadata or neighboring objects, often leading to code executionCheck the length against both source and destination before copying
CWE-125 out-of-bounds readA read uses a length or index chosen by the inputLeaked keys, session data and addresses that defeat ASLRCompare the declared length with the bytes that arrived
CWE-416 use after freeCode uses a pointer after its memory was freed and reusedA new object placed in the freed slot gives type confusion, often code executionOne owner per allocation, which clears its pointer when it frees
CWE-190 integer overflowSize arithmetic wraps to a small numberAn undersized buffer, then a heap overflowOverflow-checked arithmetic and a size limit before allocating
CWE-476 NULL pointer dereferenceA failed allocation or lookup returns NULL and the code uses itUsually a crash; worse where address 0 can be mapped, as in older kernelsCheck every result that can be NULL

Vulnerable: three functions that trust their input

These functions are typical of protocol and file-format code. echo_payload copies as many bytes as the sender's length field claims, the mistake behind Heartbleed (CVE-2014-0160), which let a connected client or server read up to 64 KB of the other side's memory. alloc_rgba multiplies image dimensions in 32-bit arithmetic: 65,536 × 16,385 × 4 wraps to 262,144, so it allocates 256 KiB for a 4 GiB image and writes past the buffer from the second row on. session_logout frees the user name but leaves the pointer in place, so session_user later reads freed memory.

Vulnerablerecord_vuln.cC
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

struct session { char *user; };

/* Record: type (1 byte), payload length (2 bytes, big-endian), payload. */
uint8_t *echo_payload(const uint8_t *msg, size_t msg_len, size_t *out_len) {
    (void)msg_len;                                  /* never consulted */
    size_t claimed = (size_t)msg[1] << 8 | msg[2];  /* reads past msg if msg_len < 3 */
    uint8_t *out = malloc(claimed);                 /* result never checked: CWE-476 */
    memcpy(out, msg + 3, claimed);                  /* CWE-125: trusts the sender's length */
    *out_len = claimed;
    return out;
}

/* width and height come from an untrusted image header. */
uint8_t *alloc_rgba(uint32_t width, uint32_t height) {
    uint32_t size = width * height * 4;             /* CWE-190: wraps for large images */
    uint8_t *px = malloc(size);
    if (px == NULL) return NULL;
    for (uint32_t y = 0; y < height; y++)           /* CWE-787/122: writes past px */
        memset(px + (size_t)y * width * 4, 0xff, (size_t)width * 4);
    return px;
}

void session_logout(struct session *s) {
    free(s->user);                                  /* s->user still points at freed memory */
}

const char *session_user(const struct session *s) {
    return s->user;                                 /* CWE-416 when called after logout */
}

Fixed: check what arrived, check the arithmetic, clear the owner

Each fix enforces the rule the input broke. echo_payload confirms the header is present, then compares the declared length with the bytes that arrived; checking only the destination would not help, because the over-read is from the source. alloc_rgba uses C23's ckd_mul (GCC 14 and Clang 18 or later) and caps the pixel count at what the format allows. Older compilers have __builtin_mul_overflow, and calloc and reallocarray check their own multiplication. session_logout clears the pointer in the function that frees it.

Clearing protects only that copy of the pointer. If other structures keep aliases, decide which one owns the allocation and make the rest borrow it for a bounded time, or use reference counting.

Fixedrecord_fixed.cC
#include <stdckdint.h>                              /* C23: ckd_add, ckd_sub, ckd_mul */
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#define MAX_PIXELS ((size_t)64 * 1024 * 1024)       /* policy limit: 64 megapixels */

struct session { char *user; };

uint8_t *echo_payload(const uint8_t *msg, size_t msg_len, size_t *out_len) {
    if (msg == NULL || msg_len < 3)
        return NULL;                                /* the header must be present */
    size_t claimed = (size_t)msg[1] << 8 | msg[2];
    if (claimed > msg_len - 3)
        return NULL;                                /* the payload must fit in what arrived */
    uint8_t *out = malloc(claimed > 0 ? claimed : 1);
    if (out == NULL)
        return NULL;
    memcpy(out, msg + 3, claimed);
    *out_len = claimed;
    return out;
}

uint8_t *alloc_rgba(uint32_t width, uint32_t height) {
    size_t pixels, size;
    if (ckd_mul(&pixels, (size_t)width, (size_t)height) || pixels > MAX_PIXELS ||
        ckd_mul(&size, pixels, (size_t)4))
        return NULL;                                /* overflow, or bigger than policy allows */
    uint8_t *px = malloc(size > 0 ? size : 1);
    if (px == NULL) return NULL;
    for (uint32_t y = 0; y < height; y++)
        memset(px + (size_t)y * width * 4, 0xff, (size_t)width * 4);
    return px;
}

void session_logout(struct session *s) {
    free(s->user);
    s->user = NULL;                                 /* one owner, cleared where it is freed */
}

const char *session_user(const struct session *s) {
    return s->user != NULL ? s->user : "(logged out)";
}

Write the hostile input down as a test

Each bug has an input that triggers it. Record those inputs as tests before you fix the code, and run them in the sanitizer build so a bug that does not crash still fails. This driver fails on the vulnerable file and passes on the fixed one with GCC 15, Clang 22 and Apple clang 21, with and without sanitizers. Run on its own without a sanitizer, the vulnerable echo_payload handed back 16,384 bytes of heap memory without crashing.

Testtest_record.cC
/* test_record.c: one hostile input per bug. Build without -DNDEBUG. */
#include <assert.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

struct session { char *user; };
uint8_t *echo_payload(const uint8_t *msg, size_t msg_len, size_t *out_len);
uint8_t *alloc_rgba(uint32_t width, uint32_t height);
void session_logout(struct session *s);
const char *session_user(const struct session *s);

int main(void) {
    assert(alloc_rgba(65536, 16385) == NULL);       /* 2^32 + 262,144 bytes in 32 bits */

    uint8_t *msg = malloc(5);                       /* heap copy, as if just received */
    memcpy(msg, "\x01\x40\x00hi", 5);               /* claims 16,384 bytes, carries 2 */
    size_t n = 0;
    assert(echo_payload(msg, 5, &n) == NULL);
    free(msg);

    struct session s = { .user = strdup("alice") };
    session_logout(&s);
    assert(strcmp(session_user(&s), "(logged out)") == 0);
    return 0;
}

Follow rules that remove whole bug classes

Bounds checks scattered through a codebase get missed. These rules make the safe form the normal one, and most of them can be enforced in review or with a linter.

  • Pass every buffer with its length, as a pointer and a size_t or as a struct holding both, and check the length before each read, write or copy.
  • Do size arithmetic in size_t with ckd_add and ckd_mul, or __builtin_add_overflow and __builtin_mul_overflow, and reject sizes above a format limit before allocating.
  • Do not use gets, strcpy, strcat or sprintf on input. Use snprintf and check its return value for truncation; glibc 2.38 and later also provide strlcpy.
  • Check every allocation and every lookup that can return NULL.
  • Give each allocation one owner. Free it in one place, set the pointer to NULL there, and document which functions borrow and which take ownership.
  • Do not size stack arrays or alloca calls from input, and memset a structure before copying it out of the process, because initializers need not clear padding bytes.
  • Keep format strings literal; -Werror=format-security turns printf(input) into a compile error.

In C++, let types carry the bounds and the owner

std::span and std::vector keep the length with the data, std::unique_ptr frees exactly once, and std::string replaces fixed char arrays. They still have unchecked preconditions: misusing span::first, operator[] or front on an empty container is undefined behavior. Turn on library hardening so a broken precondition stops the program. With the length check deleted from the code below, -D_GLIBCXX_ASSERTIONS aborted with Assertion '__count <= size()' failed, libc++'s fast mode trapped, and libc++ without hardening silently copied 16,384 bytes.

C++26 makes these checks standard as a hardened implementation (P3471) and turns reads of uninitialized local variables into erroneous rather than undefined behavior (P2795). Until your toolchain ships both, use the macros. std::string_view and std::span do not own memory, so never let one outlive the object it points into.

Fixedrecord.cppC++
// g++ -std=c++20 -O2 -D_GLIBCXX_ASSERTIONS ...                                  (libstdc++)
// clang++ -std=c++20 -O2 -stdlib=libc++ -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST ...
#include <cstddef>
#include <cstdint>
#include <memory>
#include <span>
#include <string>
#include <vector>

std::vector<std::uint8_t> echo_payload(std::span<const std::uint8_t> msg) {
    if (msg.size() < 3) return {};
    std::size_t claimed = std::size_t{msg[1]} << 8 | msg[2];
    std::span<const std::uint8_t> payload = msg.subspan(3);
    if (claimed > payload.size()) return {};       // still required: hardening only traps
    auto body = payload.first(claimed);            // precondition: claimed <= payload.size()
    return {body.begin(), body.end()};
}

struct Session { std::string user; };

// Freed exactly once, when the last owner goes out of scope; no manual delete.
std::unique_ptr<Session> login(std::string name) {
    return std::make_unique<Session>(Session{std::move(name)});
}

Harden release builds with the OpenSSF baseline

Hardening options do not fix bugs. They turn some overflows into immediate aborts and make the rest harder to exploit. The OpenSSF Compiler Options Hardening Guide for C and C++ (2026-08-20 edition) sets the baseline for GCC and Clang on Linux. Upstream GCC and binutils do not enable hardening by default. Distributions add some: Ubuntu 26.04's GCC already defines _FORTIFY_SOURCE=3 and enables the stack protector and stack-clash protection. Set the flags explicitly anyway, so a build does not depend on where it runs.

GCC 14 and later also accept -fhardened, a fixed subset that gcc --help=hardened lists. On MSVC, /GS is on by default; add /sdl and /guard:cf, and link with /CETCOMPAT.

Harden release builds with the OpenSSF baseline
OptionsEffect
-D_FORTIFY_SOURCE=3Checks libc copies against object sizes known at compile or run time. It aborted a strcpy into an 8-byte malloc buffer that level 2 let through.
-fstack-protector-strong, -fstack-clash-protectionA canary next to local arrays ends the process on overflow ("stack smashing detected"); stack probing stops a large allocation from jumping the guard page.
-fcf-protection=full, -mbranch-protection=standardMarks valid indirect-branch targets and protects return addresses where the CPU enforces Intel CET or Arm BTI and PAC.
-fPIE -pie, -z relro, -z now, -z noexecstackLets ASLR move the program, makes relocation tables read-only after startup and keeps the stack non-executable.
-ftrivial-auto-var-init=zero and the -fno-* optionsDefine behavior the optimizer could otherwise exploit: uninitialized stack data, deleted NULL checks, signed overflow and type-punned pointers.

Apply the flags in one place

Keep the flags in one file that every target includes, and keep the sanitizer flags separate so the two builds never mix. This fragment built the fixed code without warnings under GCC 15 and Clang 22, and the hardened test binary passed.

Configurationhardening.mkMakefile
# Release flags from the OpenSSF Compiler Options Hardening Guide (2026-08-20).
# Needs GCC 13+ or Clang 16+ with glibc on Linux; include it from your Makefile.
HARDEN_CFLAGS = -O2 -Wall -Wformat -Wformat=2 -Wconversion -Wimplicit-fallthrough \
  -Werror=format-security -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 \
  -fstrict-flex-arrays=3 -fstack-clash-protection -fstack-protector-strong \
  -fno-delete-null-pointer-checks -fno-strict-overflow -fno-strict-aliasing \
  -ftrivial-auto-var-init=zero -fPIE
HARDEN_CXXFLAGS = $(HARDEN_CFLAGS) -D_GLIBCXX_ASSERTIONS \
  -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST
HARDEN_LDFLAGS = -pie -Wl,-z,nodlopen -Wl,-z,noexecstack -Wl,-z,relro -Wl,-z,now \
  -Wl,--as-needed -Wl,--no-copy-dt-needed-entries

ARCH := $(shell uname -m)
ifeq ($(ARCH),x86_64)
  HARDEN_CFLAGS += -fcf-protection=full -fzero-call-used-regs=used-gpr
else ifeq ($(ARCH),aarch64)
  HARDEN_CFLAGS += -mbranch-protection=standard -fzero-call-used-regs=used-gpr
endif

# Sanitizer test build: separate from release, never shipped.
SAN_CFLAGS = -O1 -g -fno-omit-frame-pointer -U_FORTIFY_SOURCE -fsanitize=address,undefined

Check the binary you ship, not the build script

Flags get lost in vendored makefiles, CMake presets and static libraries built elsewhere, and hardening protects only code compiled with it. Check the release artifact. With the fragment above, every check passed for GCC 15 and Clang 22 builds on Ubuntu 26.04 AArch64 and for GCC 15 on x86_64; a build without PIE, RELRO, a stack protector or fortification failed six of the seven. A missing CET marking is not always a missing flag: GCC 16.2 on Debian 13 dropped it because the C library's start files lack the property, which -Wl,-z,cet-report=warning reported.

Testcheck-hardening.shShell
#!/bin/sh
# Report hardening properties of an ELF executable (GNU binutils readelf).
bin=${1:?usage: check-hardening.sh ELF-binary}
status=0
hdr=$(readelf -hlW "$bin"); dyn=$(readelf -dW "$bin")
syms=$(readelf -W --dyn-syms "$bin"); notes=$(readelf -n "$bin")
check() {
  if printf '%s\n' "$2" | grep -Eq "$3"; then echo "ok    $1"; else echo "MISS  $1"; status=1; fi
}
check "PIE"                  "$hdr"   'Type: +DYN'
check "RELRO"                "$hdr"   'GNU_RELRO'
check "BIND_NOW (full RELRO)" "$dyn"  'BIND_NOW|FLAGS_1.*NOW'
check "non-executable stack" "$(printf '%s\n' "$hdr" | grep GNU_STACK)" 'RW +0x'
check "stack protector"      "$syms"  '__stack_chk_fail'
check "FORTIFY wrappers"     "$syms"  '__[a-z0-9_]+_chk'
check "CET (x86) or BTI/PAC (arm64)" "$notes" 'x86 feature: IBT, SHSTK|AArch64 feature: BTI, PAC'
exit $status

Run sanitizers in separate test builds

Sanitizers report memory bugs that do not crash. AddressSanitizer finds out-of-bounds access to heap, stack and globals, use after free and double free, at about twice the normal run time. UndefinedBehaviorSanitizer reports signed overflow, bad shifts and misaligned or NULL pointer use; its undefined group leaves out unsigned wraparound and implicit truncation, so add those checks in Clang for size arithmetic and expect some noise from hash code. MemorySanitizer reports reads of uninitialized memory, on Linux and BSD only, and needs every library in the process instrumented.

Keep these builds away from users: the runtimes are debugging tools and read settings from the environment. Two interactions matter. With Ubuntu's default _FORTIFY_SOURCE=3, a GCC ASan build missed the use after free above until -U_FORTIFY_SOURCE was added, and -ftrivial-auto-var-init=zero hid an uninitialized read from MemorySanitizer. Apple's Xcode clang has ASan and UBSan but neither MemorySanitizer nor a libFuzzer runtime, so run those on Linux.

Testsanitize.shShell
# ASan + UBSan, GCC or Clang. Test builds only; never ship one.
cc -std=c23 -O1 -g -fno-omit-frame-pointer -U_FORTIFY_SOURCE \
   -fsanitize=address,undefined -o test_asan test_record.c record_vuln.c
./test_asan
# ERROR: AddressSanitizer: heap-buffer-overflow ... WRITE of size 262144
# SUMMARY: AddressSanitizer: heap-buffer-overflow record_vuln.c:23 in alloc_rgba

# Clang: wrapping size arithmetic, which -fsanitize=undefined leaves out
clang -std=c23 -O1 -g -fsanitize=unsigned-integer-overflow,implicit-conversion \
   -o test_int test_record.c record_vuln.c
./test_int
# record_vuln.c:19:36: runtime error: unsigned integer overflow:
#   1073807360 * 4 cannot be represented in type 'uint32_t'

# Clang on Linux: uninitialized reads; every linked library must be instrumented
clang -std=c23 -O1 -g -fsanitize=memory -fsanitize-memory-track-origins=2 \
   -o test_msan test_record.c record_fixed.c
./test_msan

Fuzz every parser with the sanitizers attached

Tests cover the inputs you thought of. A coverage-guided fuzzer such as libFuzzer mutates inputs to reach new code, and with ASan and UBSan linked in it turns silent corruption into a crash with a stack trace. This harness found the out-of-bounds read in the vulnerable echo_payload at once, with an empty message, and ran about 87 million inputs against the fixed version in a 60-second run without a failure. The trap line is an oracle: it catches a logic error that never touches invalid memory.

Seed the corpus with small valid records and keep it between runs. Run a short session on every pull request and longer ones nightly, and turn each crash into a unit test. /domains/appsec/testing/parser-fuzzing/ covers harness design and triage.

Testfuzz_record.cC
/* clang -g -O1 -U_FORTIFY_SOURCE -fsanitize=fuzzer,address,undefined \
         fuzz_record.c record_fixed.c -o fuzz_record
   mkdir -p corpus && printf '\x01\x00\x02hi' > corpus/seed && ./fuzz_record -max_total_time=60 corpus */
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>

uint8_t *echo_payload(const uint8_t *msg, size_t msg_len, size_t *out_len);

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    size_t n = 0;
    uint8_t *out = echo_payload(data, size, &n);
    if (out != NULL && n + 3 > size)
        __builtin_trap();                           /* oracle: never echo more than arrived */
    free(out);
    return 0;
}

Plan the move to memory-safe languages

Hardening and testing reduce memory bugs but do not remove the class. CISA and the FBI's Product Security Bad Practices (version 2, January 2025) call it dangerous to start new critical-infrastructure product lines in C or C++ when a memory-safe language would do, and say makers of existing products should have published a memory-safety roadmap by the end of 2025; products whose support ends before 1 January 2030 are exempt. The NSA and CISA information sheet of June 2025 names Ada, C#, Delphi/Object Pascal, Go, Java, Python, Ruby, Rust and Swift as memory-safe languages and advises starting with new code, where most memory vulnerabilities appear.

The Case for Memory Safe Roadmaps (CISA, NSA, FBI and partner agencies, December 2023) says a roadmap should contain:

Two recent incidents show the cost in shipped products. Citrix Bleed (CVE-2023-4966) was a memory over-read in an internet-facing gateway that leaked session cookies able to bypass MFA (/domains/appsec/vulnerability-response/case-study-citrix-bleed/), and CrowdStrike's July 2024 outage came from an out-of-bounds read in kernel code driven by a content file (/domains/appsec/platform-security/case-study-crowdstrike-channel-file-291/).

  • Phases with dates and outcomes: evaluating languages, a pilot, threat modeling to find the most dangerous memory-unsafe code, then refactoring it.
  • A published date after which new code is written only in a memory-safe language.
  • Developer training and build integration for the chosen language.
  • A plan for C and C++ dependencies, including open-source libraries.
  • Regular public updates and a commitment to publish CWE identifiers for all of your CVEs.

Start with new code and untrusted-input parsers

Prioritizing new code works. Android wrote new components in memory-safe languages such as Rust and Java rather than rewriting old C++, and memory-safety issues fell from 76 percent of its vulnerabilities in 2019 to 24 percent in 2024. Rewrite first where untrusted input arrives: network services, file parsers, codecs and cryptographic code. Keep the hardening, sanitizer and fuzzing jobs for the C that remains, and treat the boundary between new and old code as an attack surface of its own; /domains/appsec/platform-security/rust-unsafe-ffi/ covers it.

Pitfalls

Most memory-safety fixes fail in one of these ways.

  • Checking the destination size but not the source: memcpy(dst, src, n) needs n within both.
  • Writing offset + len > size, which can wrap; check offset <= size first, then len > size - offset.
  • Keeping sizes in int or uint16_t. Compilers do not warn about every truncation; -Wconversion stayed silent on the uint16_t total in the exercise below.
  • Freeing in an error path and again in cleanup, a double free (CWE-415).
  • Treating a clean fuzz run or ASan pass as proof; both cover only the paths that ran.
  • Shipping a sanitizer build, or running sanitizers only on a fortified build.
  • Assuming a vendored static library was built with your flags.

Guided practice

A pull request adds tlv.c, which parses Type-Length-Value items from messages of up to 1 MiB read from a TCP connection. Review it before it merges.

Supplied scenario material
C
/* tlv.c: parses Type-Length-Value items from a message of up to 1 MiB
   read from a TCP connection. Every byte of pkt is attacker-controlled. */
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

struct item { uint8_t type; uint16_t len; uint8_t *value; };

/* Parses at most max items into out[]; returns how many it parsed. */
int parse_tlv(const uint8_t *pkt, size_t pkt_len, struct item *out, int max) {
    size_t off = 0;
    int n = 0;
    while (off < pkt_len && n < max) {
        uint16_t len = (uint16_t)(pkt[off + 1] << 8 | pkt[off + 2]);
        out[n].type = pkt[off];
        out[n].len = len;
        out[n].value = malloc(len);
        memcpy(out[n].value, pkt + off + 3, len);
        off += 3 + (size_t)len;
        n++;
    }
    return n;
}

/* Concatenates and releases the values; the caller frees the result. */
uint8_t *join_values(struct item *items, int count, uint16_t *total_out) {
    uint16_t total = 0;
    for (int i = 0; i < count; i++) total += items[i].len;
    uint8_t *buf = malloc(total);
    uint16_t pos = 0;
    for (int i = 0; i < count; i++) {
        memcpy(buf + pos, items[i].value, items[i].len);
        pos += items[i].len;
        free(items[i].value);
    }
    *total_out = total;
    return buf;
}
  1. 1. Find the reads past the message

    List every place parse_tlv reads pkt and the condition under which each read goes past pkt_len.

    Expected result and reasoning

    pkt[off + 1] and pkt[off + 2] are read when fewer than three bytes remain, so a one-byte message reads past the end at the first of them. memcpy then copies len bytes without checking off + 3 + len <= pkt_len, the Heartbleed pattern (CWE-125).

  2. 2. Compute the wrap

    A message carries two legitimate 40,000-byte items. Work out total in join_values, the size passed to malloc, and what the first memcpy does.

    Expected result and reasoning

    40,000 + 40,000 = 80,000, which wraps in uint16_t to 14,464 (80,000 − 65,536). malloc(14464) succeeds and the first memcpy writes 40,000 bytes into it, a 25,536-byte heap overflow (CWE-190 leading to CWE-122). Clang's -fsanitize=implicit-conversion reports the change of value at the += line.

  3. 3. Find the NULL and lifetime bugs

    Look at every malloc and free in both functions and at what the caller holds afterwards.

    Expected result and reasoning

    Neither malloc result is checked (CWE-476). join_values frees each value but leaves items[i].value pointing at freed memory, so a caller that later frees or reads the items commits a double free or a use after free (CWE-415, CWE-416).

  4. 4. Predict what the hardening flags do

    Say what happens to the two-item message in release builds with -D_FORTIFY_SOURCE=3, with level 2, and without fortification.

    Expected result and reasoning

    With level 3, GCC knows the malloc size at the memcpy and the program aborts with buffer overflow detected. With level 2 or none, the overflow happens, and glibc aborts later in free with invalid next size, after the heap is already corrupted.

  5. 5. Write the fix and the tests

    Rewrite both functions and list the tests and build modes that must pass.

    Expected result and reasoning

    Before reading a header, check pkt_len - off >= 3; before copying, check len <= pkt_len - off - 3. Sum lengths in size_t with ckd_add and a cap, check each malloc, and set items[i].value = NULL after freeing it, or document that join_values takes ownership. Tests: an empty message, a one-byte message, a lying length and two 40,000-byte items, run in the ASan and UBSan build with -U_FORTIFY_SOURCE, plus a libFuzzer harness that calls both functions.

Your deliverable: A review comment that lists each defect with its CWE, the corrected functions, and the test inputs and build modes that prove the fix.

Paper exercise; the fixture is complete. The results in the expected answers were reproduced with Clang 22 and GCC 15 on Ubuntu 26.04 (glibc 2.43); other allocators report heap corruption differently.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Buffer over-read lab: Stop a heartbeat over-read

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OpenSSF Compiler Options Hardening Guide for C and C++ (2026-08-20): TL;DR
  2. MITRE: 2025 CWE Top 25 Most Dangerous Software Weaknesses
  3. OpenSSL Security Advisory, 7 April 2014: TLS heartbeat read overrun (CVE-2014-0160)
  4. Clang: AddressSanitizer
  5. Clang: UndefinedBehaviorSanitizer, available checks
  6. Clang: MemorySanitizer, handling external code
  7. LLVM: libFuzzer, fuzz target
  8. CISA and FBI: Product Security Bad Practices, version 2 (January 2025)
  9. NSA and CISA: Memory Safe Languages: Reducing Vulnerabilities in Modern Software Development (June 2025)
  10. CISA, NSA, FBI and partners: The Case for Memory Safe Roadmaps (December 2023)

Reference information

Download this guide as Markdown