CVE-2025-62673
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.
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)
Archer AX53 v1.0 - https://www.tp-link.com/my/support/download/archer-ax53/
10.0 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
CWE-121 - Stack-based Buffer Overflow
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.
[<_<]> !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 advisory: https://www.tp-link.com/us/support/faq/4943/
2025-10-28 - Vendor Disclosure
2026-02-03 - Vendor Patch Release
2026-03-16 - Public Release
Discovered by Lilith >_> of Cisco Talos.