IC00AJ74 · Week 3

Shellcoding and exploits

Study memory corruption, shellcode construction, and code-reuse attacks through practical exploitation.
4 activities
4 readings

Pre-requisites

This exercise requires a deep understanding of how the computer stack works, how it manages the underlying memory, and the basics of assembly language.

Before starting the exercise, it is recommended to read the first two chapters of the book “Low-Level Software Security for Compiler Developers” 1 and the paper “Smashing The Stack For Fun And Profit” 2.

Some of those concepts are also summarised here.

We only cover the Linux operating system in this exercise, although many similarities can also be found with other modern operating systems.

Background

We often see references to memory errors, and we might have encountered them ourselves while programming in a systems programming language, especially C or C++. Usually, you see a Segmentation fault or some other undefined behavior 3 when you encounter one.

We fuzz test software specifically to find memory errors. Why is this a big deal? Historically, memory bugs have caused many security disasters. In the worst case, memory bugs can be used to manipulate the execution flow of the program, allowing arbitrary code execution or reads of unauthorized memory.

The first documented case of such misuse dates back to 1988 4. The techniques were brought to public attention by Aleph One in his 1996 publication “Smashing The Stack For Fun And Profit” 2. Surveys published by Microsoft and Google in 2019 suggest that around 70% of the security bugs fixed in Microsoft products and in the Chrome browser are memory bugs 56. Memory-safety weaknesses have also ranked at the very top of MITRE’s CWE Top 25 for years: Out-of-bounds Write was the most dangerous software weakness in 2023 7, and although it had dropped to fifth place by 2025, Stack-based Buffer Overflow - the very weakness we exploit in this exercise - entered the 2025 list at rank 14 8.

In this exercise, we will examine the practical implications of memory bugs at a technical level and explore how they have been exploited, particularly through the technique of shellcoding.

As a primary theoretical source, we use the online book “Low-Level Software Security for Compiler Developers” 1.

Introduction

Below is a summary of memory errors and their dangers. If you are already familiar with these topics, or have read the previously mentioned book and understood it, you can go directly to the task assignments.

Collapsed content

What is a memory error?

Memory access errors describe memory accesses that, although permitted by a program, were not intended by the programmer. 1

Memory access errors are often defined 9 as:

  • buffer overflow
  • null pointer dereference
  • use after free
  • use of uninitialized memory
  • illegal free

The software is memory-safe if these errors never occur.

There are usually two main reasons why dangerous memory bugs are possible.

  • The software takes user-defined input
  • This input is neither validated nor sanitized, so the program flow can be controlled with the input in ways that were originally unintended

This input validation and sanitization is one of the major challenges in software development. You must ensure that every unintended effect of the user-defined input is either prevented or handled.

You want the user to provide a name that is 15 characters long at maximum. What if they provide 20 characters???

Buffer overflows

If your program does not handle input longer than 15 characters as in the previous example, a so-called buffer overflow can happen — as long as the programming language does not add a boundary check automatically.

This error is usually the most dangerous type. MITRE’s top weakness of 2023 (out-of-bounds write) 7 belongs to this category.

To understand why, we need to understand how a computer works at the stack level and what the principles of programming languages have to do with it.

The fundamental philosophy of C programming is to “trust the programmer”. Do not prevent the programmer from doing what needs to be done. The programmer has ultimate control, but also ultimate responsibility. This means that they must also use memory correctly.

In the naive example below, the compiler reserves 15 bytes of stack space for the name variable.

This means that a name of at most 14 characters (plus the null terminator \x00) can fit into this buffer. The programmer should know that the null terminator also takes up space.

#include <stdio.h>

int main() {
    char name[15];

    printf("Please enter your name: ");
    scanf("%s", name);

    printf("Hello, %s!\n", name);
    return 0;
}

Since the compiler trusts the programmer, the program only does what it is programmed to do; in this case it does not check the boundaries of the buffer.

If the end user provides input longer than 14 characters, the buffer overflows and writes into the memory area that was not reserved for it.

