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.
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
memcpysimples (nvmf_transport.c:135); só o campofuseé alterado depois;nvmf_capsule_sqe()devolve o ponteiro direto. - ctld(8) repassa o ponteiro inalterado
(
nvmf.cc:444-450); a stringqidnã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 apenaslen != 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:
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:
- CONNECT com
qid = 0— fila de admin, controlador alocado. - PROPERTY_SET
CC.EN = 1. - SET_FEATURES / NUMBER_OF_QUEUES — o cliente escolhe o tamanho do array.
- CONNECT com os bytes do
qidemb1 > b0— passa o bound, indexa fora do intervalo.
CC.EN = 1
mallocarray(N, 16)
:270 aceita;
:293 grava em io_qpairs[255]
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 00 | 256 | REJEITADO em :270 |
qid = 256 (TESTE) | 00 01 | 1 | ACEITO, 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 |
|---|---|---|---|---|
| 2 | 512 | 00 02 | 2 | aceito → store em io_qpairs[511], +8176 além de uma alocação de 32 bytes |
| 255 | 1 | 01 00 | 256 | rejeitado |
| 256 | 1 | 01 00 | 256 | aceito |
| 255 | 768…5120 | 00 0k | 3…20 | aceito → 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ão | Acertos |
|---|---|
bswap16(qid) ≤ N | 35 / 35 (100%) |
qid ≤ N | 8 / 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.
| Probe | Distância | Natureza |
|---|---|---|
| N=255, qid=256 | +4080 | folga de cauda (4096 B) — contido |
| N=2, qid=512 | +8176 | objeto vizinho (alocação de 32 B) |
| N=255, qid=768 | +12272 | objeto vizinho (alocação de 32 B) |
| N=255, qid=5120 | +81904 | objeto 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:
nvmft_finish_accept()ainda roda depois da escrita fora dos limites, setandoqp->ctrlr = ctrlre 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.- 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. Ostruct nvmft_controllernão tem refcount, então a órfã não mantém nada vivo. nvmft_controller_shutdown()iterai < num_io_queues(:313, :334), então a órfã nunca é passada anvmft_qpair_shutdown()/destroy(). O kthread de recepção dela continua rodando.nvmft_controller_terminate()libera o controlador (:406 → free(ctrlr, M_NVMFT)).- O caminho de recepção da órfã lê o
qp->ctrlrobsoleto; a guardaif (ctrlr == NULL)(nvmft_qpair.c:76) passa, porque após o free o ponteiro é memória liberada não-NULL. - 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 amtx_lock(&ctrlr->lock)(:468) sobre memória liberada.
nvmft_finish_accept()
responde sucesso
free(ctrlr,
M_NVMFT); a órfã sobrevive
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
shutdown_task
bctrl
Três runs, mesmo refill e gatilho, variando só os CIDs das capsulas:
- Ponteiro de instrução à escolha.
ta_func = 0xc0de000000000000→ o kernel buscou de0xc0de000000000000:srr0 = 0xc0de…,lr = 0xc00000000095f14c(= obctrldetaskqueue_run_locked+0x228+ 4),comm = thread taskq. Olré dispositivo: prova que foi uma chamada pelo call site, não um branch qualquer. - 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. - 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).
| Run | ta_func | Exceção |
|---|---|---|
| refill zerado | 0x0 | 0x400 |
| valor forjado | 0xc0de… | 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 |
|---|---|---|---|
main | sim | sim | afetado (sem SA para -CURRENT) |
stable/15 | sim | sim | afetado |
releng/15.1 | sim | sim | afetado |
releng/15.0 | sim | sim | afetado |
stable/14 | ausente | — | não afetado |
releng/14.4 | ausente | — | 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
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.
Reprodutor mínimo e evidências empacotados (FreeBSD 15.1-RELEASE/powerpc64 sob QEMU).
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).
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.
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.