On modern systems the most obvious culprit is probably address space layout randomization, but stack frame layout variablity was problematic for exploit development even before ASLR became widely implemented. This was alluded to in AlephOne's venerable "Smashing the Stack for Fun and Profit":
The problem we are faced when trying to overflow the buffer of another
program is trying to figure out at what address the buffer (and thus our
code) will be. The answer is that for every program the stack will
start at the same address. Most programs do not push more than a few hundred or a few thousand bytes into the stack at any one time. Therefore by knowing where the stack starts we can try to guess where the buffer we are trying to overflow will be.
Trying to guess the
offset even while knowing where the beginning of the stack lives is nearly
impossible. We would need at best a hundred tries, and at worst a couple of thousand. The problem is we need to guess exactly where the address of our code will start. If we are off by one byte more or less we will just get a segmentation violation or a invalid instruction. One way to increase our chances is to pad the front of our overflow buffer with NOP instructions.
So this is an issue even without ASLR enabled. Reasons for this involve the following (as far as I know):
- compilers/compiler toolchains and the ABI
- the runtime environment
Compilers, the ABI and Stack Frame Layout
Interestingly, the code that manages the runtime stack is generated by the compiler and the compiler cannot know what the absolute positions of stack frames during program runtime will be:
While the location of a stack frame cannot be predicted at compile time (the compiler cannot in general tell what other frames may already be on the stack), the offsets of objects within a frame usually can be statically determined. Moreover, the compiler can arrange (in the calling sequence or prologue) for a particular register, known as the frame pointer to always point to a known location within the frame of the current subroutine. Code that needs to access a local variable within
the current frame, or an argument near the top of the calling frame, can do so by adding a predetermined offset to the value in the frame pointer.1
The way in which memory is allocated and deallocated for stack frames can vary across compilers, as well as different versions of the same compiler toolchain. Why this might be the case is alluded to in the System V i386 Architecture Processor Supplement (page 36):
The stack is word aligned. Although the architecture does not require any alignment of the stack, software convention and the operating system
requires that the stack be aligned on a word boundary.
By default, GCC aligns the stack to a 16-byte boundary on i386 machines (x86):
Attempt to keep the stack boundary aligned to a 2 raised to
num byte boundary. If
-mpreferred-stack-boundary is not specified, the default is 4 (16 bytes or 128 bits).
This means that even if a local variable or argument to a function is 4 bytes in length (such as an
int), at least 16 bytes of space will be allocated on the stack frame for that function. Since this is a command line argument for
gcc, it goes without saying that this can be changed.
Other areas depend on the compiler and the code being compiled. The standard calling sequence does not define a maximum stack frame size, nor does it restrict how a language system uses the ‘‘unspecified’’ area of the standard stack frame.
This "unspecified" area of a stack frame includes all of the frame except for the return address and the saved base pointer of the previous frame:
The data written to this area can include saved registers, local variables, temporaries, and arguments to the next function2. GCC will allocate memory required to store this data in a frame plus space required to maintain stack alignment to the boundary specified when the binary was compiled:
Here is how all of this ties together:
It cannot be determined prior to run time what the absolute virtual memory addresses of data on the stack will be, so indirect addressing is used instead (using
%esp to calculate offsets, for example). However, to control EIP an absolute memory address of executable instructions must be written to EIP. So now one must figure out what this address is without having absolute addresses to work with when disassembling the binary
The precise layout of a stack frame, and by extension the runtime stack, is determined by the compiler used to create the binary one seeks to exploit.
- This means that the amount of space allocated to variables such as buffers depends at the very least on the boundary to which the compiler aligns stack frames (4 bytes? 8 bytes? 16 bytes? and so on).
- This can lead to "slack space" in stack frames to which no values associated with local variables are written (contains garbage data).
- In addition to this, the compiler also determines in what order variables are laid out within the stack frame.
- Compilation with optimization can have adverse effects on calling conventions specified in the architecture-specific ABI and how stack frames are laid out
- Compilation with stack protection will further influence how data is arranged within stack frame by the compiler
In sum, the offset between the buffer being overflowed and the target return address location can vary based on compiler toolchain (GCC, TCC, MSVC, etc), compiler version (3.x, 4.x etc) and the particular options used to compile the binary (alignment value, optimization level, stack protection, etc):
The exact distance between the return address and the start of the [buffer] can change due to different compiler versions and different optimization flags. As long as the start of the buffer is aligned with DWORDs on the stack, this mutability can be accounted for by simply repeating the return address many times. This way, at least one of the instances will overwrite the return address, even if it has shifted around due to compiler optimizations.3
The Runtime Environment
It has been suggested elsewhere that stack layout indeterminicy can be caused by environmental variables and program arguments:
Exploit development can lead to serious headaches if you don't adequately account for factors that introduce non-determinism into the debugging process. In particular, the stack addresses in the debugger may not match the addresses during normal execution. This artifact occurs because the operating system loader places both environment variables and program arguments before the beginning of the stack
This makes sense if we look at how the stack is laid out in virtual memory:
If there are differences across executions in the environment or arguments, the location of the base of the stack may change as well.
To show that this is not simply idle speculation, we can look at a concrete example. First, we disable ASLR like so:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
Then we write a quick program in C:
int test(int a, int b)
return a*a + b*b;
return test(3, 5);
When compiled and then run a few times with steadily increasing argument lengths, it is observed that the position on the stack of one of the local variables changes to lower and lower in virtual memory:
$ gcc -m32 simple.c -o simple
$ ./simple AAAAAAAA
$ ./simple AAAAAAAAAAAAAAAA
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAA
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
This indicates that changing the sizes of the arguments passed to
execve also changes the position of the runtime stack in virtual memory. The weakness of this extremely trivial example is that it does not accurately reflect the complexity of real-world programs or capture how the runtime stack changes throughout execution of more complex processes.
An additional variable influencing the layout of the runtime stack is whether the binary is dynamically linked or not. If it is dynamically linked, process startup is handled by
glibc. On x86 Linux systems the C runtime library function call sequence looks similar to this:
This will depend on what version of
glibc the binary is linked against, and the runtime environment will vary across machines. This means that the function call sequence during process startup will vary across systems as well.
1. Scott, Michael L. Programming Language Pragmatics. 3rd ed. Page 117
2. Bryant, O'Hallaron. Computer Systems: A Programmer's Perspective. 2nd ed. Page 220
3. Erickson, Jon. Hacking: The Art of Exploitation. 2nd ed. Page 136