Last updated at Tue, 28 Nov 2023 18:20:42 GMT

What are buffer overflow attacks?

Stack-based buffer overflow exploits are likely the shiniest and most common form of exploit for remotely taking over the code execution of a process. These exploits were extremely common 20 years ago, but since then, a huge amount of effort has gone into mitigating stack-based overflow attacks by operating system developers, application developers, and hardware manufacturers, with changes even being made to the standard libraries developers use. Below, we will explore how stack-based overflows work and detail the mitigation strategies that are put in place to try to prevent them.

Deep dive on stack-based buffer overflow attacks

Understanding stack-based overflow attacks involves at least a basic understanding of computer memory. Memory in a computer is simply a storage place for data and instructions—data for storing numbers, letters, images, and anything else, and instructions that tell the computer what to do with the data. Both are stored in the same memory because memory was prohibitively expensive in the early days of computing, and reserving it for one type of storage or another was wasteful. Such an approach where data and instructions are stored together is known as a Von Neumann architecture. It’s still in use in most computers to this day, though as you will see, it is not without complications.

On the bright side, while security was not a driving factor in early computer and software design, engineers realized that changing running instructions in memory was a bad idea, so even as long ago as the ‘90s, standard hardware and operating systems were doing a good job of preventing changes to instructional memory. Unfortunately, you don’t really need to change instructions to change the behavior of a running program, and with a little knowledge, writeable data memory provides several opportunities and methods for affecting instruction execution.

Take this particularly contrived example:

#include <signal.h>
#include <stdio.h>
#include <string.h>
int main(){
	char realPassword[20];
	char givenPassword[20];

	strncpy(realPassword, "ddddddddddddddd", 20);
	gets(givenPassword);
	
	if (0 == strncmp(givenPassword, realPassword, 20)){
		printf("SUCCESS!\n");
	}else{
		printf("FAILURE!\n");
	}
	raise(SIGINT);
	printf("givenPassword: %s\n", givenPassword);
	printf("realPassword: %s\n", realPassword);
	return 0;
}

If you don’t know the C programming language, that’s fine. The interesting thing about this program is that it creates two buffers in memory called realPassword and givenPassword as local variables. Each buffer has space for 20 characters. When we run the program, space for these local variables is created in-memory and specifically stored on the stack with all other local variables (and some other stuff). The stack is a very structured, sequential memory space, so the relative distance between any two local variables in-memory is guaranteed to be relatively small. After this program creates the variables, it populates the realPassword value with a string, then prompts the user for a password and copies the provided password into the givenPassword value. Once it has both passwords, it compares them. If they match, it prints “SUCCESS!” If not, it prints “FAILURE!”

Here’s an example run:

msfuser@ubuntu:~$ ./example.elf 
test
FAILURE!
givenPassword: test
realPassword: ddddddddddddddd

This is exactly as we’d expect. The password we entered does not match the expected password. There is a catch here: The programmer (me) made several really bad mistakes, which we will talk about later. Before we cover that, though, let’s open a debugger and peek into memory to see what the stack looks like in memory while the program is executing:

msfuser@ubuntu:~$ gdb example.elf
.
.
.
(gdb) run
Starting program: /home/msfuser/example.elf 
aaaaaaaaaaaaaaaa
FAILURE!

Program received signal SIGINT, Interrupt.
0x00007ffff7a42428 in __GI_raise (sig=2) at ../sysdeps/unix/sysv/linux/raise.c:54
54	../sysdeps/unix/sysv/linux/raise.c: No such file or directory.
(gdb)

At this point, the program has taken in the data and compared it, but I added an interrupt in the code to stop it before exiting so we could “look” at the stack. Debuggers let us see what the program is doing and what the memory looks like on a running basis. In this case, we are using the GNU Debugger (GDB). The GDB command ‘info frame’ allows us to find the location in memory of the local variables, which will be on the stack:

(gdb) info frame
Stack level 0, frame at 0x7fffffffdde0:
 rip = 0x7ffff7a42428 in __GI_raise (../sysdeps/unix/sysv/linux/raise.c:54); saved rip = 0x400701
 called by frame at 0x7fffffffde30
 source language c.
 Arglist at 0x7fffffffddd0, args: sig=2
 Locals at 0x7fffffffddd0, Previous frame's sp is 0x7fffffffdde0
 Saved registers:
  rip at 0x7fffffffddd8
(gdb) 

Now that we know where the local variables are, we can print that area of memory:

