Double-Free
Conceito
O conceito por trás desse exploit é bem simples, ele consiste em realizar uma operação free()
em um ponteiro para um chunk que já foi liberado. Abaixo tem um exemplo do double-free, mas note que nas versões atuais da libc isso não funcionará.
int main(){
size_t *pointer = (size_t *)malloc(20);
free(pointer);
free(pointer);
return 0;
}
Quando você realiza um free()
em um ponteiro para um chunk alocado, esse chunk passa a ser um chunk liberado, fazendo com que o início dos dados do usuário sejam o ponteiro para o próximo chunk na bin, e por fim o chunk é adicionado na respectiva bin. Porém, quando você realiza outro free()
nesse mesmo ponteiro (sem modificar ele) o mesmo chunk é adicionado na bin, ou seja, a bin conterá 2 chunks iguais.
Isso permite que seja possível alocar o mesmo chunk duas vezes com a malloc()
. Agora se pararmos para pensar, quando a malloc()
devolver pela primeira vez o chunk, ele será um chunk em estado alocado e liberado ao mesmo tempo, e se for possível alterar o conteúdo dele (por meio do alocado) nós estaremos escrevendo em cima dos ponteiros fd
e bk
, fazendo com que seja possível fazer a malloc()
nos retornar um ponteiro para uma área arbitrária.
Segue um exemplo:
//Antes de alocarmos o chunk pela primeira vez.
tcachebin: chunk1 -> lixo1 -> chunk1 -> lixo2 -> lixo3
//Depois de alocar o chunk pela primeira vez, e alterar seu conteúdo para o endereço de um outra variável.
tcachebin: lixo1 -> chunk1 -> &var
Dessa forma, se a var for uma variável importante, nós poderemos modificar seu valor. Também é possível fazer a malloc()
retornar um endereço da .got
ou de outras constantes e ponteiros, como o _free_hook
da libc.
Importante: Acima foi demonstrado como utilizar o double-free para fazer a malloc()
retornar um endereço qualquer para nós, mas note que se for possível modificar o valor do chunk usando o mesmo ponteiro, após ele ser liberado, isso também será possível.
Proteções
Anteriormente foi citado que o exemplo dado não funcionaria, e isso se deve ao fato das bins terem proteções que foram evoluindo com as atualizações da libc.
2.25 e anteriores
Nestas versões a tcachebin ainda não havia sido implementada, então os chunks liberados iam para suas respectivas bins, como fast ou unsorted. Considerando a fastbin, é muito fácil realizar o double-free nela, pois sua verificação acontece somente com o topo da bin, ou seja, se liberarmos um outro chunk do mesmo tamanho entre o double-free o exploit não será identificado.
int main(){
size_t *pointer = (size_t *)malloc(20);
size_t *lixo = (size_t *)malloc(20);
free(pointer);
free(lixo);
free(pointer);
return 0;
}
2.26 (teoricamente ficou assim até a 2.28)
Nesta versão a tcachebin foi implementada, ele veio com o ideal de otimizar algumas operações, e quando um chunk é liberado ele vai para ela inicialmente. É importante lembrar que a tcachebin contém apenas espaço para 7 chunks em cada bin.
Outro ponto importante é que quando ela fica cheia, os chunks começam a ir para as respectivas bins (como a fast) mas se a tcache ficar vazia, o sistema irá pegar o chunk na bin correspondente (como a fast) e irá jogar todos os outros chunks armazenados nessa bin para a tcache.
Em quesito do exploit, nessa versão não há nenhuma proteção, lançaram a bin sem usar nenhuma proteção já existente.
int main(){
size_t *pointer = (size_t *)malloc(20);
free(pointer);
free(pointer);
return 0;
}
2.29
Foi nesta versão que uma proteção de segurança foi implementada. Essa proteção consiste em uma variável (um ponteiro para uma estrutura denominada tcache_perthread_struct
) chamada de key
, toda vez que um chunk é liberado a função tcache_put()
é chamada, e nela a key
é setada. Aí na verificação de segurança, como _int_free()
, é verificado se essa key
já está setada, se estiver, eles simplesmente percorrem a bin para verificar se já está lá.
/* We overlay this structure on the user-data portion of a chunk when
the chunk is stored in the per-thread cache. */
typedef struct tcache_entry
{
struct tcache_entry *next;
/* This field exists to detect double frees. */
struct tcache_perthread_struct *key;
} tcache_entry;
Acima temos a estrutura tcache_entry
com a chave implementada.
/* There is one of these for each thread, which contains the
per-thread cache (hence "tcache_perthread_struct"). Keeping
overall size low is mildly important. Note that COUNTS and ENTRIES
are redundant (we could have just counted the linked list each
time), this is for performance reasons. */
typedef struct tcache_perthread_struct
{
char counts[TCACHE_MAX_BINS];
tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;
E aí está a estrtura tcache_perthread_struct
, olhando o comentário podemos ver que ela meio que existem somente para otimização (e segurança).
/* Caller must ensure that we know tc_idx is valid and there's room
for more chunks. */
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
assert (tc_idx < TCACHE_MAX_BINS);
/* Mark this chunk as "in the tcache" so the test in _int_free will
detect a double free. */
e->key = tcache;
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
Acima está a função tcache_entry()
, note que ali a key
é setada para ser o valor tcache
, esse valor nada mais é do que um outro ponteiro para tcache_perthread_struct
que é inicializado quando a função tcache_init()
é chamada.
#if USE_TCACHE
{
size_t tc_idx = csize2tidx (size);
if (tcache != NULL && tc_idx < mp_.tcache_bins)
{
/* Check to see if it's already in the tcache. */
tcache_entry *e = (tcache_entry *) chunk2mem (p);
/* This test succeeds on double free. However, we don't 100%
trust it (it also matches random payload data at a 1 in
2^<size_t> chance), so verify it's not an unlikely
coincidence before aborting. */
if (__glibc_unlikely (e->key == tcache))
{
tcache_entry *tmp;
LIBC_PROBE (memory_tcache_double_free, 2, e, tc_idx);
for (tmp = tcache->entries[tc_idx];
tmp;
tmp = tmp->next)
if (tmp == e)
malloc_printerr ("free(): double free detected in tcache 2");
/* If we get here, it was a coincidence. We've wasted a
few cycles, but don't abort. */
}
if (tcache->counts[tc_idx] < mp_.tcache_count)
{
tcache_put (p, tc_idx);
return;
}
}
}
#endif
Por fim, temos a parte da _int_free()
que está lidando com isso, note que a verificação do key
setado não é 100% garantida, pois o valor no key
pode ser uma coincidência ou o chunk não está na bin (nos exemplos há uma explicação de como fazer um chunk ter dois tamanhos ao mesmo tempo).
Vimos todas essas funções, mas no geral é importante de se ter em mente que o double-free sempre será detectado nas mesmas bins, para fazermos ele nessas versões é necessário abusar de algumas outras coisas. Exemplos são fazer um chunk ter dois tamanhos ao mesmo tempo, pois dessa forma a tcache vai identificar que a key
está setada mas ao percorrer a bin não vai encontrar ele, e outra forma é preencher a tcache e começar a trabalhar com as outras bins, como a fast, nesse caso só tem que ficar esperto com o fato da tcache pegar os outros elementos da bin.
2.32
Foi nessa versão que duas novas proteções foram adicionadas. A primeira é conhecida como mangling
, e ela é uma obfuscação do endereço armazenado nas áreas fd
e bk
, ou seja, quando você libera um chunk, ao invés dessas áres armazenarem o endereço correto do próximo chunk, ela na verdade armazena uma obfuscação.
Felizmente, essa obfuscação é muito fácil de se quebrar ou forjar (você só precisa de um vazamento da heap), pois ela consiste em uma operação de >> 12
com o endereço do chunk, e o resultado faz um XOR
com o endereço do próximo chunk.
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
Observe que a forma de se quebrar consiste em fazer a operação >> 12
com o endereço do chunk, e depois o XOR
é feito com a obfuscação. Segue um exemplo:
Tcachebin: 0x5555555592c0 -> 0x5555555592a0 -> NULL.
Mangling 1: 0x000055500000c7f9.
Mangling 2: 0x0000000555555559.
Verificando os mangling:
1: (0x5555555592c0 >> 12) ^ 0x5555555592a0 = 5550 0000 C7F9 (correto).
2: (0x5555555592a0 >> 12) ^ 0 = 5 5555 5559 (correto).
Quebrando:
1: (0x5555555592c0 >> 12) ^ 0x000055500000c7f9 = 5555 5555 92A0 (correto).
2: (0x5555555592a0 >> 12) ^ 0x0000000555555559 = 0 (correto).
A segunda proteção é a verificação de alinhamento. Existe duas implementadas:
#define aligned_OK(m) (((unsigned long)(m) & MALLOC_ALIGN_MASK) == 0)
#define misaligned_chunk(p) \
((uintptr_t)(MALLOC_ALIGNMENT == 2 * SIZE_SZ ? (p) : chunk2mem (p)) \
& MALLOC_ALIGN_MASK)
#define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1)
Importante: MALLOC_ALIGNMENT para i386 vale 16, ou em binário, 10000.
A primeira verificação é para endereços, ela apenas verifica se o endereço do seu chunk termina com 0, para garantir o alinhamento. A segunda é para chunks, a única que não usa ela é a tcache.
Exemplos
Aqui estarão aluns exemplos que podem ser úteis.
Double-Free utilizando a fastbin
#include <stdio.h>
#include <stdlib.h>
int main(){
/* Alocando chunks. */
size_t *vitima = (size_t *)malloc(sizeof(size_t));
size_t *auxiliar = (size_t *)malloc(sizeof(size_t));
size_t *lixo1 = (size_t *)malloc(sizeof(size_t));
size_t *lixo2 = (size_t *)malloc(sizeof(size_t));
size_t *lixo3 = (size_t *)malloc(sizeof(size_t));
size_t *lixo4 = (size_t *)malloc(sizeof(size_t));
size_t *lixo5 = (size_t *)malloc(sizeof(size_t));
size_t *lixo6 = (size_t *)malloc(sizeof(size_t));
size_t *lixo7 = (size_t *)malloc(sizeof(size_t));
/* Variáveis auxiliares. */
size_t *first, *second;
/* Liberando os chunks. */
printf("\nTodos alocados, enchendo a tcachebin.");
free(lixo1);
free(lixo2);
free(lixo3);
free(lixo4);
free(lixo5);
free(lixo6);
free(lixo7);
/* Double-free. */
printf("\nDouble-free sendo realizado na fastbin.");
free(vitima);
free(auxiliar); //Disfarçando o double-free.
free(vitima);
/* Pegando os chunks da tcache. */
printf("\nEsvaziando Tcache.");
lixo1 = (size_t *)malloc(sizeof(size_t));
lixo2 = (size_t *)malloc(sizeof(size_t));
lixo3 = (size_t *)malloc(sizeof(size_t));
lixo4 = (size_t *)malloc(sizeof(size_t));
lixo5 = (size_t *)malloc(sizeof(size_t));
lixo6 = (size_t *)malloc(sizeof(size_t));
lixo7 = (size_t *)malloc(sizeof(size_t));
/* Pegando o primeiro chunk da fastbin, note que o resto irá para a tcache.*/
printf("\nUm já foi, o resto caiu na tcache.");
first = (size_t *)malloc(sizeof(size_t));
/* Pegando o segundo na tcache. */
printf("\nLá vem.");
malloc(sizeof(size_t));
second = (size_t *)malloc(sizeof(size_t));
printf("\nFirst: %p\nSecond: %p",first,second);
}
Importante: É muito chato realizar double-free na tcachebin após suas proteções serem adicionadas, por isso é interessante tentar nas outras. No caso acima o double-free é realizado na fastbin.
Chunk com 2 tamanhos por POISON NULL BYTE
O conceito por trás de um ataque de POISON NULL BYTE
segue a seguinte lógica:
# ------- Representação -------- #
# Antes do null byte
# [chunk1]: 0x0000000000000000 0x0000000000000071
# 0x0000000000000000 0x0000000000000000
# 0x0000000000000000 0x0000000000000000
# 0x0000000000000000 0x0000000000000000
# 0x0000000000000000 0x0000000000000000
# 0x0000000000000000 0x0000000000000000
# 0x0000000000000000 0x0000000000000000
# [chunk2]: 0x0000000000000000 0x0000000000000111
# Após o null byte
# [chunk1]: 0x0000000000000000 0x0000000000000071
# 0xdeadbeefdeadbeef 0xdeadbeefdeadbeef
# 0xdeadbeefdeadbeef 0xdeadbeefdeadbeef
# 0xdeadbeefdeadbeef 0xdeadbeefdeadbeef
# 0xdeadbeefdeadbeef 0xdeadbeefdeadbeef
# 0xdeadbeefdeadbeef 0xdeadbeefdeadbeef
# 0xdeadbeefdeadbeef 0xdeadbeefdeadbeef
# [chunk2]: 0xdeadbeefdeadbeef 0x0000000000000100 <- O taldo Poison NULL Byte
Note que após o null byte, o tamanho do chunk passou a ser 0x100
, e antes ele era 0x111
.
Para fazer esse ataque, é importante ficar de olho em diversas falhas de segurança, vou mostrar aqui um exemplo de falha em um desafio conhecido como Zero_to_hero
do PicoCTF. Segue a função que aloca as coisas:
void FUN_00400a4d(void)
{
long lVar1;
void *pvVar2;
ssize_t sVar3;
long in_FS_OFFSET;
uint local_28;
int local_24;
long local_20;
local_20 = *(long *)(in_FS_OFFSET + 0x28);
local_28 = 0;
local_24 = FUN_004009c2();
if (local_24 < 0) {
puts("You have too many powers!");
/* WARNING: Subroutine does not return */
exit(-1);
}
puts("Describe your new power.");
puts("What is the length of your description?");
printf("> ");
__isoc99_scanf(&DAT_00400f0b,&local_28);
getchar();
if (0x408 < local_28) {
puts("Power too strong!");
/* WARNING: Subroutine does not return */
exit(-1);
}
pvVar2 = malloc((ulong)local_28);
*(void **)(&DAT_00602060 + (long)local_24 * 8) = pvVar2;
puts("Enter your description: ");
printf("> ");
lVar1 = *(long *)(&DAT_00602060 + (long)local_24 * 8);
sVar3 = read(0,*(void **)(&DAT_00602060 + (long)local_24 * 8),(ulong)local_28); <--- Escrevendo a quantidade infomada.
*(undefined *)(sVar3 + lVar1) = 0; <--- Colocando \0 após o que foi digitado.
puts("Done!");
if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}
Está difícl de entender porque eu não renomeei as variáveis, mas simplificando, o código acima aloca um chunk com o tamanho informado, e em seguida pede para você escrever os dados. E aí está a vulnerabilidade, você pode escrever exatamente a quantidade que você digitou, e após isso o programa irá colocar um \0
no final, mas se você escreveu em todo o chunk, esse \0
vai parar no próximo, e bum, chunk envenenado.
Importante ressaltar alguns pontos sobre o código acima, não foi citado mas a função que realiza o free()
não anula os ponteiros (vulnerabilidade), e o chunk que você aloca para escrever nele inteiro deve ter o tamanho 1032 (tamanho máximo permitido pelo programa), pois a malloc()
não irá fazer alinhamento com esse tamanho (é o tamanho máximo da tcache). Outro ponto importante, é que o tamanho encontrado para o segundo chunk é 0x2..
, os dois pontos indicam que tanto faz, porque após o envenenamento o chunk será considerado como 0x200
, e a malloc()
alocará um chunk desse tamanho quando o tamanho for menor que 512 (não tão menor).
Referências
Atualizado