Workshop de escalonamento de privilégios locais Looney Tunables (CVE-2023-4911) (apenas para fins educacionais)
Na computação, um vinculador dinâmico é a parte de um sistema operacional que carrega e vincula as bibliotecas compartilhadas necessárias para um executável quando ele é executado, copiando o conteúdo das bibliotecas do armazenamento persistente para a RAM, preenchendo tabelas de salto e realocando ponteiros.
Por exemplo, temos um programa que usa a biblioteca openssl para calcular o hash md5:
$ head md5_hash.c
#include
#include
#include
ld.so analisa o binário e tenta encontrar a biblioteca relacionada a
$ ldd md5_hash
linux-vdso.so.1 (0x00007fffa530b000)
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3 (0x00007f19cda00000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f19cd81e000)
/lib64/ld-linux-x86-64.so.2 (0x00007f19ce032000)
Como podemos ver, ele encontra a biblioteca criptográfica necessária em /lib/x86_64-linux-gnu/libcrypto.so.3 Durante a inicialização do programa, ele coloca o código desta biblioteca na RAM do processo e vincula todas as referências a esta biblioteca.
Quando um programa é iniciado, este carregador primeiro examina o programa para determinar as bibliotecas compartilhadas necessárias. Em seguida, ele procura essas bibliotecas, carrega-as na memória e vincula-as ao executável em tempo de execução. No processo, o carregador dinâmico resolve referências de símbolos, como referências de funções e variáveis, garantindo que tudo esteja configurado para a execução do programa. Dada a sua função, o carregador dinâmico é altamente sensível à segurança, pois seu código é executado com privilégios elevados quando um usuário local inicia um programa set-user-ID ou set-group-ID.
Tunables são um recurso da Biblioteca GNU C que permite que autores de aplicativos e mantenedores de distribuição alterem o comportamento da biblioteca de tempo de execução para corresponder à sua carga de trabalho. Eles são implementados como um conjunto de opções que podem ser modificadas de diferentes maneiras. O método padrão atual para fazer isso é por meio da variável de ambiente GLIBC_TUNABLES, configurando-a como uma sequência de pares nome=valor separados por dois pontos. Por exemplo, o exemplo a seguir ativa a verificação de malloc e define o limite de corte de malloc para 128 bytes:
GLIBC_TUNABLES=glibc.malloc.trim_threshold=128:glibc.malloc.check=3
export GLIBC_TUNABLES
Passando --list-tunables para o carregador dinâmico para imprimir todos os ajustáveis com valores mínimos e máximos:
$ /lib64/ld-linux-x86-64.so.2 --list-tunables
glibc.rtld.nns: 0x4 (min: 0x1, max: 0x10)
glibc.elision.skip_lock_after_retries: 3 (min: 0, max: 2147483647)
glibc.malloc.trim_threshold: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.perturb: 0 (min: 0, max: 255)
glibc.cpu.x86_shared_cache_size: 0x100000 (min: 0x0, max: 0xffffffffffffffff)
glibc.pthread.rseq: 1 (min: 0, max: 1)
glibc.cpu.prefer_map_32bit_exec: 0 (min: 0, max: 1)
glibc.mem.tagging: 0 (min: 0, max: 255)
Logo no início de sua execução, ld.so chama __tunables_init() para percorrer o ambiente (na linha 279), procurando por variáveis GLIBC_TUNABLES (na linha 282); para cada GLIBC_TUNABLES que encontra, ele faz uma cópia desta variável (na linha 284), chama parse_tunables() para processar e higienizar esta cópia (na linha 286) e, finalmente, substitui o GLIBC_TUNABLES original por esta cópia higienizada (na linha 288 ):
// (GLIBC ld.so sources in ./glibc-2.37/elf/dl-tunables.c)
269 void
270 __tunables_init ( char * * envp )
271 {
272 char * envname = NULL ;
273 char * envval = NULL ;
274 size_t len = 0 ;
275 char * * prev_envp = envp ;
...
279 while (( envp = get_next_env ( envp , & envname , & len , & envval ,
280 & prev_envp )) != NULL )
281 {
282 if ( tunable_is_name ( "GLIBC_TUNABLES" , envname )) // searching for GLIBC_TUNABLES variables
283 {
284 char * new_env = tunables_strdup ( envname );
285 if ( new_env != NULL )
286 parse_tunables ( new_env + len + 1 , envval ); //
287 /* Put in the updated envval. */
288 * prev_envp = new_env ;
289 continue ;
290 }
O primeiro argumento de parse_tunables() (tunestr) aponta para a cópia de GLIBC_TUNABLES que será higienizada em breve, enquanto o segundo argumento (valstring) aponta para a variável de ambiente GLIBC_TUNABLES original (na pilha). Para limpar a cópia de GLIBC_TUNABLES (que deve estar no formato "tunable1= aaa:tunable2=bbb"
), parse_tunables() remove todos os ajustáveis perigosos (os ajustáveis SXID_ERASE) do tunestr, mas mantém os ajustáveis SXID_IGNORE e NONE (nas linhas 221- 235):