(gdb) x/200x 0x7fffffffddd0
0x7fffffffddd0:	0x00000000	0x00000000	0x00400701	0x00000000
0x7fffffffdde0:	0x61616161	0x61616161	0x61616161	0x61616161
0x7fffffffddf0:	0x00000000	0x00000000	0x00000000	0x00000000
0x7fffffffde00:	0x64646464	0x64646464	0x64646464	0x00646464
0x7fffffffde10:	0x00000000	0x00007fff	0x00000000	0x00000000
.
.
.

As mentioned, the stack is sequentially stored data. If you know ASCII, then you know the letter ‘a’ is represented in memory by the value 0x61 and the letter ‘d’ is 0x64. You can see above that they are right next to each other in memory. The realPassword buffer is right after the givenPassword buffer.

Now, let’s talk about the mistakes that the programmer (me) made. First, developers should never, ever, ever use the gets function because it does not check to make sure that the size of the data it reads in matches the size of the memory location it uses to save the data. It just blindly reads the text and dumps it into memory. There are many functions that do the exact same thing—these are known as unbounded functions because developers cannot predict when they will stop reading from or writing to memory. Microsoft even has a web page documenting what it calls “banned” functions, which includes these unbounded functions. Every developer should know these functions and avoid them, and every project should automatically audit source code for them. These functions all date from a period where security was not as imperative as it is today. These functions must continue to be supported because pulling support would break many legacy programs, but they should not be used in any new programs and should be removed during maintenance of old programs.

Taking a look at the hack

We have looked at the stack, noticed that the buffers are located consecutively in memory, and talked about why gets is a bad function. Let’s now abuse gets and see whether we can hack the planet program. Since we know gets has a problem with reading more than it should, the first thing to try is to give it more data than the buffer can hold. The buffers are 20 characters, so let’s start with 30 characters:

msfuser@ubuntu:~$ gdb example.elf
.
.
.
(gdb) run
Starting program: /home/msfuser/example.elf 
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
FAILURE!
givenPassword: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
realPassword: ddddddddddddddd

Program received signal SIGINT, Interrupt.
0x00007ffff7a42428 in __GI_raise (sig=2) at ../sysdeps/unix/sysv/linux/raise.c:54
54	../sysdeps/unix/sysv/linux/raise.c: No such file or directory.
(gdb) info frame
Stack level 0, frame at 0x7fffffffdde0:
 rip = 0x7ffff7a42428 in __GI_raise (../sysdeps/unix/sysv/linux/raise.c:54); saved rip = 0x40072d
 called by frame at 0x7fffffffde30
 source language c.
 Arglist at 0x7fffffffddd0, args: sig=2
 Locals at 0x7fffffffddd0, Previous frame's sp is 0x7fffffffdde0
 Saved registers:
  rip at 0x7fffffffddd8
(gdb) x/200x 0x7fffffffddd0
0x7fffffffddd0:	0x00000000	0x00000000	0x0040072d	0x00000000
0x7fffffffdde0:	0x61616161	0x61616161	0x61616161	0x61616161
0x7fffffffddf0:	0x61616161	0x61616161	0x61616161	0x00006161
0x7fffffffde00:	0x64646464	0x64646464	0x64646464	0x00646464
0x7fffffffde10:	0x00000000	0x00007fff	0x00000000	0x00000000
0x7fffffffde20:	0x00400740	0x00000000	0xf7a2d830	0x00007fff
0x7fffffffde30:	0x00000000	0x00000000	0xffffdf08	0x00007fff

We can see clearly that there are 30 instances of ‘a’ in memory, despite us only specifying space for 20 characters. We have overflowed the buffer, but not enough to do anything. Let’s keep trying and try 40 instances of ‘a.’

msfuser@ubuntu:~$ gdb example.elf
.
.
.
(gdb) run
Starting program: /home/msfuser/example.elf 
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
FAILURE!
givenPassword: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
realPassword: aaaaaaaa

Program received signal SIGINT, Interrupt.
0x00007ffff7a42428 in __GI_raise (sig=2) at ../sysdeps/unix/sysv/linux/raise.c:54
54	../sysdeps/unix/sysv/linux/raise.c: No such file or directory.
.
.
.
(gdb) x/200x 0x7fffffffddd0
0x7fffffffddd0:	0x00000000	0x00000000	0x0040072d	0x00000000
0x7fffffffdde0:	0x61616161	0x61616161	0x61616161	0x61616161
0x7fffffffddf0:	0x61616161	0x61616161	0x61616161	0x61616161
0x7fffffffde00:	0x61616161	0x61616161	0x64646400	0x00646464
0x7fffffffde10:	0x00000000	0x00007fff	0x00000000	0x00000000
0x7fffffffde20:	0x00400740	0x00000000	0xf7a2d830	0x00007fff

