PART II
Memory safety is often described in terms of size:
too many bytes
too little space
out-of-bounds access
But an operation can use exactly the right number of bytes and still access the wrong memory.
Consider:
memcpy(dst, src, 16);
Suppose 16 bytes are valid at both addresses.
The operation can still be wrong if:
dst
does not point to the intended object.
This chapter is therefore about location.
The central question is:
> Does this address identify the object the program thinks it identifies?
Suppose a program intends to update:
object_a
but a pointer calculation produces:
object_b
and the operation writes exactly:
sizeof(object_a)
bytes.
The size is correct.
The location is not.
The result may be memory corruption even though every length calculation is perfectly reasonable.
This is why memory-safety analysis needs two separate questions:
How much?
and:
Where?
Consider:
p + offset
The result is not simply an arbitrary numerical address.
It is a pointer derived from:
p
and:
offset.
The intended meaning is generally:
move within the object associated with p.
If `offset` is wrong, the resulting location may no longer identify the intended element or subobject.
This makes pointer arithmetic part of the memory boundary calculation.
These expressions are closely related:
array[index]
and:
*(array + index)
The index determines the location.
If:
index
is wrong, the resulting access is wrong.
This is why array indexing should be reviewed in the same way as explicit pointer arithmetic.
Ask:
What object does array refer to?
What does index mean?
What is the valid range of index?
How was index calculated?
One of the most common location errors is confusing:
element index
with:
byte offset.
Suppose:
int array[100];
If:
index = 10;
then:
array + index
points to the eleventh `int`.
But:
(char *)array + index
moves only:
10 bytes.
Those are different locations.
The type of the pointer determines the unit of pointer arithmetic.
This is powerful, but it also means that changing pointer types can change the meaning of an offset.
For:
p + i
where `p` points to an object of size `S`, the conceptual address displacement is:
i * S
This is why:
int *p;
p + 3;
does not mean:
three bytes later.
It means:
three int objects later.
A programmer who calculates an offset in bytes and then applies it to a typed pointer can therefore land at the wrong location.
The number may be correct.
The unit is wrong.
Consider:
char *bytes = (char *)items;
Now:
bytes + offset
moves by:
offset bytes.
Whereas:
items + offset
moves by:
offset elements.
Both expressions use the same integer.
They do not identify the same location.
This is an important review technique:
> Whenever a pointer changes type, reconsider the units of every offset used with it.
Consider:
matrix[row][column]
The location depends on two values.
For a row-major array, the conceptual calculation includes something like:
row * row_width + column
If either value is wrong, the final location is wrong.
The same pattern appears in:
images
packet tables
multidimensional buffers
flattened arrays
grids
matrix calculations.
The arithmetic may look like ordinary indexing.
It is really address calculation.
Suppose an image has:
width
height
channels
and pixels are stored in one linear buffer.
A location might be calculated from:
y * width * channels
+
x * channels
+
channel
Every number has a unit.
The result must identify the intended byte or element.
An error in any multiplication or addition can produce the wrong location.
This connects pointer arithmetic directly back to Chapter 1.
The arithmetic is not separate from the pointer.
It is how the pointer is created.
Suppose:
offset = row * width * element_size;
The programmer may correctly validate:
row < height
and:
column < width
yet still have a problem if the multiplication used to calculate the byte offset overflows.
The logical coordinates may be valid.
The resulting address calculation may not represent the intended location.
This is a crucial distinction:
valid coordinates
do not automatically imply:
valid address calculation.
The arithmetic connecting them must also be valid.
Consider:
p = buffer + header_size + index * element_size;
This single expression contains several opportunities for error.
header_size
might be wrong.
index
might be out of range.
element_size
might be wrong.
The multiplication might overflow.
The addition might overflow.
The resulting pointer might not identify the intended element.
The expression is therefore best understood as a chain of calculations rather than one line of pointer syntax.
A pointer can be valid while referring to the wrong object.
Suppose:
p
points to a valid structure.
But the program intended:
q
which points to another structure.
An operation through `p` can therefore be entirely within valid memory and still violate the program's logical memory model.
This matters especially in programs with:
linked lists
trees
object pools
arenas
custom allocators
shared buffers
multiple related structures.
The address may be valid.
The object identity is wrong.
Suppose:
buffer
points to an allocation.
Then:
buffer + 20
may point to an interior location.
That can be useful.
But the program must know what that location represents.
An interior pointer might identify:
an array element
a structure member
a serialized field
the beginning of a subobject
or:
nothing meaningful at all.
The fact that the pointer is inside the allocation does not establish that it is the correct location for the intended operation.
C pointer semantics are more subtle than simply treating pointers as integers.
For memory-safety reasoning, it is useful to retain the identity of the object from which a pointer was derived.
Think of:
p
as meaning not merely:
address X
but:
a location within object O.
Then pointer arithmetic means:
move to another location associated with O.
This mental model prevents a common mistake:
"The numeric address happens to be inside another allocation,
so the pointer must be usable there."
The fact that two objects happen to occupy nearby addresses does not make them interchangeable.
Consider:
dst = buffer + offset;
Suppose the programmer intended `buffer` to point to the beginning of an object.
But it actually points to:
the beginning of a subobject
or:
an unrelated field
or:
an already advanced position.
The offset can be perfectly correct relative to the intended base and still produce the wrong final address.
When debugging a bad pointer, do not inspect only the final offset.
Trace the pointer back to its base.
A particularly common mistake occurs when a pointer has already been advanced:
p = buffer + offset;
and later code does:
p += offset;
The final pointer is:
buffer + 2 * offset
rather than:
buffer + offset.
Both operations may look individually reasonable.
The problem is that the same logical displacement was applied twice.
This kind of bug is especially common in layered parsing and buffer-processing code.
A pointer can also identify the wrong location because the object it originally referred to has moved or ceased to exist.
For example:
p = buffer;
followed by:
buffer = realloc(buffer, new_size);
If the allocation moves, `p` no longer identifies the current allocation.
The numerical address stored in `p` has not changed.
The object's location has.
This is therefore a location problem intertwined with lifetime.
The pointer has become stale.
Before:
buffer -> allocation A
After a successful reallocation, the program may have:
buffer -> allocation B
A stale pointer may still contain:
allocation A's old address
The old address might now be:
unmapped
or:
reused for another object.
The stale pointer therefore no longer represents the intended object.
This is why pointer validity is temporal as well as spatial.
A NULL pointer is not merely a small address.
Dereferencing it is invalid because it does not identify an appropriate object.
For example:
p = get_object();
p->field = value;
If `get_object()` returns NULL, the destination is wrong before the write begins.
The important point is that a pointer can fail because it identifies:
no object
rather than:
an object with insufficient capacity.
These are different failure modes.
A corrupted pointer may become a small non-NULL value.
The program may then attempt to access:
address 0x10
or:
address 0x100
The fact that the value is not zero does not make it a valid object location.
Again, pointer validity is not a simple numeric property.
The address must identify an appropriate accessible object.
Location errors affect reads too.
Consider:
memcpy(dst, src + offset, length);
The destination may be perfect.
The source may be wrong.
The program could therefore read:
the wrong field
the wrong record
the wrong packet
another object
freed memory
unrelated data.
This can lead to information disclosure without any destination overflow.
The same amount of data can be copied correctly from the wrong place.
Imagine:
struct credentials a;
struct credentials b;
and:
memcpy(&a, &b, sizeof(a));
The size is correct.
The source and destination are valid.
But suppose the programmer intended to copy from:
c
instead of:
b.
There is no spatial memory violation in the operation.
It is nevertheless a logical memory error.
This distinction matters when diagnosing bugs.
Not every wrong-location bug is necessarily an out-of-bounds access.
Some are valid accesses to the wrong object.
Security consequences depend on what the wrong object contains and what the operation does with it.
A common vulnerability pattern is:
p = base + user_offset;
followed by:
access(p);
The attacker may control:
user_offset.
The security question becomes:
What locations can the attacker make p identify?
If the bounds are weak, the resulting pointer may reach memory outside the intended object.
The important property is not simply that the offset is untrusted.
It is that the offset controls an address calculation whose valid range has not been adequately established.
It is tempting to think:
"I have a pointer, therefore I have access to the object."
In C, the programmer must still reason about:
which object
which subobject
which offset
which lifetime
which access size.
A pointer value does not carry a complete proof that the intended operation is valid.
The programmer has to preserve that proof through the calculations that follow.
Suppose:
p = buffer + offset;
and then:
memcpy(p, src, length);
To establish that the destination range is valid, we need both:
the correct base object
and:
a valid offset.
Then we need:
enough remaining capacity for length.
Conceptually:
base object
->
offset
->
location
->
remaining capacity
->
access length
A location cannot be validated independently from its object and its boundary.
One of the most useful habits from the previous chapters is to stop thinking about a pointer as a single location.
For:
p = buffer + offset;
followed by a write of:
length
bytes, think:
[buffer + offset,
buffer + offset + length)
Then ask:
Does this entire range belong to the intended object?
This immediately exposes several classes of bugs:
wrong base
wrong offset
wrong length
wrong object
insufficient capacity
stale pointer.
The range is the thing that the operation actually uses.
Consider:
p = buffer + attacker_offset;
memcpy(p, src, fixed_length);
The length might be a constant and completely safe.
The vulnerability can still exist because:
attacker_offset
moves the destination to an unintended location.
The chain is:
attacker-controlled offset
->
wrong address
->
fixed-size write
->
corruption.
The size did not need to be wrong.
The location was enough.
The reverse is also common.
Suppose:
offset = index * element_size;
If the multiplication overflows, the resulting offset may be smaller than intended.
The program may then calculate:
buffer + offset
and access the wrong location.
Here the root cause is arithmetic.
The visible problem is a bad address.
This is why bug categories should not be treated as mutually exclusive.
A single defect can move through several categories as it propagates.
For any pointer calculation, write down:
base object
base location
offset
offset unit
resulting location
access size
Then ask:
Is the base pointer associated with the intended object?
Is the offset measured in the correct unit?
Can the offset calculation overflow?
Is the resulting location within the intended object?
Is the complete access range within the object?
This method is especially effective for:
packet parsers
binary decoders
image processing
array manipulation
custom allocators
object pools
serialization code.
A memory operation can have:
the right size
and still use:
the wrong location.
The location may be wrong because:
the base pointer is wrong
the offset is wrong
the unit is wrong
the index is wrong
the arithmetic overflowed
the pointer is stale
the object was confused with another object
the pointer identifies no valid object.
The most useful question is therefore:
> What object does this address belong to, and how did the program establish that this is the object it intended?
Once that question is answered, the next question is naturally:
How much of that object is actually being accessed?
That brings the two dimensions back together.
A memory operation is safe only when both its location and its amount are correct.
The chain is now complete:
integer arithmetic
->
allocation size
->
pointer arithmetic
->
bounds
->
memory access
And once that access goes wrong, the next question is no longer merely whether the program has a bug.
It is what that bug allows an attacker to do.