PART II
A write changes memory.
That makes a size error particularly dangerous.
A program that writes too little may leave an object incomplete.
A program that writes too much may modify bytes belonging to another object.
The resulting corruption can alter data, metadata, pointers, control information, or program state.
The basic operation is:
write length bytes
starting at destination
The central question is:
> Does the complete destination range belong to the object the program intends to modify?
Consider:
memcpy(dst, src, length);
For the write side, the important range is:
[dst, dst + length)
If the destination object has:
capacity
bytes, the fundamental requirement is:
length <= capacity
If the destination begins at an offset:
dst + offset
then the requirement becomes:
offset <= capacity
length <= capacity - offset
The physical destination boundary is what matters.
Suppose:
char buffer[16];
and:
memcpy(buffer, input, 32);
The source may contain 32 readable bytes.
The destination does not contain 32 writable bytes.
The copy therefore writes beyond the destination object.
This is the classic buffer overflow.
But the interesting part is not the name.
The interesting part is the violated relationship:
bytes_written > bytes_available_at_destination
That formulation applies equally to:
memcpy
memmove
strcpy
manual loops
formatted output
serialization routines
and many other operations.
Consider:
memcpy(buffer + offset, input, length);
The total buffer capacity might be:
100
and the offset might be:
80
That does not leave 100 bytes available.
It leaves:
20
The correct condition is therefore:
length <= 100 - 80
not:
length <= 100.
This is one of the most common patterns in real code.
A programmer knows the buffer is large enough for the complete operation in some abstract sense, but forgets that the operation begins partway through it.
Suppose:
char buffer[16];
and the program wants to write a string of at most 15 characters plus a terminator.
The available capacity is:
16
If the program calculates the required size as:
length
instead of:
length + 1
the terminator has nowhere to go.
Conversely, a loop such as:
for (i = 0; i <= length; ++i)
dst[i] = src[i];
may perform one more iteration than intended.
The difference between:
<
and:
<=
can therefore determine whether the last write is inside or outside the object.
For a buffer of size `n`, the valid byte indexes are:
0 through n - 1
The address:
buffer + n
can be used as a one-past-the-end pointer for certain purposes, but it cannot be dereferenced.
This distinction is easy to state and important to preserve.
There is a difference between:
forming an endpoint
and:
accessing the endpoint.
A range can be represented using:
[begin, end)
where `end` identifies the position immediately after the final element.
The write range must still end no later than that endpoint.
Consider:
char buffer[8];
and:
for (i = 0; i <= 8; ++i)
buffer[i] = 0;
The loop performs nine writes.
The valid range contains eight bytes.
The ninth write crosses the boundary.
This is a small error in source code.
Its consequences depend on what lies beyond the buffer.
That might be irrelevant padding.
It might be another variable.
It might be an object containing important state.
It might be memory whose corruption affects later control flow.
The size of the mistake does not determine its importance.
Conceptually:
+----------------+
| buffer |
+----------------+
| object B |
+----------------+
An oversized write to `buffer` may continue into object B.
The program may therefore appear to have a bug involving:
buffer
while the security consequence involves:
object B.
This is a useful distinction.
The object that is too small is not necessarily the object that becomes interesting to an attacker.
A write beyond its intended boundary might overwrite:
ordinary data
or:
length fields
or:
pointers
or:
function pointers
or:
object state
or:
control-flow-related data.
The same underlying spatial error can therefore have very different consequences.
A write is not automatically a code-execution vulnerability.
But an attacker-controlled write to an important target can be substantially more powerful than an accidental overwrite of an unused field.
The exploitability depends on what can be written, where it can be written, and how precisely the attacker controls the write.
A corruption does not need to replace an entire object.
Suppose a pointer occupies several bytes and a bug overwrites only part of it.
The resulting value may still be:
valid-looking
but point somewhere unintended.
Similarly, a length field might be changed by only one byte.
A flag might be changed from:
0
to:
1.
A small out-of-bounds write can therefore have a disproportionate effect if it lands on a sensitive field.
The relevant question is not simply:
How many bytes were corrupted?
It is:
Which bytes were corrupted?
Consider an object containing:
pointer
length
capacity
An out-of-bounds write into that object might corrupt the metadata rather than the data itself.
The immediate write is one memory-safety violation.
The corrupted metadata can then cause later code to perform additional invalid operations.
For example:
corrupted length
->
oversized copy
->
further corruption
or:
corrupted pointer
->
write to unintended location
->
further corruption.
This is one way a single memory bug can become a chain of memory bugs.
A particularly dangerous pattern occurs when the data being overwritten controls the size or location of later operations.
Suppose:
object->length
controls how much data will later be copied.
If an out-of-bounds write changes:
object->length
the next operation may use a corrupted boundary.
This produces a feedback loop:
memory corruption
->
corrupted metadata
->
incorrect bounds
->
further memory corruption.
Once metadata is corrupted, reasoning about later operations becomes much harder.
Suppose:
size = calculate_size(count);
buffer = malloc(size);
Later:
length = calculate_length(count);
memcpy(buffer, src, length);
The critical relationship is:
length <= size
If the two calculations are intended to represent the same logical object, they may ideally derive from the same validated quantity.
The danger is greatest when the allocation and write use different interpretations.
For example:
allocation = count * sizeof(struct item);
while:
copy = count * sizeof(struct larger_item);
Both expressions may look plausible.
Only the relationship between them reveals the problem.
Consider:
sprintf(buffer, "%s", input);
The operation writes characters into `buffer`.
The amount is determined by the formatted result.
The destination therefore needs enough capacity for the complete output.
A formatting function may hide the length calculation, but the memory-safety question remains:
How many bytes will be written?
and:
How much space is available?
The same principle applies to serialization functions, encoding routines, and custom output functions.
A bounded formatting function is generally preferable to an unbounded one.
But even with:
snprintf(buffer, capacity, ...);
the programmer still needs to understand:
what `capacity` means
and:
what the return value means
and:
whether truncation is acceptable.
A bounded operation may prevent a physical overflow while still producing an incomplete logical object.
That may be safe.
It may also create a different bug if the program assumes the complete output was produced.
Again:
physical correctness
and:
logical correctness
are related but distinct.
Consider:
int *dst;
char *src;
A programmer may calculate:
count
and then write:
for (i = 0; i < count; ++i)
dst[i] = src[i];
This is not equivalent to copying `count` bytes.
Each `dst[i]` occupies the size of an `int`.
Each `src[i]` is one byte.
The loop's write amount is therefore:
count * sizeof(int)
while its source consumption is:
count
bytes.
The two sides may not describe the same amount of data.
The unit of the index matters.
Consider:
dst += count;
memcpy(dst, src, length);
The correctness of the later copy depends on the earlier pointer arithmetic.
The program must know that the new `dst` still identifies the intended object and that enough capacity remains there.
This illustrates why pointer arithmetic belongs in the same chain as bounds and copying.
A copy can be perfectly written while receiving a destination pointer that was calculated incorrectly several statements earlier.
Many programs append data repeatedly:
memcpy(buffer + used, data, length);
used += length;
The intended invariant is:
used <= capacity
before the append.
And for the individual copy:
length <= capacity - used
Afterward:
used_new = used + length
must still represent the actual amount of data stored.
This pattern is extremely common in:
dynamic buffers
packet construction
encoders
logging
protocol implementations
parsers
file builders.
A bug in one update to `used` can therefore corrupt every subsequent append.
Suppose code does:
new_used = used + length;
if (new_used > capacity)
return;
The arithmetic itself can overflow before the comparison.
If the type is unsigned, `new_used` can wrap to a small value.
The check may then incorrectly succeed.
A safer conceptual check is:
if (used > capacity)
fail;
if (length > capacity - used)
fail;
Only after establishing the relationship should the addition be performed.
This is another example of the central theme:
the check itself manipulates the same numbers
that determine the memory boundary.
Suppose a buffer contains:
used
bytes of meaningful data.
A function might need to write:
required
bytes.
The physical capacity condition is:
required <= capacity - used
But the logical condition might be different.
Perhaps only:
remaining_payload
bytes are allowed by the protocol.
Perhaps a terminator must be added.
Perhaps the object has a maximum logical size smaller than its physical allocation.
A safe operation therefore sometimes needs several bounds:
physical capacity
logical capacity
protocol limit
object-specific limit.
The smallest applicable boundary wins.
From a security perspective, an attacker-controlled out-of-bounds write can be especially powerful when the attacker controls:
where the write occurs
how many bytes are written
what values are written.
These dimensions are often called the location, size, and contents of a write primitive.
They determine what kinds of corruption are possible.
A write of a fixed value to a fixed nearby location is different from a write whose location and contents can both be influenced.
This distinction becomes important in Chapter 24 and beyond.
Suppose a write crosses the end of a buffer by one byte.
That is a memory-safety bug.
It does not automatically mean:
arbitrary code execution.
The corrupted byte might land in:
unused padding
or:
harmless data
or:
memory that causes an immediate crash.
Alternatively, it might alter something security-critical.
The vulnerability analysis therefore comes after establishing the memory-safety violation.
First establish:
what was written
and:
where.
Then determine:
what that corruption can affect.
When reviewing a write, write down:
destination object
destination start
destination capacity
write length
Then determine:
[destination, destination + length)
and compare it with the object's valid range.
For an offset destination:
[buffer + offset,
buffer + offset + length)
This simple representation removes much of the ambiguity from pointer-heavy code.
A write is safe only when the complete destination range belongs to the intended writable object.
The most useful questions are:
Where does the write begin?
How many bytes does it write?
Which object owns that range?
How much capacity remains there?
How was that capacity calculated?
Can the size arithmetic overflow?
Can the destination pointer be corrupted or miscalculated?
What lies immediately beyond the intended object?
The final question matters for exploitability.
The earlier questions determine whether the write is valid at all.
The next chapter turns to a related but distinct problem.
Sometimes the amount is completely correct.
The program still accesses the wrong place.