The first thing to notice is that we went far enough to pass through the allotted space for givenPassword and managed to alter the value of realPassword, which is a huge success. We did not alter it enough to fool the program, though. Since we are comparing 20 characters and we wrote eight characters to the realPassword buffer, we need to write 12 more characters. So, let’s try again, but with 52 instances of ‘a’ this time:

msfuser@ubuntu:~$ gdb example.elf
.
.
.
(gdb) run
Starting program: /home/msfuser/example.elf 
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SUCCESS!
givenPassword: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
realPassword: aaaaaaaaaaaaaaaaaaaa

Program received signal SIGINT, Interrupt.
0x00007ffff7a42428 in __GI_raise (sig=2) at ../sysdeps/unix/sysv/linux/raise.c:54
54	../sysdeps/unix/sysv/linux/raise.c: No such file or directory.
(gdb) info frame
Stack level 0, frame at 0x7fffffffdde0:
 rip = 0x7ffff7a42428 in __GI_raise (../sysdeps/unix/sysv/linux/raise.c:54); saved rip = 0x40072d
 called by frame at 0x7fffffffde30
 source language c.
 Arglist at 0x7fffffffddd0, args: sig=2
 Locals at 0x7fffffffddd0, Previous frame's sp is 0x7fffffffdde0
 Saved registers:
  rip at 0x7fffffffddd8
(gdb) x/200x 0x7fffffffddd0
0x7fffffffddd0:	0x00000000	0x00000000	0x0040072d	0x00000000
0x7fffffffdde0:	0x61616161	0x61616161	0x61616161	0x61616161
0x7fffffffddf0:	0x61616161	0x61616161	0x61616161	0x61616161
0x7fffffffde00:	0x61616161	0x61616161	0x61616161	0x61616161
0x7fffffffde10:	0x61616161	0x00007f00	0x00000000	0x00000000

Success! We overflowed the buffer for givenPassword and the data went straight into realPassword, so that we were able to alter the realPassword buffer to whatever we wanted before the check took place. This is an example of a buffer (or stack) overflow attack. In this case, we used it to alter variables within a program, but it can also be used to alter metadata used to track program execution.

Altering metadata



Using stack overflow attacks against program metadata to affect code execution is not much different than the above example. The key is understanding the concept of a return value. Like us, computers do a lot of things at once and will stop working on one thing to do another before returning to the original task. When the computer executes instructions located somewhere else in the instruction memory, it stores a note of where it was before it starts executing so that it knows where to return when it finishes the new task. That note—called the return address—is simply the address in instructional memory where it returns and starts executing instructions.

The computer is brilliant, and if you can change the value of the return address, you can send it wherever you like. Exploits will often write the instructions in the same buffer they overflow and then point execution back to the buffer itself, which allows an attacker to hand a program code and then force it to execute the code.

One caveat is that none of these examples will work on remotely modern operating systems anymore. Operating system developers, application developers, hardware engineers, and even compilers have all reacted and made performing stack overflow attacks much harder.

What’s being done to mitigate these exploits?

It has been nearly 20 years since the heyday of stack overflow attacks, and there are a lot of protections in place that prevent them from working as well now as they did back then. Some of these protections include stack canaries, Address Space Layout Randomization (ASLR), compiler warnings, and hardware changes to prevent execution of code on the stack. (Side note: For a historical discussion on ASLR on Windows, see this most excellent Twitter thread by John Lambert.)

First and foremost, the best defense against stack-based overflow attacks is the use of secure coding practices—mostly through stopping the use of functions that allow for unbounded memory access and carefully calculating memory access to prevent attackers from modifying adjacent values in memory. Quite simply, if attackers can only access the memory of the variable they intend to change, they cannot affect code execution beyond the expectations of the developer and architect.

Unfortunately, there are thousands of programs that implemented the unsafe, unbounded functions to access memory, and recoding all of them to meet secure coding practices is simply not feasible. For those legacy programs, operating system manufacturers implemented several mitigations to prevent poor coding practices that result in arbitrary code execution. We can see this in action somewhat in our example by toggling the protections and pushing further in our overflow.

One quick change that compilers made in the immediate aftermath of the stack-based attacks was starting to include protections on important pieces of data, such as return addresses. Since most stack overflow attacks involved overflowing one data location and writing to another, the compiler placed a sacrificial known value between buffers and important data, then the program would check to see whether the sacrificial value had been changed before using the important data. If that value had been changed, it was likely that the important data was also altered, so execution would stop immediately. Since a change in these sacrificial values could be determined before malicious code execution would start, the values are known as “canaries.” If the canary was disturbed, exception code was executed and the program terminated.

