👻 EndianGhost: o qid validado cru e indexado decodificado no nvmft(4) do FreeBSD

No controlador NVMe-over-Fabrics do kernel, nvmft_handoff_io_queue() valida o campo qid cru como chegou na wire, mas indexa io_qpairs[] com o valor decodificado por le16toh(). Em powerpc64 (big-endian) os dois diferem por uma troca de bytes: um cliente NVMe/TCP remoto e pré-autenticação passa pelo bounds check com um valor e grava fora do array com outro. A correção é de uma linha.

TL;DR

EndianGhost é um erro de validação de índice no nvmft(4), o componente de kernel do controlador NVMe-over-Fabrics do FreeBSD. A função nvmft_handoff_io_queue() compara o campo cmd->qid — os bytes crus da wire — contra o número de filas concedidas, mas usa le16toh(cmd->qid) como subscrito de io_qpairs[]. Num build little-endian os dois valores são iguais e nada acontece. Num build big-endian (powerpc64, a única arquitetura big-endian ainda suportada no 15.x) eles diferem por uma troca de bytes, então o valor validado não é o valor usado. Como o tamanho do array é escolhido pelo próprio cliente via SET_FEATURES / NUMBER_OF_QUEUES, um cliente NVMe/TCP remoto e sem autenticação escolhe o tamanho da alocação e a distância além do fim, de forma independente. O par de filas resultante fica fora do array — e, por isso, nunca é registrado dentro dele, sobrevive à destruição do controlador e vira um use-after-free. O piso é um pânico remoto e não autenticado do kernel. Afeta 15.0 e 15.1 (o driver surgiu no 15.0); 14.x não tem o driver. A correção é de uma linha. Reportado ao FreeBSD; na data desta publicação, sem commit no main e sem CVE.

Aviso Legal

Material educacional e defensivo, publicado em coordenação com o projeto FreeBSD. A falha foi reportada primeiro ao FreeBSD Security Team; a correção (uma linha) está em revisão em reviews.freebsd.org/D60474, com teste em D60478. Na data desta publicação ainda não há commit no main nem CVE atribuída. Por esse motivo este texto descreve o mecanismo e um reprodutor mínimo — que apenas demonstra o desvio do bounds check — e não publica cliente de exploração nem a técnica de controle de execução. Qualquer reprodução deve ocorrer apenas em VM descartável, em sistemas próprios ou com autorização escrita.

Ficha técnica — EndianGhost

Componente: nvmft(4), sys/dev/nvmf/controller/nvmft_controller.c, função nvmft_handoff_io_queue().  ·  Classe: CWE-129 (validação imprópria de índice) → CWE-787 (escrita fora dos limites) → CWE-416 (use-after-free).  ·  CVSS v3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H — 9.8 Critical, com a ressalva de que o escopo é apenas big-endian.  ·  Afetado: FreeBSD 15.0 e 15.1 em powerpc64 (big-endian); o driver surgiu no 15.0.  ·  Não afetado: todas as plataformas little-endian (amd64, arm64, armv7, riscv64, powerpc64le) e todo o 14.x.  ·  Introduzido em: a15f7c96a276.  ·  Commit examinado: 88e7371d9dc2 (release/15.1.0-p3).  ·  Correção: em revisão — D60474 (fix) e D60478 (teste kyua).  ·  Crédito: este autor, como Ermenson Junior; patrocínio BSDTrust.

O defeito, em uma frase

nvmft_handoff_io_queue() faz o bounds check sobre cmd->qid — o campo little-endian exatamente como chegou na wire — mas usa le16toh(cmd->qid) como subscrito do array. Num build big-endian o valor que é validado não é o valor que é usado, então um cliente NVMe/TCP remoto e não autenticado coloca uma escrita fora de ctrlr->io_qpairs[] num deslocamento à escolha dele.

A escrita fora dos limites não é a pior consequência. Como o par de filas é armazenado fora do array, ele nunca é registrado dentro dele, e todos os caminhos de teardown percorrem apenas o array válido. O par de filas fica então órfão — vivo e ainda atado ao controlador — sobrevive à destruição do controlador, e o caminho de recepção dele passa a desreferenciar memória liberada num momento que o cliente escolhe. Em laboratório essa cadeia foi composta até o controle do ponteiro de instrução do kernel; o detalhe está na seção dedicada mais abaixo.

