
Análise técnica e desenvolvimento de exploit para CVE-2021-3156, um buffer overflow baseado em heap no Sudo, incluindo três exploits funcionais para escalonamento de privilégios locais nas principais distribuições Linux.
Qualys Security Advisory
Baron Samedit: Estouro de buffer baseado em heap no Sudo (CVE-2021-3156)
Resumo Análise Exploração Agradecimentos Cronologia
Descobrimos um estouro de buffer baseado em heap no Sudo (https://www.sudo.ws/). Esta vulnerabilidade:
é explorável por qualquer usuário local (usuários normais e usuários do sistema, sudoers e não-sudoers), sem autenticação (ou seja, o invasor não precisa saber a senha do usuário);
foi introduzida em julho de 2011 (commit 8255ed69) e afeta todas as versões legadas de 1.8.2 a 1.8.31p2 e todas as versões estáveis de 1.9.0 a 1.9.5p1, em sua configuração padrão.
Desenvolvemos três exploits diferentes para esta vulnerabilidade e obtivemos privilégios totais de root no Ubuntu 20.04 (Sudo 1.8.31), Debian 10 (Sudo 1.8.27) e Fedora 33 (Sudo 1.9.2). Outros sistemas operacionais e distribuições provavelmente também são exploráveis.
Se o Sudo for executado para rodar um comando no modo "shell" (shell -c comando):
seja através da opção -s, que define a flag MODE_SHELL do Sudo;
ou através da opção -i, que define as flags MODE_SHELL e MODE_LOGIN_SHELL do Sudo;
então, no início da main() do Sudo, parse_args() reescreve argv (linhas 609-617), concatenando todos os argumentos da linha de comando (linhas 587-595) e escapando todos os metacaracteres com barras invertidas (linhas 590-591):
Mais tarde, em sudoers_policy_main(), set_cmnd() concatena os argumentos da linha de comando em um buffer baseado em heap "user_args" (linhas 864-871) e desescapa os metacaracteres (linhas 866-867), "para fins de correspondência e registro do sudoers":
Infelizmente, se um argumento da linha de comando termina com um único caractere de barra invertida, então:
na linha 866, "from[0]" é o caractere de barra invertida, e "from[1]" é o terminador nulo do argumento (ou seja, não é um caractere de espaço);
na linha 867, "from" é incrementado e aponta para o terminador nulo;
na linha 868, o terminador nulo é copiado para o buffer "user_args", e "from" é incrementado novamente e aponta para o primeiro caractere após o terminador nulo (ou seja, fora dos limites do argumento);
o loop "while" nas linhas 865-869 lê e copia caracteres fora dos limites para o buffer "user_args".
Em outras palavras, set_cmnd() é vulnerável a um estouro de buffer baseado em heap, porque os caracteres fora dos limites que são copiados para o buffer "user_args" não foram incluídos em seu tamanho (calculado nas linhas 852-853).
Em teoria, no entanto, nenhum argumento da linha de comando pode terminar com um único caractere de barra invertida: se MODE_SHELL ou MODE_LOGIN_SHELL estiver definido (linha 858, uma condição necessária para atingir o código vulnerável), então MODE_SHELL está definido (linha 571) e parse_args() já escapou todos os metacaracteres, incluindo barras invertidas (ou seja, escapou cada barra invertida com uma segunda barra invertida).
Na prática, no entanto, o código vulnerável em set_cmnd() e o código de escape em parse_args() são cercados por condições ligeiramente diferentes:
versus:
Nossa pergunta, então, é: podemos definir MODE_SHELL e MODE_EDIT ou MODE_CHECK (para atingir o código vulnerável) mas não o MODE_RUN padrão (para evitar o código de escape)?
A resposta, ao que parece, é não: se definirmos MODE_EDIT (opção -e, linha 361) ou MODE_CHECK (opção -l, linhas 423 e 519), então parse_args() remove MODE_SHELL das "valid_flags" (linhas 363 e 424) e sai com um erro se especificarmos uma flag inválida como MODE_SHELL (linhas 532-533):
Mas encontramos uma brecha: se executarmos o Sudo como "sudoedit" em vez de "sudo", então parse_args() define automaticamente MODE_EDIT (linha 270) mas não redefine "valid_flags", e as "valid_flags" incluem MODE_SHELL por padrão (linhas 127 e 249):
Consequentemente, se executarmos "sudoedit -s", então definimos tanto MODE_EDIT quanto MODE_SHELL (mas não MODE_RUN), evitamos o código de escape, atingimos o código vulnerável e estouramos o buffer baseado em heap "user_args" através de um argumento da linha de comando que termina com um único caractere de barra invertida:
perl -e 'print "A" x 65536'
malloc(): corrupted top size
Aborted (core dumped)Do ponto de vista de um invasor, esse estouro de buffer é ideal:
controlamos o tamanho do buffer "user_args" que estouramos (o tamanho dos nossos argumentos de linha de comando concatenados, nas linhas 852-854);
controlamos independentemente o tamanho e o conteúdo do estouro em si (nosso último argumento de linha de comando é convenientemente seguido pelas nossas primeiras variáveis de ambiente, que não são incluídas no cálculo de tamanho nas linhas 852-853);
podemos até escrever bytes nulos no buffer que estouramos (todo argumento da linha de comando ou variável de ambiente que termina com uma única barra invertida escreve um byte nulo em "user_args", nas linhas 866-868).
Por exemplo, em um Linux amd64, o seguinte comando aloca um buffer "user_args" de 24 bytes (um chunk de heap de 32 bytes) e sobrescreve o campo de tamanho do próximo chunk com "A=a\0B=b\0" (0x00623d4200613d41), seu campo fd com "C=c\0D=d\0" (0x00643d4400633d43) e seu campo bk com "E=e\0F=f\0" (0x00663d4600653d45):
--|--------+--------+--------+--------|--------+--------+--------+--------+-- | | |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.| --|--------+--------+--------+--------|--------+--------+--------+--------+-- size <---- buffer user_args ----> size fd bk
Como o Sudo chama funções de localização no início da sua função main():
e passa strings de tradução (através da função gettext() e da macro _()) para funções de formatação de string, como:
inicialmente queríamos reutilizar a técnica fascinante do halfdog de https://www.halfdog.net/Security/2017/LibcRealpathBufferUnderflow/ e transformar o estouro de buffer baseado em heap do Sudo em um exploit de string de formato. Mais precisamente:
na linha 154, em setlocale(), alocamos (malloc()) e liberamos (free()) várias variáveis de ambiente LC (LC_CTYPE, LC_MESSAGES, LC_TIME, etc), criando assim pequenos buracos no início do heap do Sudo (chunks fast ou tcache livres);
na linha 155, bindtextdomain() aloca (malloc()) uma struct binding, que contém um ponteiro dirname para o nome de um diretório que contém arquivos de catálogo ".mo" e, portanto, strings de tradução;
em set_cmnd(), alocamos o buffer "user_args" em um dos buracos no início do heap do Sudo e estouramos esse buffer, sobrescrevendo assim o ponteiro dirname da struct binding;
na linha 301 (por exemplo), gettext() (através da macro _()) carrega nossa própria string de tradução do dirname sobrescrito -- em outras palavras, controlamos a string de formato que é passada para sudo_printf().
Para implementar esta técnica inicial, escrevemos um brute-forcer rudimentar que executa o Sudo dentro do gdb, estoura o buffer "user_args" e seleciona aleatoriamente os seguintes parâmetros:
as variáveis de ambiente LC que passamos para o Sudo e seu comprimento (usamos a locale "C.UTF-8" e anexamos um "@modifier" aleatório);
o tamanho do buffer "user_args" que estouramos;
o tamanho do próprio estouro;
se passamos pelo código de autenticação do Sudo (opção -A ou -n) ou não (opção -u #realuid).
Infelizmente, esta técnica inicial falhou; nosso brute-forcer conseguiu sobrescrever o ponteiro dirname da struct binding:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6e0dde1ea9 in __dcigettext (domainname=domainname@entry=0x7f6e0d9cc020 "sudoers", msgid1=msgid1@entry=0x7f6e0d9cc014 "user NOT in sudoers", msgid2=msgid2@entry=0x0, plural=plural@entry=0, n=n@entry=0, category=5) at dcigettext.c:619
=> 0x7f6e0dde1ea9 <__dcigettext+1257>: cmpb $0x2f,(%rax)
mas LC_MESSAGES era sempre a locale padrão "C" (não "C.UTF-8"), o que desabilita a tradução de strings em gettext() (ou seja, gettext() retorna a string de formato original, não a nossa).
Felizmente, no entanto, nosso brute-forcer produziu dezenas de crashes únicos do Sudo e backtraces do gdb; entre estes, três chamaram nossa atenção e eventualmente exploramos todos os três.
O primeiro crash que chamou nossa atenção é:
Program received signal SIGSEGV, Segmentation fault.
0x000056291a25d502 in process_hooks_getenv (name=name@entry=0x7f4a6d7dc046 "SYSTEMD_BYPASS_USERDB", value=value@entry=0x7ffc595cc240) at ../../src/hooks.c:108
=> 0x56291a25d502 <process_hooks_getenv+82>: callq *0x8(%rbx)
rbx 0x56291c1df2b0 94734565372592
Incrivelmente, a função process_hooks_getenv() do Sudo travou (na linha 108) porque sobrescrevemos diretamente um ponteiro de função, getenv_fn (um membro de uma struct sudo_hook_entry baseada em heap):
Para explorar esta sobrescrita de struct sudo_hook_entry, observamos que:
a chamada para getenv_fn (na linha 108) é compatível com uma chamada para execve():
. name ("SYSTEMD_BYPASS_USERDB") é compatível com o argumento pathname de execve();
. &val (um ponteiro para um ponteiro NULL) é compatível com argv de execve();
. hook->closure (um ponteiro NULL) é compatível com envp de execve();
podemos derrotar o ASLR sobrescrevendo parcialmente o ponteiro de função getenv_fn (que aponta para a função sudoers_hook_getenv() na biblioteca compartilhada sudoers.so); e felizmente, o início de sudoers.so contém uma chamada para execve() (ou execv()):
Consequentemente, adotamos a seguinte estratégia:
Testamos com sucesso este primeiro exploit no Ubuntu 20.04.
O segundo crash que chamou nossa atenção é:
Program received signal SIGSEGV, Segmentation fault.
0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344
=> 0x7f6bf9c294ee <nss_load_library+46>: cmpq $0x0,0x8(%rbx)
A função nss_load_library() da glibc travou (na linha 344) porque sobrescrevemos o ponteiro "library", um membro de uma struct service_user baseada em heap:
Podemos facilmente transformar esta sobrescrita de struct service_user em uma execução de código arbitrário:
sobrescrevemos ni->library com um ponteiro NULL, para entrar no bloco nas linhas 330-342, evitar o crash na linha 344 e entrar no bloco nas linhas 344-359;
sobrescrevemos ni->name (um array de caracteres, inicialmente "systemd") com "X/X";
as linhas 353-357 constroem o nome de uma biblioteca compartilhada "libnss_X/X.so.2" (em vez de "libnss_systemd.so.2");- na linha 359, carregamos nossa própria biblioteca compartilhada "libnss_X/X.so.2" do diretório de trabalho atual e executamos nosso construtor _init() como root.
Testamos com sucesso este segundo exploit no Ubuntu 20.04, Debian 10, e Fedora 33.
Nosso terceiro exploit não é derivado de um dos crashes do Sudo, mas de uma observação casual: durante nossa força bruta, o Sudo criou dezenas de novos diretórios em nosso diretório de trabalho atual (AAAAAA, AAAAAAAAA, etc). Cada um desses diretórios pertence ao root e contém apenas um pequeno arquivo, nomeado após nosso próprio usuário: o arquivo de timestamp do Sudo -- nós evidentemente sobrescrevemos def_timestampdir, o nome do diretório de timestamp do Sudo.
Se sobrescrevermos def_timestampdir com o nome de um diretório que não existe, então podemos competir contra o ts_mkdirs() do Sudo, criar um link simbólico para um arquivo arbitrário, e:
3a/ seja chown() este arquivo arbitrário para o usuário root e grupo root;
3b/ seja abrir (ou criar) este arquivo arbitrário como root, e escrever um struct timestamp_entry nele.
Não conseguimos transformar 3a/ em privilégios totais de root (por exemplo, se fizermos chown() do nosso próprio binário SUID para root, então o kernel automaticamente remove o bit SUID do nosso binário). Se você, caro leitor, encontrar uma solução para este problema, por favor publique-a na lista de discussão pública oss-security!
Eventualmente, conseguimos transformar 3b/ em privilégios totais de root, mas inicialmente enfrentamos dois problemas:
O timestamp_open() do Sudo exclui nosso link simbólico arbitrário se o arquivo ao qual ele aponta for mais antigo que o tempo de boot. Conseguimos resolver este primeiro problema criando um arquivo de timestamp muito antigo (desde a época Unix), esperando até que o timestamp_open() o exclua, e competindo contra o timestamp_open() para criar nosso link simbólico final e arbitrário.
Não controlamos o conteúdo do struct timestamp_entry que é escrito no arquivo arbitrário. Até onde sabemos, controlamos apenas três bytes (um ID de processo ou um struct timespec), e não conseguimos transformar esta escrita de três bytes em privilégios totais de root. Se você, caro leitor, encontrar uma solução para este problema, por favor publique-a na lista de discussão pública oss-security!
No entanto, conseguimos contornar este segundo problema abusando de um bug menor no timestamp_lock() do Sudo. Se vencermos as duas corridas contra ts_mkdirs() e timestamp_open(), e se nosso link simbólico arbitrário apontar para /etc/passwd, então este arquivo é aberto como root, e:
na linha 644, os primeiros 0x38 bytes de /etc/passwd ("root❌0:0:...") são lidos em um struct timestamp_entry baseado na pilha, entry;
na linha 652, entry.type é 0x783a (":x"), não TS_LOCKEXCL;
nas linhas 657 e 318, entry->size bytes do entry baseado na pilha são escritos em /etc/passwd, mas entry->size é na verdade 0x746f ("ot"), não sizeof(struct timestamp_entry).
Como resultado, escrevemos todo o conteúdo da pilha do Sudo em /etc/passwd (incluindo nossos argumentos de linha de comando e nossas variáveis de ambiente): nós injetamos um usuário arbitrário em /etc/passwd e, portanto, obtemos privilégios totais de root. Testamos com sucesso este terceiro exploit no Ubuntu 20.04.
Nota: este bug menor no timestamp_lock() foi corrigido em janeiro de 2020 pelo commit 586b418a, mas esta correção não foi retroportada para versões legadas.
Agradecemos a Todd C. Miller por seu profissionalismo, resposta rápida e atenção meticulosa a cada detalhe em nosso relatório. Agradecemos também aos membros de distros@openwall.
2021-01-13: Aviso enviado para Todd.Miller@sudo.
2021-01-19: Aviso e patches enviados para distros@openwall.
2021-01-26: Data de lançamento coordenada (6:00 PM UTC).