In practice, a software buffer overflow means that the space reserved for the data is insufficient for the data being stored.

“Buffer overflows are Mother Nature’s little reminder of that law of physics that says: if you try to put more stuff into a container than it can hold, you’re going to make a mess.” 10

Conversely, a buffer over-read means that a read operation may read more than it should.

We mainly focus on stack buffer overflows in this exercise.

If you are curious about how memory in the heap works, take a look at this page 11.

Understanding the stack

The computer stack is like a stack of books.

  1. You can only add (push) or remove (pop) a book at the top (also known as FILO: first in, last out).
  2. It’s used to keep track of operations like function calls: when a function starts, its details are added (pushed) to the stack, and when it ends, they are removed (popped).
  3. If the pile of books grows past the edge of the table, the whole pile collapses. Something similar happens in a program when the stack grows past its limit — that is a stack overflow. A stack buffer overflow is a different (and more interesting) bug: a single buffer on the stack is written past its own end, which is the vulnerability we are after in this exercise.

When an application runs, it uses the stack and registers to manage the program’s execution flow. The stack is split into frames, each holding the data of a function that has not yet returned. A frame stores the function’s arguments, its local variables, the return address, and more. For instance, a program with three nested function calls generates three stack frames.

Below is a simplified example of the stack of a 32-bit program, where funcA() is called first and then calls funcB().

Memory Address Content Description
0xffbfe14c Local Variable of funcA() A local variable from funcA()
0xffbfe148 Local Variable of funcA() Another local variable from funcA()
0xffbfe144 Return Address for funcA() The return address after funcA() completes
0xffbfe140 EBP for funcA() Base pointer (EBP) for funcA()
0xffbfe13c Local Variable of funcB() A local variable from funcB()
0xffbfe138 Return Address for funcB() The return address after funcB() completes
0xffbfe134 EBP for funcB() Base pointer (EBP) for funcB()
… … …

Dangers of the overflow

While the stack grows towards lower memory addresses, an overflowing local variable writes towards higher memory addresses. See the illustration below.

|---------------------|
| Return Address      |  <-- Higher Memory Address
|---------------------|
| Saved Base Pointer  |
|---------------------|
| Local Variable 1    |
|---------------------|
| Array (e.g., char)  |  <-- End of local array
| (Variable 2)|
|                     |
|---------------------|  <-- Start of local array
| Local Variable 2    |
|---------------------|
| ...                 |  <-- Stack Pointer (Lower Memory Address)
|---------------------|

When the data written into the array (Variable 1) exceeds the space allocated for it, it overwrites the adjacent memory regions, which are exactly the ones that control the program’s execution flow!

If an attacker successfully overwrites the return address, they can dictate where the program resumes execution next. If the manipulated return address points to a location containing malicious instructions, the program will unwittingly execute that code.

In earlier eras, many compilers lacked mechanisms to detect or prevent such overflows. Consequently, these vulnerabilities have sometimes led to arbitrary code execution.

For more information, read the section 2.3, “Stack buffer overflows”, in “Low-Level Software Security for Compiler Developers” 1.

Shellcoding

The term “shellcoding” comes from the scenario in which these memory bugs are exploited in such a way that they end up opening the computer’s shell.

Manipulating the execution flow of a vulnerable program can potentially result in privilege escalation. A vulnerable program running with system-level privileges might unintentionally run arbitrary code with those elevated rights. Historically, exploiting a setuid program to launch a shell gave the attacker a shell with that program’s elevated permissions. Modern UNIX systems make this harder — the kernel ignores the setuid bit on interpreted scripts, and hardening measures such as privilege dropping and nosuid mounts reduce the number of useful targets — but the setuid bit is still honoured on binaries, so the risk has not disappeared.

Acquiring shell access this way usually leads to full control of the system, which is why spawning a shell is one of the most common goals of attackers.


General tips

In most cases you need to use C or C++ to create a program with a buffer overflow vulnerability.

The tasks can be done with either 32-bit or 64-bit machine instructions, as long as the machine supports them. Use the -m32 flag with gcc to compile for 32-bit.

