Skip to content
Courses

Out-of-Bounds Writes and Variable Layout

Bug Classes and PrimitivesLesson 4 of 875 minutesLessonNo lab next

The depot in Out-of-Bounds Reads read through an index nobody checked, and what it reached was whatever the linker had put nearby. Writing through one is the same bug pointing the other way, and it changes the question. Reading is interesting when the neighbour holds a secret. Writing is interesting when the neighbour is something the program will act on.

A variable holding a decision is a useful target even if we never change a return address. We’ll put a badge buffer beside an approval flag in two ways: as members of one structure and as separate local variables. The source looks similar, but those arrangements give the compiler different freedom over where the flag ends up.

In the Ubuntu terminal, create the working directory if needed and move into it:

Terminal window
mkdir -p ~/underdunn-course/bug-classes ~/underdunn-course/notes
cd ~/underdunn-course/bug-classes

Save the complete program below as badge.c. Keep the terminal in this directory for the build commands; no source file from an earlier lesson is needed.

#include <stdio.h>
#include <string.h>
struct request {
char badge[16];
int approved;
};
/* Arrangement one: members of a struct. C fixes their order. */
static void by_struct(const char *input)
{
struct request r;
memset(&r, 0, sizeof r);
strcpy(r.badge, input);
printf(" struct path: %s\n", r.approved ? "DOOR OPENS" : "refused");
}
/* Arrangement two: two locals. The compiler chooses their order. */
static void by_locals(const char *input)
{
int approved = 0;
char badge[16];
strcpy(badge, input);
printf(" locals path: %s\n", approved ? "DOOR OPENS" : "refused");
}
int main(int argc, char **argv)
{
const char *input = argc > 1 ? argv[1] : "sam";
setvbuf(stdout, NULL, _IONBF, 0);
printf("badge presented: %zu characters\n", strlen(input));
by_struct(input);
by_locals(input);
puts("controller done");
return 0;
}

memset clears the complete struct request, including approved, before the copy. That is why a later nonzero value has to come from the badge input rather than from uninitialized storage. The setvbuf call only keeps output immediate for the command-line experiments.

Neither function ever assigns to approved after initialising it to zero, so a door that opens has been opened by the badge text and nothing else.

Terminal window
gcc -g -O0 -fno-omit-frame-pointer badge.c -o badge
./badge sam
badge presented: 3 characters
struct path: refused
locals path: refused
controller done

Let’s compare the badge lengths at which the two paths start opening.

Terminal window
for n in 16 17 20 24 25 28; do
./badge "$(python3 -c "print('A'*$n, end='')")"
done
Characters Struct path Locals path Exit
16 refused refused 0
17 DOOR OPENS refused 0
20 DOOR OPENS refused 0
24 DOOR OPENS refused 0
25 DOOR OPENS *** stack smashing detected *** 134
28 DOOR OPENS *** stack smashing detected *** 134

The two paths stop agreeing as soon as the name exceeds the buffer. Compare the first length that changes the struct’s decision with the first length that makes the locals path abort:

The struct path opens at seventeen characters. Sixteen already fills the badge buffer, but the byte that strcpy puts after those sixteen characters is a terminator. We need to account for that byte before deciding whether crossing the boundary changes the flag.

The locals path never opens at all. It refuses, then at twenty-five it stops refusing and starts aborting. That’s the canary from Stack Canaries, doing precisely what it was built for, without approved ever having changed.

Twenty-four is the last quiet length. The canary sits exactly twenty-four bytes along with a zero in its low byte (the terminator byte from that lesson), so a copy ending there writes a zero over a zero, leaving the check to pass.

badge is sixteen bytes and approved begins immediately after it:

Terminal window
gdb -q -batch -ex 'ptype /o struct request' ./badge
/* 0 | 16 */ char badge[16];
/* 16 | 4 */ int approved;

strcpy copies the text and then writes a terminating zero, so sixteen characters put sixteen As into badge and a \0 into the first byte of approved. Zero into a variable that already held zero changes nothing, which is why sixteen is refused.

Seventeen characters put an A there instead. On this little-endian build, the first byte is the low byte of approved, so its value becomes 0x41, or 65.

The program only tests whether that value is nonzero; it doesn’t require a particular approval code. That is why one chosen byte is enough here, even though the flag occupies four bytes. A different test of the same flag could require more control.

The locals path has the same two variables and refuses everything, so find out where they are. Read them from a running process rather than from the declarations:

