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:
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:
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.
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.
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
:
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 registradorfs
, 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.
Importante: É preciso atenção ao campo cnt
da estrutura _IO_lock_t
. Se ele for zerado ou manipulado incorretamente, os demais valores podem ser sobrescritos ou reconfigurados pela glibc, corrompendo nossa string. Felizmente, isso pode ser contornado simplesmente atribuindo um valor diferente de 1 a esse campo.
Vamos considerar o seguinte programa vulnerável:
Com base nesse código, podemos obter acesso a um terminal interativo com o seguinte script:
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.
Importante: Não existe um padrão fixo entre os offsets da TLS e da libc. Isso significa que o script pode funcionar localmente, mas falhar em um ambiente remoto devido a variações nos endereços de memória.
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
: recebeNULL
caso o campocnt
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:
Com base nesse código, podemos vazar um endereço da TLS usando o seguinte script:
Importante: Esse script só é funcional nas versões da libc entre 2.30 e 2.36. Em versões anteriores, o valor do registrador RDI só é atualizado em situações específicas. Já em versões mais recentes, uma verificação adicional foi implementada. Ainda assim, há formas alternativas de explorar esse comportamento, como veremos a seguir.
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
= 0cnt
= LIXO (qualquer valor)*owner
= LIXOLIXO
Durante o segundo
gets()
(aquisição do lock):lock
= 1cnt
= LIXO*owner
= TLS
Meio do segundo
gets()
(reescrevendo a estrutura):lock
= LIXOcnt
= LIX\x00*owner
= TLS
Final do segundo
gets()
(liberação do lock):cnt
= LIX\x00 - 1 → resultando em0x4C4957FF
, 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+:
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 comNULL
, 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 paraunsafe_state
da glibc (área interna usada pelo RNG), que normalmente possui permissão de escrita;getchar(junk)
ouputchar(junk)
: Apesar de não confiáveis, em alguns casos podem resultar em RDI se tornandoNULL
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