PART I

Chapter 2 - Allocation Size

Auditing C Code for the Arithmetic-to-Memory Chain

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.

1. Start at the memory access

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?"

2. First example: an obvious vulnerability

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.

3. Identify the controlling quantities

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.

4. Trace values backward

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.

5. The first useful invariant

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?

6. Example: multiplication hidden in a helper

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.

7. Example: a checked allocation that is still wrong

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.

8. Example: the loop bound looks correct

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.

9. Example: checking the endpoint rather than the range

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.

10. Example: multiplication followed by addition

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.

11. Example: two-dimensional sizes

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?

12. Signed and unsigned values

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.

13. Do not trust a validation performed before conversion

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.

14. `realloc` introduces another version of the chain

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.

15. `realloc` and pointer invalidation

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.

16. Parser code deserves special attention

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.

17. The parser's second boundary

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.

18. Nested length fields

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.

19. Pointer ranges are often easier to reason about than indexes

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.

20. Beware of "clever" overflow checks

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.

21. Look for duplicated calculations

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.

22. Watch for hidden units

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.

23. Search for multiplication, but do not stop there

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.

24. Follow attacker-controlled values forward

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?"

25. Follow memory backward from a crash

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.

26. Use sanitizers to confirm the final link

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.

27. Static analysis is especially valuable for the arithmetic stage

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.

28. A practical review worksheet

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.

29. A complete review example

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.

30. Separate allocation bounds from object bounds

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

31. Custom allocators make the chain harder to see

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.

32. Be suspicious of size fields derived from subtraction

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.

33. The "huge value" trap

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.

34. Check both sides of a copy

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.

35. Do not confuse successful validation with permanent validity

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.

36. Make invariants local when possible

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.

37. Prefer one source of truth

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.

38. A compact audit algorithm

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.

39. What experienced reviewers should resist

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.

40. The most valuable habit

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.

41. From code review to exploitability

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?

42. The chain is the unit of analysis

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.

43. Final checklist

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

← Previous 4 of 13 Next →