Terminal window
gdb -q -batch -ex 'break by_locals' -ex 'run sam' \
-ex 'printf "badge at rbp%+ld\n", (long)((char*)badge - (char*)$rbp)' \
-ex 'printf "approved at rbp%+ld\n", (long)((char*)&approved - (char*)$rbp)' \
./badge
badge at rbp-32
approved at rbp-36

approved sits four bytes below badge. A strcpy runs forward, so no badge text of any length reaches it. The variable the overflow was aimed at is behind the overflow, and everything in front of it is the rest of the frame and then the canary.

Reading the declarations from top to bottom wouldn’t tell us that the flag is below the buffer. Separate locals don’t have a source-defined order in the frame, so the distance measured in GDB is what tells us whether this forward copy can reach the flag.

Build it again with the stack protector suppressed, and ask the same question:

Terminal window
gcc -g -O0 -fno-omit-frame-pointer -fno-stack-protector badge.c -o badge-noprot
gdb -q -batch -ex 'break by_locals' -ex 'run sam' \
-ex 'printf "badge at rbp%+ld\n", (long)((char*)badge - (char*)$rbp)' \
-ex 'printf "approved at rbp%+ld\n", (long)((char*)&approved - (char*)$rbp)' \
./badge-noprot
badge at rbp-32
approved at rbp-4

badge hasn’t moved. approved has travelled thirty-two bytes, from four below the buffer to twenty-eight above it (same two declarations, same compiler, one flag), and it’s now in front of a forward write:

Terminal window
for n in 28 29 32; do ./badge-noprot "$(python3 -c "print('A'*$n, end='')")"; done
locals path: refused
locals path: DOOR OPENS

Twenty-nine characters, which is twenty-eight to cross the gap and one to land a non-zero byte. At thirty-two the process faults instead, because by then the write has gone past approved and into the saved frame pointer.

The reordering only applies to locals in a frame, and only where the compiler is free to choose. Two things it cannot touch:

  • Struct members. C preserves their order, and the layout measured here puts approved immediately after badge. The compiler can’t move that member below the array as it did with the separate local. The till’s label and total have the same relationship: they are neighbours inside one structure, rather than independently placed locals.

  • Objects the linker places. The depot’s globals in Out-of-Bounds Reads had nothing to do with a frame, and their order came from the toolchain rather than from any protection.

So the primitive here is: a forward write of bytes you choose, starting at a fixed offset, reaching whatever the compiler or linker placed after it. The last clause is the one that varies, and it isn’t decided by the program’s author.

Knowledge check

Question 1 of 3

A sixteen-character badge leaves the struct path refused. What stops it?

  • If the struct path opens at sixteen rather than seventeen, check that memset is still zeroing the whole structure. An uninitialised approved can start non-zero and open on any input, which is a different finding worth recording rather than a mistake.
  • If your offsets differ from rbp-32 and rbp-36, use your own. The exact numbers depend on the compiler version and they’re not the point. What matters is the sign of the distance between the two.
  • If GDB reports approved as optimised out, confirm you built with -O0. At higher levels it may never reach the frame at all.
  • If the locals path opens in your default build, check gcc -Q --help=common | grep stack-protector and inspect what it reports. A toolchain that defaults the protector off will behave like the comparison build throughout.

To recover a known state, rebuild both binaries with the two commands above, keeping the output names distinct.

You have two builds of one source file, but only one lets this input change the local approval flag. Which build has the bug?

Finally, suppose the struct’s decision changed to r.approved == 1, while you kept using seventeen As. Predict whether the door would open and explain which bytes support your answer. You don’t need a new offset to answer this one.

Compare your reasoning

Both do. strcpy into a fixed buffer from unchecked input is the defect, and it is identical in the two builds. What differs is whether anything useful is within reach of it, and that’s a property of the build rather than of the code.

With the changed comparison, seventeen As still reach the flag but set it to 0x41, not 1. The write’s reach hasn’t changed, but those particular bytes no longer satisfy the decision. Reaching a field and setting the value its consumer needs are separate requirements.

Keep the build names with that comparison. A compiler upgrade, a different optimisation level, or a moved declaration can change the layout again without repairing the unchecked copy.

Every length on this page was one you chose directly, and the program used it as given.

The next lesson looks at programs that compute a length instead of being handed one, where the arithmetic is ordinary, every value stays inside the type it was declared, and the number that comes out is nothing like the one the author expected.