PART II

Chapter 7 - Writing the Wrong Amount

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?


1. The destination defines the boundary

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.


2. The classic buffer overflow

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.


3. The destination may be an interior location

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.


4. Off-by-one errors

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.


5. The last byte matters

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.


6. Writing one byte too many

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.


7. Corrupting another object

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.


8. Data corruption versus control corruption

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.


9. Partial overwrites

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?

10. Metadata corruption

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.


11. The write can corrupt its own future bounds

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.


12. Allocation size and write size must agree

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.


13. String output is still a write

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.


14. `snprintf` changes the problem, not the need for reasoning

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.


15. Array element size matters

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.


16. The write destination can move

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.


17. Repeated writes

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.


18. The danger of checking after addition

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.

19. Write length versus logical length

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.


20. The write primitive

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.


21. Not every overflow is exploitable

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.

22. Review the complete destination range

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.


23. The central lesson

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.


← Previous 9 of 13 Next →