Contexto: nvmft(4), ctld(8) e o handoff

nvmft(4) é o componente de kernel de um controlador NVMe over Fabrics, implementado como um frontend do ctl(4). O ctld(8) aceita a associação, faz a negociação de transporte e entrega o par de filas já conectado ao kernel pelo ioctl CTL_NVMF_HANDOFF. É nesse handoff que o campo qid da fila de I/O é processado.

Nada no caminho normaliza a ordem de bytes

O comando chega como bytes crus da wire e atravessa três camadas sem jamais ser reconstruído campo a campo:

  • libnvmf guarda o SQE recebido com um memcpy simples (nvmf_transport.c:135); só o campo fuse é alterado depois; nvmf_capsule_sqe() devolve o ponteiro direto.
  • ctld(8) repassa o ponteiro inalterado (nvmf.cc:444-450); a string qid não aparece em lugar nenhum do daemon — não há validação de id de fila ali. O único teste em libnvmf é cmd->qid == 0, invariante por byte-order.
  • O kernel recupera o comando com nvlist_get_binary() conferindo apenas len != sizeof(*cmd) (ctl_frontend_nvmf.c:1002-1008).

Mecanismo: validar o valor cru, indexar o decodificado

Dentro de nvmft_handoff_io_queue(), o qid é decodificado uma vez e guardado, mas o bounds check usa o campo cru e os acessos ao array usam o decodificado:

qid = le16toh(cmd->qid);                          /* :195  decodificado */
if (qid == 0)
        return (EINVAL);
...
if (cmd->qid > ctrlr->num_io_queues) {            /* :270  CAMPO CRU da wire */
        ... rejeita ...
}
if (ctrlr->io_qpairs[qid - 1].qp != NULL) {       /* :281  decodificado  */
        ... rejeita ...
}
ctrlr->io_qpairs[qid - 1].qp = qp;                /* :293  decodificado, ESCRITA */

A linha :270 é o único uso do campo não decodificado na função. A irmã nvmft_handoff_admin_queue() trata o mesmo campo corretamente, comparando com htole16(0) em :116. Escrevendo os bytes da wire do qid como b0 (menos significativo) e b1 (mais significativo), num host big-endian, o mesmo campo de 16 bits é lido de dois jeitos:

Ataque — qid = 256
00 01
bounds check (cru) :270 1
índice le16toh() :281 / :293 256
1 ≤ 1 passa → grava em io_qpairs[255] — fora dos limites
Legítimo — qid = 1
01 00
bounds check (cru) :270 256
índice le16toh() :281 / :293 1
256 > 1 rejeita — o qid = 1 válido é barrado
Num host big-endian, o mesmo campo de 16 bits é lido cru no bounds check e decodificado por le16toh() no índice. Os dois valores diferem por uma troca de bytes.

Sair dos limites exige índice > num_io_queues >= verificado, o que se reduz a b1 > b0. E num_io_queues é escolhido pelo próprio cliente via SET_FEATURES / NUMBER_OF_QUEUES (:735-767, intervalo 1..65535, dimensionando um mallocarray(N, 16)). O cliente seleciona o tamanho da alocação e a distância além do fim de forma independente — em qualquer ponto de [b0·256+b1, b1·256+b0−1].

Dois limites honestos do primitivo: a escrita só prossegue se o slot alvo estiver zerado (:281) — só transforma um ponteiro NULL em não-NULL; e o valor escrito é um ponteiro para struct nvmft_qpair determinado pelo alocador, não dado escolhido pelo atacante. O poder vem do que acontece depois, não do conteúdo da escrita.

Por que isto é pré-autenticação

nvmft_handoff_io_queue() contém três comparações que parecem autenticação e não são: a busca do controlador (:207-217) e os memcmp sobre hostid (:224) e hostnqn (:234). Elas comparam o CONNECT de I/O contra valores guardados no controlador — mas esses valores foram copiados do CONNECT de admin do próprio atacante momentos antes, em nvmft_controller_alloc (:66-67). Atam uma fila de I/O a uma associação que o mesmo cliente criou; não autenticam o host.

