SuperAPI AI Directory

aegis-memory-safety

Coding & development

View repository

Updated 10-15 17:19

About this skill

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 unsafe blocks, unsafe fn, unsafe impl, FFI (extern "C") surfaces, and -sys binding 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 printf family (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:

  1. Network/message parsers: anything fed from sockets, TLS plaintext, HTTP bodies, protocol dissectors (parse_*, decode_*, handle_*, *_recv, on_data, read_packet, process_message).
  2. File-format importers/exporters: image, audio, archive, document, font decoders — attacker-supplied files are network-equivalent input.
  3. IPC and local-attack-surface handlers: Unix sockets, named pipes, D-Bus service backends.
  4. Shared libraries consumed by a privileged or network-facing process.
  5. 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:

  1. Where does this buffer's capacity come from — fixed array size, sizeof of what, malloc argument?
  2. Who controls the length used here — packet field, header count, string return value, decompressor output?
  3. What units is that length in — bytes, elements, UTF-8 code points, wchar_t units? Do both sides agree?
  4. Is the length validated against destination capacity BEFORE the copy/index, with the comparison in matching types?
  5. Does any cast sit between declaration and use (size_t to int/uint16_t/unsigned short)?
  6. Do signed and unsigned values mix in any comparison on the path?
  7. Can a multiplication or addition computing an allocation size overflow first?
  8. Does every write leave room for the NUL terminator, and is it actually written?
  9. After any free/delete/release on this path, can the same object be touched again (error paths, callbacks, logging)?
  10. Is every field of every struct fully written before it is sent, hashed, or published?
  11. If Rust: does each unsafe block 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] where type is a 32-bit attacker value.
  • Negative index through signedness: C99 defines % on negative operands as negative remainder, so (int)field % TABLE_SIZE can still yield a negative subscript.
  • Pointer arithmetic past one-past-the-end: reading p[len] "just to peek" is out-of-bounds unless len < 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; for loops over a linked list whose current node the body frees.
  • Error paths that free then log: cleanup calls free(conn) and the next line logs conn->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::vector held across push_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 (not calloc) 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/strcpy applied to buffers parsed from binary data that may lack a terminator.
  • Off-by-one terminators: loops writing buf[i] for i <= len, or buf[len] = '\0' where len == 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:

  1. Raw pointer dereference: provenance valid, aligned, initialized for the full claimed length; aliasing rules respected for references.
  2. mem::transmute: source and destination sizes/layout equal; prefer typed conversions; flag any transmute of &T to &mut T or lifetime extension.
  3. slice::from_raw_parts(ptr, len) / _mut: ptr non-null and valid for len * size_of::<T>() bytes; len comes from a trusted counter, never raw FFI input unvalidated.
  4. mem::uninitialized is instant UB for most types — always a finding; MaybeUninit misuse means assume_init() called before every byte was written.
  5. unwrap()/expect() reachable from extern "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.
  6. unsafe impl Send/Sync: proof that the type really is thread-safe; these escape compile-time checking.
  7. 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_t units vs UTF-8 code points; wcslen counts wide chars, strlen counts bytes — mixing them corrupts copies silently.
  • Ownership transfer is explicit: who frees, with WHICH allocator (free vs delete vs 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 Drop impl or panic unwinding across an extern "C" boundary is undefined behavior — check for catch_unwind at every exported entry point; mem::forget on RAII guards wrapping FFI-owned resources leaks them; Box::into_raw ownership-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/delete pairs spanning throw-capable calls, manual lock/file/handle management instead of RAII, destructors that can throw.
  • Dangling-view patterns: std::string_view/std::span outliving their backing store; reads after std::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: -sys binding crates, then build.rs (runs arbitrary compiled code at build time), then modules exporting #[no_mangle] extern "C".
  • Any static mut shared 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:

  1. Name the length variable reaching the sink.
  2. Walk backwards line-by-line: every assignment, cast, arithmetic op, and branch it passes through.
  3. 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).
  4. Derive the worst-case value an attacker can place there given the protocol's declared field widths.
  5. 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

  1. Report findings primarily as static evidence: sink line, length origin, missing bound, worst-case value, reachability path.
  2. Compile local harnesses yourself from scratch — never reuse untrusted PoC code from the internet inside the target repo or build.
  3. 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:

  1. Sink line (file:line) and why it is a sink.
  2. Length/value origin line with the attacker-controlled field identified.
  3. The specific missing/insufficient check line (or proof none exists on any path).
  4. Reachability: entry point -> call chain to the sink.
  5. 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 as std::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 _s functions — 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 with catch_unwind, and leave C callers unchanged — strangler-pattern migration rather than a big-bang port.
  • Prefer calloc over malloc(n*m) where a zeroed region is acceptable: calloc checks 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_ptr for sole owners, shared_ptr only when lifetime is genuinely shared, weak_ptr for back-references that would otherwise create cycles; raw new/delete become review flags.
  • Sanitizer gates in CI: build with -fsanitize=address,leak for test suites (LSAN reports leaks at exit) and run valgrind --leak-check=full on 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 policy catch_unwind present) — 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 snprintf return 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 unsafe block 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:

  1. Network-reachable, pre-authentication memory corruption (overflow, UAF, double free) with any code-execution or control-flow potential → Critical.
  2. Network-reachable pure information leak (over-read, uninitialized send, format-string read) → High.
  3. Memory corruption requiring authentication or local access → High if privileged process, Medium otherwise.
  4. Information leak requiring local access → Medium.
  5. 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.
  6. Adjust within one band for exploitability reducers (canaries, ASLR, NX lower RELIABILITY, never existence); document the adjustment rationale in the finding.

Common False Positives

  • strncpy hits where n is provably <= destination size AND the code forces termination afterwards — verify both halves before dismissing.
  • memcpy with a literal length equal to a same-named fixed buffer's sizeof — check they refer to the SAME object, not identically named fields of different structs.
  • snprintf flagged 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::string operations or comments — confirm the types involved are raw arrays/pointers.
  • Rust unsafe blocks 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