14

So when taking a simple scenario and exploiting a stack buffer overflow in which I can execute code on the stack, my payload simply is:

NOP Sled -> Shellcode -> Padding -> New RET Address (Pointing to the NOP Sled)

What I get is that we need to have a memory address included in the shellcode which points somewhere into the NOP Sled, which we need because we "can't be sure" what the return address is.

Why can't we be sure?

Could it be simple things such as argv[0] being different, eg. on one system the file might be "/vuln" and on another "/home/user/vuln" and for this reason the stack is already different? Do environment Variables play a role? What else is involved?

Basically my question is: Which factors lead to the stack being different every time we execute / executing the vuln program on another machine?

2 Answers 2

11

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):

  1. 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):

    -mpreferred-stack-boundary=num
    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 anint), 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.

  2. 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:

    SYS V i386 standard stack 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:

    x86 stack frame layout

Here is how all of this ties together:

  1. 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 %ebp and %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

  2. 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: The Linux Programming Interface, page 119

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:

#include <stdio.h>

int test(int a, int b)
{
        printf("%p\n", &a);
        return a*a + b*b;
}

int main(void)
{
        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 
0xffffd210
$ ./simple AAAAAAAA
0xffffd200
$ ./simple AAAAAAAAAAAAAAAA
0xffffd200
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1f0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1f0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1e0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1e0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1d0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1d0
$ ./simple AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
0xffffd1c0

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: x86 Linux Program Startup

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.


See also:


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

1

As you mentioned, many things can leave you with a different memory layout than the one you expect:

  • The name of the binary is one.
  • The environment variables the binary gets is another one.
  • If it's being debugged (related to the previous point)
  • Security measures such as stack cookies
  • Etc

Using hardcoded addresses pointing to the stack as the address that will be taken by EIP is the wrong approach. That's how it was done in the early days. Sketchy approach and no DEP/NX in place.

A more stable approach is that instead of jumping to the middle of the stack, you can directly jump to code that will get executed. There are different techniques to do so such as return-to-libc/library or ROP.

Another simpler approach is checking where your registers point in the moment when the overflow happens (actually, when the segfault is triggered). Imagine that ESP points to 0x12345678. If you have control of the contents of 0x12345678 at the moment of the segfault in a stable way, you can just use the address of a JMP ESP-kind instruction as what will be taken by EIP.

  • Why is this approach better?

Because you are certain that the binary itself or the libraries that get loaded (therefore, their instructions) when the binary is executed will always be stored in the same address. (1)

Long story short, the motto goes by... "If your exploit contains NOPs, you are doing something wrong!".

To answer the question in your title, there is no need for NOP sleds.

  1. As far as there's no ASLR or module re-base.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.