O nvmft não implementa autenticação in-band nesse caminho, e o ACL do ctl.conf (host_permitted()) só é chamado no caminho de discovery (nvmf_discovery.cc:101,108), nunca no portal de I/O. O atacante fornece o próprio hostid/hostnqn no admin e repete os mesmos valores no CONNECT de I/O malicioso; as checagens passam por construção.

É pré-autenticação. Nenhuma credencial, nenhum segredo, nenhuma interação: o único requisito é alcançar o portal de I/O NVMe/TCP.

Reprodução: o PoC mínimo

A sequência completa, contra um alvo rodando ctld(8) com um portal NVMe-oF na configuração documentada:

  1. CONNECT com qid = 0 — fila de admin, controlador alocado.
  2. PROPERTY_SET CC.EN = 1.
  3. SET_FEATURES / NUMBER_OF_QUEUES — o cliente escolhe o tamanho do array.
  4. CONNECT com os bytes do qid em b1 > b0 — passa o bound, indexa fora do intervalo.
nvmf-tcp · I/O portal :4420 LIVE
1
admin CONNECT (qid = 0) controlador alocado; CC.EN = 1
SET_FEATURES / NUMBER_OF_QUEUES
2
N = 1 fila concedida o cliente escolhe o tamanho do mallocarray(N, 16)
I/O CONNECT, wire qid = [00 01]
3
cru = 1 passa, índice = 256 :270 aceita; :293 grava em io_qpairs[255]
+4080 bytes numa alocação de 16
escrita fora dos limites o par de filas nasce fora do array
[1] admin CONNECT → cntlid=0, controller alloc
[2] SET_FEATURES NUMBER_OF_QUEUES → granted N=1
[3] I/O CONNECT qid=256 (wire 00 01) → raw read = 1 ≤ 1
[+] stored at io_qpairs[255] — out of bounds

A tabela controle/teste e o log do kernel

Contra um controlador com uma fila concedida, dois CONNECTs bastam. Não há pacote forjado: num cliente big-endian, htole16(256) já põe os bytes [00 01] na wire — a codificação little-endian correta de 256.

Pedido Bytes na wire Leitura crua Resultado (big-endian)
qid = 1 (CONTROLE)01 00256REJEITADO em :270
qid = 256 (TESTE)00 011ACEITO, indexa io_qpairs[255] — fora dos limites

Isto aparece no console/dmesg do alvo, sem interpretação do lado do cliente — e, revelador, a mensagem imprime o valor decodificado, não o que a checagem usou. O IPO: 0x2a00 é offsetof(..., qid) = 42 = 0x2a entregue byte-swapped, identificando a chamada nvmft_connect_invalid_parameters() em :276; a mensagem de re-criação é o nvmft_printf de :285.

nvmft0: attempt to create invalid I/O queue 1 from nqn...
        (qid=1: wire 01 00, verificado cru como 256 > 1 -> rejeitado; a mensagem imprime o decodificado 1)
nvmft0: attempt to re-create I/O queue 256 from nqn...   (:285, dentro do ramo de :281)

Não há rota legítima para essa mensagem: alcançar :281 com qid = 256 exige passar por :270, o que exigiria num_io_queues >= 256. Foi concedida uma. O reprodutor publicado para aqui, deliberadamente: demonstra apenas o desvio do bounds check, e não constrói o use-after-free nem qualquer cadeia além dele.

Medições: varrendo o primitivo

Em vez de uma amostra, uma varredura. Medindo quais valores de qid passam por :270 para uma contagem concedida N, ao longo de 35 probes, a regra de aceitação fica inequívoca:

N concedido qid Bytes na wire Leitura crua (BE) Resultado
251200 022aceito → store em io_qpairs[511], +8176 além de uma alocação de 32 bytes
255101 00256rejeitado
256101 00256aceito
255768…512000 0k3…20aceito → stores em +12272 … +81904 ((qid−1)·16)

A linha N=2, qid=512 é a mais clara: uma alocação de 32 bytes, um store 8176 bytes além do fim dela, dentro do objeto que vier a seguir. Prever a aceitação como bswap16(qid) ≤ N acertou 35 de 35 probes; prever como qid ≤ N acertou 8 de 35.