You must use emulation on ARM-based host machine!

To enable 32-bit support for Arch Linux, uncomment or add the following lines in /etc/pacman.conf:

[multilib]
Include = /etc/pacman.d/mirrorlist

Then install the 32-bit development packages for gcc:

pacman -Sy multilib-devel

On Debian-based systems (e.g. Kali Linux), install the following packages:

sudo apt-get install gcc-multilib g++-multilib

The implementation differs between versions and can be more challenging. Using 32-bit binaries is recommended, since more examples are available for them.

On some distributions Task 3A may not be possible, because ASCII Armoring is in place.

Encoding matters a great deal in these tasks. Python 2’s print statement wrote raw bytes, whereas print() in Python 3 writes a str, which is encoded with the encoding of stdout (UTF-8 by default) as it leaves the program. Note that Python 2 reached end of life in 2020, so use Python 3.

The following external tools are used in the tasks:

  • radare2 - advanced disassembler and forensics tool
  • pwntools - controlled generation and execution of payloads

Mitigation

You should be aware of the following Linux protections. You can find most of them in the book 1.

  • Stack canaries (SSP)

    • -fno-stack-protector gcc compiler flag to disable
  • Non-executable pages or stacks (NX)

    • -z execstack gcc compiler flag to disable
  • Address Space Layout Randomization (ASLR)

    • To disable globally: echo 0 > /proc/sys/kernel/randomize_va_space
  • Less known, no need to note unless specified in the task: ASCII Armoring, RELRO, PIE, _FORTIFY_SOURCE, PTR_MANGLE

Disable them if a task requires it.

In the later tasks we try to bypass some of them; those tasks specifically tell you not to disable them.

Since GCC 14, a single option, -fhardened, applies multiple protections at once 12:

-D_FORTIFY_SOURCE=3 (=2 instead when glibc is older than 2.35)
-D_GLIBCXX_ASSERTIONS
-ftrivial-auto-var-init=zero
-fPIE -pie -Wl,-z,relro,-z,now
-fstack-protector-strong
-fstack-clash-protection
-fcf-protection=full (x86 GNU/Linux only)

The exact set of flags may change between GCC releases, and -fhardened only enables an option if it was not already given on the command line. Check gcc --help=hardened for your compiler.

Control-flow integrity

Modern processors and compilers apply even more complex mitigations to prevent shellcoding. Code-reuse techniques (ROP, JOP) are mitigated by a technique called control-flow integrity 13. Usually this is implemented either with authentication tags on return addresses or by maintaining a shadow stack, which is compared against the return addresses stored on the real stack. However, these can still be bypassed — for example, if an adversary somehow obtains the private key. Check the book for more details.

Different companies use different names for it: Intel calls it Control-flow Enforcement Technology (CET) 14, Microsoft calls it Control Flow Guard (CFG) 15, and ARM calls it Pointer Authentication Code (PAC) 16.

These protections come with a performance cost, which is one reason why they are also implemented in hardware.

Footnotes

  1. Low-Level Software Security for Compiler Developers ↩ ↩2 ↩3 ↩4 ↩5

  2. Smashing The Stack For Fun And Profit ↩ ↩2

  3. Undefined behavior ↩

  4. The Internet Worm of 1988 ↩

  5. A proactive approach to more secure code ↩

  6. Memory safety ↩

  7. 2023 CWE Top 25 Most Dangerous Software Weaknesses ↩ ↩2

  8. 2025 CWE Top 25 Most Dangerous Software Weaknesses ↩

  9. SoK: Eternal War in Memory ↩

  10. 2009 CWE/SANS Top 25 Most Dangerous Programming Errors ↩

  11. Memory Allocation ↩

  12. RFC: Introduce -fhardened to enable security-related flags ↩

  13. Control-flow Integrity ↩

  14. A Technical Look at Intel’s Control-flow Enforcement Technology ↩

  15. Control Flow Guard for platform security ↩

  16. Pointer Authentication on ARMv8.3 ↩