PART I
The previous chapter described this chain:
integer arithmetic
->
allocation size
->
pointer arithmetic
->
bounds
->
memory corruption
This chapter turns that model into a code-review technique.
The goal is not to memorize a collection of dangerous functions. It is to learn how to inspect a piece of C code and determine whether a value can travel from an external input to an invalid memory access.
The most useful question throughout this chapter is:
What quantity controls the memory access, and where did that quantity come from?
Once you know that, follow it.
A common mistake in code review is to begin with:
malloc()
memcpy()
strcpy()
realloc()
Those functions deserve attention, but they are usually not where the reasoning begins.
Instead, start with an operation that accesses memory:
buffer[index] = value;
memcpy(dst, src, length);
memset(buffer, 0, size);
items[i].field = value;
*ptr = value;
Then work backward.
For:
buffer[index] = value;
ask:
Where did buffer come from?
How large is the allocation?
Where did index come from?
What proves index is within that allocation?
For:
memcpy(dst, src, length);
ask:
How large is the destination?
How was dst calculated?
What proves length fits after dst?
How was length calculated?
The important shift is from:
"Is this function dangerous?"
to:
"Can the values reaching this operation violate the object's bounds?"
Consider:
// Define a function to copy a specified length of data from source to destination
void copy_data(char *dst, const char *src, size_t len)
{
// Perform a memory copy operation using the provided pointers and length
// (potentially vulnerable if 'dst' or 'src' lack sufficient capacity or
// buffers overlap improperly, though memcpy handles size safely via len)
memcpy(dst, src, len);
}
Notwithstanding the comments, there is not enough information here to conclude that the function is vulnerable.
The function may be perfectly safe if its caller guarantees:
destination_size >= len
The interesting question is therefore not whether `memcpy` is present.
It is:
Where is destination_size established?
If the caller does this:
// Declare a fixed-size character buffer on the stack with a capacity of 1024 bytes
char buffer[1024];
// Check if the length to copy is less than or equal to the buffer's size
// to ensure the operation is bounds-safe and prevent a stack buffer overflow
if (len <= sizeof(buffer))
// Safely copy data from the input into the stack buffer using the validated length
copy_data(buffer, input, len);
then the relationship is explicit.
If instead the caller does this:
// Declare a fixed-size character buffer on the stack with a capacity of 1024 bytes
char buffer[1024];
// Copy data from the input into the fixed-size stack buffer
// (vulnerable to a classic stack-based buffer overflow if input_length exceeds 1024 bytes,
// as there is no bounds checking before the copy operation)
copy_data(buffer, input, input_length);
and `input_length` comes directly from an untrusted source, the review needs to continue.
For every memory access, identify the quantities that determine its range.
For an array access:
array[index]
the relevant quantities are usually:
base object
object size
index
For:
array[index + offset]
they become:
base object
object size
index
offset
For:
memcpy(dst, src, len)
they become:
destination object
destination offset
destination capacity
source object
source offset
source available data
length
This is where experienced programmers can save a great deal of time.
Do not try to understand every variable in a large function.
First isolate the variables that control memory.
Suppose you find:
memcpy(buffer + offset, input, length);
Start at `length`.
Ask:
Where was length assigned?
Perhaps:
length = packet->payload_length;
Now ask:
Where did payload_length come from?
Perhaps:
payload_length = read_u32(packet);
Now ask:
Is it constrained by the packet size?
Perhaps not.
Next inspect `offset`.
Maybe:
offset = sizeof(struct header);
Then inspect the allocation:
buffer = malloc(sizeof(struct header) + length);
Now the chain is visible:
packet->payload_length
->
length
->
header_size + length
->
allocation
->
buffer + header_size
->
memcpy(..., length)
At this point, the key invariant is:
header_size + length <= allocated_size
If `allocated_size` is exactly that expression, then the next question becomes:
Can the addition overflow?
This is how the chain naturally leads from bounds to arithmetic.
When auditing an allocation followed by an access, write down the intended relationship explicitly.
For an array:
allocation_size >= count * element_size
For an offset into a buffer:
offset <= allocation_size
For an access of length `length`:
offset + length <= allocation_size
For a two-dimensional object:
rows * columns * element_size <= allocation_size
These equations are more useful than vague statements such as:
"This looks bounded."
You want to know exactly what must be true.
Then ask:
Where is each part of this equation established?
Consider:
// Define a static helper function to allocate memory for
// a specified number of items of a given size
static void *allocate_items(size_t count, size_t item_size)
{
// Return a memory allocation by multiplying count and item_size
// (vulnerable to an integer overflow if the product exceeds SIZE_MAX,
// which wraps around to a small value and causes malloc to allocate insufficient memory)
return malloc(count * item_size);
}
and:
// Define a function to create and allocate an array of items
// given a specific count
struct item *create_items(size_t count)
{
// Call the helper function to allocate memory, passing the
// count and the size of the item struct
// (vulnerable to an integer overflow during multiplication
// inside allocate_items if count is sufficiently large)
return allocate_items(count, sizeof(struct item));
}
The multiplication is no longer visible next to the allocation at the call site.
That does not make the calculation safer.
When auditing code, follow data flow across helper functions.
You should recognize this:
count
|
v
allocate_items()
|
v
count * item_size
|
v
malloc()
|
v
returned pointer
|
v
item access
as the same pattern as an explicit multiplication.
This is one reason security review cannot be reduced to simple textual searches.
Consider:
// Check if the item count exceeds the maximum allowed items limit
if (count > MAX_ITEMS)
// Return an error if the count is too large to handle safely
return ERROR;
// Allocate memory for the items array by multiplying count by the item size
// (vulnerable to an integer overflow if count is large enough to
// wrap the multiplication product, since checking against MAX_ITEMS alone
// does not guarantee that count * sizeof(*items) won't overflow SIZE_MAX)
items = malloc(count * sizeof(*items));
Not withstanding the comments, the reviewer may see the check and move on.
Instead, calculate what the check proves.
It proves:
count <= MAX_ITEMS
It does not necessarily prove:
count * sizeof(*items) <= SIZE_MAX
Those are different propositions.
A good review therefore asks:
What is the maximum value of sizeof(*items)?
What is the maximum value of count?
Does their product fit?
If the answer depends on the implementation, the code may be portable on one platform and unsafe on another.
A stronger pattern is:
// Check if multiplying count by the size of each item
// would exceed SIZE_MAX to prevent integer overflow
if (count > SIZE_MAX / sizeof(*items))
// Return an error if an integer overflow would occur
// during allocation size calculation
return ERROR;
// Safely allocate memory for the items array since the
// multiplication is now guaranteed not to overflow
items = malloc(count * sizeof(*items));
The check directly proves the property required by the multiplication.
Consider:
// Retrieve the number of items to allocate and process
size_t count = get_count();
// Allocate memory for the items array by multiplying count by the item size
// (vulnerable to an integer overflow if count is large enough to wrap the
// multiplication product,
// resulting in a smaller-than-expected buffer allocation)
items = malloc(count * sizeof(*items));
// Loop through each item index up to count
// (vulnerable to a severe heap buffer overflow because the loop executes
// 'count' times while malloc only allocated a truncated amount of memory
// due to the unchecked integer overflow)
for (size_t i = 0; i < count; ++i)
process(&items[i]);
Notwithstanding the comments, the loop is perfectly conventional.
The condition:
i < count
is correct.
But there are two separate questions:
Is count a valid logical number of items?
Does the allocation actually contain count items?
The loop only answers the first.
If the allocation calculation overflowed, the second may be false.
This distinction is worth emphasizing:
logical bounds
are not automatically
physical bounds
A program can have a perfectly correct loop over an incorrectly sized allocation.
Consider:
// Check if the starting offset is strictly less than the total buffer size
if (offset < size)
// Copy data into the buffer at the calculated position
// (vulnerable to an out-of-bounds write or buffer overflow because the
// check only validates that the starting offset is within bounds,
// but does not check if offset + length exceeds size)
memcpy(buffer + offset, input, length);
This checks that the starting point is inside the buffer.
It does not establish that the entire copy fits.
The required property is:
offset + length <= size
But even that expression must be handled carefully because:
offset + length
can itself overflow.
A safe conceptual check is:
// Check if the starting offset exceeds the total buffer size
// to prevent out-of-bounds positioning
if (offset > size)
// Return an error if the offset is invalid
return ERROR;
// Check if the copy length exceeds the remaining space in the
// buffer starting from the offset to prevent an overflow
if (length > size - offset)
// Return an error if the write would exceed the buffer boundaries
return ERROR;
// Safely copy data into the buffer at the specified offset using
// the bounds-checked length
memcpy(buffer + offset, input, length);
The subtraction is performed only after establishing that `offset <= size`.
The general pattern is important:
Check the relationship without first performing
the arithmetic that could overflow.
Real code often contains more complicated size expressions:
// Calculate the total allocation size by adding a fixed header
// size to the product of count and element size (vulnerable to
// multiple integer overflow risks: first during the multiplication
// of count and element_size, and second during the addition of
// header_size, which can cause the total to wrap around to a small value)
total = header_size + count * element_size;
The required invariant is:
header_size + count * element_size <= SIZE_MAX
There are now two possible arithmetic failures.
First:
count * element_size
can overflow.
Then:
header_size + result
can overflow.
Checking only one operation is insufficient.
Conceptually:
count <= SIZE_MAX / element_size
followed by:
count * element_size <= SIZE_MAX - header_size
Only after both conditions hold should the final allocation size be calculated.
This is a common point where apparently careful code remains vulnerable.
Consider image processing code:
// Calculate the total number of bytes required for a pixel buffer by
// multiplying width, height, and channels (vulnerable to a multi-variable
// integer overflow if the product exceeds SIZE_MAX, causing the resulting
// byte count to wrap around to a small value)
size_t bytes = width * height * channels;
// Allocate memory for the pixel buffer using the potentially wrapped
// 'bytes' value (vulnerable to a heap buffer overflow or out-of-bounds
// write during subsequent pixel processing because malloc allocated
// insufficient memory due to the integer overflow)
unsigned char *pixels = malloc(bytes);
This expression contains a chain of multiplications.
A safe allocation requires all intermediate calculations to fit.
The danger becomes particularly easy to miss because the final type may be `size_t`, which programmers often assume means:
"This is the type for sizes, so it cannot overflow."
It can.
`size_t` is an unsigned integer type. It has a finite maximum value.
The type is appropriate for representing sizes. It does not make arbitrary size calculations safe.
For a multidimensional allocation, review each multiplication as a separate operation.
Conceptually:
width
->
width * height
->
previous_result * channels
->
allocation
At every arrow ask:
Can this operation exceed SIZE_MAX?
Now consider:
// Retrieve an integer length from an external or untrusted function
int length = get_length();
// Check if the length is strictly less than the maximum allowed length limit
if (length < MAX_LENGTH)
// Process the buffer using the validated length
// (vulnerable to a signed integer overflow or out-of-bounds error
// if 'length' can be a negative value, as many checks like '< MAX_LENGTH'
// allow negative numbers which may bypass size validations
// or cast improperly to unsigned values during buffer processing)
process(buffer, length);
Notwithstanding the comments, this may look reasonable.
But what happens if `length` is negative?
The answer depends on what `process` does with it.
Suppose:
// Define a function to process a buffer by copying input data into it using a specified length
void process(char *buffer, int length)
{
// Copy data from the input source into the destination buffer using the provided length
// (vulnerable to a buffer overflow if 'length' is negative—since it is a signed int,
// it can be interpreted as a massive positive value when cast to size_t by memcpy—or if
// 'length' exceeds the actual allocated capacity of the destination buffer)
memcpy(buffer, input, length);
}
A negative signed length passed to a function expecting a `size_t` is converted to an unsigned value.
A small negative number can therefore become a very large positive size.
For example, conceptually:
-1 -> conversion to size_t -> SIZE_MAX
The security lesson is not simply:
"signed/unsigned conversions are dangerous."
It is:
"A value can change meaning when it crosses a type boundary."
When auditing a size or index, record its type at every important stage.
Consider:
// Read a signed integer number from an input or untrusted source
int n = read_number();
// Check if the number is within the valid non-negative range up to MAX
if (n >= 0 && n <= MAX)
// Cast the validated non-negative integer to size_t and pass it
// to a size-consuming function (safe from negative-to-unsigned
// conversion issues due to the explicit lower bound check 'n >= 0')
use_size((size_t)n);
This is much easier to reason about because the validation establishes that `n` is nonnegative before conversion.
Compare it with:
// Read an unsigned size_t value from an input or untrusted source
size_t n = read_number();
// Check if the read value is less than or equal to the maximum allowed limit
if (n <= MAX)
// Perform operations using the validated size
// (vulnerable or potentially problematic if 'read_number()'
// returns a signed type that undergoes an implicit conversion to
// size_t before the comparison, or if negative values wrap around to
// very large positive numbers because a standard size_t is unsigned)
...
Here the parsing function's behavior matters.
Or:
unsigned int n = read_number();
int length = n;
if (length > 0)
...
Now the conversion may have changed the value before the check.
A reliable review habit is:
Find the point at which the value enters the type
used for the memory calculation, and validate there.
Consider:
// Calculate the new item count by adding the additional count to the existing count
// (vulnerable to an integer overflow if the addition exceeds the maximum capacity
// of the count type, causing the value to wrap around to a smaller number)
new_count = count + additional;
// Calculate the new byte size required for realloc by multiplying the new count by
// the item size (vulnerable to a secondary integer overflow if the wrapped
// new_count multiplied by sizeof(*items) exceeds SIZE_MAX, wrapping around to
// a small value)
new_size = new_count * sizeof(*items);
// Resize the existing memory allocation using the potentially wrapped new_size
// (vulnerable to a heap buffer overflow because realloc allocates insufficient memory,
// leading to out-of-bounds writes when subsequent code populates up to new_count items)
items = realloc(items, new_size);
Notwithstanding the comments, the obvious question is whether the multiplication can overflow.
But there is another question:
Can count + additional overflow?
Both operations must be safe.
Then there is the lifetime question.
This is dangerous:
// Resize the existing memory allocation using a previously calculated
// new_size (vulnerable to a potential memory leak or null pointer
// assignment if realloc fails and returns NULL, as overwriting the
// original 'items' pointer directly with the result of realloc loses
// the original reference, preventing proper cleanup of the existing buffer)
items = realloc(items, new_size);
because if `realloc` fails, the original allocation remains valid, but the original pointer has been overwritten by `NULL`.
A safer pattern is:
// Attempt to resize the memory allocation by passing the existing
// pointer and new size to realloc, storing the result in a temporary
// pointer to prevent memory leaks if the allocation fails
tmp = realloc(items, new_size);
// Check if the temporary pointer is NULL, indicating that the realloc
// operation failed due to insufficient memory
if (tmp == NULL)
// Return an error to handle the allocation failure safely while
// preserving the original 'items' pointer
return ERROR;
// Safely update the original items pointer with the successfully
// resized memory block stored in 'tmp'
items = tmp;
The arithmetic chain and the pointer-lifetime chain meet here.
The full reasoning is:
count
->
count + additional
->
new_count
->
new_count * sizeof(*items)
->
new_size
->
realloc
->
new allocation
->
pointer used by later accesses
Every transition needs its own invariant.
Suppose:
p = malloc(size);
q = p + offset;
p = realloc(p, new_size);
The old value of `q` must not automatically be assumed to remain valid.
`realloc` may move the allocation.
After a successful reallocation, the new allocation may have a different address.
This produces another useful audit question:
Which pointers refer into an allocation that may have moved?
When reviewing code around `realloc`, search not only for the allocation pointer but also for derived pointers.
For example:
base
end
cursor
current
next
data
payload
These names often represent pointers derived from the original allocation.
A reallocation can invalidate all of them.
C parsers are especially fertile ground for this vulnerability chain because they repeatedly convert external numbers into memory operations.
A typical parser might do:
// Read a 32-bit unsigned integer representing the length
// from the input stream or buffer
length = read_u32(input);
// Allocate memory for the buffer using the untrusted length
// read from the input (vulnerable to an integer overflow or
// excessive memory allocation if 'length' is extremely large,
// or vulnerable to a zero-size allocation depending on how malloc
// handles a length of 0)
buffer = malloc(length);
// Read data from the input stream into the allocated buffer using
// the untrusted length (vulnerable to a heap buffer overflow if
// malloc failed and returned NULL, or if the underlying read
// implementation does not correctly enforce the specified length)
read_bytes(input, buffer, length);
That is already a chain:
input
->
length
->
allocation
->
pointer
->
read length bytes
Now consider a slightly more complicated format:
// Read a 32-bit unsigned integer representing the total number of items
// from the input stream
count = read_u32(input);
// Read a 32-bit unsigned integer representing the size (in bytes) of
// a single item from the input stream
item_size = read_u32(input);
// Calculate the total memory required by multiplying the item count
// and item size (vulnerable to an integer overflow if the product
// exceeds the maximum capacity of the integer type)
total = count * item_size;
// Allocate a contiguous heap buffer of the calculated total size
// to store all the parsed items (vulnerable to an out-of-memory crash
// if allocation fails and returns NULL, or vulnerable to security issues
// if an integer overflow resulted in a small allocation size)
items = malloc(total);
// Iterate through the input stream to parse each item individually,
// using pointer arithmetic to calculate the exact byte offset for
// storing each item in the buffer
for (i = 0; i < count; ++i)
parse_item(items + i * item_size);
The number of arithmetic operations has increased.
So has the number of assumptions.
The allocation requires:
count * item_size
to fit.
Each pointer calculation requires:
i * item_size
to remain within the allocation.
Each parsed item must itself fit inside the input.
A single malicious count can therefore affect multiple stages of the chain.
A common mistake is to validate the output allocation but forget the input buffer.
For example:
// Check if the requested item count exceeds the predefined maximum
// allowed limit to prevent excessive resource consumption or overflows
if (count > MAX_COUNT)
return ERROR;
// Allocate memory for an array of items based on the validated count
// and the size of each item (vulnerable to an integer overflow if
// count * sizeof(*items) exceeds the maximum capacity of the allocation size type)
items = malloc(count * sizeof(*items));
// Iterate through the input stream to parse each item and store it
// directly into the corresponding index of the allocated array
for (i = 0; i < count; ++i)
parse_item(input, &items[i]);
The allocation may be perfectly safe.
But the parser may still read beyond `input` if the input does not contain enough bytes for all `count` items.
There are therefore often two independent chains:
external count
->
output allocation
->
output bounds
and:
external count
->
required input size
->
input bounds
Security review should follow both.
A parser is safe only if its assumptions about both input and output remain valid.
Suppose a file contains:
outer_length
|
+-- inner_count
|
+-- item_length
The program might calculate:
total = outer_length + inner_count * item_length;
Now the arithmetic depends on values nested inside the data structure.
This is where local checks become misleading.
For example:
if (outer_length <= remaining)
parse_outer(...);
does not necessarily prove that:
inner_count * item_length
fits inside the remaining outer region.
The correct approach is to preserve the available range as parsing proceeds.
Conceptually:
remaining
->
consume header
->
remaining
->
validate inner length
->
consume item
->
remaining
->
validate next item
A parser should continuously maintain the invariant:
bytes consumed <= bytes available
rather than calculating a large final size and hoping it fits.
Consider:
// Iterate through a collection or buffer a specified number of times
// determined by the 'count' variable
for (i = 0; i < count; ++i)
// Process the data at a calculated memory offset by advancing the base
// pointer by the specified stride for each iteration (vulnerable to an
// integer overflow during the 'i * stride' calculation or a buffer
// out-of-bounds access if 'count' or 'stride' exceeds the actual
// valid size of the allocated buffer)
process(buffer + i * stride);
The multiplication:
i * stride
must be safe and the resulting pointer must remain within the intended object.
Sometimes a range-based representation makes the invariant clearer:
// Initialize a pointer to the beginning of the buffer
char *begin = buffer;
// Calculate and set a pointer to the exact end boundary of the buffer
// by adding the total size in bytes to the base pointer
char *end = buffer + size;
// Loop through the buffer as long as the current position pointer
// remains strictly before the end boundary
while (begin < end) {
...
// Advance the current position pointer by the specified stride value
// for the next iteration (vulnerable to an infinite loop if the stride
// is zero or negative, or potential buffer over-read/undefined behavior
// if the stride causes the pointer to overshoot the end boundary rather
// than landing on it exactly)
begin += stride;
}
This does not automatically make the code safe. The increment still needs to be valid.
But it can make the relationship between:
current position
end position
remaining bytes
more explicit.
The important point is to make the bounds model visible in the code.
A particularly dangerous review pattern is code that tries to detect overflow after it has already occurred.
For example:
// Calculate the total required size in bytes by multiplying the
// number of elements by the size of each individual element
size = count * element_size;
// Check for an integer overflow resulting from the multiplication
// (if the product wrapped around, the resulting size will be smaller
// than the original count, assuming element_size is at least 1; if an
// overflow is detected, aborts execution and returns an error)
if (size < count)
return ERROR;
This is not a general overflow check.
It depends on assumptions about the operands and the operation.
Likewise:
// Calculate the sum of two integer values ('a' and 'b')
total = a + b;
// Check for an unsigned integer addition overflow (if the sum wrapped
// around and became smaller than the original operand 'a', an overflow
// has occurred; if detected, aborts execution and returns an error)
if (total < a)
return ERROR;
can be meaningful for certain unsigned calculations, but it is not a universal substitute for reasoning about the operation and its types.
The best checks are usually expressed in terms of the operands before the risky operation:
count > SIZE_MAX / element_size
or:
length > size - offset
provided the subtraction itself is performed only after establishing the required ordering.
The principle is:
Prove safety before performing the operation.
Another common source of bugs is calculating the same size in different ways.
For example:
allocation = count * sizeof(struct item);
Later:
copy_size = count * ITEM_SIZE;
If:
sizeof(struct item) != ITEM_SIZE
the two parts of the program have different models of the object.
Even if they currently happen to be equal, future changes can break the relationship.
A better approach is to derive related quantities from the same source:
allocation = count * sizeof(*items);
and use the actual object type wherever possible.
The security principle is:
Avoid maintaining multiple independent representations
of the same memory layout.
Every duplicate size calculation is another opportunity for the bounds model to diverge.
Not every size is measured in bytes.
A variable called:
length
might mean:
bytes
or:
characters
or:
elements
or:
records
or:
sectors
or:
code units
A conversion such as:
bytes = count * sizeof(*items);
is therefore a change of units.
This is worth making explicit during review.
For example:
record_count
->
record_count * sizeof(record)
->
bytes
Then:
byte_offset
->
pointer arithmetic
If the code accidentally treats a byte count as an element count, or vice versa, the resulting pointer arithmetic can be wrong by a factor of the element size.
Many buffer overflows are effectively unit-conversion errors.
A practical first pass through unfamiliar C code can search for expressions involving:
*
+
-
/
sizeof
malloc
calloc
realloc
memcpy
memmove
memset
strcpy
strncpy
sprintf
snprintf
But this is only the beginning.
For each result, ask:
Does this value control memory?
A multiplication used for a checksum is different from a multiplication used to calculate an allocation.
Likewise:
x + y
is not inherently interesting.
It becomes interesting when:
x + y
determines:
allocation size
copy length
pointer offset
loop bound
array index
The surrounding data flow determines the security significance.
Suppose you identify:
length = packet->length;
Mark it as a source.
Then follow it:
length
->
allocation
->
pointer offset
->
copy
->
loop bound
At each step ask:
Is the value changed?
Is its type changed?
Is it multiplied?
Is it added to something?
Is it truncated?
Is it validated?
Is the validation still applicable after the transformation?
This is essentially taint analysis performed mentally.
You are not asking only:
"Can the attacker control length?"
You are asking:
"Where can attacker control of length eventually influence memory?"
The opposite direction is equally useful.
Suppose a crash occurs here:
dst[index] = value;
Start with:
Why is index invalid?
Then:
Where did index come from?
Suppose:
index = offset / sizeof(*dst);
Now ask:
Where did offset come from?
Suppose:
offset = header_size + payload_size;
Now ask:
Where did payload_size come from?
Suppose:
payload_size = packet->length;
Now the chain is:
packet length
->
addition
->
offset
->
division
->
index
->
array access
The vulnerability may have started several functions and several transformations away from the crash.
This is why a crash location is evidence, not necessarily a root cause.
Dynamic tools are extremely useful for this class of problem.
AddressSanitizer can detect many forms of:
heap buffer overflow
stack buffer overflow
global buffer overflow
use-after-free
use-after-scope
UndefinedBehaviorSanitizer can detect many classes of undefined arithmetic and other language-level problems.
These tools are particularly useful because they can confirm the final transition:
incorrect bounds
->
invalid memory access
But they do not necessarily explain the entire chain.
Suppose AddressSanitizer reports:
heap-buffer-overflow
at:
memcpy()
You still need to determine:
Why was the destination too small?
That may lead to:
wrong length
->
integer overflow
->
undersized allocation
The sanitizer found the final link.
Your job is to find the first incorrect assumption.
Static analyzers can sometimes identify:
integer overflow
truncation
signed/unsigned conversion
impossible bounds
unchecked allocation sizes
suspicious pointer arithmetic
use-after-free
The important thing is to interpret these warnings in context.
For example:
possible integer overflow
is more urgent when the result controls:
malloc()
than when it controls:
logging statistics
Likewise:
possible null dereference
is more interesting when the pointer came from:
realloc()
than when the analyzer cannot establish a defensive check that is actually guaranteed by a higher-level invariant.
The chain gives you a way to prioritize findings.
For a suspicious memory operation, write down:
Source:
Where does the controlling value originate?
Type:
What type does it have at each stage?
Transformations:
What arithmetic or conversions are applied?
Allocation:
What allocation size results?
Object:
What object is the pointer supposed to refer to?
Offset:
How is the access location calculated?
Access size:
How many bytes or elements are accessed?
Bound:
What proves the entire access fits?
Failure:
What happens if any assumption is false?
This forces the review to follow the entire chain.
Consider:
// Define a structure representing a message header containing the
// total number of items and the size of each individual item
struct message {
uint32_t count;
uint32_t item_size;
};
// Function to load and parse items based on the metadata in the message struct
void *load_items(const struct message *m)
{
size_t total;
unsigned char *items;
// Calculate the total memory required by multiplying the item count
// and item size (vulnerable to an integer overflow if the product
// exceeds the maximum capacity of size_t/uint32_t, which can result
// in allocating a buffer much smaller than expected)
total = m->count * m->item_size;
// Allocate a contiguous heap buffer for the calculated total size
items = malloc(total);
// Check if the memory allocation failed and return NULL if it did
// (vulnerable to a NULL pointer dereference or undefined behavior
// in subsequent operations if malloc returns NULL due to memory exhaustion
// or an extremely small/zero allocation size from an overflow)
if (items == NULL)
return NULL;
// Iterate through the specified item count to parse each item
// directly into its calculated offset within the buffer
for (uint32_t i = 0; i < m->count; ++i) {
// Parse the item using pointer arithmetic to find its position
// (vulnerable to a heap buffer overflow if an integer overflow
// occurred during the 'total' calculation, causing the allocated
// buffer to be smaller than the space required for all items
// written by the loop, or if 'i * m->item_size' overflows)
parse_item(items + i * m->item_size);
}
// Return the pointer to the populated items buffer
return items;
}
Notwithstanding the comments, a superficial review might say:
malloc is checked.
The deeper review sees several separate questions.
### Question 1: Can the allocation calculation overflow?
m->count * m->item_size
Both values may be attacker-controlled.
So:
total <= SIZE_MAX
must be established.
### Question 2: Can the per-item offset overflow?
i * m->item_size
Even if `total` was calculated safely, the multiplication used for the pointer offset deserves independent analysis.
### Question 3: Is the pointer range valid?
The intended item must fit inside:
[items, items + total)
The relationship is approximately:
i * item_size + item_size <= total
for every valid `i`.
### Question 4: Does `parse_item` itself stay within one item?
Even if the outer pointer is valid, `parse_item` could read or write beyond the item if it assumes a larger structure.
This illustrates an important principle:
Bounds are compositional.
A caller can prove that a pointer is inside an allocation without proving that the callee stays inside the intended subobject.
Suppose:
buffer = malloc(4096);
and the program places three logical objects in it.
The allocation bound is:
4096 bytes
But a particular object might occupy:
bytes 100 through 199
A pointer into that object should not automatically be allowed to access:
bytes 200 through 4095
even though those bytes belong to the same allocation.
This distinction matters in parsers, serialization code, object pools, and custom allocators.
The relevant bound is often not:
allocation size
but:
size of the current logical object
Security review therefore has to identify both:
physical allocation
and:
logical object
Large C programs often wrap allocation:
object_alloc()
buffer_alloc()
arena_alloc()
pool_get()
slab_alloc()
grow_buffer()
A reviewer must determine what each abstraction guarantees.
For example:
p = buffer_alloc(count * item_size);
might hide the actual allocator.
But the arithmetic problem remains.
Likewise:
p = vector_reserve(v, count);
may internally calculate:
count * sizeof(*v->data)
The wrapper does not remove the risk.
When reviewing unfamiliar code, find the implementation or documented contract of custom allocation functions.
Ask:
What unit does the size argument use?
Does it check overflow?
Does it preserve the old allocation on failure?
Does it return an allocation with additional metadata?
What alignment does it guarantee?
What happens when the requested size is zero?
These details can affect the memory-safety reasoning.
Consider:
length = end - start;
Pointer subtraction has its own validity requirements.
Likewise:
remaining = total - offset;
requires:
offset <= total
before the subtraction is meaningful as the intended size calculation.
A common parser pattern is:
remaining = packet_size - header_size;
If `header_size` can exceed `packet_size`, the resulting unsigned value can become enormous.
That enormous value may then be used as:
malloc(remaining)
or:
memcpy(..., remaining)
The chain is:
invalid subtraction
->
huge size
->
allocation or copy
->
memory error
So arithmetic review must include subtraction, not just addition and multiplication.
Unsigned wraparound often produces a value that looks absurdly large.
For example, conceptually:
// Perform subtraction using unsigned types (vulnerable to an integer
// underflow since the subtrahend 20 is greater than the minuend 10,
// causing the result to wrap around to a very large positive number
// near the maximum limit of the unsigned type instead of returning a negative value)
10 - 20
in an unsigned type does not produce negative ten.
It produces a large unsigned value.
That can turn a failed bounds check into a dangerous size.
This pattern is particularly common in code like:
if (offset <= size)
remaining = size - offset;
which is safe, versus:
remaining = size - offset;
followed later by:
if (offset > size)
return ERROR;
The second version has already performed the dangerous calculation.
Again:
establish the ordering before performing the subtraction.
For:
memcpy(dst, src, length);
there are at least two independent size conditions:
dst_capacity >= length
and:
src_available >= length
A common review mistake is to prove only the destination side.
For a parser:
input + offset
may be a valid pointer, but the input may contain fewer than `length` bytes after that point.
The same arithmetic-to-bounds chain applies to both source and destination.
The security consequences differ:
destination overflow
->
memory corruption
source overflow
->
out-of-bounds read
->
information disclosure or crash
Both are important.
Suppose:
if (length <= capacity)
...
Inside the block, code modifies:
capacity
or:
length
or:
buffer
or:
pointer
The original validation may no longer establish the property required later.
This is especially important in complex functions.
A useful review question is:
What state changes between the check and the access?
If the memory object changes, the old bounds proof may no longer apply.
A good design makes the relationship between size and object difficult to break.
Instead of passing:
pointer
size
through many unrelated functions, use an abstraction that keeps them associated.
For example:
struct buffer {
unsigned char *data;
size_t size;
};
Now an operation can receive:
struct buffer *
and reason explicitly about:
data
size
This does not make the code memory-safe automatically.
But it reduces the chance that a pointer and its corresponding size become separated or independently modified.
The same principle applies to:
pointer + length
pointer + capacity
pointer + end
as long as the representation is kept internally consistent.
Suppose code has:
count
bytes
capacity
end
remaining
all describing related memory.
Every additional representation introduces another invariant.
For example:
bytes == count * sizeof(*items)
and:
remaining == bytes - offset
and:
end == buffer + bytes
If the code updates one value without updating the others, the model becomes inconsistent.
When possible, derive values from a small number of authoritative quantities.
This reduces both ordinary bugs and security bugs.
When auditing C for this vulnerability class, the following procedure is effective.
### Step 1: Find memory accesses
Look for:
[]
*
->
memcpy
memmove
memset
string functions
allocation wrappers
### Step 2: Identify controlling values
For each access, identify:
pointer
index
offset
length
count
capacity
### Step 3: Trace them backward
Find:
source
type
conversions
arithmetic
validation
### Step 4: Find the allocation
Determine:
what object exists
how large it is
how that size was calculated
### Step 5: Write the invariant
For example:
offset + length <= allocation_size
or:
count * sizeof(*items) <= allocation_size
### Step 6: Prove the arithmetic
Check:
addition
subtraction
multiplication
division
shifts
signed/unsigned conversions
truncation
### Step 7: Check the actual access
Make sure the complete range, not just its starting point, is valid.
### Step 8: Follow the consequence
If the invariant can fail, determine whether the result is:
out-of-bounds read
out-of-bounds write
use-after-free
double-free
information disclosure
crash
memory corruption
This process scales surprisingly well.
There are several tempting shortcuts.
### "The value was checked."
Ask:
Which value?
In which type?
Before or after conversion?
Does the check establish the property actually needed?
### "The allocation succeeded."
Ask:
Was the requested size correct?
### "The index is checked."
Ask:
Is the complete accessed range checked?
### "The pointer is inside the buffer."
Ask:
Is the entire object being accessed inside the buffer?
### "The multiplication is in size_t."
Ask:
Does the multiplication fit in size_t?
### "The compiler warns about it."
Ask:
What happens under the production compiler and optimization settings?
### "AddressSanitizer did not find anything."
Ask:
Was the vulnerable path executed?
Was the relevant input exercised?
Is the bug an arithmetic or logical error that did not reach an invalid access in this run?
Tools provide evidence. They do not replace the invariant.
When you see:
malloc(...)
+
pointer arithmetic
+
loop
+
memory access
do not read the statements independently.
Read them as one equation.
For example:
items = malloc(count * size);
for (i = 0; i < count; ++i)
use(items + i * size);
The code is effectively asserting:
allocated_size = count * size
and for every:
0 <= i < count
the access:
i * size ... (i + 1) * size
fits within:
allocated_size
The security question is whether those assertions are actually true for every possible input.
That is the heart of the review.
Once an invariant can fail, there is another question:
What can the invalid access affect?
An out-of-bounds read may disclose:
pointers
lengths
cryptographic material
configuration
object contents
An out-of-bounds write may corrupt:
adjacent data
object state
function pointers
allocator state
control-flow data
A use-after-free may allow:
stale-object access
type confusion
controlled data to occupy a formerly trusted object
The exact consequence depends heavily on the allocator, object layout, compiler, architecture, mitigations, and surrounding program logic.
Do not assume that:
memory corruption = code execution
But also do not dismiss memory corruption as merely a crash.
The vulnerability analysis should continue from:
invalid access
to:
what object can be reached?
and:
what data can influence the resulting operation?
The most useful final mental model is not:
integer overflow
or:
buffer overflow
or:
use-after-free
It is:
external value
->
arithmetic
->
size
->
allocation
->
pointer
->
range
->
access
->
corrupted or disclosed memory
The first incorrect transition is often the most interesting part of the vulnerability.
The final crash may be many steps later.
When reviewing C, therefore, ask two questions:
Where did the memory model first become wrong?
and:
Where did that wrong assumption finally become an invalid memory operation?
The distance between those two points is often the vulnerability.
When reviewing a C function that handles externally influenced sizes, counts, offsets, or indexes, ask:
[ ] Where did each value originate?
[ ] What type represents it?
[ ] Can it be negative before conversion?
[ ] Can conversion change its value?
[ ] Can addition overflow?
[ ] Can subtraction underflow?
[ ] Can multiplication overflow?
[ ] Can shifts change the intended value?
[ ] Does the calculated allocation size fit?
[ ] Does the allocation represent the logical object?
[ ] Can pointer arithmetic leave the object?
[ ] Is the complete access range checked?
[ ] Are both source and destination bounds established?
[ ] Can realloc move or invalidate derived pointers?
[ ] Can the logical size and physical capacity diverge?
[ ] Are multiple copies of the same size calculation maintained?
[ ] Are parser input bounds checked separately from output bounds?
[ ] What happens if any calculation fails?
[ ] What is the consequence of the first invalid access?
The most important question remains the simplest:
What proves that this memory access is inside the object?
If the answer depends on arithmetic, follow the arithmetic.
If the arithmetic depends on input, follow the input.
If the allocation depends on the arithmetic, follow the allocation.
If the pointer depends on the allocation, follow the pointer.
And if the bounds depend on any of them, do not accept the bounds check until you have proved that it is checking the right quantity.
That is how the chain becomes a practical vulnerability-auditing method:
integer arithmetic
->
allocation size
->
pointer arithmetic
->
bounds
->
memory corruption