Acurácia da previsão de aceitação, em 35 probes: o modelo correto é a troca de bytes, não o valor literal.

Modelo de previsãoAcertos
bswap16(qid) ≤ N35 / 35 (100%)
qid ≤ N8 / 35 (23%)

Distância da escrita além do fim da alocação

Como tanto o tamanho da alocação quanto o índice são escolhidos pelo cliente, a distância da escrita fora dos limites varia por probe. Um caso é benigno e eu mesmo o declaro:

Distância do store além do fim da alocação, por probe. +4080 cai na folga de cauda de um item de 4096 bytes — o único caso contido.

ProbeDistânciaNatureza
N=255, qid=256+4080folga de cauda (4096 B) — contido
N=2, qid=512+8176objeto vizinho (alocação de 32 B)
N=255, qid=768+12272objeto vizinho (alocação de 32 B)
N=255, qid=5120+81904objeto vizinho (alocação de 32 B)

Ressalva que levanto eu mesmo: no caso N=255, qid=256 o store +4080 cai na folga de cauda de um item de 4096 bytes — uma escrita dentro do item, não num vizinho. Os casos +8176 e +12272…+81904 são escritas genuínas em objetos vizinhos.

Da fila órfã ao use-after-free

A sequência, cada passo lido do código:

  1. nvmft_finish_accept() ainda roda depois da escrita fora dos limites, setando qp->ctrlr = ctrlr e respondendo sucesso ao cliente (:296; nvmft_qpair.c:367). O cliente tem uma fila de I/O plenamente funcional da qual o controlador não guarda registro.
  2. Fechar um par de filas de I/O é explicitamente ignorado — comentário If an I/O queue pair is closed, just ignore it em :422, código :424-426. O struct nvmft_controller não tem refcount, então a órfã não mantém nada vivo.
  3. nvmft_controller_shutdown() itera i < num_io_queues (:313, :334), então a órfã nunca é passada a nvmft_qpair_shutdown()/destroy(). O kthread de recepção dela continua rodando.
  4. nvmft_controller_terminate() libera o controlador (:406 → free(ctrlr, M_NVMFT)).
  5. O caminho de recepção da órfã lê o qp->ctrlr obsoleto; a guarda if (ctrlr == NULL) (nvmft_qpair.c:76) passa, porque após o free o ponteiro é memória liberada não-NULL.
  6. O primeiro capsule de I/O executa atomic_store_int(&ctrlr->ka_active_traffic, 1) (:1024) — escrita em memória liberada antes de qualquer despacho de opcode; fechar o socket, em vez disso, chega a mtx_lock(&ctrlr->lock) (:468) sobre memória liberada.
nvmft_controller lifecycle LIVE
1
escrita OOB aceita nvmft_finish_accept() responde sucesso
fora de io_qpairs[] → sem registro
2
fila órfã, viva kthread de recepção segue rodando; sem refcount
teardown só percorre o array válido
3
controlador liberado free(ctrlr, M_NVMFT); a órfã sobrevive
cliente envia o próximo capsule — no momento à escolha
use-after-free piso: pânico remoto e não autenticado

Hipótese, não afirmação. O struct nvmft_controller tem 5280 bytes (embute struct nvme_controller_data, 4096 B) e cai na zona malloc-8192. O refill natural do slot liberado é outro nvmft_controller, que um cliente obtém abrindo uma segunda associação. Se esse reuso for confiável, a fila órfã de uma associação operaria sobre o controlador de outra e alcançaria os namespaces dela, ignorando as checagens de hostid/hostnqn — passando de um bug de memória para uma falha de isolamento. Não demonstrei isso e não o afirmo; é a pergunta que decide a natureza da falha.

Controle de execução do kernel

O use-after-free não fica num pânico. Tudo o que segue foi dirigido num convidado QEMU descartável — FreeBSD 15.1-RELEASE/powerpc64, GENERIC de fábrica, sem INVARIANTS/WITNESS/KASAN — e é descrito como evidência de laboratório, não como exploit distribuído. O powerpc64 não tem KASLR, então endereços de texto do kernel são fixos.

O call site, no próprio kernel

