PART I
The previous chapters established a chain:
integer arithmetic
->
allocation size
->
pointer arithmetic
->
bounds
->
memory corruption
They also established a review method:
find the access
->
identify the controlling values
->
trace those values backward
->
reconstruct the intended invariant
->
find the first point where the invariant can fail
This chapter takes the next step.
A programming error is not automatically a security vulnerability. The important question is how the error interacts with attacker-controlled data, memory layout, object lifetime, compiler behavior, and the surrounding program.
The goal is therefore to move from:
"This arithmetic can overflow."
to:
"This input can cause this calculation to produce this incorrect object size, which causes this access to leave this object, producing this security consequence."
That is the difference between finding a bug and understanding a vulnerability.
When analyzing a suspected memory-safety bug, separate three questions.
For example:
size = count * element_size;
may overflow.
That establishes a correctness problem.
Suppose:
count
comes from a network packet.
Now the arithmetic problem is externally reachable.
An overflow might produce:
crash
out-of-bounds read
out-of-bounds write
use-after-free
corrupted object state
information disclosure
control-flow corruption
The answers to these questions should not be conflated.
A static analyzer may correctly identify an integer overflow without knowing whether an attacker can reach it.
Conversely, a crash may occur in code where the underlying cause is a subtle arithmetic error several layers earlier.
A useful abstraction is:
input
|
v
attacker-controlled value
|
v
conversion
|
v
arithmetic
|
v
incorrect size or offset
|
v
incorrect object or pointer
|
v
invalid access
|
v
security consequence
The interesting part is that each stage may transform the value.
For example:
packet length
->
uint32_t
->
size_t
->
count * element_size
->
allocation size
->
pointer offset
->
memcpy length
A reviewer who stops at the first arithmetic operation may miss the actual consequence.
A reviewer who starts only at `memcpy` may miss the root cause.
The vulnerability is the chain.
Several different numerical problems are commonly grouped together under the phrase "integer overflow."
They should be distinguished.
For an unsigned type, arithmetic is performed modulo one more than its maximum representable value.
Conceptually:
SIZE_MAX + 1
wraps to:
0
and:
0 - 1
wraps to:
SIZE_MAX
This behavior is defined by C.
Defined behavior does not mean safe behavior.
If an unsigned calculation determines an allocation size, defined wraparound can still create a security vulnerability.
Signed integer overflow is a different matter.
For example:
int x = INT_MAX;
x++;
The language does not define this as ordinary modular arithmetic.
Signed overflow can therefore create undefined behavior.
This distinction matters because compiler optimizations can rely on the absence of undefined behavior.
A reviewer must not reason about signed overflow as though it were simply unsigned wraparound.
Consider:
uint64_t requested;
uint32_t length;
length = requested;
The value may be perfectly representable in `uint64_t` but not in `uint32_t`.
The resulting value can be smaller than the original.
If `length` later controls an allocation or copy, the truncation can create an undersized object.
The chain becomes:
large value
->
narrowing conversion
->
smaller value
->
allocation
->
later use of original logical size
->
out-of-bounds access
No arithmetic overflow was necessary.
Consider:
int length = get_length();
if (length < 0)
return ERROR;
memcpy(dst, src, length);
This is usually straightforward because the negative case is rejected before the conversion.
Compare:
int length = get_length();
if (length <= MAX_LENGTH)
memcpy(dst, src, length);
Now negative values pass the check.
The conversion to `size_t` occurs at the call.
The security problem is therefore a type-domain mismatch:
validation domain:
signed integers
consumption domain:
unsigned size_t
A useful rule is:
Validate a value in the domain in which it will be consumed.
`size_t` is the natural C type for object sizes.
That makes it appropriate.
It does not make arithmetic involving sizes safe.
Consider:
size_t bytes = count * element_size;
The multiplication still has a maximum representable result.
The type communicates:
"This value represents a size."
It does not communicate:
"This calculation cannot overflow."
This distinction is fundamental.
A safe size calculation is therefore about the relationship between operands and the maximum representable size, not merely about choosing `size_t`.
Consider:
uint32_t count;
size_t size = count * element_size;
The result of the multiplication depends on the types involved.
A reviewer should not reason from the apparent destination type alone.
The relevant questions are:
What are the operand types?
What conversions occur before the operation?
What type is used for the operation?
What type receives the result?
Can the result be truncated afterward?
This becomes especially important when code is expected to compile on multiple architectures.
A bug may be reachable on one architecture and unreachable on another.
For example, suppose:
size_t
is 32 bits on one platform and 64 bits on another.
A multiplication that overflows on the 32-bit platform may be perfectly representable on the 64-bit platform.
That does not necessarily make the source code correct.
It means the vulnerability depends on the implementation environment.
Conversely, a truncation to a fixed-width 32-bit field can remain dangerous even on a 64-bit machine.
Portability is therefore part of vulnerability analysis.
Ask:
What are the widths of the relevant types?
What are the ranges of the input fields?
What assumptions does the code make about those widths?
Names such as:
length
size
count
offset
index
tell you what the programmer intended.
They do not tell you the actual type.
You may encounter:
int length;
unsigned length;
uint32_t length;
size_t length;
ptrdiff_t length;
These types have different ranges and conversion behavior.
Similarly:
count * sizeof(*items)
may be safer to reason about than:
count * ITEM_SIZE
because the former directly derives the size from the actual C object type.
The compiler knows the type.
The reviewer should use the same source of truth whenever possible.
Consider:
struct item {
uint32_t type;
uint64_t value;
};
A programmer might assume:
sizeof(struct item) == 12
because:
4 + 8 == 12
But C structures can contain padding.
The actual object representation may therefore be larger.
This matters for:
malloc(count * sizeof(struct item))
versus:
malloc(count * 12)
and for:
buffer + i * sizeof(struct item)
versus:
buffer + i * 12
Hard-coded structure sizes are especially dangerous when code is intended to be portable.
The safest approach is usually:
sizeof(struct item)
or:
sizeof(*items)
rather than duplicating the layout manually.
Padding is not merely an allocation-size issue.
Suppose code serializes a structure by doing:
memcpy(output, &object, sizeof(object));
This may copy padding bytes as well as the actual fields.
If those bytes contain uninitialized data, the operation can disclose information.
This is a different chain:
object layout
->
padding
->
copied representation
->
externally visible data
->
information disclosure
The general lesson is that memory-safety analysis should consider both:
how much memory is accessed
and:
what the accessed bytes actually represent.
Consider:
struct packet {
uint32_t length;
unsigned char data[];
};
A common allocation pattern is:
packet = malloc(sizeof(*packet) + length);
This is exactly the arithmetic-to-allocation chain.
The intended invariant is:
sizeof(*packet) + length <= SIZE_MAX
But the object also has a logical boundary:
data has length bytes
A correct allocation therefore does not automatically imply that every operation on `data` is safe.
For example:
packet->data[offset]
requires:
offset < length
and:
memcpy(packet->data + offset, src, copy_length);
requires:
offset <= length
and:
copy_length <= length - offset
The structure gives the program a physical representation.
The code still has to preserve the logical bounds.
Consider:
size_t total = sizeof(struct packet) + length;
struct packet *p = malloc(total);
This is often the right general shape.
But review:
length
as an external quantity.
Ask:
Can the addition overflow?
Is length itself trustworthy?
Is the resulting object expected to contain exactly length bytes?
Are later fields or metadata also included?
A more complicated object may look like:
total = sizeof(struct packet)
+ header_length
+ payload_length
+ trailer_length;
Now there are several additions.
Every one is part of the allocation proof.
The more independently supplied size fields exist, the more important it becomes to establish the total size incrementally.
Consider:
items = calloc(count, sizeof(*items));
This is preferable to:
malloc(count * sizeof(*items))
in some contexts because the allocator interface receives the count and element size separately.
However, the security property still matters.
The implementation needs to ensure that the requested total size is representable.
More importantly, callers must not assume that using `calloc` eliminates all size-calculation problems.
For example:
total = count * element_size;
items = calloc(1, total);
still performs the multiplication before calling `calloc`.
The safe reasoning remains:
Is the requested total representable?
The interface does not remove the underlying mathematical constraint.
Some environments provide an interface commonly known as:
reallocarray
Its purpose is to express:
resize to count * element_size
while allowing the allocation routine to perform the multiplication with overflow checking.
Conceptually:
reallocarray(ptr, count, element_size)
represents:
realloc(ptr, count * element_size)
with the multiplication treated as part of the allocation operation.
This can eliminate one common class of mistakes:
size = count * element_size;
ptr = realloc(old, size);
But it does not solve every problem.
The caller still needs to reason about:
count
element_size
subsequent indexing
logical object bounds
integer conversions before the call
other arithmetic used to calculate offsets
The useful lesson is:
Move arithmetic into a primitive that checks it when possible,
but do not confuse allocation-size safety with complete memory safety.
The chain does not end when the allocation succeeds.
Consider:
p = buffer + offset;
The pointer operation itself has requirements.
In normal C pointer arithmetic, the pointer must be associated with an appropriate array object, and the resulting pointer must stay within that object or one-past-the-end where permitted.
The one-past-the-end pointer is useful:
begin
|
v
[ valid elements ]
|
v
end
where:
end = begin + count
The pointer `end` can be used as a boundary.
It must not be dereferenced.
This distinction is important:
valid pointer position
is not always the same as:
valid dereferenceable object
Suppose:
int values[10];
Then:
values + 10
is a useful one-past-the-end pointer.
But:
*(values + 10)
is not a valid access.
This is why range-based code often looks like:
int *p = values;
int *end = values + 10;
while (p != end) {
process(*p);
++p;
}
The range is:
[values, values + 10)
The right endpoint is excluded.
This half-open representation is extremely useful when reasoning about bounds.
Represent a buffer as:
[begin, end)
Then:
size = end - begin
and a pointer `p` is inside the range when:
begin <= p
&&
p < end
For a copy of `length` bytes beginning at `p`, the conceptual requirement is:
p + length <= end
The same reasoning applies to indexes:
[0, count)
means:
0 <= index < count
This notation removes many off-by-one ambiguities.
It also aligns naturally with C loops:
for (i = 0; i < count; ++i)
Consider:
size_t remaining = end - current;
This is only meaningful when `end` and `current` are pointers into the same array object, with the appropriate ordering.
It is not a general-purpose operation for subtracting arbitrary addresses.
This matters when reviewing parser code that uses pointers as cursors.
A common pattern is:
current = buffer;
end = buffer + size;
while (current < end) {
...
}
This can be a good design because the bounds are explicit.
But the proof still depends on:
end
being a valid boundary derived from the same object.
Pointer arithmetic is not simply integer arithmetic disguised as syntax.
A pointer carries a relationship to an object.
For example:
char *p = buffer + offset;
is meaningful because `buffer` points into an object and `offset` is interpreted relative to that object.
Converting pointers to integers and manipulating those integers can therefore introduce reasoning problems that do not exist with ordinary array indexing.
Code such as:
uintptr_t x = (uintptr_t)p;
x += offset;
p = (void *)x;
may be used in systems programming, but it requires much more careful reasoning than:
p = p + offset;
The broad rule is:
Keep pointer calculations in pointer form when possible.
This preserves the connection between the pointer and the object it is intended to reference.
Suppose:
struct object {
char header[16];
char data[32];
};
The complete structure is 48 bytes in this simplified example.
A pointer into `header` does not automatically acquire permission to treat the entire structure as one undifferentiated byte region for every operation.
The language's object and subobject model matters.
Security review therefore needs to distinguish:
allocation boundary
from:
object boundary
and:
object boundary
from:
subobject boundary
This becomes particularly important when a buffer contains several logical fields.
Not every memory corruption crosses an allocation boundary.
Consider:
struct state {
char name[16];
void (*callback)(void);
};
A write beyond `name` may remain inside the same allocated `struct state`.
An address sanitizer may not necessarily describe this simply as a heap-buffer-overflow because the write may still be inside the overall allocation.
Yet the logical object has been corrupted.
This is an important distinction:
physical allocation bounds
are not sufficient to define:
semantic object bounds
Security review must consider both.
Large C systems frequently implement allocation abstractions.
Examples include:
arena_alloc()
pool_alloc()
slab_alloc()
vector_reserve()
buffer_grow()
object_new()
These abstractions can hide the arithmetic.
For example:
vector_reserve(v, count);
may internally perform:
count * sizeof(*v->data)
A reviewer who searches only for `malloc` may miss it.
When encountering a custom allocator, establish its contract.
Ask:
What unit does the size represent?
Does it check multiplication overflow?
What happens on failure?
Does it align the result?
Can the allocation move?
Are existing pointers invalidated?
Does it reserve physical capacity or logical size?
The abstraction should be treated as part of the memory model.
Consider:
struct buffer {
unsigned char *data;
size_t length;
size_t capacity;
};
The intended invariant is usually:
length <= capacity
and:
data points to at least capacity bytes
An operation such as:
b->data[b->length++] = c;
depends on:
b->length < b->capacity
before the write.
If a resize operation incorrectly updates:
capacity
without actually allocating enough memory, the logical and physical models diverge.
The code may then appear internally consistent while accessing memory incorrectly.
This is one of the most important patterns in real-world C:
metadata says one thing
while:
memory actually contains another.
Memory corruption frequently involves corrupting metadata that later controls memory access.
For example:
object->length
might determine:
memcpy(..., object->length);
If another vulnerability lets an attacker modify `object->length`, the resulting memory operation may become dangerous.
The chain is now recursive:
memory corruption
->
corrupted metadata
->
incorrect size
->
new memory corruption
This is why the security consequences of one memory bug can be much broader than its immediate write.
Memory allocators maintain their own internal state.
The exact representation varies by implementation.
An out-of-bounds write may therefore corrupt data belonging to:
another application object
or:
allocator bookkeeping
The latter can produce consequences such as:
allocator crashes
inconsistent heap state
later invalid allocations
later invalid frees
The details are highly implementation-dependent.
The important point for vulnerability analysis is:
The bytes immediately adjacent to the corrupted object
determine what the corruption can influence.
Do not assume that an overflow is useful simply because it is a write.
Determine what is actually reachable.
Suppose a program contains:
memcpy(buffer, input, length);
and `length` can exceed `buffer`'s capacity.
That establishes a memory-safety vulnerability.
But the severity depends on questions such as:
Can an attacker reach the function?
Can the attacker control length?
Can the attacker control the bytes being written?
How large can the overwrite be?
What object follows buffer?
Can the overwrite reach security-sensitive state?
Does the process have useful privileges?
Are there memory-safety mitigations?
The vulnerability should therefore be described precisely before discussing exploitability.
An out-of-bounds write is especially interesting when an attacker controls both:
how much is written
and:
what is written
Consider:
memcpy(dst, attacker_data, attacker_length);
The attacker potentially controls two dimensions:
range
and:
contents
Compare this with:
memset(dst, 0, attacker_length);
Here the length may still be attacker-controlled, but the bytes are fixed.
Both can be vulnerabilities.
Their exploitability may differ significantly.
This distinction is useful when assessing impact.
Consider:
memcpy(output, buffer + offset, length);
If the source range exceeds the buffer, the result is an out-of-bounds read.
The consequence may be:
crash
or:
information disclosure
The latter can be particularly important if the bytes beyond the intended object contain:
pointers
cryptographic material
credentials
object metadata
addresses
other sensitive state
An out-of-bounds read can also become an information primitive that enables exploitation of a separate vulnerability.
Thus:
read
does not mean:
harmless.
Modern systems often use randomized addresses and other defenses.
An information disclosure can reveal values that reduce the effectiveness of those defenses.
For example, if an out-of-bounds read exposes a pointer into a randomized memory region, an attacker may gain information about the process's memory layout.
This illustrates why vulnerabilities should not always be evaluated independently.
A seemingly limited read can increase the exploitability of another memory-corruption bug.
The vulnerability chain can therefore look like:
arithmetic error
->
out-of-bounds read
->
address disclosure
->
weakened memory-layout protection
->
easier exploitation of another bug
C's abstract machine rules matter to security analysis.
Consider signed arithmetic that can overflow.
A programmer might reason:
if (x + y < x)
overflow();
But if the addition is performed using a signed type and can overflow, the language does not guarantee ordinary wrapping behavior.
Compilers may make transformations based on the assumption that undefined behavior does not occur.
This means that source-level reasoning based on "what the CPU probably does" can be wrong.
The correct question is:
What behavior does the language and implementation permit?
Then consider:
What assumptions does the compiler make?
This is especially important when auditing checks intended to detect arithmetic overflow.
A common low-level programming habit is to reason from two's-complement machine behavior:
INT_MAX + 1
"will wrap."
That may describe what a particular processor does at the machine level.
It is not sufficient as a C-language argument.
Security-sensitive arithmetic should be written so that the operation is valid under the language rules rather than relying on accidental machine behavior.
This is particularly important for code compiled with aggressive optimization.
Consider:
uint16_t a;
uint16_t b;
size_t size = a * b;
The multiplication does not necessarily occur as a 16-bit multiplication.
C's integer promotions apply.
The actual arithmetic type depends on the types and implementation.
This can make expressions difficult to reason about by inspection.
The lesson is not to memorize every promotion rule while reviewing every line.
Instead:
When an arithmetic expression is security-critical,
determine the actual types involved.
This is especially important when fixed-width integer types are mixed with:
int
unsigned int
size_t
ptrdiff_t
long
unsigned long
Reviewers often search for:
+
-
*
but overlook:
<<
A size calculation such as:
size = count << shift;
is still an arithmetic operation.
It can overflow the destination type or produce an unintended result.
Similarly:
index = value << alignment;
may control a pointer offset.
The chain remains:
external value
->
shift
->
offset
->
pointer
->
access
The general rule is:
Any arithmetic operation that influences memory
belongs in the bounds proof.
Consider:
element_size = total_size / count;
If:
count == 0
the operation is invalid.
More subtly, division may truncate.
For example:
bytes / sizeof(*item)
produces an element count that may be smaller than expected.
This can affect:
loop bounds
allocation counts
parser logic
The numerical operation itself may be valid while the semantic interpretation is wrong.
Again:
mathematically valid
does not necessarily mean:
memory-safe.
Systems code often rounds sizes upward.
For example:
aligned = (size + alignment - 1) & ~(alignment - 1);
This is a common pattern.
But the addition:
size + alignment - 1
can overflow.
If the result controls an allocation, the chain becomes:
requested size
->
rounding arithmetic
->
wrapped size
->
undersized allocation
->
later overflow
A safer design must ensure the rounding calculation itself cannot overflow.
The important lesson is that "helper arithmetic" is still part of the security boundary.
Consider:
blocks = (size + block_size - 1) / block_size;
total = blocks * block_size;
There are now:
addition
subtraction
division
multiplication
The final `total` may not equal the original `size` if arithmetic wraps or if assumptions about the rounding calculation are incorrect.
Whenever a size is rounded, padded, aligned, or expanded, treat the entire transformation as one size-calculation chain.
Network and file parsers frequently contain all the ingredients:
attacker-controlled lengths
attacker-controlled counts
nested structures
variable-sized records
arithmetic
allocations
pointer arithmetic
copies
A typical parser may do:
count = read_u32(input);
item_size = read_u32(input);
total = count * item_size;
buffer = malloc(total);
for (i = 0; i < count; ++i)
parse_item(buffer + i * item_size);
This is almost a textbook example of the complete chain.
The key review technique is to identify every field that contributes to:
size
offset
count
pointer
and trace it from the wire format to the final memory access.
Suppose a packet contains:
outer_length
|
+-- header
|
+-- inner_length
|
+-- payload
A robust parser should first establish:
outer region fits in input
Then interpret:
inner_length
within that region.
The conceptual invariant becomes:
inner_length <= outer_remaining
rather than:
inner_length <= input_size
The distinction matters because an inner field should normally be constrained by its containing object, not by the entire input buffer.
This is the parser equivalent of respecting subobject bounds.
For a hierarchy:
packet
|
+-- message
|
+-- records
|
+-- payload
the bounds should be hierarchical.
For example:
message_end <= packet_end
and:
records_end <= message_end
and:
payload_end <= records_end
A parser that repeatedly validates everything against the top-level input size can accidentally permit inner objects to escape their logical containers.
The physical buffer may still be large enough.
The logical object is nevertheless violated.
Not every vulnerability begins with arithmetic.
Consider:
length = header->length;
if (length <= MAX_LENGTH)
process_packet(data, length);
If the code assumes that `header` itself has already been validated but that assumption is false, the first broken invariant may be:
header is a valid object
rather than:
length is a valid size
This matters because security analysis should not force every bug into the arithmetic category.
The arithmetic-to-memory chain is a particularly useful pattern, but it sits inside a larger model:
input interpretation
->
object validity
->
arithmetic validity
->
memory validity
Suppose:
size = calculate_size(...);
buffer = malloc(size);
if (buffer == NULL)
return ERROR;
That handles allocation failure.
But what if:
calculate_size()
returns an error encoded as:
0
or:
SIZE_MAX
or:
(size_t)-1
The caller must understand the contract.
A failed size calculation that is mistaken for a legitimate size can produce another vulnerability.
For example:
size = calculate_size(...);
buffer = malloc(size);
If the function uses `SIZE_MAX` to indicate failure, the caller may accidentally request an enormous allocation.
Security properties depend on error handling just as much as ordinary control flow.
Code sometimes uses:
-1
to indicate failure.
This is particularly dangerous when the result is later converted to `size_t`.
For example:
int get_length(...);
int length = get_length(...);
buffer = malloc((size_t)length);
If:
get_length()
returns:
-1
the conversion produces a very large unsigned value.
The right design is often to make failure explicit rather than encode it in a value that will later cross an integer domain boundary.
For example:
int get_length(..., size_t *result);
or another API that clearly separates:
success/failure
from:
size.
Good interfaces make invalid states harder to represent.
Compare:
void copy(void *dst, void *src, size_t length);
with an interface that receives an object carrying its capacity:
void copy_into(struct buffer *dst,
const void *src,
size_t length);
The second interface can potentially centralize:
capacity checking
pointer validation
length handling
This does not make it automatically safe.
But it makes the invariant explicit and reduces the number of callers that need to reproduce it.
A recurring security engineering principle is:
Put the invariant as close as possible to the data
it protects.
Consider:
unsigned char *buffer;
size_t allocated;
size_t used;
These values are related.
The intended invariants might be:
used <= allocated
and:
buffer points to at least allocated bytes.
If a function modifies:
allocated
without resizing the buffer, the model becomes inconsistent.
Likewise, if it resizes the buffer without updating:
allocated
the metadata becomes stale.
This is a common source of vulnerabilities in hand-written dynamic containers.
A reviewer should search for every operation that changes either:
pointer
or:
size metadata
and verify that the relationship remains true.
Consider:
p = malloc(size);
q = p;
free(p);
use(q);
The obvious issue is lifetime.
But resizing introduces a similar problem:
q = p + offset;
p = realloc(p, new_size);
use(q);
Now the old derived pointer may be invalid even though the allocation itself still exists in some form.
This suggests a broader chain:
object lifetime
->
pointer validity
->
bounds
->
access
The arithmetic-to-memory chain and lifetime-to-memory chain often meet in real code.
Consider:
count * element_size
overflows.
At first, the bug is:
integer arithmetic error.
The result is used by:
malloc()
Now it becomes:
allocation-size error.
Later:
items[i]
uses the original `count`.
Now it becomes:
out-of-bounds access.
If the write changes another object's state, it becomes:
memory corruption.
If that state controls a later function call, the security consequence may become:
control-flow corruption.
The same underlying mistake therefore changes character as it propagates.
When documenting a vulnerability, identify both:
root cause
and:
final security effect.
Suppose:
count * sizeof(*items)
overflows only for a very large count.
The trigger may be:
a malicious packet containing a large count.
The root cause is:
unchecked multiplication.
The vulnerable operation is:
allocation based on the wrapped result.
The consequence may be:
out-of-bounds write.
These are different pieces of the analysis.
A good vulnerability report distinguishes them.
Suppose a sanitizer reports:
heap-buffer-overflow
at:
process_item()
The root cause may be:
attacker-controlled count
->
multiplication overflow
->
undersized allocation
The crash site is simply where the incorrect state finally became observable.
A useful debugging question is:
What value was wrong immediately before the crash?
Then:
Where did that value come from?
Then repeat.
This is backward slicing through the program.
AddressSanitizer and related tools are excellent for finding many memory errors.
But a clean run does not prove that the program is safe.
A vulnerability may require:
a particular input
a particular architecture
a particular allocation layout
a particular execution path
a particular optimization configuration
Dynamic testing observes one execution.
The invariant-based analysis reasons about the possible executions.
The two approaches complement each other.
Static analyzers are particularly useful for finding paths where:
x + y
x * y
x - y
may violate constraints.
But the most useful question after a warning is:
What does this value control?
For example:
possible integer overflow
is more significant if the result feeds:
malloc
than if it feeds:
logging.
Similarly:
possible out-of-bounds access
should be traced backward to determine whether the bound can be attacker-controlled.
The arithmetic-to-memory chain provides a natural prioritization mechanism.
When several findings are available, consider this rough ordering.
Higher concern:
attacker-controlled arithmetic
->
allocation size
->
attacker-controlled write
Also high concern:
attacker-controlled arithmetic
->
pointer offset
->
out-of-bounds write
Potentially high concern:
attacker-controlled length
->
out-of-bounds read
->
disclosure of sensitive state
Lower concern in some contexts:
arithmetic error
->
only an allocation failure
->
clean process termination
This is not a formal severity system.
It is a way to prioritize investigation.
The surrounding program determines the actual impact.
Consider:
struct entry {
uint32_t length;
unsigned char data[];
};
int append(struct entry **e,
const unsigned char *src,
size_t extra)
{
size_t new_length;
size_t total;
struct entry *tmp;
new_length = (*e)->length + extra;
total = sizeof(**e) + new_length;
tmp = realloc(*e, total);
if (tmp == NULL)
return -1;
*e = tmp;
memcpy((*e)->data + (*e)->length,
src,
extra);
(*e)->length = new_length;
return 0;
}
At first glance this looks plausible.
Now reconstruct it.
Suppose:
extra
is derived from an attacker-controlled message.
old_length + extra
can overflow.
The intended invariant is:
extra <= SIZE_MAX - old_length
sizeof(*e) + new_length
can also overflow.
The intended invariant is:
new_length <= SIZE_MAX - sizeof(**e)
`realloc` receives `total`.
If either calculation wrapped, the allocation may be smaller than intended.
The copy uses:
(*e)->data + (*e)->length
The pointer is based on the old logical length.
The access requires:
extra <= allocated_capacity - old_length
But the code has not explicitly established that relationship.
Only after the copy does the code update:
length = new_length
If the copy overflows, the object can be corrupted before the metadata reflects the new state.
The complete chain is:
attacker-controlled extra
->
length addition
->
total addition
->
allocation
->
data pointer
->
copy offset
->
copy length
->
memory corruption
This is exactly the kind of bug that looks reasonable when each statement is inspected independently.
The vulnerability becomes obvious when the statements are treated as one state transition.
The safest solution is not always to add more checks to the existing code.
Sometimes the data model should change.
Instead of:
length
capacity
pointer
being manipulated independently, centralize resizing.
For example, conceptually:
int buffer_reserve(struct buffer *b, size_t additional);
Then make that function responsible for:
addition overflow
allocation-size overflow
reallocation
capacity update
pointer update
The caller can then operate under a simpler invariant:
capacity - length >= required_space
This reduces the number of places where the arithmetic-to-memory chain has to be reconstructed.
A useful dynamic-buffer pattern is:
if (additional > capacity - length)
grow_buffer(...);
Then, after successful growth:
memcpy(data + length, src, additional);
The conceptual proof is:
additional <= capacity - length
which implies:
length + additional <= capacity
without requiring the unchecked addition to establish the condition.
This pattern is broadly useful for:
parsers
serializers
network buffers
string builders
decompression buffers
dynamic arrays
A common dynamic-array strategy is:
new_capacity = old_capacity * 2;
This can overflow.
So can:
new_capacity = old_capacity + old_capacity / 2;
and:
new_capacity = old_capacity + increment;
A robust growth function needs to establish that the new capacity is representable before performing the arithmetic.
Then it needs to ensure that:
new_capacity * element_size
is also representable.
Capacity growth is therefore another instance of the same chain.
For a container of elements, distinguish:
element count
from:
byte capacity.
Suppose:
count = number of elements
capacity = number of allocated elements
S = sizeof(element)
Then:
count <= capacity
and:
capacity * S <= SIZE_MAX
must both hold.
If a function grows the container by:
additional
elements, the new count must satisfy:
additional <= capacity - count
if no reallocation is needed.
If reallocation is needed, the new capacity must satisfy both:
new_capacity >= count + additional
and:
new_capacity * S <= SIZE_MAX
This is a useful example of how a single logical operation can require several linked invariants.
The arithmetic-to-memory chain is particularly important when small inputs can produce large outputs.
For example:
compressed_length
->
decompressed_length
->
allocation
->
output pointer
->
decompressor write
The decompressed size may be:
count * element_size
or:
rows * columns * channels
or:
blocks * block_size
If the calculated size is wrong, the decompressor may write according to its logical model into an allocation based on a different physical size.
This is a classic separation between:
logical output size
and:
allocated output size.
The same chain applies even when the input itself is small.
Serialization code often performs:
field_count * field_size
or:
header + payload + trailer
Deserialization reverses the process.
The danger is that serialization may assume trusted in-memory values while deserialization receives attacker-controlled representations.
Therefore:
serialization invariant
and:
deserialization invariant
are not symmetric from a security perspective.
A serialized field should be treated as untrusted until its:
representation
range
relationship to the containing object
have been validated.
A useful parser mindset is:
A length field is a claim made by the input.
For example:
payload_length = read_u32(...);
does not mean:
the payload has payload_length bytes.
It means:
the input claims that the payload has payload_length bytes.
The parser must establish:
payload_length <= remaining_input
before treating the claim as a boundary.
Similarly, a count field claims:
there are count objects.
The parser must determine whether:
count
is compatible with:
available input
and:
available output capacity.
This mental model prevents a great many parser bugs.
Suppose a parser establishes:
length <= remaining
Then it converts:
length
from:
uint32_t
to:
size_t
The property should remain true after conversion.
If it is narrowed instead:
size_t
->
uint16_t
the original proof may no longer apply.
Likewise, if the code calculates:
length * element_size
the original bound:
length <= remaining
does not automatically imply:
length * element_size <= remaining
The security property must be re-established after each transformation.
This is one of the most important lessons in data-flow analysis.
For difficult functions, it can be useful to maintain an explicit ledger.
For example:
count:
source = packet
type = uint32_t
invariant = count <= MAX_COUNT
item_size:
source = packet
type = uint32_t
invariant = item_size <= MAX_ITEM_SIZE
total:
derived = count * item_size
invariant = total <= SIZE_MAX
allocation:
size = total
offset:
derived = index * item_size
invariant = offset <= total
copy:
length = item_size
invariant = item_size <= total - offset
This makes it much easier to identify where a proof disappears.
It also works well when reviewing code with many helper functions.
Suppose a function contains:
if (count <= MAX_COUNT)
...
Then later:
count *= element_size;
The earlier invariant:
count <= MAX_COUNT
is no longer sufficient to reason about the new value.
The first broken proof may therefore occur not when the multiplication actually overflows, but when the code begins treating the old invariant as though it still described the transformed value.
This distinction is subtle but valuable.
A program can lose a safety proof before it actually performs an invalid operation.
The reviewer should identify the point where the proof ceases to apply.
A weak report says:
"Integer overflow in allocation size."
A stronger report says:
"An attacker-controlled element count is multiplied by the
element size without an overflow check. When the product wraps,
the allocation is smaller than the number of elements later
processed. The loop continues to use the original count, causing
writes beyond the allocation."
The second description explains:
source
arithmetic
allocation
pointer/indexing
consequence
That makes the vulnerability understandable and actionable.
For this vulnerability class, a concise technical report can contain:
Summary
Affected code path
Attacker-controlled input
First broken invariant
Arithmetic operation
Resulting incorrect size or offset
Invalid memory operation
Security impact
Preconditions
Suggested remediation
The most important part is the chain.
Avoid reporting only the final sanitizer message.
Suppose the problem is:
count * element_size
overflowing.
A poor remediation might simply reduce:
MAX_COUNT
until the current platform happens not to overflow.
That may hide the bug without establishing a general invariant.
A stronger remediation establishes:
count <= SIZE_MAX / element_size
before multiplication.
If the logical count also has a policy limit, retain that separately.
The remediation should therefore encode the property that must remain true.
If ten callers independently perform:
count * sizeof(*items)
ten callers may eventually implement ten slightly different checks.
A central helper can enforce the invariant once.
For example, conceptually:
checked_mul_size(count, sizeof(*items), &bytes)
Then all callers receive either:
success with a representable size
or:
failure.
The exact API is a design decision.
The principle is:
Centralize repeated security invariants when practical.
This reduces duplicated arithmetic reasoning.
A good memory-size calculation tends to have these properties:
The units are obvious.
The types are appropriate.
The arithmetic is checked before it occurs.
The allocation uses the checked result.
The logical count is not confused with byte capacity.
Pointer arithmetic is derived from the same size model.
The complete access range is validated.
Failure paths are explicit.
Related metadata remains synchronized.
Helper functions preserve their contracts.
This is more valuable than memorizing a list of "dangerous functions."
For an unfamiliar C component, use the following sequence.
Look for:
lengths
counts
offsets
dimensions
indexes
allocation sizes
Track:
conversions
arithmetic
rounding
alignment
multiplication
addition
subtraction
shifts
Track where the values reach:
malloc
calloc
realloc
reallocarray
memcpy
memmove
memset
array indexes
pointer arithmetic
loops
Write down:
allocation size
logical object size
capacity
offset
access length
Ask whether each invariant remains true after every transformation.
Ask:
read or write?
controllable contents?
controllable length?
which object?
how far can the access extend?
what state can be corrupted?
Use:
sanitizers
fuzzing
targeted tests
debugger inspection
to validate the suspected path.
This is the transition from static code reading to vulnerability analysis.
At the beginning of this series, the chain looked like:
integer arithmetic
->
allocation size
->
pointer arithmetic
->
bounds
->
memory corruption
After working through the advanced cases, it is useful to expand it:
external representation
->
interpretation
->
integer type
->
conversion
->
arithmetic
->
size or offset
->
allocation or existing object
->
pointer arithmetic
->
logical object boundary
->
physical memory boundary
->
access
->
corrupted or disclosed state
->
security consequence
At every arrow there is an invariant.
At every transformation there is an opportunity for the invariant to be lost.
The central skill is therefore not memorizing which C constructs are dangerous.
It is learning to ask:
What does this value mean?
What type represents that meaning?
What transformations does it undergo?
What object does it eventually control?
What proves that the resulting access is valid?
What happens if that proof is false?
Once you can answer those questions, many apparently unrelated C vulnerabilities become variations of the same underlying problem.
The arithmetic may be different.
The allocator may be different.
The parser may be different.
The memory operation may be different.
But the reasoning remains:
value
->
transformation
->
invariant
->
object
->
access
->
consequence
That is the point at which a collection of C "traps and pitfalls" becomes a coherent security model.
The objective of auditing is not merely to find code that looks dangerous.
It is to reconstruct the program's assumptions, identify the first assumption that can be violated, and follow that violation all the way to its security consequence.
That is how a bug becomes a vulnerability.