PART III
At this point, the individual pieces can be combined.
A memory operation is not defined solely by:
pointer
+
length.
To reason about it correctly, we need at least:
object
location
amount
lifetime
ownership.
Remember the phrase introduced at the beginning of this book:
I calculated, addressed, allocated, copied, or freed the wrong amount or location of memory.
Each verb describes a stage in a chain.
A mistake at one stage can become a different-looking mistake at a later stage.
Consider a program receiving:
count
items from an external source.
It might perform:
bytes = count * sizeof(*items);
then:
buffer = malloc(bytes);
then:
p = buffer + offset;
then:
memcpy(p, input, length);
then later:
free(buffer);
Each operation depends on the correctness of the previous ones.
Conceptually:
input
->
integer arithmetic
->
allocation size
->
object
->
pointer arithmetic
->
location
->
bounds
->
copy amount
->
memory access
->
lifetime
->
release
This is the central chain of the book.
Suppose:
count
is enormous.
The multiplication:
count * sizeof(*items)
overflows.
The allocation becomes too small.
The program then copies the calculated number of bytes.
The copy may or may not immediately cross the allocation boundary.
Later, code accesses:
items[i]
using the original `count`.
The crash occurs there.
The visible failure is an out-of-bounds access.
The original defect was an integer calculation.
This is why debugging from the crash site alone can be misleading.
Suppose:
length
is too large.
The program performs:
memcpy(buffer, input, length);
and overwrites:
object->size
The next operation does:
memcpy(object->data, input2, object->size);
Now the second copy has a bad length.
The program has gone from:
one spatial error
to:
corrupted metadata
->
another spatial error.
This cascading behavior is common in memory corruption.
Suppose:
object->next
is a pointer.
An out-of-bounds write changes it.
Later:
free(object->next);
The pointer now identifies something other than the intended allocation.
The original bug was a write to the wrong location.
The later symptom is an invalid or unintended free.
The categories are connected because memory pointers are themselves data stored in memory.
Suppose:
p
points to an object that has been freed.
The allocator reuses that memory for another object.
A later write through `p` now reaches the new object.
The original bug was:
lifetime.
The resulting effect is:
wrong object/location.
Again, the categories are not isolated.
Suppose:
offset = index * element_size;
If the multiplication wraps, the offset may point somewhere unexpected.
The program then performs:
buffer + offset.
The resulting problem is a wrong location.
The underlying defect was arithmetic.
This is why it is useful to trace memory bugs backward through their calculations.
Suppose the intended allocation size is:
count * element_size
but the actual calculation produces:
smaller_size.
The allocation succeeds.
The pointer is valid.
The copy length may even be correct relative to the intended object.
But the physical destination is too small.
The first operation that notices the discrepancy may be:
memcpy.
The allocation created the wrong boundary.
The copy crossed it.
Suppose an object contains:
size
pointer
An out-of-bounds write corrupts:
pointer.
Later:
free(object->pointer);
The allocator receives corrupted state.
The memory-management bug is therefore downstream from the original copy.
This pattern is particularly important when investigating security vulnerabilities.
The operation that crashes may be several stages removed from the operation that gave the attacker control.
For practical analysis, five questions cover much of the ground:
1. What object is this?
2. Where within that object is the operation occurring?
3. How much memory is being accessed?
4. Is the object still alive?
5. Who owns it?
The first three are spatial.
The fourth is temporal.
The fifth is about responsibility.
A valid memory operation requires all of them to line up.
When reviewing code, it helps to write down the object explicitly.
For example:
object:
allocation of 1024 bytes
logical contents:
80 records
current position:
640 bytes
remaining capacity:
384 bytes
lifetime:
allocated and live
owner:
parser context
Now consider:
memcpy(buffer + 640, src, 400);
The source may be valid.
The destination is not.
There are only:
384
bytes remaining.
The operation asks for:
400.
The error becomes obvious once the object is described explicitly.
A memory operation can be viewed as requiring a small proof.
For:
memcpy(dst, src, length);
the proof needs to establish:
src identifies a live source object
length bytes are readable from src
dst identifies a live destination object
length bytes are writable from dst
the operation's overlap requirements are satisfied
the size calculation produced the intended length
the pointer calculations produced the intended locations.
This is a much stronger mental model than:
"The pointers look right."
For:
p = malloc(size);
the important facts include:
size represents the intended object size
the calculation producing size is valid
the result is checked appropriately
the program records the actual capacity
later accesses do not assume more storage than was obtained.
The allocation is the point where an abstract size becomes a physical boundary.
If the boundary is wrong, every later access is working from a false premise.
For:
q = p + offset;
the relevant facts include:
p identifies the intended live object
offset has the correct unit
offset is within the permitted range
the arithmetic produces the intended location.
The pointer calculation should not be treated as an opaque transformation.
It is part of the memory-safety proof.
For:
access(q, length);
we need:
q identifies the intended location
and:
length bytes remain available there.
Conceptually:
q + length <= object_end
But the arithmetic used to establish this relationship must itself be safe.
A bounds check that can overflow is not a valid proof of the bound.
For any pointer access, ask:
Does the object still exist?
This question is independent of the numerical address.
An address can be:
mapped
readable
writable
and still be wrong because the program is using it after the original object's lifetime ended.
Lifetime is therefore a separate dimension of memory safety.
Finally:
Who is responsible for this object?
This determines:
who may free it
when it may be freed
whether another component may retain a pointer
whether a shallow copy creates a shared resource
whether ownership has been transferred.
Without an ownership model, lifetime reasoning becomes fragile as programs become larger.
Memory bugs often appear where one concept is translated into another:
count -> bytes
bytes -> allocation
allocation -> pointer
pointer -> offset
offset -> range
range -> copy
object -> owner
owner -> lifetime
Each transition introduces an assumption.
The most useful review strategy is therefore not to stare at individual instructions.
Look for the transitions.
Ask:
What does this value mean here?
What did it mean before?
What unit does it use?
What boundary does it describe?
What object does it refer to?
Who is responsible for keeping that object alive?
A variable named:
size
may mean:
allocation size
input size
payload size
element count
byte count
remaining capacity
logical object size
string length
serialized length.
These are not interchangeable.
Many memory bugs begin when two values with the same type but different meanings are treated as equivalent.
The type system sees:
size_t
The programmer must preserve:
what kind of size?
Similarly, a variable named:
p
may point to:
the beginning of an allocation
an element inside an array
a structure member
a serialized field
a borrowed object
an object owned elsewhere
freed storage.
The numerical value does not tell us which role it has.
Good memory-safety reasoning therefore tracks pointer meaning, not merely pointer values.
A buffer may have:
physical capacity
logical length
remaining capacity
input length
output length
maximum permitted length.
A function that receives:
buffer
and:
length
still needs to know what the length represents and what boundary it is supposed to respect.
The word `buffer` does not establish the range.
A memory bug becomes a security vulnerability when an attacker can influence the conditions of the invalid operation and the resulting corruption or disclosure has useful consequences.
A useful sequence is:
attacker influence
->
incorrect calculation/location/lifetime
->
invalid memory operation
->
observable effect
->
security consequence.
The observable effect might be:
crash
information disclosure
data corruption
arbitrary memory write
control-flow corruption
privilege-relevant state change.
The final consequence depends on the exact program.
Compare:
one-byte out-of-bounds read
with:
attacker-controlled arbitrary write.
Both violate memory safety.
They do not provide the same capability.
A useful vulnerability analysis therefore asks:
What can the attacker influence?
What memory can be accessed?
In which direction?
For how many bytes?
With what values?
How reliably?
Under what conditions?
This is the bridge from bug classification to exploit analysis.
It is useful to describe an exploitable memory bug in terms of the primitive it creates.
For example:
out-of-bounds read
might provide:
disclosure of adjacent memory.
An out-of-bounds write might provide:
corruption of adjacent memory.
A use-after-free might provide:
access to memory reused for another object.
A double free might provide:
corruption or manipulation of allocator state.
The primitive is more informative than the bug name alone.
Suppose an attacker has some form of write primitive.
The next question is:
What can be reached?
If the only reachable bytes are unimportant data, the impact may be limited.
If the attacker can affect:
object metadata
pointers
authorization state
control-flow data
the consequences may be much greater.
Thus exploitability depends on the relationship between:
primitive
and:
target.
A bug that crashes the program once in a thousand attempts is different from one that provides a deterministic write every time.
Security analysis therefore considers:
reliability
repeatability
environmental dependence
allocator behavior
process layout
concurrency.
The underlying memory error may be identical.
The practical exploitability can be very different.
Modern systems commonly employ defenses such as:
address randomization
non-executable memory
stack protection
hardened allocators
control-flow protections
compiler instrumentation
These can make exploitation harder.
They do not make the underlying memory operation correct.
A program that writes outside an object remains incorrect even if a particular defense causes the program to crash instead of being exploited.
This distinction matters when evaluating vulnerabilities:
bug correctness
versus:
exploitability
are separate questions.
When a memory bug crashes under a debugger, the temptation is to fix the instruction that crashed.
That can be useful.
But the crash may be only the last visible consequence.
Trace backward:
What pointer was used?
How was it calculated?
What size was used?
How was the size calculated?
What allocation established the boundary?
Is the object still alive?
Who owns it?
This backward chain often reaches the real defect.
The central chain can now be written in a more complete form:
integer arithmetic
->
allocation size
->
object boundary
->
pointer arithmetic
->
object location
->
bounds
->
amount accessed
->
memory operation
->
object lifetime
->
ownership
->
release
->
possible reuse
->
possible corruption or disclosure.
The stages are not strictly linear.
They form a network.
A bad integer can create a bad allocation.
A bad allocation can create a bad bound.
A bad bound can create a bad pointer.
A bad pointer can create corruption.
Corruption can alter ownership metadata.
That can cause an invalid release.
The invalid release can create a dangling pointer.
The dangling pointer can later access a new object.
One mistake can therefore feed another.
When faced with suspicious C memory code, reduce the problem to this:
OBJECT
What object is this?
LOCATION
Where inside that object is the operation?
AMOUNT
How many bytes or elements are involved?
LIFETIME
Does the object still exist?
OWNERSHIP
Who is responsible for it?
Then trace every value that establishes those facts.
If one of them cannot be justified, there is a gap in the memory-safety argument.
The difficult part of C memory safety is rarely the syntax.
The syntax is usually simple:
p[i]
p + offset
malloc(size)
memcpy(dst, src, length)
free(p)
The difficulty is maintaining the relationships between the values.
A correct program must preserve relationships such as:
allocation_size >= required_size
offset <= capacity
length <= remaining_capacity
source_range is live and readable
destination_range is live and writable
pointer identifies the intended object
object remains alive while references exist
ownership is transferred before the previous owner releases it
These are invariants.
Memory bugs occur when the program assumes an invariant that is no longer true.
The phrase that began this book is a compact description of a large class of C memory failures:
I calculated, addressed, allocated, copied, or freed the wrong amount or location of memory.
It covers several different moments in the life of a memory object.
I calculated the wrong amount:
integer overflow
truncation
unit confusion
incorrect size formulas
I allocated the wrong amount:
undersized allocation
oversized or inconsistent allocation
allocation based on corrupted metadata
I addressed the wrong location:
bad index
bad offset
wrong base pointer
stale pointer
wrong object
I copied the wrong amount:
buffer overflow
out-of-bounds read
truncated object
missing terminator
I freed the wrong thing:
invalid free
double free
premature free
wrong owner
wrong allocation
And each category can feed another.
When reviewing a potentially dangerous memory operation, ask one question first:
What exact bytes does the program believe it is operating on, and what evidence establishes that those bytes belong to the intended live object?
Then decompose the answer:
Which object?
Which location?
Which amount?
Which lifetime?
Which owner?
If the program cannot answer one of these questions, that is where the investigation should begin.
The machine will execute the pointer arithmetic.
The machine will perform the copy.
The machine will dereference the pointer.
The machine will call the allocator.
It will not know what the programmer intended.
The programmer's job is to preserve the relationship between intention and memory.
That relationship is the real subject of memory safety in C.