O struct nvmft_controller embute um struct task shutdown_task. O caminho de erro em nvmft_controller_error() faz taskqueue_enqueue(taskqueue_thread, &ctrlr->shutdown_task) (:482) sobre o controlador já liberado, e o worker do taskqueue então chama task->ta_func(task->ta_context, pending) lendo ambos de memória liberada. O desmonte do kernel RELEASE em 0x95f138:

95f138: ld    12, 16(29)    ; r12 = task->ta_func     (task + 16)
95f140: ld     3, 24(29)    ; r3  = task->ta_context  (task + 24)
95f144: mtctr 12
95f148: bctrl               ; chama ta_func(ta_context, pending)

shutdown_task está no offset 5136 do controlador, então ta_func fica em 5152 e ta_context em 5160.

A primitiva de refill

Como escrever conteúdo escolhido nesses campos? Cada qpair aloca qp->cids = BITSET_ALLOC(65536 bits = 8192 bytes) (nvmft_qpair.c:117), a mesma zona UMA (malloc-8192) do struct nvmft_controller. O cliente seta qualquer bit desse bitmap escolhendo o CID de uma capsula que envia (nvmft_qpair.c:91, cru, sem byteswap), e um AER estacionado — nunca completado — deixa seu bit setado. Assim o cliente escreve os bits de ta_func e ta_context com os valores que quiser, sobre o slot recém-liberado.

Três execuções demonstradas

taskqueue_run_locked+0x228 · powerpc64 LIVE
UAF no controlador liberado erro de transporte enfileira shutdown_task
refill: bitmap de CIDs na zona malloc-8192
1
ta_func / ta_context forjados escritos bit a bit via CIDs de AERs estacionados
taskqueue worker: bctrl
PC do kernel controlado chama qualquer função com um argumento à escolha

Três runs, mesmo refill e gatilho, variando só os CIDs das capsulas:

  1. Ponteiro de instrução à escolha. ta_func = 0xc0de000000000000 → o kernel buscou de 0xc0de000000000000: srr0 = 0xc0de…, lr = 0xc00000000095f14c (= o bctrl de taskqueue_run_locked+0x228 + 4), comm = thread taskq. O lr é dispositivo: prova que foi uma chamada pelo call site, não um branch qualquer.
  2. Função do kernel executada até o fim. ta_func = kdb_backtrace, ta_context = 0 → o kernel a executou e o convidado sobreviveu (repetível); o frame #1 do backtrace é taskqueue_run_locked+0x228 — o mesmo call site, agora nomeado pelo trace.
  3. Argumento à escolha, desfecho distinto. ta_func = shutdown_nice, ta_context = RB_POWEROFF (0x4000) → a máquina desligou e não voltou (Uptime: como última linha, sem banner de firmware depois). O argumento chegou intacto.

Classe de exceção do powerpc64 por valor forjado de ta_func: 0x400 (página não mapeada, típico de NULL) vs 0x480 (segmento sem SLB — alcançável só por um branch para muito longe de qualquer segmento mapeado).

Runta_funcExceção
refill zerado0x00x400
valor forjado0xc0de…0x480

A classe de exceção muda com o valor: 0x400 (segmento mapeado, página não mapeada — o que um fetch de NULL produz) vs 0x480 (nenhuma entrada de SLB para aquele segmento — alcançável só por um branch para muito longe de qualquer segmento mapeado). O número se moveu exatamente na direção que o valor forjado prevê; um NULL residual não produz 0x480. A primitiva, dita uma vez: remoto, pré-autenticação, chamar qualquer função do kernel com um primeiro argumento à escolha, no momento à escolha, com o pânico remoto não autenticado como piso.

O teto honesto

Execução de código arbitrária plena (por exemplo, um arquivo escrito em disco) foi tentada e não alcançada — por geometria de heap, não por falta de esforço, e não é prova de que não exista caminho. O pivot precisa cair em memória cujo conteúdo o atacante controla (o bitmap de 8 KB existe), mas cujo endereço é desconhecido, e o texto do kernel não é gravável — então a cadeia não tem onde pousar. Uma primitiva de divulgação de endereço a partir do mesmo bug foi projetada, mas não construída, e revelaria o endereço errado (o do objeto qpair, não o do bitmap dentro dele). Então: controle do fluxo de execução do kernel está demonstrado; a ACE plena está bloqueada por um pré-requisito ausente (um leak de um endereço específico) que uma segunda vulnerabilidade com leitura arbitrária forneceria.

