Restricted shellcode, limited characters and whitelist seccomp.
Last updated
Problem Description
I want to get the flag file with a shellcode but the char is limited :(.
Author: farisv
Analysis
We are given two binaries, called runner and sandbox. Checking them in checksec shows that both of them has full protection.
We're also given a Dockerfile, which shows an important information: the flag's file name on the server. The flag file name is unusually long, so this could potentially be a part of the challenge.
long lVar1;
lVar1 = ptrace(PTRACE_TRACEME,0,0,0);
if (lVar1 < 0) {
fwrite("Error ptrace\n",1,0xd,stderr);
}
else {
execl(param_1,param_1,0);
}
return;
sandbox.run_debugger()
long lVar1;
long in_FS_OFFSET;
uint local_100;
int local_fc;
int local_f8;
__pid_t local_f4;
int local_f0;
int local_ec;
undefined local_e8 [120];
ulong local_70;
undefined8 local_10;
local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);
local_f4 = waitpid(param_1,(int *)&local_100,0);
if ((local_f4 == -1) || ((local_100 & 0xff) != 0x7f)) {
kill_process(param_1);
/* WARNING: Subroutine does not return */
exit(1);
}
lVar1 = ptrace(PTRACE_SETOPTIONS,(ulong)param_1,0,1);
local_f0 = (int)lVar1;
if (local_f0 != 0) {
kill_process(param_1);
/* WARNING: Subroutine does not return */
exit(1);
}
while( true ) {
ptrace(PTRACE_SYSCALL,(ulong)param_1,0,0);
local_f4 = waitpid(param_1,(int *)&local_100,0);
if ((local_f4 == -1) || (local_f4 == 0)) {
puts("[Sandbox] Error waitpid");
kill_process(param_1);
/* WARNING: Subroutine does not return */
exit(1);
}
if ((local_100 & 0x7f) == 0) goto LAB_001016a4;
if ('\0' < (char)(((byte)local_100 & 0x7f) + 1) >> 1) {
puts("[Sandbox] Unhandled signal");
goto LAB_001016a4;
}
if ((local_100 & 0x80) != 0) {
puts("[Sandbox] Runtime Error");
goto LAB_001016a4;
}
if ((local_100 & 0xff) != 0x7f) goto LAB_001016a4;
if (((int)local_100 >> 8 & 0xffU) < 0x21) break;
ptrace(PTRACE_GETREGS,(ulong)param_1,0,local_e8);
local_ec = (int)local_70;
local_fc = 0;
for (local_f8 = 0; local_f8 < syscall_len; local_f8 = local_f8 + 1) {
if (local_ec == *(int *)(syscall_numbers + (long)local_f8 * 4)) {
local_fc = 1;
}
}
if (local_fc == 0) {
fprintf(stderr,"[Sandbox] Forbidden syscall (%d) detected. Kill the program.",
local_70 & 0xffffffff);
kill_process(param_1);
LAB_001016a4:
/* WARNING: Subroutine does not return */
exit(0);
}
}
if ((local_100 & 0xff00) == 0xb00) {
puts("[Sandbox] Segmentation Fault");
}
else if ((local_100 & 0xff00) == 0x400) {
puts("[Sandbox] Illegal Instruction");
}
else if ((local_100 & 0xff00) == 0x700) {
puts("[Sandbox] Bus Error");
}
From the decompilation, the program's main (intended) flow is pretty straightforward:
runner is run from sandbox (in simpler terms, ./sandbox ./runner). Sandbox acts as a wrapper to "monitor" runner's process.
runner asks for shellcode with a maximum of 700 characters (175 bytes).
From the shellcode, sandbox decides whether the syscalls from the shellcode are all allowed or not. If a syscall isn't on the whitelist, the process gets killed. Otherwise, the shellcode gets executed.
For the inputted shellcode itself, there are two limitations: from length (only 700 chars/175 bytes allowed) and seccomp, which keywords can be seen throughout sandbox's run_debugger. The binary doesn't use the usual seccomp library, so we can't use seccomp-tools. Because of that, we need to do a manual analysis to find what syscalls are being banned. In run_debugger, we have this.
There are global variables syscall_numbers and syscall_len. Looking at the loop, it goes through the syscall numbers and sets local_fc to 1 if the current syscall number being examined from the shellcode matches any of the iterated. From here, we know that syscall_numbers contains allowed syscalls instead of banned syscalls. From syscall_len, we know that there are 20 allowed syscalls, and we can see them using Ghidra.
After mapping the syscall numbers to its syscall operation, here are the allowed syscalls:
0x0 (read)
0x1 (write)
0x2 (open)
0x3 (close)
0x4 (stat)
0x5 (fstat)
0x9 (mmap)
0xa (mprotect)
0xb (munmap)
0xc (brk)
0x11 (rt_sigaction)
0x15 (access)
0x3c (exit)
0x9e (arch_prctl)
0xda (set_tid_address)
0x101 (openat)
0x111 (set_robust_list)
0x12e (prlimit64)
0x13e (getrandom)
0x14e (rseq)
Solution Breakdown
From here, it's safe to say that the intention of the problemsetter was to make us perform open-read-write (ORW) to read the flag file from the server, since get shell syscalls like execve aren't being whitelisted but ORW shellcodes are. But, as previously mentioned, the flag file name is unusually long. Now, the challenge is how can we craft a <= 175 bytes shellcode that writes the long flag file name into memory and still fits the entirety of the ORW shellcode?
Initial Shellcode Crafting
Initially, I came up with this assembly code, soon-to-be converted to shellcode:
Yes, this is ridiculously inefficient because initially, I forgot loops exist in assembly. It does the job though: writes the long flag file name into memory, stores it to the stack, then pointed by rdi, then used to perform ORW. But it exceeds the limitations mentioned, because apparently the shellcode is more than 175 bytes long.
Shellcode Optimization
We can optimize this assembly in two ways:
No need to mov rax, 0x6767...before every mov [rsp+i], raxinstructions. The raxregister content stays the same, so no need to move the exact same value repeatedly to the exact same register.
This gives us just 142 bytes of shellcode, which is more than enough! Compiling the assembly to shellcode, then converting it to the bytes format (\x prefix) and sending it to the server gives us the flag.