Talos Vulnerability Report

TALOS-2025-2290

Tp-Link AX53 v1.0 tdpServer ssh port update stack-based buffer overflow vulnerability

March 16, 2026
CVE Number

CVE-2025-62673

SUMMARY

A stack-based buffer overflow vulnerability exists in the tdpServer ssh port update functionality of Tp-Link AX53 v1.0 1.3.1 Build 20241120 rel.54901(5553). A specially crafted network packet can lead to stack-based buffer overflow. An attacker can send a packet to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

Tp-Link Archer AX53 v1.0 1.3.1 Build 20241120 rel.54901(5553)

PRODUCT URLS

Archer AX53 v1.0 - https://www.tp-link.com/my/support/download/archer-ax53/

CVSSv3 SCORE

10.0 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

CWE

CWE-121 - Stack-based Buffer Overflow

DETAILS

The TP-Link Archer AX53 AX3000 Dual Band Gigabit Wi-Fi 6 Router is currently among the most popular routers sold online, and boasts impressive gigabit speeds for the price. This router also features remote cloud access via the TP-Link HomeShield application and smart home functionality.

The tdpServer network service of the TP-Link AX53 router listens on port UDP 0.0.0.0:20002 and mainly handles mesh networking, allowing for multiple routers to form a singular wireless network. Notably a previous RCE bug in this service was found on the older TP-Link Archer AX21 router and documented by NCC group to be used for Pwn2Own 2022 Toronto here: https://www.nccgroup.com/research-blog/meshyjson-a-tp-link-tdpserver-json-stack-overflow/. This vulnerability follows a very similar code flow on the exact same protocol, so please refer to the link for more in-depth information for potential exploitation. Regardless, let us proceed with documenting this newer vulnerability.

The code flow starts from the start_tdpd_server() function, which waits in a select loop listening for network traffic:

000259c4    int32_t start_tdpd_server()
// [...]
00025b5c                    int32_t nfds = select(__nfds: serv_sock + 1, &__readfds, __writefds: nullptr, __exceptfds: nullptr, &__timeout)
00025b5c                    
00025b6c                    if (nfds s< 0)
00025b80                        if (*__errno_location() != 4)
00025b80                            break
00025b80                        
00025b88                        usleep(usec: 0x1f4)
00025b6c                    else if (nfds != 0)
00025c38                        memset(s: sndbuf, c: 0, n: 0x410)
00025c50                        memset(s: recvpkt, c: 0, n: 0x410)
00025c58                        int32_t var_2c_1 = 0
00025c60                        uint32_t var_d0 = 0
00025c98                        struct sockaddr addr
00025c98                        int32_t recvlen = recvfrom(fd: serv_sock, buf: recvpkt, n: 0x410, flags: 0, &addr, &addr_len)  // [1]
00025c98                        
00025ca8                        if (recvlen s< 0)
00025cbc                            do_log("tdpdServer.c:1369", "tdpd recv error!!!")
00025ca8                        else if (recvlen != 0)
00025cf0                            pthread_mutex_lock(&tdpprocess_mutex)
00025d18                            uint32_t recvip
00025d18                            int32_t r0_15 = tdpd_pkt_process(recvpkt, recvlen, resppkt: sndbuf, respsize: &var_d0, recvip: ntohl(recvip)) // [2]

The server reads in 0x410 bytes at [1] and subsequently parses the data within tdpd_pkt_process at [2]. We first show the packet structure before proceeding into the function:

struct TdpPkt __packed
{
    uint8_t rbuf_start_ver;
    uint8_t reserved_0x0_or_0xf0;
    enum tdp_opcode opcode;
    uint16_t size_of_data;
    uint8_t flags;
    uint8_t errcode;
    uint32_t tdp_sn;
    uint32_t cheksum;
    char payload[0x400];
};

00025224    int32_t tdpd_pkt_process(struct TdpPkt* recvpkt, int32_t recvlen, struct TdpPkt* resppkt, int32_t* respsize, int32_t recvip)