Postura de divulgação. O cliente de exploração weaponizado que produziu esses consoles não é publicado — é um exploit funcional de execução de código em kernel para software sem correção. Por prática de divulgação coordenada, ele não é distribuído sem solicitação; uma reprodução viva em powerpc64 foi oferecida ao FreeBSD sob pedido. O reprodutor público para no desvio do bounds check.

O mesmo defeito quebra o uso normal em big-endian

Como qid = k é comparado como 256·k, uma fila de I/O normal só é aceita quando o controlador concedeu pelo menos 256·k filas. Medido: qid = 1 é rejeitado com 255 filas concedidas e aceito com 256. Iniciadores pedem aproximadamente uma fila de I/O por CPU, então, na prática, I/O NVMe-oF contra um alvo big-endian falha — mas sendo preciso em vez de dramático: não é categoricamente impossível, apenas exige um iniciador pedindo ≥ 256 filas.

Dois outros defeitos de ordem de bytes apareceram na mesma sessão: o caminho de discovery reportou Too many discovery log entries: 4294967296 (um 1 byte-swapped), e o campo IPO na resposta de erro do CONNECT chega byte-swapped. Levanto tudo isso porque corta contra o meu próprio relato: é improvável que exista uma frota de alvos NVMe-oF funcionando em powerpc64 para atacar. Isso não faz o erro de memória desaparecer — o listener responde, a associação de admin completa, e SET_FEATURES é um comando da fila de admin — mas é o contexto honesto para decidir entre um aviso de segurança e uma errata. Essa decisão é do projeto.

Análise de variantes: sem irmãos

Varri toda a árvore nvmf atrás da mesma forma — um campo multibyte da wire comparado cru enquanto seu valor decodificado é usado em outro lugar. A linha :270 é a única comparação multibyte crua; todos os outros campos da wire são consistentemente decodificados com le*toh antes do uso (kato :168, qid :195, cntlid :198, cdw10-13 :547-550, cns :666, nsid :697, fid/num_queues :733-747, aer_mask :780, offsets de property-get/set :861/:899). O cmd->cid é cru por intenção documentada (comentário Store the CID without byte-swapping em :992 e nvmft_qpair.c:90). Os frontends e o transporte vizinhos não têm site equivalente.

Ou seja, a correção de uma palavra em :270 é completa — não há irmão a caçar — e o fato de este driver decodificar corretamente todos os outros campos faz de :270 um descuido, não um estilo de casa deliberado.

Severidade, CVSS e CWE

A cadeia de classes é CWE-129 (validação imprópria de índice, o mecanismo: a checagem valida um valor e o subscrito usa outro) → CWE-787 (escrita fora dos limites) → CWE-416 (use-after-free). Não é CWE-20 (uma categoria) nem CWE-119 (o pai).

CVSS v3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8 Critical. AV:N porque é NVMe/TCP remoto; PR:N porque não há autenticação in-band; AC:L porque o desvio → escrita fora dos limites → órfã → use-after-free → pânico não precisa de grooming (e o refill de controle é uma única alocação na cabeça LIFO do alocador, repetível à vontade a partir de uma associação nova — sucesso repetível, não uma corrida); S:U porque o componente vulnerável e o impactado são ambos o kernel; A:H demonstrado (desligamento ordenado sob comando); I:H valor escolhido pelo atacante executado como PC do kernel; C:H pelo tratamento padrão de potencial de execução de código.

O score se aplica a FreeBSD big-endian com nvmft configurado e seu portal alcançável; little-endian não é afetado. Esse limite é escopo de produto afetado, não uma métrica CVSS — não está dobrado no AC. O FreeBSD é sua própria CNA e atribui CVEs do próprio pool.

Novidade