Now, stack canaries, by themselves, aren’t bulletproof, since there are a few ways to bypass them. One method is by finding the canary value through an unbounded read of memory or guessing. In some cases, canary values are static and predictable. Once attackers know the canary value, they can replace it in the overwrite. For this reason, canaries often contain characters that are difficult to send, such as “enter” (\x0a) or “vertical tab” (\x0b).“enter” While a challenge for the attacker, this reduces the entropy of the canary value and makes them easier to find in memory.

To bypass the canary stack protections using the GNU Compiler Collection (GCC), upi must specific that you want the protections turned off, with the flag ‘‘-fno-stack-protection.’

To demonstrate, let’s compile the program without protections and pass it a large buffer. In this case, I am using a small inline perl script to generate a series of 90 instances of ‘a’ and pass that into the program example.elf:

msfuser@ubuntu:~$ gcc -o example.elf -fno-stack-protector overwrite.c 
.
.
.
msfuser@ubuntu:~$ perl -e 'print "a"x90' | ./example.elf 
SUCCESS!
givenPassword: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
realPassword: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Segmentation fault (core dumped)

This resulted in a program crash, which is expected when memory structures are corrupted with bad data. This is likely the result of overwriting the return value, and then the processor crashing when trying to access the new memory. If we’d overwritten the location with somewhere that the CPU could access, it would have been happy to do so.

Now let’s redo the experiment, but without disabling the gcc stack protections:

msfuser@ubuntu:~$ gcc -o example.elf overwrite.c 
overwrite.c: In function ‘main’:
.
.
.
msfuser@ubuntu:~$ perl -e 'print "a"x90' | ./example.elf 
FAILURE!
givenPassword: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
realPassword: ddddddddddddddd
*** stack smashing detected ***: ./example.elf terminated
Aborted (core dumped)
msfuser@ubuntu:~$ 

Changes to hardware and operating systems took longer, but they did happen. One of the first mitigations introduced by hardware and operating system vendors was the NX, or no-execute bit. On Windows, this was known as Data Execution Prevention (DEP). It allowed operating systems to define certain areas of memory as non-executable, and when flagged as such, the CPU would simply not execute that memory. In theory, there should never be executable code on the stack, as it is designed for storing data values only. Based on that understanding, operating systems classified the stack as non-executable, preventing arbitrary code from being placed on the stack and executed.

In addition to bypasses for this mitigation, it quickly became apparent that despite being a poor practice, multiple legitimate programs placed instructions on the stack and executed them, and NX broke them all. That forced operating systems to allow some programs to opt out of the protection, and those programs were well-known to hackers and continued to be targeted. Aside from those programs that opted out, the most common bypass for NX was through the use of return-oriented programming (ROP), which leverages pre-existing code in instructional memory to perform desired tasks. Most programs use common sets of code to perform tasks, and ROP leverages this common code to perform a desired task. Sometimes, attackers set up execution of several sections of code across multiple libraries in a process known as ROP chaining. Since the code the attacker needed was already present in instructional memory, there was no need to place it on the stack for execution.

In an effort to stop ROP-based attacks, operating systems started to randomize the location of instructional memory to prevent attackers from knowing where desired code was stored. That randomization of instructional memory is called ASLR, which shuffles blocks of memory and makes it so that the location of a given object (including code) in memory is no longer a constant value. An attack that works once may not work again, as the code the attacker tried to execute might no longer be there, causing unpredictable results.

While effective, ASLR is constrained because, like NX, not every piece of instructional memory responds well to moving, so some code must opt out of the protection. Even for code that can handle ASLR, there are bypasses. The most common bypass leverages the limitation that the memory can only be randomized in blocks. If there is a way to determine where a block of memory is, an attacker can calculate the location of the desired memory from the leaked value. Unfortunately, since ASLR was not something that was baked into operating systems, they sometimes store the randomized location of something important in a known place, not unlike an employee choosing a good password but putting it on a Post-It note under their keyboard. Such a “cheat” by the operating system allows attackers to determine the location of a known object in memory, and then based on its location, they can calculate the location of the desired code or object. Again, just like NX, ASLR does not completely prevent an attack, but it does make attacks harder and less predictively successful.

In conclusion

It would be nice to say that stack-based overflow attacks are gone due to the mitigation strategies in place, but that is simply not the case. Stack-based attacks might not be as common today, but they do exist. Due to the large size of operating system vendors, it is unlikely that a stack-based attack exists in Windows or Linux anymore, but smaller groups that pay less attention to security still release vulnerable code—and not every vulnerability can be mitigated by the operating system.