Scope & Objectives
Inventory all native code in the repository, rank it by attacker reachability, then audit each parser, copy site, allocation site, and lifetime transition for memory-safety violations. Confirm or refute every candidate finding by answering one question per sink: who controls the length, and does that length match the allocation?
- In scope (languages): C (.c/.h), C++ (.cc/.cpp/.cxx/.hpp), Objective-C / Objective-C++ (.m/.mm), Rust limited to
unsafeblocks,unsafe fn,unsafe impl, FFI (extern "C") surfaces, and-sysbinding crates; mixed-language boundaries between any of these. - In scope (defect classes): buffer overflow (CWE-120/121/122); out-of-bounds read (CWE-125); index arithmetic from untrusted fields; integer truncation, signedness confusion, and size-calculation overflow (CWE-190); use-after-free (CWE-416); double free (CWE-415); dangling pointers including container-reallocation invalidation; NULL dereference reached from untrusted input (CWE-476); format-string flaws in the
printffamily (CWE-134); use of uninitialized or partially initialized memory (CWE-457); length-unit and ownership mistakes at FFI boundaries. - Out of scope (cross-reference): regex/engine-level resource exhaustion patterns (DOS module, including NSRegularExpression input blowup); injection via interpreted languages (INJECTION); secrets in native binaries (SECRETS); dependency version auditing for known-vulnerable vendored libraries (track separately under A06:2021 – Vulnerable and Outdated Components).
- Objective: produce findings a maintainer can verify by reading two or three files — sink location, length origin, the missing or insufficient bound check, and worst-case attacker-controlled value — without requiring dynamic tooling.
Prerequisites & Vocabulary
Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.
- buffer overflow: writing past the end of a fixed-size memory block, corrupting neighboring data
- out-of-bounds read: reading beyond the allocated bytes, leaking whatever sits next in memory
- use-after-free (UAF): using memory after it was released; an attacker may have replaced its contents
- integer truncation: size math that wraps around, so a huge length suddenly looks small
- format string flaw: passing attacker text where a print template is expected, leaking or overwriting memory
- bounds check: verifying index and length match the allocation before touching memory
- sanitizer (ASAN): test-build instrumentation that catches these bugs at runtime instead of silently corrupting
- taint flow / source→sink: path untrusted data travels from entry point to dangerous function
- finding status: Confirmed > Probable > Needs-Review; evidence rules in templates/finding-report.md
Mental Model
Reduce every memory-safety bug to a failure on one of three axes. When reading any candidate line, identify which axis is under stress:
| Axis | The question | Failure classes on this axis |
|---|---|---|
| Bounds | How many bytes are read/written, versus how many were allocated? | Stack/heap overflow (120/121/122), over-read (125), off-by-one terminator |
| Type | How are those bytes (and their counts) interpreted? | Integer truncation/overflow (190), signedness confusion, format string (134), length-unit mismatch |
| Lifetime | For how long are those bytes valid, and who owns them? | Use-after-free (416), double free (415), dangling pointer/reference, uninitialized use (457), NULL deref (476) |
Stack versus heap changes technique, not severity:
- Stack buffers live in fixed-size frames adjacent to saved return addresses and canaries; overflow there historically enables control-flow hijack, and modern compilers place a canary that detects (not prevents) most linear stack smashes.
- Heap buffers sit inside allocator-managed chunks adjacent to other objects and allocator metadata; overflow corrupts neighboring allocations, and the same write primitive is often reachable.
- Treat both as Critical-class when network-reachable pre-auth; do not soften a finding because it says "heap".
Mitigations are not fixes — frame every report this way:
- Stack canaries detect some overwrites after the fact; large copies can still land past them, and they say nothing about heap writes.
- ASLR randomizes addresses; any single info-leak bug (over-read, format string, uninitialized data) hands addresses back to the attacker and defeats it.
- DEP/NX marks data pages non-executable; return-to-libc and ROP-style chains work around it using existing executable code.
- Therefore never downgrade a finding because "the binary ships hardened"; hardening raises exploit cost, it does not remove the defect. Source-level correctness is the only fix.
Carry the central audit question through the whole module: WHO controls the length, and DOES it match the allocation?
What To Check
Step 0: Inventory Native Code Before Auditing
Do not audit what you have not enumerated. Build the file list first:
rg --files -g '*.{c,cc,cpp,cxx,h,hpp,hh,m,mm,s,S}'
rg -n 'unsafe' --type rust -g '*.rs'
rg -l 'cmake_minimum_required|CMakeLists' ; rg --files -g 'Makefile' -g '*.mk' -g 'meson.build' -g 'BUILD.gn' -g '*.xcconfig' -g 'configure.ac'
Record: total native LOC, whether Rust crates exist and how many unsafe hits they contain, and which build system compiles them. A repository with zero native files ends this module after recording that fact.
Rank Targets by Attacker Reachability
Audit in this order; stop going deeper when findings already demand remediation:
- Network/message parsers: anything fed from sockets, TLS plaintext, HTTP bodies, protocol dissectors (
parse_*,decode_*,handle_*,*_recv,on_data,read_packet,process_message). - File-format importers/exporters: image, audio, archive, document, font decoders — attacker-supplied files are network-equivalent input.
- IPC and local-attack-surface handlers: Unix sockets, named pipes, D-Bus service backends.
- Shared libraries consumed by a privileged or network-facing process.
- Internal-only utilities last.
Apply the Audit Question Ladder at Every Sink
For each copy, index, allocation-size, format, and free site, walk this ladder in order and record answers as evidence:
- Where does this buffer's capacity come from — fixed array size,
sizeofof what, malloc argument? - Who controls the length used here — packet field, header count, string return value, decompressor output?
- What units is that length in — bytes, elements, UTF-8 code points, wchar_t units? Do both sides agree?
- Is the length validated against destination capacity BEFORE the copy/index, with the comparison in matching types?
- Does any cast sit between declaration and use (
size_ttoint/uint16_t/unsigned short)? - Do signed and unsigned values mix in any comparison on the path?
- Can a multiplication or addition computing an allocation size overflow first?
- Does every write leave room for the NUL terminator, and is it actually written?
- After any
free/delete/release on this path, can the same object be touched again (error paths, callbacks, logging)? - Is every field of every struct fully written before it is sent, hashed, or published?
- If Rust: does each
unsafeblock satisfy its own validity contract even when the caller lies?
Check Copies and Sinks Against the Banned/Risky Tables
Sweep with the regexes in Patterns & Signatures, then manually classify every hit using the banned-function table and the risky-with-care table. A hit is a finding only if the dangerous argument is attacker-influenced or the bound is wrong; record the justification either way.
Check Index Arithmetic From Untrusted Fields
Hunt for array subscripts computed from parsed fields. Verify three specific traps:
- Direct index without range check:
table[pkt->type]wheretypeis a 32-bit attacker value. - Negative index through signedness: C99 defines
%on negative operands as negative remainder, so(int)field % TABLE_SIZEcan still yield a negative subscript. - Pointer arithmetic past one-past-the-end: reading
p[len]"just to peek" is out-of-bounds unlesslen < capacity.
Check Integer Handling on Length Paths
Apply the integer rules table (Patterns & Signatures) to every variable that flows into an allocation, copy, or loop bound. Prioritize: truncating casts, signed/unsigned comparisons, and unguarded n * m allocation expressions.
Check Lifetimes (Use-After-Free / Double-Free)
Walk these recurring shapes and trace ownership across each:
- Callbacks retaining freed objects: object registers a callback, is freed on teardown, callback fires later from another thread or timer.
- Iterator/list mutation during traversal: erasing while iterating without taking
erase()'s returned iterator;forloops over a linked list whose current node the body frees. - Error paths that free then log: cleanup calls
free(conn)and the next line logsconn->name. - Refcount imbalance concepts: a getter that retains but callers don't release under some branch; over-release in an error branch causing later use or double free.
- Container reallocation invalidation (C++): pointer/reference/iterator into a
std::vectorheld acrosspush_back;std::string::c_str()stored beyond the string's next mutation. - Double free: two error branches both freeing the same buffer, or free inside a callee plus cleanup in the caller.
Check Format Strings
Search the printf/syslog/err families for non-literal format arguments, including wrappers that forward va_list. Any function whose first string parameter is data rather than code is a candidate (CWE-134).
Check Uninitialized Memory
Look for the heartbleed shape and its siblings:
- A heap struct from
malloc(notcalloc) with fields assigned conditionally, then serialized/sent wholesale. - A response built by writing N bytes into a buffer but sending M > N bytes because M comes from the request.
- The general form: message-declared length versus actual fill level diverge, and the send/copy uses the message's number.
- Stack arrays read up to declared size rather than filled size after a short read.
Check String Handling
strlen/strcpyapplied to buffers parsed from binary data that may lack a terminator.- Off-by-one terminators: loops writing
buf[i]fori <= len, orbuf[len] = '\0'wherelen == capacity. - Truncation splitting multi-byte UTF-8 sequences when copying to fixed widths, producing invalid downstream parsing.
Audit Every unsafe Block in Rust
For each unsafe occurrence, require documented evidence of all relevant items:
- Raw pointer dereference: provenance valid, aligned, initialized for the full claimed length; aliasing rules respected for references.
mem::transmute: source and destination sizes/layout equal; prefer typed conversions; flag any transmute of&Tto&mut Tor lifetime extension.slice::from_raw_parts(ptr, len)/_mut: ptr non-null and valid forlen * size_of::<T>()bytes; len comes from a trusted counter, never raw FFI input unvalidated.mem::uninitializedis instant UB for most types — always a finding;MaybeUninitmisuse meansassume_init()called before every byte was written.unwrap()/expect()reachable fromextern "C"entry points: a panic crossing the boundary crashes or, per older Rust references, is UB — treat any panic-reachable-from-C path as a finding.unsafe impl Send/Sync: proof that the type really is thread-safe; these escape compile-time checking.- cbindgen-generated headers: confirm the C side validates lengths/types before calling, since the generated header carries no contracts.
Bridge Checks (Objective-C/Swift)
Brief pass; memory safety in bridges concentrates on C-string handling:
UTF8String/cStringUsingEncoding:results are owned transiently by the autorelease pool and invalidated by mutation of the source string — storing them across mutations is dangling-pointer territory.getBytes:maxLength:usedLength:encoding:— check maxLength is the DESTINATION capacity in BYTES, not element counts.- User-supplied NSRegularExpression patterns are a resource-exhaustion risk, not memory corruption — note and cross-reference the DOS module.
Check FFI Boundaries in Both Directions
At every language-A-calls-language-B seam, verify:
- Length units agree: bytes vs elements vs
wchar_tunits vs UTF-8 code points;wcslencounts wide chars,strlencounts bytes — mixing them corrupts copies silently. - Ownership transfer is explicit: who frees, with WHICH allocator (
freevsdeletevs Rust's global allocator vs platform CRT); freeing across allocators is corruption. - Struct layout/padding and calling convention match on both sides of the header.
- Validation done on side A is not invalidated by reinterpretation on side B.
- Rust FFI depth: a
Dropimpl or panic unwinding across anextern "C"boundary is undefined behavior — check forcatch_unwindat every exported entry point;mem::forgeton RAII guards wrapping FFI-owned resources leaks them;Box::into_rawownership-transfer contracts must be documented at the receiving side.
Check Leak & Failure-Pattern Classes (distinct from corruption)
- Allocations without matching free on ERROR paths specifically: early returns and exception paths that skip cleanup while success paths free correctly.
- File-descriptor leaks in loops and error branches (open/socket/connect per attempt, close only on the happy path).
- Growing-only containers/maps keyed by user input with no eviction — unbounded session/cache growth = slow DoS cross-ref DOS module.
- Reconnect/retry loops that allocate per attempt (new buffer per reconnect without freeing the old).
- C++ exception-safety gaps: raw
new/deletepairs spanning throw-capable calls, manual lock/file/handle management instead of RAII, destructors that can throw. - Dangling-view patterns:
std::string_view/std::spanoutliving their backing store; reads afterstd::move. - Container invalidation during iteration: erase-while-iterating; reallocation invalidating held references/pointers into a vector.
Where To Look
File Inventory Globs
rg --files -g '*.{c,cc,cpp,cxx,h,hpp,hh,m,mm}'
rg --files -g 'Cargo.toml' # then inspect sibling crates for unsafe
rg --files -g '{CMakeLists.txt,Makefile,*.mk,meson.build,BUILD.gn,*.xcconfig,configure.ac,Package.swift}'
High-Yield Directories and Names
| Location pattern | Why it matters |
|---|---|
src/parser*, */net/*, */proto*, */codec*, */packet*, *message* |
Primary attack surface |
| import/export loaders, image/media/font decoders | File-parsing exposure |
third_party/, vendored dirs |
Still your report; mark vendor origin and version |
| test fixtures, fuzz corpora, seed inputs | Existing malformed inputs hint at parser fragility |
bindings directories (*-sys, *-bind, jni, bridge) |
FFI seams concentrate unit/ownership bugs |
Symbol Hotspots
rg -n '\b(parse|decode|handle|process)_\w+\s*\(' -g '*.{c,cc,cpp,m,mm}'
rg -n '\b(malloc|calloc|realloc)\s*\(' -g '*.{c,h,cpp,cc}'
rg -n '\bfree\s*\(' -g '*.{c,h}' | wc -l # volume estimate for lifetime review
rg -n '#\[no_mangle\]|extern\s*"C"' --type rust
Rust-Specific Locations
grep unsafe crate-by-crate; highest risk order:-sysbinding crates, thenbuild.rs(runs arbitrary compiled code at build time), then modules exporting#[no_mangle] extern "C".- Any
static mutshared between threads found during the unsafe sweep is also a data-access hazard worth recording.
Build and CI Evidence
rg -n 'fsanitize|_FORTIFY_SOURCE|stack-protector' -g '!third_party/**' # existing hardening
rg -n 'LLVMFuzzerTestOneInput|libFuzzer|afl-fuzz|cargo fuzz' # existing fuzz targets
ls fuzz fuzz/ tests/fuzz oss-fuzz 2>/dev/null # common harness homes
Patterns & Signatures
Banned Functions (Sink Table)
Every use of a left-column function is at minimum a code-review finding; classify reachability before escalating.
| Function | Risk | Safe replacement |
|---|---|---|
gets |
Unbounded stdin read; impossible to bound; removed from C11 | fgets(buf, sizeof buf, stdin) and strip newline |
strcpy |
No bound on copy; overflows whenever source exceeds destination | snprintf(dst, dst_size, "%s", src) (portable); strlcpy only where the target libc provides it (see availability note below) |
strcat |
No bound; also O(n²) rescanning | snprintf(dst, dst_size, "%s%s", dst, src); or explicit strncat-with-computed-space then verify terminator |
sprintf |
No bound on total output length | snprintf with explicit destination size; check return value < size |
vsprintf |
Same as sprintf for va_list wrappers | vsnprintf with size passed down |
scanf family using %s or %[ without width |
Unbounded token write into fixed buffer | Explicit width %63s sized to buffer-1; or fgets into buffer then parse with sscanf; reject lines longer than capacity |
atoi |
No error signaling; out-of-range input has undefined behavior; accepts "12abc" silently |
strtol/strtoul with errno == 0, end-pointer at end-of-string, and range re-check against the field's real limits |
Risky With Care (Sink Table)
These are not banned; each hit requires manual proof that the bound is right in matching types.
| Function | Risk | Required care / safe replacement |
|---|---|---|
memcpy(dst,src,n) |
Trusts n completely; undefined if regions overlap |
Prove n <= dst_capacity AND n <= src_valid_bytes with both operands the same unsigned type; use memmove when overlap is possible; consider memcpy_s where available |
strncpy(dst,src,n) |
Does NOT guarantee NUL termination when strlen(src) >= n; zero-pads remainder (perf cost) |
After the call force dst[n-1] = '\0'; prefer snprintf(dst,dst_size,"%s",src) which always terminates |
snprintf |
Return value is the WOULD-BE full length on truncation; using it as an offset/length causes out-of-bounds writes later | Treat return as OK only when rc >= 0 && rc < size; clamp any derived offset to size - 1 |
alloca(n) with runtime n |
Allocates from the current stack frame; no failure return; attacker-sized n = stack overflow that canaries may not catch | Replace with fixed maximum plus heap fallback (malloc with checked size); avoid VLAs likewise (optional even in C11) |
realloc(ptr,n) |
ptr = realloc(ptr, n) leaks the old block on failure AND leaves ptr dangling if you assign NULL back |
Assign to temporary, check NULL, then commit |
strlen(p) on parser output |
Reads until it finds a NUL — walks past the allocation if none exists | Carry explicit lengths from the parser; never derive length from untrusted binary data via strlen |
strlcpy availability honesty: strlcpy/strlcat ship with the BSDs and macOS; musl historically does not provide them; glibc added them only in glibc 2.38 (2023). Microsoft's CRT instead offers the Annex K-style _s functions (strcpy_s, strncpy_s). C11 Annex K itself is optional and is NOT implemented by glibc or musl. Do not recommend strlcpy or _s functions without first checking the project's supported libc matrix; the snprintf("%s") idiom is the portable choice everywhere.
Regex Signatures
Ripgrep-compatible patterns; run from repo root. Expect noise — each hit needs the ladder applied.
Buffer-copy sinks (banned set):
\b(gets|strcpy|strcat|sprintf|vsprintf|stpcpy|wcscpy|wcscat)\s*\(
Unbounded scanf tokens (also inspect %[ manually in flagged files):
\b(scanf|fscanf|sscanf|vscanf|vfscanf|vsscanf)\s*\([^;]*%[0-9]*[hl]*(s|\[)
Runtime-sized stack allocation:
\balloca\s*\(
Format strings — non-literal format argument:
\bprintf\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\s*\)
\bfprintf\s*\(\s*(stderr|stdout|[A-Za-z_][A-Za-z0-9_]*)\s*,\s*[A-Za-z_][A-Za-z0-9_]*\s*\)
\bsyslog\s*\(\s*[^,]+\s*,\s*[A-Za-z_][A-Za-z0-9_]*\s*[,)]
va_list passthrough wrappers (manual caller review required):
\bv(syslog|printf|fprintf|snprintf|asprintf)\s*\(
Allocation sizes computed by arithmetic (overflow candidates):
\b(malloc|realloc|alloca)\s*\([^)]*[*/][^)]*\)
\bmalloc\s*\([^)]*\+[^)]*\)
Truncating casts near length-like identifiers (add -i to widen):
\(int|short|uint16_t|int16_t|unsigned short\)\s*[A-Za-z0-9_]*(len|length|size|count|sz)
Signed loop counter against unsigned-looking bound:
for\s*\(\s*int\s+[A-Za-z_]\w*\s*=\s*0\s*;\s*[A-Za-z_]\w*\s*<\s*[A-Za-z_]\w*(len|length|size|count|num)
Uninitialized-memory indicators (C stack allocas plus Rust):
\b(alloca\s*\(|mem::uninitialized|MaybeUninit::uninit|assume_init\(\))
Rust unsafe inventory:
unsafe\s*(fn|impl|\{)|#\[no_mangle\]|extern\s*"C"
\b(mem::transmute|transmute_copy|from_raw_parts_mut?|mem::forget)\b
Objective-C bridge C-string handling:
UTF8String|cStringUsingEncoding|getCString|getBytes:
Vulnerable / Fixed Pair: Integer Truncation in Packet Parse
// VULNERABLE: 16-bit length field trusted against a 256-byte stack buffer.
void handle_packet(const uint8_t *pkt, size_t pkt_len) {
if (pkt_len < 4) return;
uint16_t msg_len;
memcpy(&msg_len, pkt + 2, sizeof msg_len); // attacker controls all 16 bits
if ((size_t)msg_len > pkt_len) return; // check vs PACKET length...
char body[256]; // ...but never vs BODY capacity
memcpy(body, pkt + 4, msg_len); // msg_len up to 65535 -> overflow
}
// FIXED: widen immediately, enforce destination capacity, same type comparison.
void handle_packet(const uint8_t *pkt, size_t pkt_len) {
if (pkt_len < 4) return;
uint16_t raw;
memcpy(&raw, pkt + 2, sizeof raw);
size_t msg_len = raw; // no truncation possible now
char body[256];
if (msg_len < 1u || msg_len > sizeof body) return; // capacity check
if (msg_len > pkt_len - 4u) return; // source has the bytes?
memcpy(body, pkt + 4, msg_len);
body[msg_len - 1u] = '\0'; // safe: msg_len <= sizeof body
}
Vulnerable / Fixed Pair: Error-Path Use-After-Free
// VULNERABLE: object freed, then its fields logged.
static void drop_stale(struct conn_table *t, uint64_t id) {
struct conn *c = table_find(t, id);
if (!c || c->state != CONN_STALE) return;
conn_free(t, c);
log_info("dropped conn %llu peer=%s", id, c->peer_name); // UAF read
}
// FIXED: capture what logging needs BEFORE releasing ownership.
static void drop_stale(struct conn_table *t, uint64_t id) {
struct conn *c = table_find(t, id);
if (!c || c->state != CONN_STALE) return;
char peer[sizeof ((struct conn *)0)->peer_name];
snprintf(peer, sizeof peer, "%s", c->peer_name);
conn_free(t, c);
log_info("dropped conn %llu peer=%s", id, peer);
}
Vulnerable / Fixed Pair: Container Reallocation
// VULNERABLE: reference held across push_back; vector may reallocate.
std::vector<Item> items;
Item &first = items[0];
items.push_back(next_item()); // growth invalidates 'first'
audit(first); // dangling reference
// FIXED: index-based access, or reserve() up front.
std::vector<Item> items;
items.push_back(initial_item());
items.reserve(items.size() + 1); // no reallocation on next push_back
items.push_back(next_item());
audit(items[0]); // always valid
Vulnerable / Fixed Pair: Rust FFI Slice Construction
// VULNERABLE: trusts caller-supplied len; panics can unwind across FFI.
#[no_mangle]
pub unsafe extern "C" fn checksum(ptr: *const u8, len: usize) -> u32 {
let s = std::slice::from_raw_parts(ptr, len);
s.iter().map(|&b| b as u32).sum()
}
// FIXED: non-unsafe signature, validated construction, panic contained.
#[no_mangle]
pub extern "C" fn checksum(ptr: *const u8, len: usize) -> u32 {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if ptr.is_null() { return 0; }
let s = unsafe { std::slice::from_raw_parts(ptr, len) }; // contract documented:
// caller guarantees ptr valid for len bytes for this call's duration
s.iter().map(|&b| b as u32).sum()
}))
.unwrap_or(0)
}
Format-String Shape (minimal)
// VULNERABLE: data used as format.
void log_event(const char *tag) { printf(tag); printf("\n"); }
// FIXED: literal format, data as argument.
void log_event(const char *tag) { printf("%s\n", tag); }
Integer Safety Rules Table
| Rule | Why | Enforcement / how to check |
|---|---|---|
Never store lengths/sizes/counts in int, short, or bitfields |
Truncation silently reintroduces huge values | Use size_t end-to-end; compile -Wconversion and audit every cast it reports |
| Check multiplication BEFORE allocating | malloc(n*m) wraps: 65536*65536 = 0 in 32-bit math -> tiny alloc, giant loop |
GCC/Clang: __builtin_mul_overflow(n, m, &total) returning bool; MSVC: ULongMult/ULongAdd from intsafe.h; Rust: n.checked_mul(m).ok_or(...)?; C#: checked { } block |
| Make both comparison operands the same signedness explicitly | Negative signed value converts to huge unsigned and passes > 0 checks |
Compile -Wsign-compare (in -Wextra); cast the SMALLER-range side up, never the wider one down |
Validate off + len <= total as off <= total && len <= total - off |
The direct form can itself overflow past the check | Standard subtraction idiom; apply in every parser bounds branch |
Loop counters going DOWN must not be unsigned tested against >= 0 |
for (size_t i = n; i >= 0; i--) never terminates |
Prefer upward size_t loops; for downward use while (i-- > 0) idiom |
sizeof the object, not the type |
Type drift during refactors desynchronizes bounds | Write memcpy(buf, src, sizeof *buf); flag literal byte counts next to declarations |
Realistic overflow-in-size-calculation pair:
// VULNERABLE: 32-bit product wraps before malloc sees it.
uint32_t n = hdr.count, m = hdr.elem_size; // both attacker-controlled
uint8_t *buf = malloc(n * m); // 65536 * 65536 wraps to 0
for (uint32_t i = 0; i < n; i++)
memcpy(buf + (size_t)i * m, row(i), m); // massive heap overflow
// FIXED: overflow-checked product, zero-size rejected.
uint32_t n = hdr.count, m = hdr.elem_size;
if (n == 0 || m == 0) return ERR_EMPTY;
size_t total;
if (__builtin_mul_overflow((size_t)n, (size_t)m, &total)) return ERR_RANGE; // GCC/Clang
uint8_t *buf = malloc(total);
if (!buf) return ERR_NOMEM;
Leak & Failure-Pattern Signature Table
| Pattern | Language | Signature/example | Failure mode |
|---|---|---|---|
| Error-path leak | C | early return -1 between malloc and free |
slow exhaustion |
| FD leak in loop/retry | C/Python | open/connect per attempt, close on happy path only |
fd-table exhaustion |
| Unbounded user-keyed cache | all | sessions[key] = ... with no eviction/TTL |
memory DoS over time |
| Per-reconnect allocation | C++/Go | new buffer each retry, old never freed | growth under flaky network |
| Exception-unsafe cleanup | C++ | raw new/delete across calls that can throw |
leak or double-free paths |
| Dangling view | C++ | std::string_view returned from function over local string |
UAF read |
| Use-after-move | C++/Rust | object read after std::move/moved value used |
logic/UAF hybrid |
| Erase during iteration | C++ | for (...) v.erase(it) without it = v.erase(it) |
iterator UB |
| Panic across FFI | Rust | extern "C" fn can panic (unwrap/index) unwinds into C |
UB / abort |
| forget/guard leak | Rust | mem::forget(guard) where guard wraps FFI resource |
resource leak |
Signature greps (proxies — proximity of new/free is NOT statically grep-detectable; treat hits as leads for reading):
(fopen|socket|connect)\(fopen|fdopen
\.push_back\(|\.append\(unsafe
extern\s+"C"|\[no_mangle\]|mem::forget|Box::into_raw|catch_unwind
string_view|std::span|\.erase\(
Read each hit's enclosing function for the error-path question: does every branch after acquisition reach the release call?
// VULNERABLE — early return skips cleanup
char *buf = malloc(len);
if (!read_header(sock, hdr)) { return -1; } // buf leaked; fd stays open
process(buf); free(buf);
// FIXED — RAII owns it; every path cleans up
auto buf = std::make_unique<char[]>(len);
if (!read_header(sock, hdr)) { return -1; } // unique_ptr frees on unwind/return
process(buf.get());
// VULNERABLE — panic here unwinds into C caller (UB)
#[no_mangle]
pub extern "C" fn parse(ptr: *const u8, len: usize) -> i32 {
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
data[0] as i32 // panics when len == 0
}
// FIXED — boundary catches; contract documented
#[no_mangle]
pub extern "C" fn parse(ptr: *const u8, len: usize) -> i32 {
let r = std::panic::catch_unwind(|| {
let data = unsafe { std::slice::from_raw_parts(ptr, len) };
if data.is_empty() { return -1; }
data[0] as i32
});
r.unwrap_or(-2) // never unwind into C
}
Taint Tracing Guidance
You have no dataflow engine; do this manually but systematically, and grade confidence per finding.
Define Sources First
Mark every point where attacker bytes enter: recv/recvfrom/read on sockets, TLS plaintext callbacks (SSL_read, BIO chains), HTTP body/chunk parsers, message-queue callbacks, fread/getline/mmap of uploaded files, decompressor outputs (post-decompression lengths LIE relative to allocation), IPC reads, and for local-surface audits argv/envp.
Define Sinks by Class
| Class | Sink positions to mark |
|---|---|
| Bounds | 3rd arg of copy family (memcpy/memmove/memset/strcpy-likes), array subscripts, loop bounds |
| Allocation | size argument of malloc/calloc/realloc/alloca/new[] |
| Format | first string argument of printf/syslog/err families and their v* wrappers |
| Lifetime | free/delete/release calls, and any pointer returned by a function owning a stack buffer |
Trace the Length Variable, Not the Buffer
Buffers are easy to spot; lengths are where bugs hide. For each candidate sink:
- Name the length variable reaching the sink.
- Walk backwards line-by-line: every assignment, cast, arithmetic op, and branch it passes through.
- At each validation branch ask: validated against WHICH capacity, in WHICH types? A check against packet length does not cover a fixed destination buffer (see the truncation pair above).
- Derive the worst-case value an attacker can place there given the protocol's declared field widths.
- Record evidence as
file:line -> file:line -> sink, plus the derived worst-case number.
Grade each finding: CONFIRMED (all steps evidenced statically) / LIKELY (one step inferred) / NEEDS-RUNTIME-PROOF. Only CONFIRMED and LIKELY go in the report with severity; NEEDS-RUNTIME-PROOF items go to a watchlist with the missing link named.
Cross-Language Boundaries Extend Taint
Taint survives FFI. When C hands Rust a (ptr, len) or Java via JNI hands a byte array pointer, the receiving side inherits the sending side's taint. Document a one-line contract at each boundary: units, ownership, lifetime expectations, and which side validates.
Wrapper and Macro Traps
Projects wrap dangerous calls (xmemcpy, SAFE_COPY, custom log() around vsnprintf). Expand every wrapper before classifying its callers — a safe wrapper makes all callers safe only if IT validates; otherwise the taint passes through untouched. Grep wrapper definitions once, classify once, then treat callers accordingly.
Exploitation & Reproduction
Static confirmation is the deliverable. Dynamic proof is optional, LOCAL-ONLY, and exists to remove doubt about a CONFIRMED finding. Never run crafted input against systems you do not own; never ship remote-exploitation tooling in the report.
Rule of Engagement
- Report findings primarily as static evidence: sink line, length origin, missing bound, worst-case value, reachability path.
- Compile local harnesses yourself from scratch — never reuse untrusted PoC code from the internet inside the target repo or build.
- Run everything in a throwaway directory outside the repository tree; ASAN builds are for your harness, not for the project's production binaries unless the project already ships sanitizer targets.
Procedure: Prove a Stack Overflow Locally (ASAN)
Minimal vulnerable program:
// vuln.c
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
char small[8];
const char *src = argc > 1 ? argv[1] : "AAAAAAAAAAAAAAAA";
strcpy(small, src); // VULNERABLE: no bound
printf("%s\n", small);
return 0;
}
Compile and run locally:
gcc -g -O0 -fsanitize=address -fno-omit-frame-pointer -o vuln vuln.c
./vuln
Reading the report you will see:
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc...
WRITE of size 1 at 0x7ffc... thread T0
#0 ... in main vuln.c:6
Address 0x7ffc... is located in stack of thread T0 at offset 40 in frame
#0 ... in main vuln.c:4
This frame has 1 object(s):
[32, 40) 'small' <== Memory access at offset 40 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /path/vuln.c:6 in main
Line-by-line meaning: header names the bug CLASS and faulting address; WRITE of size N says direction and granularity (READ here would mean over-read); frame #0 ... vuln.c:6 pinpoints YOUR sink line; located in stack ... at offset X in frame names the victim variable; [32, 40) 'small' shows the object's real byte range so you can compute overflow distance; SUMMARY is what CI grep should alert on.
Fixed counterpart — rerun to show clean exit:
// fixed.c
#include <stdio.h>
int main(int argc, char **argv) {
char small[8];
const char *src = argc > 1 ? argv[1] : "AAAAAAAAAAAAAAAA";
int n = snprintf(small, sizeof small, "%s", src); // FIXED: always terminates
if (n < 0 || (size_t)n >= sizeof small) puts("[truncated]");
else printf("%s\n", small);
return 0;
}
Procedure: Demonstrate Integer Truncation (CWE-190)
// trunc.c — attacker length truncated before allocation, original used for write
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
int main(int argc, char **argv) {
if (argc < 2) return 1;
size_t n = strtoul(argv[1], 0, 10);
uint16_t short_n = (uint16_t)n; // VULNERABLE: wraps mod 65536
char *buf = malloc(short_n);
memset(buf, 'A', n); // writes ORIGINAL n bytes
free(buf);
return 0;
}
gcc -g -O0 -fsanitize=address -o trunc trunc.c && ./trunc 70000
# 70000 % 65536 == 4464 -> tiny alloc, 70000-byte memset -> heap-buffer-overflow WRITE
Expected report header: heap-buffer-overflow ... WRITE of size 70000 with the allocation stack showing malloc called from trunc.c:8. The fix mirrors the packet pair above: keep n as size_t throughout and validate against capacity before any cast.
Procedure: Interpret a Use-After-Free Report
// uaf.c
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
int main(void) {
char *p = malloc(16);
strcpy(p, "session-token");
free(p);
printf("%s\n", p); // VULNERABLE: read after free
return 0;
}
The ASAN output contains THREE stacks — teach reviewers this anatomy:
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602...
READ of size 1 ... thread T0
#0 ... in main uaf.c:8 <- USE site (your finding's sink)
freed by thread T0 here:
#0 free #1 main uaf.c:7 <- FREE site (fix ownership here)
previously allocated by thread T0 here:
#0 malloc #1 main uaf.c:6 <- ALLOCATION site (lifetime began)
Map: use-stack names the line to change; free-stack tells you which error path released too early; allocation-stack identifies the object type/owner. A double-free attempt produces an attempting double-free header with two free-stacks — same mapping. Null-pointer crashes appear as SEGV on unknown address 0x000000000000 with READ/WRITE — that is CWE-476 territory, usually DoS-only.
Procedure: Show Format-String Read Primitive Locally (%p)
// fmt.c
#include <stdio.h>
int main(int argc, char **argv) {
printf(argc > 1 ? argv[1] : "hi"); // VULNERABLE: data as format
putchar('\n');
return 0;
}
gcc -g -O0 -o fmt fmt.c && ./fmt "%p %p %p %p"
# prints register/stack garbage such as: 0x7ffd... 0x55... 0x0 0x20
Each %p consumes one vararg slot that was never provided, dumping process memory values to output — that IS the information leak (CWE-134). The %n conversion is the write primitive concept: %n stores the count of characters printed so far into a pointer argument, so an attacker-supplied format turns the same bug into controlled memory writes; do NOT demo %n beyond noting the mechanism, and never aim format-string tests at services you do not own. Log-forging adjacency: %s-heavy formats plus attacker text also forge log lines — cross-reference logging review.
Static-Only Confirmation Template
When compiling a harness is not possible, a CONFIRMED finding still stands if the report states all five:
- Sink line (file:line) and why it is a sink.
- Length/value origin line with the attacker-controlled field identified.
- The specific missing/insufficient check line (or proof none exists on any path).
- Reachability: entry point -> call chain to the sink.
- Worst-case numeric derivation from protocol field widths (e.g., "16-bit field admits 65535 vs 256-byte buffer").
Remediation
Replace Dangerous Calls
Use the banned/risky tables in Patterns & Signatures as the replacement authority. Order fixes by severity rating, and pair every fix with a regression test per Verification & Validation.
Compiler and Linker Hardening (mitigations, ship them anyway)
| Toolchain | Flags | Notes |
|---|---|---|
| GCC / Clang | -fstack-protector-strong |
Canary on functions with local arrays/addresses taken |
| GCC / Clang | -D_FORTIFY_SOURCE=2 |
Requires an optimizing build (-O1+); adds runtime-checked bounds to many libc calls |
| GCC / Clang | -fPIE compile + -pie link; -Wl,-z,relro,-z,now |
ASLR-capable executable with full RELRO |
| GCC / Clang | -Wall -Wextra -Wconversion -Wsign-compare -Wformat=2 -Werror=format-security |
Catches truncation, signedness, and non-literal formats at build time |
| GCC / Clang (test builds) | -fsanitize=address,undefined -fno-omit-frame-pointer -g |
ASAN+UBSAN for CI runs, not release |
| MSVC | /GS /SDL |
Stack canaries plus SDL-era extra checks |
| MSVC | /DYNAMICBASE /NXCOMPAT /guard:cf |
ASLR, DEP, Control Flow Guard |
State plainly in remediation notes: these flags raise exploit cost; they do not close any defect found by this module.
Modern API and Type Adoption
- C++: migrate parser buffers to
std::string/std::vector(length travels WITH the data) and pass views asstd::span(C++20) so capacity is part of the type; when stuck pre-C++20, pass explicit length parameters and never return pointers to internals. - C portable copies: the
snprintf(dst, dst_size, "%s", src)idiom works everywhere. Availability honesty for alternatives:strlcpy— BSDs/macOS yes, glibc only since 2.38, musl no; Annex K_sfunctions — implemented mainly by Microsoft's CRT, optional in C11 and absent from glibc/musl. Check the project's supported libc matrix before recommending either. - Rust rewrite boundary strategy: rewrite the highest-risk parser as a safe-Rust crate, keep its
extern "C"ABI thin, validate every(ptr, len)at entry, contain panics withcatch_unwind, and leave C callers unchanged — strangler-pattern migration rather than a big-bang port. - Prefer
callocovermalloc(n*m)where a zeroed region is acceptable:callocchecks multiplication overflow internally on modern libcs.
Introduce Fuzzing Where Absent
Presence check first:
rg --files -g '*fuzz*' ; rg -n 'LLVMFuzzerTestOneInput|cargo fuzz|AFL_|afl-fuzz'
If nothing exists, propose (do not implement) a harness of this shape wired into the project's own test build:
// fuzz/parse_fuzz.c — harness shape pseudocode
#include <stdint.h>
#include <stddef.h>
int parse_frame(const uint8_t *data, size_t len); // existing target entry point
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
parse_frame(data, size); // one attacker-shaped buffer, one call
return 0;
}
// Build under libFuzzer or AFL++ per project toolchain; feed crashers back as unit tests.
Leak & Lifetime Hardening
- Smart-pointer ownership defaults (C++):
unique_ptrfor sole owners,shared_ptronly when lifetime is genuinely shared,weak_ptrfor back-references that would otherwise create cycles; rawnew/deletebecome review flags. - Sanitizer gates in CI: build with
-fsanitize=address,leakfor test suites (LSAN reports leaks at exit) and runvalgrind --leak-check=fullon the nightly integration suite; a leak finding fails the build like any other test. - Container-growth budgets: every user-keyed cache/map gets an explicit max-entries + eviction policy (LRU or TTL); assert the bound in code rather than trusting operators to notice growth.
- Rust FFI convention: every
#[no_mangle]export carries a doc comment stating the OWNERSHIP CONTRACT (who frees what, panic policycatch_unwindpresent) — undocumented-boundary = review flag.
Verification & Validation
GIVEN/WHEN/THEN Scenarios
Oversized input rejected cleanly:
GIVEN a parser whose declared maximum body is 256 bytes
WHEN a frame arrives declaring a 300-byte body
THEN the parser rejects it with a protocol error
AND no write occurs beyond any buffer boundary (ASAN-clean run)
AND subsequent valid traffic on the same session parses normally
Normal traffic unaffected (negative/regression guard):
GIVEN the same parser and a recorded golden capture of valid frames
WHEN all frames replay through the fixed code
THEN outputs match the pre-fix baseline byte-for-byte
Truncation path:
GIVEN a length field of 70000 arriving at code that previously cast to uint16_t
WHEN the frame is processed
THEN the value is either rejected (> declared max) or handled in widened size_t math
AND the value 4464 (the wrap residue) never appears in any allocation decision
Error-path lifetime:
GIVEN session teardown while one request is mid-flight
WHEN teardown frees the connection object
THEN the in-flight request completes-or-rejects WITHOUT touching freed memory (ASAN-clean)
AND the log line for the drop contains the peer name captured before free
Format-string hardening:
GIVEN an event tag containing "%n%p%s"
WHEN the tag is logged
THEN the output contains the tag verbatim
AND the process does not crash and no memory words appear in output
Regression Test Pseudocode (boundary lengths)
/* test_parse_boundaries.c — shape; adapt names to project test framework */
static void test_parse_boundaries(void) {
static const size_t lens[] = {0u, 1u, MAX_BODY - 1u, MAX_BODY,
MAX_BODY + 1u, 0xFFFFu};
uint8_t frame[0xFFFF];
memset(frame, 'x', sizeof frame);
for (size_t i = 0; i < sizeof lens / sizeof lens[0]; ++i) {
set_declared_len(frame, lens[i]);
enum pstatus st = parse_frame(frame, sizeof frame);
ASSERT(st == PS_OK || st == PS_REJECTED); /* never UB, never crash */
}
}
Manual Post-Fix Checklist
- Every flagged sink now compares attacker length against destination capacity in matching types, before the copy/index.
- No length variable changes width between validation and use.
- All allocation-size arithmetic is overflow-checked or provably bounded by constants.
- Every freed object is unreachable afterwards on ALL paths (success, error, callback, logging).
- Every
snprintfreturn used as an offset is clamped to the buffer bound. - Error paths release what success paths release (walk each early return after an acquire).
- Leak gates green: LSAN build of the test suite exits clean; nightly valgrind run has no new "definitely lost" records.
- Soak assertion: after N=10k iterations of the worst-case flow, RSS and open-fd count (
ls /proc/self/fd | wc -l) are within a stated tolerance — a bounded-memory regression test. - Negative control: triggering the error path on purpose does NOT grow fd/RSS counters between before/after samples.
- Each Rust
unsafeblock carries a written invariant comment matching the checklist items. - Hardening flags added to release build config; sanitizer flags added to CI-only config.
Post-Fix Greps
Run from repo root excluding vendored code (-g '!third_party/**'); every command below should return zero hits unless an allowlisted justification exists in the file itself:
rg -n '\b(gets|strcpy|strcat|sprintf|vsprintf)\s*\(' -g '!third_party/**'
rg -n '\bscanf\s*\([^;]*%s' -g '!third_party/**'
rg -n '\bprintf\s*\(\s*[A-Za-z_][A-Za-z0-9_]*\s*\)' -g '!third_party/**'
rg -n '\balloca\s*\(' -g '!third_party/**'
Maintain an allowlist file listing each surviving hit with owner and reason; unexplained hits reopen the finding.
Sanitizer Builds in CI
Example CMake invocation for a nightly job:
cmake -S . -B build-asan \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined"
cmake --build build-asan -j && ctest --test-dir build-asan --output-on-failure
Policy: any sanitizer report in CI blocks release until triaged. MSVC equivalent exists via /fsanitize=address (VS 2019 16.9+). For Rust crates, address-sanitizer builds require a nightly compiler via RUSTFLAGS="-Zsanitizer=address" — state that constraint honestly in the CI ticket rather than assuming stable-toolchain support.
Severity Assessment
CWE Mapping and CVSS v3.1 Vectors
Rate the IMPACT the defect enables, not the root-cause class; integer overflow (CWE-190) inherits the severity of what it unlocks.
| Finding | CWE | Example CVSS v3.1 vector | Default rating |
|---|---|---|---|
| Network pre-auth stack/heap corruption (overflow or UAF) with control-flow potential | CWE-120 / CWE-121 / CWE-122 / CWE-416 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
Critical (9.8) |
| Network pre-auth out-of-bounds READ leaking memory (heartbleed shape) | CWE-125 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
High (7.5) |
Format string, read-only leak (%p/%s walk) |
CWE-134 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
High (7.5) |
Format string reachable write (%n) or UAF/double-free network-reachable |
CWE-134 / CWE-415 / CWE-416 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
Critical (9.8) |
| Same corruption behind authentication | above classes | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N shape |
Medium–High per privilege tier |
| Local-only info leak (uninitialized data disclosure) | CWE-457 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N |
Medium (5.5) |
| DoS-only NULL dereference from untrusted input | CWE-476 | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L |
Low–Medium context (~4.3) |
Stack vs heap nuance for reporting precision: use CWE-121 when the corrupted object is a local/array in a function frame, CWE-122 when it is heap-allocated via malloc/new; CWE-120 is the generic family acceptable when the site is ambiguous.
Rating Rubric
Apply in order; first match wins:
- Network-reachable, pre-authentication memory corruption (overflow, UAF, double free) with any code-execution or control-flow potential → Critical.
- Network-reachable pure information leak (over-read, uninitialized send, format-string read) → High.
- Memory corruption requiring authentication or local access → High if privileged process, Medium otherwise.
- Information leak requiring local access → Medium.
- DoS-only (NULL deref, assertion abort) from network input → Medium; downgrade toward Low where process isolation/restart makes availability loss transient and the component is non-core.
- Adjust within one band for exploitability reducers (canaries, ASLR, NX lower RELIABILITY, never existence); document the adjustment rationale in the finding.
Common False Positives
strncpyhits wherenis provably<=destination size AND the code forces termination afterwards — verify both halves before dismissing.memcpywith a literal length equal to a same-named fixed buffer'ssizeof— check they refer to the SAME object, not identically named fields of different structs.snprintfflagged because its return feeds an offset — only a finding if the return is used UNclamped on a later bounded write.- Regex hits inside C++ files that are actually
std::stringoperations or comments — confirm the types involved are raw arrays/pointers. - Rust
unsafeblocks whose invariants are genuinely upheld and documented — record as reviewed-safe rather than findings, so future edits know the contract was checked once. - Unsigned downward loops written as
while (i-- > 0)— correct idiom despite looking suspicious. - Test fixtures, fuzz corpora generators, and demo binaries excluded from production builds — verify build flags actually exclude them before marking anything.
- Vendored third-party hits — still report, but tag vendor/version and note upstream tracking instead of demanding an immediate local patch.
References
- CWE-120: Buffer Copy without Checking Size of Input ("Classic Buffer Overflow") — https://cwe.mitre.org/data/definitions/120.html
- CWE-121: Stack-based Buffer Overflow — https://cwe.mitre.org/data/definitions/121.html
- CWE-122: Heap-based Buffer Overflow — https://cwe.mitre.org/data/definitions/122.html
- CWE-125: Out-of-bounds Read — https://cwe.mitre.org/data/definitions/125.html
- CWE-134: Use of Externally-Controlled Format String — https://cwe.mitre.org/data/definitions/134.html
- CWE-190: Integer Overflow or Wraparound — https://cwe.mitre.org/data/definitions/190.html
- CWE-415: Double Free — https://cwe.mitre.org/data/definitions/415.html
- CWE-416: Use After Free — https://cwe.mitre.org/data/definitions/416.html
- CWE-457: Use of Uninitialized Variable — https://cwe.mitre.org/data/definitions/457.html
- CWE-476: NULL Pointer Dereference — https://cwe.mitre.org/data/definitions/476.html
- OWASP Buffer Overflow Attack — https://owasp.org/www-community/attacks/Buffer_overflow_attack
- OWASP Format String Attack — https://owasp.org/www-community/attacks/Format_string_attack
- OWASP Cheat Sheet Series: C-Based Toolchain Hardening Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/C-Based_Toolchain_Hardening_Cheat_Sheet.html
- SEI CERT C Coding Standard (Carnegie Mellon University Software Engineering Institute) — browse the rule index at wiki.sei.cmu.edu; its memory-management (MEM) and string-handling (STR) rules map directly to this module's tables
- OWASP Top 10 2021 category A06:2021 – Vulnerable and Outdated Components — https://owasp.org/Top10/A06_2021-Vulnerable_and_Outdated_Components/