Checado em OSV (por commit e por pacote), NVD, GHSA e OSS-Fuzz: sem correspondência. git log --all -S 'cmd->qid > ctrlr->num_io_queues' -- sys/dev/nvmf/ retorna só o commit introdutor. Também li à mão a correção do FreeBSD-SA-26:07.nvmf / CVE-2026-4652 (commit 09c5bb35425b, 2026-03-23): um fix de uma linha para NULL-deref no printf de erro desta mesma função (ctrlr->cntlid → cntlid) que não toca a linha 270. São falhas distintas.

Escopo: ramos, arquiteturas e laboratório

O código e a linha foram verificados em cada ramo suportado:

Ramo Driver presente Linha vulnerável Situação
mainsimsimafetado (sem SA para -CURRENT)
stable/15simsimafetado
releng/15.1simsimafetado
releng/15.0simsimafetado
stable/14ausente—não afetado
releng/14.4ausente—não afetado

nvmft(4) surgiu no FreeBSD 15.0, então o 14.x não tem exposição. Arquitetura: apenas builds big-endian — powerpc64, a única big-endian ainda suportada no 15.x. Em amd64, arm64, armv7, riscv64 e powerpc64le, le16toh() é a identidade e as duas expressões coincidem. Introduzido em a15f7c96a276; commit examinado 88e7371d9dc2 (release/15.1.0-p3); a linha não mudou em nenhum ramo desde então. Laboratório: QEMU -machine pseries, hw.byteorder: 4321, nvmft.ko carregado, ctld(8) do sistema base com ctl.conf padrão (um transport-group, um namespace ramdisk, sem auth).

Cronologia da divulgação

2026-08-28

Reporte ao FreeBSD Security Team, nada publicado. Gordon Tetlow confirma o recebimento e, por afetar só big-endian (plataforma tier-2, fora da cobertura do secteam, reservada a amd64/arm64), recomenda tratar como bug normal via Bugzilla.

2026-08-28

Reprodutor mínimo e evidências empacotados (FreeBSD 15.1-RELEASE/powerpc64 sob QEMU).

2026-10-13

John Baldwin publica uma correção creditando o autor em reviews.freebsd.org/D60474, com teste kyua em D60478 (precisou corrigir dois bugs vizinhos no envio de erros de CONNECT para o teste passar).

2026-10-22

O Phabricator do FreeBSD volta ao ar (ficou fora no dia 21 por manutenção ligada a CVEs recentes de PHP); a revisão D60474/D60478 está acessível em reviews.freebsd.org e segue em aberto.

Situação atual

Na data desta publicação: sem commit no main e sem CVE atribuída.

Correção e mitigação

A correção primária é de uma linha — comparar o valor decodificado que de fato é usado como índice:

--- a/sys/dev/nvmf/controller/nvmft_controller.c
+++ b/sys/dev/nvmf/controller/nvmft_controller.c
@@ nvmft_handoff_io_queue @@
-	if (cmd->qid > ctrlr->num_io_queues) {
+	if (qid > ctrlr->num_io_queues) {

Além disso, vale considerar uma asserção nos caminhos de teardown (nvmft_controller_shutdown / nvmft_controller_terminate) de que todo par de filas vivo é alcançável a partir de io_qpairs[] — ou uma lista encadeada de pares vivos. É isso que transforma o erro de limites num use-after-free em vez de uma corrupção contida: um erro de contabilidade futuro falharia ruidosamente. (Como nota de correção, não de segurança, o offset % 3 no topo de handle_get_log_page parece um typo de % 4, alinhamento de dword.)

Referências

  • reviews.freebsd.org/D60474 — correção proposta (uma linha), creditando o autor
  • reviews.freebsd.org/D60478 — teste kyua para o caso
  • man nvmft(4) — o controlador NVMe over Fabrics (HISTORY: surgiu no 15.0)
  • man arch(7) — arquiteturas suportadas e sua ordem de bytes
  • FreeBSD-SA-26:07.nvmf / CVE-2026-4652 — correção vizinha de uma linha (NULL-deref no printf de erro da mesma função), distinta desta falha.
Compartilhe:

Ermenson Marcos Rodrigues Junior

Segurança Ofensiva | Pentester | Red Team

Analista de Segurança Ofensiva com experiência prática em testes de intrusão. Acredita que compartilhar conhecimento é a melhor forma de crescer na área. Formado pela Desec Security e praticante constante de HTB e TryHackMe.