00025244        int32_t var_c = 0
00025244        
00025250        if (recvpkt == 0)
00025254            return 0xffffffff
00025254        
0002526c        if (0x10 s> recvlen)
00025284            do_log("tdpdServer.c:993", "recvbuf length = %d, less than hdr's 16", recvlen, recvlen)
00025288            return 0xffffffff
00025288        
00025294        // valid size == 0x10-0x410
00025294        int32_t pktlen = get_pktsize_if_lt_0x410(recvpkt)  // [3]
00025294        
000252a4        if (pktlen s<= 0)
000252b8            do_log("tdpdServer.c:1000", "tdp pkt is too big")
000252bc            return 0xffffffff
000252bc        
000252d8        do_log("tdpdServer.c:1003", "tdp pkt length is %d", pktlen, pktlen)
000252d8        
000252f4        if (validate_tdp_header_fields(rbufh: recvpkt, size: pktlen) s< 0)  // [4]
000252f8            return 0xffffffff
000252f8        
00025314        if (zx.d(recvpkt->reserved_0x0_or_0xf0) == 0)
00025338            return process_tdp_repeater_pkt_not_hit(recvpkt, inpsize: recvlen, resppkt, respsize, sendingip: recvip)  
00025338        
00025360        if (zx.d(recvpkt->reserved_0x0_or_0xf0) == 0xf0 && zx.d(data_415c4) == 1)
00025384            // way to bug
00025384            return process_tdp_by_opcode(recvpkt, inpsize: recvlen, resppkt, outsize: respsize, recvip) // [5]
00025384        
0002539c        do_log("tdpdServer.c:1028", "invalid tdp packet type")
000253a0        return 0xffffffff

Assuming the packet size is from 0x10-0x410 bytes, we pass the check at [3] and then the checksum is validated within [4] along with a quick check to see if pkt->rbuf_start_ver == 0x1. Assuming this is all good, we enter into process_tdp_by_opcode at [5] by default since the router is normally configured to not be a repeater:

00025670    int32_t process_tdp_by_opcode(struct TdpPkt* recvpkt, int32_t inpsize, struct TdpPkt* resppkt, int32_t* outsize, int32_t recvip)

00025690        int32_t var_c = 0
00025690        
000256b4        if (recvpkt == 0 || resppkt == 0 || outsize == 0)
000256b8            return 0xffffffff
000256b8        
000256dc        struct cloud_info out
000256dc        
000256dc        if (get_my_ip(&out) != 0)
000256e0            return 0xffffffff
000256e0        
00025700        do_log("tdpdServer.c:1169", "recv ip is %x, my ip is %x", recvip, out.lanip)
00025704        uint32_t lanip = out.lanip
00025704        
00025710        if (lanip == recvip)
00025724            do_log("tdpdServer.c:1172", "Ignore onemesh tdp packet to myself...", lanip, recvip)
00025728            return 0xffffffff
00025728        
00025754        do_log("tdpdServer.c:1176", "opcode %x, flags %x", zx.d(recvpkt->opcode), zx.d(recvpkt->flags))
00025754        
00025768        switch (zx.d(recvpkt->opcode) - 1)  // [6]
0002579c            case 0
00025794                // TDP_PROBE
0002579c                if ((zx.d(recvpkt->flags) & 1) == 0)
00025820                    do_log("tdpdServer.c:1195", "Invalid flags")
00025824                    return 0xffffffff
00025824                
000257ac                if (check_if_autodiscovery_disabled() == 0)
000257c0                    do_log("tdpdServer.c:1183", "Disable auto discovery.")
000257c4                    return 0xffffffff
000257c4                
000257d0                int32_t recvip_1 = recvip
000257d0                
000257f0                if (process_TDP_Probe(tdppkt: recvpkt, inpsize, rbfh_1: resppkt, outsize) s>= 0) // [7] 
000259b4                    return 0
000259b4                

Assuming that the network packet does not source from localhost, which does happen as this server spawns another process that continually sends out probes, then we hit the opcode based processing at [6]. For our current vulnerability we only care about case 0, the TDP_PROBE packet type, so let’s look inside the process_TDP_probe function at [7]:

