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_overfloworcallocand cap them; a wrapped multiplication allocates a buffer that is too small. _FORTIFY_SOURCE=3caught a heap overflow in testing that level 2 missed, but it also hid a use after free from AddressSanitizer; add-U_FORTIFY_SOURCEto 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.
| Setting | Value | Note | Source |
|---|---|---|---|
| Warnings | -Wall -Wformat -Wformat=2 -Wconversion -Wimplicit-fallthrough -Werror=format-security | Compile-time only. Use blanket -Werror in development, not in build files you distribute. | OpenSSF C/C++ hardening guide (2026-08-20), TL;DR |
| Source fortification | -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=3 | Needs -O1 or higher (use -O2), and GCC 12+ with glibc 2.35+ or Clang with glibc 2.33+. Leave it out of sanitizer builds. | OpenSSF guide; glibc 2.33 and 2.35 NEWS |
| C++ library checks | -D_GLIBCXX_ASSERTIONS (libstdc++) or -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_FAST (libc++ 18+) | Recommended for production; neither changes the ABI. | OpenSSF guide |
| Stack | -fstack-protector-strong -fstack-clash-protection | Canary checks for functions with local arrays or address-taken locals; probes large stack allocations. | OpenSSF guide |
| Control flow | -fcf-protection=full (x86_64) or -mbranch-protection=standard (AArch64), with -fzero-call-used-regs=used-gpr | Enforced only where the CPU and OS support CET or BTI and PAC, and only if every linked object carries the marking. | OpenSSF guide |
| Arrays and undefined behavior | -fstrict-flex-arrays=3 -fno-delete-null-pointer-checks -fno-strict-overflow -fno-strict-aliasing -ftrivial-auto-var-init=zero | Production builds. Leave -ftrivial-auto-var-init out of MemorySanitizer builds. | OpenSSF guide |
| Linking | -fPIE -pie -Wl,-z,relro -Wl,-z,now -Wl,-z,noexecstack -Wl,-z,nodlopen -Wl,--as-needed -Wl,--no-copy-dt-needed-entries | Shared libraries use -fPIC -shared instead of -fPIE -pie. | OpenSSF guide |
| GCC shortcut | -fhardened | GCC 14 and later. Enables a fixed subset; gcc --help=hardened lists it. | OpenSSF guide; GCC 16.2 --help=hardened |
| Sanitizer test build | -O1 -g -fno-omit-frame-pointer -U_FORTIFY_SOURCE -fsanitize=address,undefined | Never ship it. MemorySanitizer (-fsanitize=memory) needs Clang on Linux or BSD and instrumented libraries. | OpenSSF guide; Clang sanitizer documentation |
| MSVC | /sdl /guard:cf, and link with /CETCOMPAT | /GS is on by default; /sdl adds stricter checks and is off by default. | Microsoft Learn: /sdl, /guard:cf, /CETCOMPAT |
- Run
check-hardening.sh, orreadelfby hand, on the artifact you ship: PIE, GNU_RELRO with BIND_NOW, a non-executable stack,__stack_chk_fail,__*_chksymbols and the IBT/SHSTK or BTI/PAC property. - On x86_64, link once with
-Wl,-z,cet-report=errorto find objects, including C runtime start files, that drop the CET marking. - 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. - 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.
- 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 prerequisites
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.
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.
| Weakness | What goes wrong | What an attacker gets | Fix |
|---|---|---|---|
| CWE-787 out-of-bounds write; CWE-120, CWE-121 and CWE-122 buffer overflows | A copy or loop writes past a stack or heap buffer | Overwritten return addresses, function pointers, allocator metadata or neighboring objects, often leading to code execution | Check the length against both source and destination before copying |
| CWE-125 out-of-bounds read | A read uses a length or index chosen by the input | Leaked keys, session data and addresses that defeat ASLR | Compare the declared length with the bytes that arrived |
| CWE-416 use after free | Code uses a pointer after its memory was freed and reused | A new object placed in the freed slot gives type confusion, often code execution | One owner per allocation, which clears its pointer when it frees |
| CWE-190 integer overflow | Size arithmetic wraps to a small number | An undersized buffer, then a heap overflow | Overflow-checked arithmetic and a size limit before allocating |
| CWE-476 NULL pointer dereference | A failed allocation or lookup returns NULL and the code uses it | Usually a crash; worse where address 0 can be mapped, as in older kernels | Check every result that can be NULL |
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.
#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 */
}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.
#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)";
}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.
/* 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;
}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_tor as a struct holding both, and check the length before each read, write or copy. - Do size arithmetic in
size_twithckd_addandckd_mul, or__builtin_add_overflowand__builtin_mul_overflow, and reject sizes above a format limit before allocating. - Do not use
gets,strcpy,strcatorsprintfon input. Usesnprintfand check its return value for truncation; glibc 2.38 and later also providestrlcpy. - 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
allocacalls from input, andmemseta structure before copying it out of the process, because initializers need not clear padding bytes. - Keep format strings literal;
-Werror=format-securityturnsprintf(input)into a compile error.
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.
// 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)});
}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.
| Options | Effect |
|---|---|
-D_FORTIFY_SOURCE=3 | Checks 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-protection | A 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=standard | Marks 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 noexecstack | Lets 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-* options | Define behavior the optimizer could otherwise exploit: uninitialized stack data, deleted NULL checks, signed overflow and type-punned pointers. |
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.
# 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,undefinedFlags 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.
#!/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 $statusSanitizers 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.
# 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_msanTests 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.
/* 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;
}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.
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.
Most memory-safety fixes fail in one of these ways.
- Checking the destination size but not the source:
memcpy(dst, src, n)needsnwithin both. - Writing
offset + len > size, which can wrap; checkoffset <= sizefirst, thenlen > size - offset. - Keeping sizes in
intoruint16_t. Compilers do not warn about every truncation;-Wconversionstayed silent on theuint16_ttotal 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
/* 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. Find the reads past the message
List every place
parse_tlvreadspktand the condition under which each read goes pastpkt_len.Expected result and reasoning
pkt[off + 1]andpkt[off + 2]are read when fewer than three bytes remain, so a one-byte message reads past the end at the first of them.memcpythen copieslenbytes without checkingoff + 3 + len <= pkt_len, the Heartbleed pattern (CWE-125).2. Compute the wrap
A message carries two legitimate 40,000-byte items. Work out
totalinjoin_values, the size passed tomalloc, and what the first memcpy does.Expected result and reasoning
40,000 + 40,000 = 80,000, which wraps in
uint16_tto 14,464 (80,000 − 65,536).malloc(14464)succeeds and the firstmemcpywrites 40,000 bytes into it, a 25,536-byte heap overflow (CWE-190 leading to CWE-122). Clang's-fsanitize=implicit-conversionreports the change of value at the+=line.3. Find the NULL and lifetime bugs
Look at every
mallocandfreein both functions and at what the caller holds afterwards.Expected result and reasoning
Neither
mallocresult is checked (CWE-476).join_valuesfrees each value but leavesitems[i].valuepointing 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. 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
mallocsize at thememcpyand the program aborts withbuffer overflow detected. With level 2 or none, the overflow happens, and glibc aborts later infreewithinvalid next size, after the heap is already corrupted.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, checklen <= pkt_len - off - 3. Sum lengths insize_twithckd_addand a cap, check eachmalloc, and setitems[i].value = NULLafter freeing it, or document thatjoin_valuestakes 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.
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
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.
- OpenSSF Compiler Options Hardening Guide for C and C++ (2026-08-20): TL;DR
- MITRE: 2025 CWE Top 25 Most Dangerous Software Weaknesses
- OpenSSL Security Advisory, 7 April 2014: TLS heartbeat read overrun (CVE-2014-0160)
- Clang: AddressSanitizer
- Clang: UndefinedBehaviorSanitizer, available checks
- Clang: MemorySanitizer, handling external code
- LLVM: libFuzzer, fuzz target
- CISA and FBI: Product Security Bad Practices, version 2 (January 2025)
- NSA and CISA: Memory Safe Languages: Reducing Vulnerabilities in Modern Software Development (June 2025)
- CISA, NSA, FBI and partners: The Case for Memory Safe Roadmaps (December 2023)
Reference information
- SDLC phase
- DevelopmentTesting
- CAPEC attack patterns