
Recherche originale et PoC non destructif pour un débordement de tampon de pile avant authentification via un scanset sscanf non borné dans le gestionnaire ipFilterList du Netis NC63
sscanf %[^,] dans le gestionnaire ipFilterList de skk_set.cgi sur le routeur Netis NC63Chercheur : Özcan Ersan (@ozcanpng)
CVE-2026-76071NC63_V3.0.0.3327/bin/netis.cgiPOST /cgi-bin/skk_set.cgiipFilterList=moddestHostsrcHostL'analyseur générique MIB/valeur du micrologiciel Netis NC63 V3.0.0.3327
analyse le champ destHost de ipFilterList avec deux ensembles %[^,]
mais sans largeur de champ maximale. Chaque conversion écrit dans un tampon
de pile local de 16 octets. Un client HTTP direct peut donc soumettre un
composant long sans virgule et écraser les données de contrôle sauvegardées
de la fonction.
Pour le deuxième composant destHost testé dynamiquement, le ra sauvegardé
se trouve à exactement 112 octets du tampon local. Le traçage QEMU contre le
CGI à empreinte d'origine a confirmé une troisième entrée choisie par
l'attaquant à 0x0040f7f4. Un test séparé d'observation uniquement a redirigé
le retour vers le chemin PLT d'origine de system() à 0x00423ab0 tout en
préservant les données de requête contrôlées par l'attaquant comme argument
MIPS a0 exact. Un /bin/sh surveillé a journalisé le marqueur et exécuté
aucune commande.
Le PoC public contient délibérément uniquement un motif B surlong. Il
n'inclut pas la valeur privée de transfert de contrôle ni la construction de
limite de commande.
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

Le frontal du fournisseur construit srcHost et destHost sous forme de
trois composants séparés par des virgules et les envoie avec ipFilterList
à /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
});

La requête CGI validée et conforme HTTP ne contenait aucun en-tête Cookie ou
Authorization. /tmp/boa_auth était absent, pourtant l'exécution s'est
poursuivie dans le chemin d'analyse ipFilterList=mod et destHost. Le
défaut de corruption mémoire est l'ensemble sans largeur dans FUN_0040f7f4 ;
l'échec d'autorisation CGI plus large est la condition qui expose ce
gestionnaire privilégié avant authentification.

Client HTTP non authentifié
|
| POST /cgi-bin/skk_set.cgi
| ipFilterList=mod
| destHost=1,0.0.0.0,<composant long sans virgule>
v
FUN_004138a0
v
FUN_004134c8 (ligne de déclenchement ipFilterList)
v
FUN_00410898(request, "ipFilterList")
v
FUN_0040f7f4(request, trigger, mib_table, pMib)
|
| get_request_param("destHost")
v
sscanf(value, "%d,%[^,],%[^,]", ...)
|
| deuxième destination : char[16]
| aucune largeur d'ensemble maximale
v
écrasement de fp sauvegardé -> écrasement de ra sauvegardé -> PC contrôlé
Les métadonnées d'origine de libapmib.so mappent à la fois srcHost et
destHost au type 0x0c, ce qui sélectionne ce cas d'analyseur. La
validation dynamique a été effectuée avec destHost ; srcHost est inclus
comme couverture statique du même analyseur plutôt que comme revendication
dynamique distincte.

Pseudo-code normalisé dérivé de 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'est pas intrinsèquement la vulnérabilité. Le défaut est que
%[^,] n'a pas de largeur de champ maximale, donc sscanf n'a aucun moyen
de savoir que chaque destination ne fait que 16 octets. Un format sensible à
la capacité utiliserait une largeur telle que %15[^,], vérifierait
qu'exactement trois conversions ont réussi, puis validerait les adresses
analysées. Il s'agit d'une atténuation à titre d'exemple, pas d'un correctif
du fournisseur.
FUN_0040f7f4 commence à 0x0040f7f4 et crée une trame de 0x1d0 octets :
0040f7f4 addiu sp,sp,-0x1d0
0040f7f8 sw ra,0x1cc(sp)
0040f7fc sw fp,0x1c8(sp)
0040f800 sw s0,0x1c4(sp)
Les destinations de type 0x0c sont à fp+0x14c et fp+0x15c. Le ra
sauvegardé est à fp+0x1cc, ce qui donne la distance exacte depuis le
deuxième tampon :
0x1cc - 0x15c = 0x70 = 112 octets