0002e2ac    int32_t process_TDP_Probe(struct TdpPkt* tdppkt, int32_t arg2, struct TdpPkt* rbfh_1, struct TdpPkt* arg4)
// [...]
0002e37c        
0002e384        if (r3_2 != 0)
0002e388            r3_2 = rbfh_1
0002e388            
0002e390            if (r3_2 != 0)
0002e394                r3_2 = arg4
0002e394                
0002e39c                if (r3_2 != 0)
0002e3d4                    do_basic_chain_xor(pktpayload: &tdppkt->payload, len: zx.d(tdppkt->size_of_data))  // [7]
0002e3e4                    uint32_t size_of_data = zx.d(tdppkt->size_of_data)
0002e3f4                    inside_process_tdp_probe(&tdppkt->payload, size_of_data, "probe", size_of_data, arg4, rbfh_1, arg2, tdppkt) // [8]
// [...]

The data of our input packet is decoded with a chain XOR against a static array inside of the function at [7], and then the decoded data is processed inside of the inside_process_tdp_probe function at [8]:

0002bd84    int32_t inside_process_tdp_probe(uint16_t arg1, int32_t payload_size, char* probe_string_arg)
// [...]
0002be90        do_log("tdpOneMesh.c:1363", "Enter..., pRecvBuf is %s", payload, 0xffffffff)
0002bea8        struct onemesh_info info
0002bea8        memset(s: &info, c: 0, n: 0x50)
0002bec4        if (get_onemesh_info(&info) == 0)
0002bef8            struct onemesh_shm onemesh_dev
0002bef8            memset(s: &onemesh_dev, c: 0, n: 0x278)
0002bef8            
0002bf04            if (payload == 0)
0002bf18                do_log("tdpOneMesh.c:1376", "Invalid parameters")
0002bf1c                return 0xffffffff
0002bf1c            
0002bf34            reply_cjson = buf_to_cjson(payload, payloadsize: payload_size) // [9]
0002bf40            if (reply_cjson == 0)
0002bf54                do_log("tdpOneMesh.c:1382", "Invalid replyBuf")
0002bf58                return 0xffffffff
0002bf58            
0002bf6c            struct cjson_obj* r0_8 = cj_get_objItem(reply_cjson, "method")
0002bf8c            int32_t r0_10
0002bf8c            
0002bf8c            if (r0_8 != 0 && r0_8->type == CJ_STRING)
0002bfa0                // "probe"
0002bfa0                r0_10 = strcmp(s1: r0_8->value, s2: probe_string_arg)  // [10]
0002bfac            if (r0_8 != 0 && r0_8->type == CJ_STRING && r0_10 == 0)
0002bfdc                int32_t probresp = strcmp(s1: "probe_response", s2: probe_string_arg) // [11]
0002bfe8                struct cjson_obj* errcode
0002bfe8                

Not much to start with, but our decoded input packet is read in as a JSON string and converted to a CJSON object at [9]. The method field must either be probe or probe_response to get past the strcmp checks at [10] and [11]. Which one we need to send depends on if we’re sending traffic to the server process or the client process, but the code flow is the same in either case. Continuing on, a large amount of various fields are read in from the input JSON data field hashmap until we get down to the following code:

0002c94c      struct cjson_obj* ssh_port = cj_get_objItem(cj_probe_data, "ssh_port") // [12]
0002c94c
0002c96c      if (ssh_port != 0 && ssh_port->type == CJ_STRING)
0002c988        strcpy(dest: &overflowable, src: ssh_port->value)                    // [13]
0002c9a0        portdup = strdup(s: ssh_port->value)                                 // [14]
0002c9bc        do_log("tdpOneMesh.c:1642", "Split source buffer is = %s", portdup)
0002c9bc
0002cad4        for (char* strseped = strsep(s: &portdup, delim: ;"); strseped != 0;  // [15]
0002cad4            strseped = strsep(s: &portdup, delim: ;"))                         
0002ca00            int32_t r0_57 = atoi(str: strseped)                               // [16]
0002ca00
0002ca20            if (r0_57 != 22 && r0_57 == slaveport)
0002ca28              dont_update = 0
0002ca2c            break
0002ca2c
0002ca38            if (r0_57 == 22)
0002ca50              strcpy(dest: &newport, src: strseped)                        // [17]
0002ca38            else if (r0_57 s> 0 && r0_57 s<= 0xfffe)
0002ca8c              memset(s: &slavepport, c: 0, n: 6)
0002caa4              strcpy(dest: &newport, src: strseped)                        // [18]
0002caa8              break
0002caa8

At [12], the ssh_port field is read in from our json and treated as a string which is copied to the stack [13] and then strdup‘ed at [14]. This duplicated string is then treated as a comma-separated list and we iterate over each entry in the strsep for-loop at [15]. For each entry in the loop, the server calls atoi since it’s expecting a list of ports, and then assuming this port converts to a valid integer value within the range of 0-65535, the entire strsep‘ed string gets copied to a 8-byte stack buffer such that it can be written into the configuration further on.
Discerning eyes will quickly see the vulnerability present, as atoi has some lesser known properties. Assuming we pass a port string that resembles “22?AAAAAAAAAA…”, atoi will actually just return the integer of 22 since it hits the first non-integer character and stops, returning whatever integer it has read in at that point. Thus, with such a string we can overflow the newport variable with either call to strcpy at [17] and cause memory corruption on the stack up to 0x3f8 bytes past the newport variable. Due to the layout of the stack, there are a couple potential options for utilizing this memory corruption for gaining subsequent code execution.

Crash Information

[<_<]> !ls
***********************************************************************************
***********************************************************************************
r0         : 0x42414141                         | r9         : 0x0
r1         : 0x3b                               | r10        : 0x1
r2         : 0x0                                | r11[S]     : 0x7ed4696c
r3         : 0x42414141                         | r12[X]     : 0x40230
r4         : 0x42414141                         | sp[S]      : 0x7ed46170
r5[X]      : 0x3bf40                            | lr[L]      : 0x76f7eca4
r6         : 0x1                                | pc[L]      : 0x76f7ebac           <strchrnul+36>
r7[X]      : 0x26bf8                            | cpsr       : 0x20000010
r8         : 0x20                               | fpscr      : 0x0
tpidruro   : <unavailable>                      |
***********************************************************************************
   0x76f7eb9c <strchrnul+20>:   mov     r4, r0
   0x76f7eba0 <strchrnul+24>:   bl      0x76f7ef24 <strlen>
   0x76f7eba4 <strchrnul+28>:   add     r0, r4, r0
   0x76f7eba8 <strchrnul+32>:   pop     {r4, r5, r6, pc}
=> 0x76f7ebac <strchrnul+36>:   ldrb    r2, [r3], #1
   0x76f7ebb0 <strchrnul+40>:   cmp     r2, #0
   0x76f7ebb4 <strchrnul+44>:   popeq   {r4, r5, r6, pc}
   0x76f7ebb8 <strchrnul+48>:   cmp     r1, r2
   0x76f7ebbc <strchrnul+52>:   popeq   {r4, r5, r6, pc}
***********************************************************************************
#0  0x76f7ebac in strchrnul () from /home/thiefy/boop/tplink/ax3000/dumped_fw/newest_1.3.1/lib/ld-musl-arm.so.1
#1  0x76f7eca4 in strcspn () from /home/thiefy/boop/tplink/ax3000/dumped_fw/newest_1.3.1/lib/ld-musl-arm.so.1
#2  0x76f7f1a8 in strsep () from /home/thiefy/boop/tplink/ax3000/dumped_fw/newest_1.3.1/lib/ld-musl-arm.so.1
#3 0x0002cac8: (0x10000 0x40000 0x30000 0x0 r-xp /usr/bin/tdpServer)
***********************************************************************************
VENDOR RESPONSE

Vendor advisory: https://www.tp-link.com/us/support/faq/4943/

TIMELINE

2025-10-28 - Vendor Disclosure
2026-02-03 - Vendor Patch Release
2026-03-16 - Public Release

Credit

Discovered by Lilith >_> of Cisco Talos.