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á.

double-free.c
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.

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.

double-free-2-25.c
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.

double-free-2-26.c
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á.

tcache_entry.c
/* 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.

tcache_perthread_struct.c
/* 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).

tcache_put.c
/* 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.

USE_TCACHE.c
#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 keyestá 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.

mangling.c
#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:

aligned.c
#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)

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

double-free-fastbin.c
#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);
}

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:

FUN.c
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

Documentação Malloc

Heap-Exploitation

Cybersec

Atualizado