
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은 바이너리 자체를 제외한 모든 곳에 적용됩니다: