
CVE-2023-32233
O código afetado é originário do kernel Linux oficial disponível em https://kernel.org/ e faz parte do componente Netfilter nf_tables (net/netfilter/nf_tables_api.c).
O Netfilter nf_tables permite atualizar sua configuração como uma operação atômica. Ao usar esse recurso, os clientes em modo de usuário enviam requisições em lote contendo uma lista de operações básicas. O Netfilter nf_tables então processa todas as operações dentro do lote como uma única transação. Ao processar o lote, o Netfilter nf_tables verifica as atualizações de estado da configuração para garantir que cada operação básica sucessiva seja válida, e isso também leva em conta as atualizações de estado de todas as operações anteriores dentro do lote. No entanto, a verificação atualmente implementada é insuficiente.
Em nosso cenário específico, começamos com uma configuração do Netfilter nf_tables
que possui um nft_rule com expressão lookup em um nft_set anônimo,
e onde o nft_set anônimo contém alguns elementos. Em seguida, enviamos uma requisição em lote
contendo as seguintes duas operações básicas:
NFT_MSG_DELRULE para excluir o nft_rule.lookup e o
nft_set anônimo.NFT_MSG_DELSETELEM para excluir qualquer um dos elementos do
nft_set anônimo excluído.A versão atual do Netfilter nf_tables aceita a requisição em lote acima.
Em seguida, ela chama nf_tables_commit_release() que anexa recursos liberados à
nf_tables_destroy_list. A nf_tables_destroy_list é então processada por
nf_tables_trans_destroy_work() que primeiro desaloca recursos relacionados à
operação NFT_MSG_DELRULE chamando:
nft_commit_release()
nf_tables_rule_destroy()
nf_tables_expr_destroy()
expr->ops->destroy() que aponta para nft_lookup_destroy()
nf_tables_destroy_set()
nft_set_destroy()
kvfree() que desaloca a memória usada por `nft_set`
antes de processar a operação NFT_MSG_DELSETELEM, onde a referência ao
nft_set desalocado é acessada via nft_trans_elem_set() durante as
seguintes chamadas:
nft_commit_release()
nf_tables_set_elem_destroy()
nft_set_elem_ext()
Dentro de nft_set_elem_ext() acima, a localização de memória do nft_set
desalocado é acessada para determinar a localização de nft_set_ext:
static inline struct nft_set_ext *nft_set_elem_ext(const struct nft_set *set,
void *elem)
{
return elem + set->ops->elemsize;
}
para as operações seguintes. Portanto, sempre que o valor de set->ops->elemsize
for corrompido, uma localização de memória inesperada pode ser interpretada como
uma lista de nft_expr a ser destruída:
static void nf_tables_set_elem_destroy(const struct nft_ctx *ctx,
const struct nft_set *set, void *elem)
{
struct nft_set_ext *ext = nft_set_elem_ext(set, elem);
if (nft_set_ext_exists(ext, NFT_SET_EXT_EXPRESSIONS))
nft_set_elem_expr_destroy(ctx, nft_set_ext_expr(ext));
Explorar a vulnerabilidade acima requer vencer uma condição de corrida com nf_tables_trans_destroy_work() que executa em uma thread de trabalho em segundo plano do kernel Linux. Isso parece complicar a exploração prática mesmo antes de considerarmos mitigações existentes, como o endurecimento do alocador de slabs do kernel, Randomização de Espaço de Endereçamento do Kernel (KASLR) e especialmente Integridade de Fluxo de Controle. No entanto, o PoC anexado prova que ainda é possível alcançar uma exploração razoavelmente confiável na prática.
Para explorar a vulnerabilidade, precisamos modificar o conteúdo da memória
do nft_set depois que ele é desalocado em nf_tables_rule_destroy(), mas
antes de ser usado em nf_tables_set_elem_destroy(). Ambas
nf_tables_rule_destroy() e nf_tables_set_elem_destroy() são chamadas dentro de
uma única invocação de nf_tables_trans_destroy_work() que executa em
uma thread de trabalho em segundo plano do kernel Linux. Além disso, o bloco de memória
desalocado geralmente está disponível para reutilização apenas a partir do mesmo núcleo de CPU.
Ao competir com nf_tables_trans_destroy_work(), melhoramos nossas chances
adicionando um atraso controlado para a thread de trabalho em segundo plano entre suas chamadas
nf_tables_rule_destroy() e nf_tables_set_elem_destroy(). Para isso, inserimos
uma operação adicional para destruir outro nft_set contendo um grande
número de elementos. Adicionalmente, mantemos todos os outros núcleos de CPU ocupados, de modo
que a thread de trabalho em segundo plano provavelmente será escalonada em um núcleo de CPU
específico, para que possamos tentar alocar uma nova estrutura a partir do mesmo núcleo de CPU
logo após ele desalocar nft_set em nf_tables_rule_destroy(). Nosso objetivo
é alocar um novo nft_set de tipo diferente para reutilizar a localização de memória do
nft_set desalocado em nf_tables_rule_destroy().
O novo tipo de nft_set é selecionado para usar um valor diferente para
set->ops->elemsize. Então, quando a thread de trabalho em segundo plano finalmente chama
nf_tables_set_elem_destroy() para processar a operação NFT_MSG_DELSETELEM, ela
interpreta seu argumento elem incorretamente, de modo que o
nft_set_ext *ext corrompido está alguns bytes após a localização correta. Isso significa que
determinados campos de dados controlados pelo usuário do nft_set_ext original são agora
interpretados como cabeçalhos, resultando em confusão de tipo.
Uma maneira de abusar dessa confusão de tipo é crafting os cabeçalhos do
nft_set_ext corrompido com valores de deslocamento tais que
nf_tables_set_elem_destroy() interprete o conteúdo de quaisquer blocos de memória adjacentes
como a lista de nft_expr a ser destruída através das seguintes chamadas:
nft_set_elem_expr_destroy()
__nft_set_elem_expr_destroy()
nf_tables_expr_destroy()
expr->ops->destroy()
Neste ponto da exploração, ainda não temos detalhes do layout da memória do
kernel. Portanto, não é possível criar endereços de ponteiro absolutos.
No entanto, ao criar os cabeçalhos do nft_set_ext corrompido, ainda podemos usar
deslocamentos fora do intervalo, de modo que expr->ops->destroy() seja chamado em
certos nft_expr válidos nos blocos de memória adjacentes.
Para isso, espalhamos expressões nft_log, com NFTA_LOG_PREFIX controlado.
Esse nft_log->prefix é então desalocado por nft_log_destroy() quando
expr->ops->destroy() é chamado:
static void nft_log_destroy(const struct nft_ctx *ctx,
const struct nft_expr *expr)
{
struct nft_log *priv = nft_expr_priv(expr);
struct nf_loginfo *li = &priv->loginfo;
if (priv->prefix != nft_log_null_prefix)
kfree(priv->prefix);
Observe que ainda podemos acessar e até mesmo desalocar novamente essa memória através da
outra referência da expressão nft_log espalhada.
Além disso, também podemos controlar o tamanho de nft_log->prefix, de modo que
ele possa ser alocado a partir de qualquer um dos slabs kmalloc-{8, ..., 192}. Finalmente, a
memória referida é interpretada como uma string de caracteres pelo kernel, então não há
necessidade de se preocupar com corrupções ao sobrepor diferentes objetos sobre ela.
Isso é essencialmente game over.
Um inconveniente é que quaisquer caracteres NULL terminam nft_log->prefix, então
não podemos ler além de bytes NULL ao vazar conteúdo de memória. Isso é resolvido
na próxima etapa, onde alocamos nft_object->udata para reutilizar
o bloco de memória de nft_log->prefix e destruímos a expressão nft_log. Isso
desaloca a memória de nft_object->udata, mas agora ainda podemos usar o
ponteiro pendente de nft_object->udata para vazar conteúdo de memória sem
restrições de bytes NULL.
Procurando estruturas adequadas para as etapas seguintes, decidimos por
nft_expr alocado a partir de nft_dynset_new(). Estes vivem nos mesmos slabs que
nft_log->prefix e nft_object->udata. E também, temos controle
razoável sobre o tamanho da alocação, de modo que posteriormente possamos alternar facilmente
entre slabs de tamanhos diferentes, se necessário.
Para usar essas estruturas, criamos um filtro de pacotes com expressão nft_dynset.
E quando enviamos quaisquer pacotes pela interface loopback,
a expressão nft_dynset chama nft_dynset_new() para criar novos elementos para o
nft_set associado. Os elementos criados são expressões com estado dos seguintes
tipos:
nft_counter para obter a localização de nf_tables.ko na memória do kernel.
A estrutura inclui um ponteiro para nft_counter_ops no módulo do kernel
nf_tables.ko. Vazamos esse ponteiro lendo nft_object->udata.
nft_quota para leitura e escrita arbitrária de memória.
Podemos repetidamente desalocar e realocar nft_object->udata para modificar
o ponteiro nft_quota->consumed. Em seguida, realizamos uma operação
NFT_MSG_GETSETELEM que chama nft_quota_do_dump() para ler o conteúdo da
memória referenciada e passa o resultado como atributo NFTA_QUOTA_CONSUMED
no resultado. Quanto a escritas, simplesmente enviamos pacotes pela
interface loopback, onde nft_quota_do_eval() chama:
static inline bool nft_overquota(struct nft_quota *priv,
const struct sk_buff *skb)
{
return atomic64_add_return(skb->len, priv->consumed) >=
Usamos a leitura arbitrária de memória acima para obter o endereço base do núcleo do kernel. E então prosseguimos para modificar a substring "sbin" do caminho "/sbin/modprobe", para que seja substituída por "/tmp". O caminho resultante "//tmp/modprobe" é então usado pelo kernel para iniciar um processo com privilégios de root, onde controlamos o conteúdo do arquivo.
Observe que não fizemos nenhum esforço intencional para contornar a Integridade de Fluxo de Controle. No entanto, para cada uma das etapas de exploração, escolhemos conscientemente os primitivos mais flexíveis e robustos. Acontece que nossa seleção de alguma forma evitou qualquer um dos primitivos que poderiam potencialmente ser bloqueados pela Integridade de Fluxo de Controle. Agora estamos curiosos para confirmar com testes se o exploit resultante realmente funciona contra sistemas com mitigações de Integridade de Fluxo de Controle.
para modificar nft_quota->consumed.