La preuve PC isolée a utilisé 112 octets de bourrage suivis des trois octets
de poids faible en little-endian de 0x0040f7f4 ; le terminateur de sscanf
a fourni le quatrième octet nul. QEMU a observé deux entrées ordinaires de
l'analyseur suivies d'une troisième entrée provoquée par l'adresse de retour
écrasée :
parser_entry_hit=3 pc=0x0040f7f4
GPR28: ... s8 41414141 ra 0040f7f4
total_parser_entry_hits=3
PASS: la troisième entrée de l'analyseur est le RA sauvegardé écrasé.

Une validation privée distincte a formé ra=0x00423ab0, le chemin PLT de
system() du binaire d'origine. Un suffixe de clé de requête contrôlé est
resté dans a0 MIPS au retour. L'environnement d'exécution jetable a
remplacé /bin/sh par un journaliseur statique :
argv[0]=</bin/sh>
argv[1]=<-c>
argv[2]=<NC63_IPFILTER_RCE_PROOF>
CONTROLLED_MARKER_PREFIX_REACHED
PASS: les données de requête contrôlées par l'attaquant ont atteint system() en tant que a0 exact.
PASS: /bin/sh surveillé a enregistré argv et n'a exécuté aucune commande.
Cela établit une primitive RCE dans le chemin de code de production isolé. La fiabilité exacte de l'exploitation sur le routeur physique, le comportement de randomisation du noyau et l'exposition WAN par défaut n'ont pas été testés.
Le netis.cgi testé est octet pour octet identique à l'artefact de
production. Parce que l'état MIB adossé à la flash n'est pas disponible dans
qemu-user, le système de fichiers racine jetable a utilisé une accommodation
de laboratoire divulguée de libapmib.so qui allouait un état MIB à zéros et
ajustait un alignement de champ condensé. Elle n'a pas modifié le CGI,
l'analyseur de requêtes, le sscanf vulnérable, la trame de pile, le décalage
de retour sauvegardé, l'épilogue ni le chemin system(). Les métadonnées
statiques provenaient du libapmib.so d'origine du fournisseur.
L'exécutable d'origine a une base fixe, n'a ni canari de pile ni RELRO, et a une pile exécutable et un segment RWX. Ces propriétés soutiennent l'analyse d'exploitabilité mais ne remplacent pas les tests dynamiques de PC et de limite surveillée.

Génération à sec du corps encodé URL :
python3 poc/poc.py
Transmission explicite à une cible jetable autorisée :
python3 poc/poc.py --target http://192.168.1.1 --send
Le script public utilise un composant B de 115 octets pour démontrer la
condition de débordement. L'envoyer peut faire planter le processus CGI. Il
ne contient aucune chaîne de commande, aucun shellcode, aucune adresse de
retour vers system, aucun reverse shell ni persistance.
Une exploitation réussie peut exécuter des commandes choisies par l'attaquant dans le contexte de gestion du routeur. La configuration Boa d'origine exécute CGI en tant que root. Les conséquences potentielles incluent la divulgation de la configuration et des secrets du routeur, la manipulation DNS/pare-feu/routage, la redirection de trafic, l'interruption de service et la compromission complète de l'appareil.
%15[^,] pour chaque destination de 16 octets et exiger trois
conversions réussies.%s et %[...]
sans largeur.Voir evidence/README.md. Le pseudo-code normalisé et
les preuves d'instructions se trouvent sous
attachments/decompiled-functions/.
CVE-2026-76071 et autorisé la
divulgation publique.Aucun routeur physique n'a été flashé. Aucune commande réelle, aucun shellcode, aucun reverse shell, aucune persistance, aucune connexion réseau externe ni opération destructrice n'a été utilisée.