CVE-2025-58455

A stack-based buffer overflow vulnerability exists in the tmpServer opcode 0x1003 functionality of Tp-Link AX53 v1.0 1.3.1 Build 20241120 rel.54901(5553). A specially crafted network packets can lead to arbitrary code execution. An attacker can send packets 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/

9.1 – CVSS:3.1/AV:N/AC:L/PR:H/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.

In order to facilitate remote management of the TP-Link AX53 and many other TP-Link devices, the HomeShield phone app can connect to the device through the cloud. After authentication, an SSH port forward is setup from the cloud to the TP-Link router, which then allows for communication to network services listening on the TP-Link router’s localhost interfaces. Specifically for the HomeShield app, this is the tmpServer network service, which listens on TCP 127.0.0.1:20002. This service is able to modify a large amount of settings and configuration on the TP-Link router, however it is not 1-1 to the admin webportal that is accessible via LAN. Regardless, we start from where the tmpServer service starts parsing network packets:

“`
000247b4 int32_t p2_recv(struct tmp_client* tmpcli) // […] 00024808 int32_t bytes_recvd = 00024808 recv(__fd: tmpcli->fd, __buf: get_read_buf_ptr(tmpcli), __n: get_bytes_left_in_buf(tmpcli), __flags: 0) // [1] 00024808 00024818 if (bytes_recvd s< 0) 0002482c log(“tmpdServer.c:1382”, “TMP RECV ERROR”) 00024830 return 0xffffffff 00024830 // […] 000248dc tmpcli->insize += bytes_recvd 000248fc log(“tmpdServer.c:1398”, “TMP RECV DATA length = %d”, tmpcli->insize) 000248fc 00024910 while (true) 00024910 if (tmpcli->insize s<= 2) 00024914 return 0 00024914 00024920 uint32_t authhdrlen = GET_AUTH(tmpcli) // [2] 00024920 00024930 if (authhdrlen s< 0) 00024944 log(“tmpdServer.c:1413”, “GET AUTH ERROR”) 00024948 return 0xffffffff 00024948 00024960 if (tmpcli->insize s<= authhdrlen) 00024964 return 0 00024964 00024970 int32_t* datasize = GET_PKT_BY_STATUS(tmpcli) // [3]
“`

As always, our code flow starts from the call to `recv`[1], and in this case up to 0x4000 bytes can be sent in a single message or set of messages and then read in without error. The check at [2] doesn’t really do much important if our opcode is not `xfe`, which it never is, and then at [3] some actual checks are made depending on the state of the client connection. A quick digression into the client connection structure before continuing into the code:

“`
struct tmp_client __packed { uint32_t fd; void* sysinfo; uint32_t status; struct TmpPktIn* tdpin; uint32_t insize; struct TdpPkt* out; uint32_t outpkt_size; uint32_t serviceType; uint32_t serviceMask; uint32_t authHdrLen; };
“`

Most of these fields should be self explanatory, but it’s worth noting that the `status` field always starts at 0x1 for new connections, the `tdpin` buffer is max size 0x4000, and the `tdpout` buffer is also max size 0x4000. Continuing in the code, before we can send any actual data, we must perform a mini handshake in the form of ASSOCIATION packets, which we can see continuing into `GET_PKT_BY_STATUS`[3]:

“`
000245e8 int32_t GET_PKT_BY_STATUS(struct tmp_client* tmpcli) 000245fc uint32_t status = tmpcli->status 000245fc 00024604 if (status s>= 1) 0002460c if (status s<= 2) 0002462c log(“tmpdServer.c:1264”, “TMP GET ASSOC PKT FROM BUF”) 00024638 return check_assoc_pktlen_at_least_4(tmpcli) 00024638 00024614 if (status == 3) 00024650 log(“tmpdServer.c:1268”, “TMP GET DATA PKT FROM BUF”) 0002465c return get_data_pkt_from_buf(tmpcli) 0002465c 00024664 return 0xffffffff
“`

As mentioned, our client connection always starts at 0x1, so we hit the function at 0x24638, which just checks to see if the size of the input packet minus the size of the auth header (which can be up to 0x40) is greater than 0x4. Assuming this is true, we step back up to the function that started with `recv`:

“`
//[…] 00024970 int32_t* datasize = GET_PKT_BY_STATUS(tmpcli) // [3] 00024980 if (datasize s< 0) 00024994 log(“tmpdServer.c:1427”, “GET PKT ERROR”) 00024998 return 0xffffffff 00024998 000249a8 if (datasize == 0) 000249a8 break 000249a8 000249d0 int32_t result = ENTER_ASSOC_OR_DATA_CENTER(tmpcli, datasize) //[4] 000249d0
“`

Immediately after hopefully passing the initial checks, we enter the function at [4] which processes both ASSOC packets and DATA packets:

“`
00023d10 int32_t ENTER_ASSOC_OR_DATA_CENTER(struct tmp_client* tmpcli, int32_t* datasize) 00023d28 uint32_t status = tmpcli->status 00023d28 00023d30 if (status s>= 1) 00023d38 if (status s<= 2) 00023d58 log(“tmpdServer.c:1087”, “ENTER ASSOC CENTER”) 00023d64 return assoc_parse_set_tmpcli_status(tmpcli) // [5] 00023d64 00023d40 if (status == 3) 00023d8c return decode_data_pkt(tmpcli, datasize, log(“tmpdServer.c:1091”, “ENTER DATA CENTER”)) // [6] 00023d8c 00023d94 return 0
“`

Since our status is still currently 0x1, we enter the function at [5]:

“`
00023610 int32_t assoc_parse_set_tmpcli_status(struct tmp_client* tmpcli) 00023624 int32_t var_18 = 0 0002362c int32_t var_1c = 0 00023634 int32_t var_20 = 0 0002363c uint32_t authHdrLen = tmpcli->authHdrLen 0002363c 0002364c if (tmpcli == 0) 00023650 return 0xffffffff 00023650 00023674 memset(s: tmpcli->out, c: 0, n: 0x4000) 0002367c struct TdpPkt* out = tmpcli->out 00023694 out->rbuf_start_ver = 1 000236a8 out->reserved_0x0_or_0xf0 = 0 000236b4 out->opcode:1.b = 0 000236c4 struct assoc_pkt* assoc = tmpcli->tdpin + authHdrLen 000236c4 000236d8 if (tmpcli->status == 1) 000236e8 if (zx.d(assoc->assoc_opcode) == 1 && is_zero(assoc->is_a_request) != 0) 00023718 log(“tmpdServer.c:897”, “GET TMP ASSOC REQUEST PKT”) 00023724 out->opcode.b = TDP_REPEATER_SG_0x2 00023754 int32_t result = send(__fd: tmpcli->fd, __buf: tmpcli->out, __n: 4, __flags: 0) 00023754 00023768 if (4 != result) 00023770 result = 0xffffffff 00023770 0002377c set_tmpcli_status(tmpcli, status: 2) 000238ac return result
“`

Without much detail, for ASSOC packets, there’s only rudimentary checks, and a packet containing just `x00x00x01x00` will allow us to get past the first step. This sets our status to 0x2, and if we send another packet, we will hit a different code branch within `assoc_parse_set_tmpcli_status`:

“`
000237fc if (zx.d(assoc->assoc_opcode) == 2 && is_zero(assoc->is_a_request) != 0 000237fc && is_one_and_zero(assoc->needs_1, assoc->needs_0) != 0) 00023854 log(“tmpdServer.c:920”, “GET TMP ASSOC ACCEPT PKT”) 00023860 set_tmpcli_status(tmpcli, status: 3) 00023868 return 0
“`

Likewise for a status of 0x2, the checks are still rudimentary and a packet of `x01x00x02x00` allows us to reach the actual data packet processing within `decode_data_pkt(tmpcli, datasize, log(“tmpdServer.c:1091”, “ENTER DATA CENTER”)` which requires us to have a status of 0x3 [6]:

“`
000238bc int32_t decode_data_pkt(struct tmp_client* tmpcli, uint32_t datasize, int32_t arg3) 000238ec uint32_t authHdrLen = tmpcli->authHdrLen 000238f8 int32_t out_function_cb = 0 000238fc void* tmp = tmpcli 000238fc 00023904 if (tmp != 0) 0002390c tmp = tmpcli->status 0002390c 00023914 if (tmp == 3) 0002391c arg3 = 0x4000 00023920 tmp = datasize 00023920 00023928 if (0x4000 s>= tmp) 00023954 struct TmpDataPkt* in = tmpcli->tdpin + authHdrLen 00023974 int32_t decode_ret = check_tdp_data_in(in: tmpcli->tdpin + authHdrLen, inpsize: datasize) // [7]
“`

Starting out, assuming we’re within the 0x4000 size limit, we hit the first check on our data packet at [7]. Since the format for DATA packets is not the same as ASSOC packets, an example DATA HELLO packet is given below:

“`
pkt3 = b”” #pkt3 += b”x00″ # opcode. xfe => needs auth len/auth header #pkt3 += b”x00″ # serNameLen #pkt3 += b”x00″ # service mask # auth stuff would go here # need at least 0x10 bytes for datapkt pkt3 += b”x01″ # Need 0x1 pkt3 += b”x00″ # Need 0x0 pkt3 += b”x04″ # opcode # opcode != 0x5 => len 0x0 pkt3 += b”x00″ # idk 0x3 pkt3 += b”x00x00″ # datasize BE, max 0x3ff0 (doesn’t include 0x10 hdr) pkt3 += b”x00″ # flags # opcode != 0x5 => 0x0 pkt3 += b”x00″ # errcode pkt3 += b”x00x00x00x00″ # tdp_sn pkt3 += b”x00x00x00x00″ # checksum
“`

Continuing into `check_tdp_data_in`:

“`
00022e0c int32_t check_tdp_data_in(struct TmpDataPkt* in, int32_t inpsize) // […] 00022e34 00022e68 if (is_one_and_zero(in->need_0x1, in->need_0x0) != 0 && is_zero(in->need_0x0) != 0) // [8] 00022e9c uint32_t r0_5 = ntohl(in->checksum) 00022eb8 in->checksum = htonl(0x5a6b7c8d) 00022ec4 int32_t r0_8 = do_checksoum(in, inpsize) 00022ec4 00022ed8 if (r0_8 != r0_5) // [9] 00022ef4 log(“tmpdServer.c:681”, “TMP curCheckSum=%x; newCheckSum=%x”, r0_5, r0_8, inpsize, in) 00022ef8 return 3 00022ef8 00022f08 in->checksum = r0_5 00022f28 in->datalen = ntohs(in->datalen) 00022f28 00022f60 if (zx.d(in->opcode) == 5 && zx.d(in->datalen) != 0 && zx.d(in->datalen) + 0x10 != inpsize) 00022f78 log(“tmpdServer.c:694”, “TMP DATA TRANSFER PKT LENGTH ERROR %d”, inpsize, inpsize, inpsize, in) 00022f7c return 4 00022f7c 00022fa0 if (zx.d(in->opcode) != 5 && zx.d(in->datalen) != 0) 00022fc0 log(“tmpdServer.c:701”, “TMP PKT LENGTH ERROR %x”, zx.d(in->datalen)) 00022fc4 return 4 00022fc4 00022fe4 if (is_zero_(in->flag) == 0) // [10] 00023004 log(“tmpdServer.c:708”, “TMP FLAG ERROR %x”, zx.d(in->flag)) 00023008 return 5 00023008 00023028 in->tmp_sn = ntohl(in->tmp_sn) 00023048 log(“tmpdServer.c:716”, “TMP SN = %x”, in->tmp_sn) 0002304c return 0 0002304c 00022e88 return 1
“`

The main things to be aware of here are that we need 0x1 and 0x0 as our first two bytes to pass the check at [8], we need to have a correct CRC to pass the check at [9], and we also need to have our flag field set to 0x0 to pass the check at [10]. Assuming that’s all good, we return back up to `decode_data_pkt`:

“`
00023974 int32_t decode_ret = check_tdp_data_in(in: tmpcli->tdpin + authHdrLen, inpsize: datasize) // [7] 00023984 if (decode_ret s< 0) 00023998 log(“tmpdServer.c:974”, “TMP DECODE PKT error!!!”) 0002399c return 0xffffffff 0002399c 000239c0 memset(s: tmpcli->out, c: 0, n: 0x4000) 000239c8 struct TdpPkt* out = tmpcli->out 000239d8 int32_t build_an_outpkt_flag 000239d8 000239d8 if (decode_ret == 0) 00023a2c uint32_t opcode = zx.d(in->opcode) 00023a2c 00023a34 if (opcode == 5) 00023ad0 log(“tmpdServer.c:1003”, “TMP RECV DATA PKT”) 00023ae8 tmpcli->sysinfo = get_sysinfo() + 0x3c 00023af8 out->tdp_sn = in->tmp_sn 00023b1c out->errcode = handle_data_recv_(tmpcli, datasize, out_cb: &out_function_cb) & 0xff // [11] 00023b1c 00023b2c if (zx.d(out->errcode) != 0) 00023b8c log(“tmpdServer.c:1018”, “TMP DISPATCH PKT ERROR”) 00023b98 out->opcode.b = TDP_IDK_0x6 00023ba4 out->size_of_data = 0 00023bac build_an_outpkt_flag = 0 00023b2c else 00023b4c log(“tmpdServer.c:1011”, “TMP DISPATCH PKT OK %d”, tmpcli->outpkt_size) 00023b58 out->opcode.b = TMP_DATA_TRANSFER 00023b6c out->size_of_data = (tmpcli->outpkt_size).w & 0xffff 00023b74 build_an_outpkt_flag = 1 00023a34 else if (opcode s<= 5) 00023a44 if (opcode != 4) 00023c20 log(“tmpdServer.c:1044”, “TMP RECV ASSOC PKT and CLOSE SOCK”) 00023c24 return 0xffffffff 00023c24 00023a70 log(“tmpdServer.c:994”, “TMP RECV HELLO PKT”) // [12] 00023a88 tmpcli->sysinfo = get_sysinfo() + 0x3c 00023a94 out->opcode.b = TDP_ATTACH_MASTER 00023aa0 out->size_of_data = 0 00023ab0 out->tdp_sn = in->tmp_sn 00023ab8 build_an_outpkt_flag = 1 00023a3c else if (opcode == 6) 00023bc4 log(“tmpdServer.c:1026”, “TMP RECV BYE PKT”) 00023bd0 out->opcode.b = TDP_IDK_0x6 00023bdc out->size_of_data = 0 00023be4 build_an_outpkt_flag = 0 00023a50 else 00023a58 if (opcode != 0xff) 00023c20 log(“tmpdServer.c:1044”, “TMP RECV ASSOC PKT and CLOSE SOCK”) 00023c24 return 0xffffffff 00023c24 00023bfc log(“tmpdServer.c:1034”, “TMP RECV RENEGOTIATE PKT, SEND BYE PKT TO APP”) 00023c00 send_bye_pkt_to_app() 00023c08 build_an_outpkt_flag = 0 000239d8 else 000239f0 log(“tmpdServer.c:983”, “TMP PKT error NO. %d”, decode_ret, decode_ret, datasize, tmpcli) 000239fc out->opcode.b = TDP_IDK_0x6 00023a0c out->errcode = decode_ret.b & 0xff 00023a18 out->size_of_data = 0 00023a20 build_an_outpkt_flag = 0
“`

Before we can actually send DATA packets with, well, data, we first have to send a dummy DATA HELLO packet with an opcode of 0x5, such that we hit the code path at [12]. After this has happened, we can now send a DATA packet of similar format to hit the branch at [11] and then enter `handle_data_recv`:

“`
00023e4c int32_t handle_data_recv_(struct tmp_client* tmpcli, int32_t datasize, void* out_cb) 00023e68 char var_25 = 2 00023e90 uint32_t authHdrLen = tmpcli->authHdrLen 00023e9c int32_t var_24_1 00023e9c __builtin_memset(dest: &var_24_1, ch: 0, count: 0x14) 00023e9c 00023ea8 if (tmpcli == 0) 00023eac return 0xffffffff 00023eac 00023ec8 // okay, finally used lol 00023ec8 char serviceType = tmpcli->tdpin->sername[0xd + authHdrLen] 00023ec8 // […] 000245a0 000245a0 while (true) 000245b8 psess = (&psess1)[x].sesfunc 000245b8 000245c0 if (psess == 0) 000245c0 break 000245c0 000242e4 log(“tmpdServer.c:1193”, “serviceMask is 0x%x, try to login serviceType 0x%x”, tmpcli->serviceMask, 000242e4 zx.d((&psess1)[x].servicetype)) 00024308 log(“tmpdServer.c:1196”, “serviceType is %d, pSession->serviceType is %d”, zx.d(serviceType), tmpcli->serviceType) 0002432c log(“tmpdServer.c:1197”, “User Name is %s”, &tmpcli->tdpin->sername) 0002432c 00024370 if (check_service_type_and_mask(servicemask: tmpcli->serviceMask, serviceytpe: (&psess1)[x].servicetype) != 0) 000243d8 if (tmpcli->serviceType == 0 && zx.d((&psess1)[x].servicetype) == zx.d(serviceType)) 00024418 int32_t r0_16 = get_cli_by_ST(ST: serviceType) 0002444c int32_t idk = (&psess1)[x].idk 0002444c 00024454 if (r0_16 u>= idk) 0002447c log(“tmpdServer.c:1216”, “TMP_HDR_ERR_BT_EXCEED”, &psess1, idk) 00024480 return 7 00024480 00024464 tmpcli->serviceType = zx.d(serviceType) 00024464 00024494 // 0x1 or 0xf1 00024494 if (tmpcli->serviceType != 0 && tmpcli->serviceType == zx.d((&psess1)[x].servicetype)) 0002452c uint32_t r0_20 = // actually call psess func 0002452c (&psess1)[x].sesfunc(&tmpcli->tdpin->sername[0xd + authHdrLen], datasize – 0x10, &tmpcli->out->payload, out_cb) 0002452c 0002453c if (r0_20 s< 0) 00024550 log(“tmpdServer.c:1231”, “TMP_HDR_ERR_BT”) 00024554 return 6 00024554 00024564 tmpcli->outpkt_size = r0_20 00024578 log(“tmpdServer.c:1235”, “TMP_HDR_ERR_NONE”) 0002457c return 0
“`

To summarize the above code without getting too much into detail – based on the `uint8_t service_type` byte of our input packet, the code will walk a corresponding set of structures to find the opcode that we’re sending and then calling that specific function. An example packet calling the `service_type` opcode of 0x421 is given below:

“`
####################################### pkt4 = b”” pkt4 += b”x01″ # Need 0x1 pkt4 += b”x00″ # Need 0x0 pkt4 += b”x05″ # opcode # opcode != 0x5 => len 0x0 pkt4 += b”x00″ # idk 0x3 pkt4 += b”x00x00″ # datasize BE, max 0x3ff0 (doesn’t include 0x10 hdr). pkt4 += b”x00″ # flags # opcode != 0x5 => 0x0 pkt4 += b”x00″ # errcode? pkt4 += b”x00x00x00x00″ # tdp_sn?? pkt4 += b”x00x00x00x00″ # checksum # start of psession layer pkt4 += b”x01″ # service type (x01 or xf1) pkt4 += b”x01″ # version psess_opcode = 0x421 pkt4 += struct.pack(“>H”,psess_opcode) // Begin actual opcode data
“`

We’re almost to the point of talking about specific opcodes, but one more digression must be allowed. Every opcode follows the same overall code flow – our opcode-specific packet data is read in either as a JSON string which is converted to cjson objects or it’s read in via a basic TLV format. This parsed data is potentially written to specific offsets within a size 0x8000 static buffer, with each of the offsets and resulting data formats being opcode specific. This size 0x8000 buffer is then passed to luci wrappers around TP-Link specific lua bytecode binaries which all call their own specific functions. After the lua bytecode binary has finished, the output data is written back into this 0x8000 sized buffer and, assuming no errors have occurred, data is copied to the 0x4000 sized `struct TdpPkt* out` member of our client session, which is then sent back to us. Unfortunately, this basic overview is extremely important for understanding any of the `tmpServer` vulnerabilities, so a quick summary of the summary:

“`
[recv()] -> [0x4000 client session input data] -> [cjson or TLV parsing] -> [opcode specific 0x8000 buffer struct] -> [luci form data] -> [lua bytecode] -> [same 0x8000 buffer response data] -> [ 0x4000 client session output buffer ] -> [send()]
“`

Continuing on, finally we can start talking about vulnerability-specific code. For this vulnerability, unlike all the others, we need to change the `service_type` value in the above bytes to 0xf1 from 0x1, such that we hit a different set of opcodes and can reach the function that handles opcode 0x1003, `tpAPPMesh_addSlave`:

“`
0005018c int32_t tpAppMesh_addSlave(struct psess_farg* inp) 000501a0 int32_t result = 0 000501a8 int32_t var_20 = 0 000501b0 struct cjson_obj* cjhash = nullptr 000501b8 void* s = nullptr 000501cc int32_t entry_r2 000501cc log(“tpOneMesh.c:1198”, “You are in onmesh process center of tpAppMesh_addSlave”, entry_r2, 0) 000501cc 000501d8 if (inp != 0) 000501f4 if (tpAppMeshCheckAutoSync() != 0) // [13] 0005021c struct big_0x8000* inp_1 0005021c int32_t r2 0005021c // returned is big struct populated with wifi data 0005021c // bug inm here 0005021c inp_1, r2 = tpAPP_inf_parse_wifi_data(inp) // [14] 0005021c
“`

To start, there’s a configuration check at [13] to see if mesh auto-syncing is disabled, but by default this check passes, so we can ignore it. As such we hit the `tpAPP_inf_parse_wifi_data` function at [14] that actually parses our input data:

“`
0004dc78 struct big_0x8000* tpAPP_inf_parse_wifi_data(struct psess_farg* inp) 0004dcd4 int32_t var_30_1 0004dcd4 __builtin_memset(dest: &var_30_1, ch: 0, count: 0x28) 0004dcf0 char inpcpy[0x400] 0004dcf0 memset(s: &inpcpy, c: 0, n: 0x800) 0004dd08 char const* const var_838 = “2.4G” 0004dd08 void* const var_834 = &data_625e8 0004dd0c clear_bigstruct() 0004dd14 struct big_0x8000* result = &bigstruct 0004dd20 struct cjson_obj* inpcj 0004dd20 int32_t var_c_1 0004dd20 0004dd20 if (inp == 0) 0004dd34 var_c_1 = 0xffffffff 0004dd48 log(“tpOneMesh.c:321”, “pSession or pSyncWifiParams is null.”) 0004dd20 else 0004dd58 void* inpbuf = &inp->payload[8] 0004dd68 uint32_t inplen = inp->payload_len – 8 0004dd94 log(“tpOneMesh.c:328”, “%s %d: payloadLength %d”, “tpAPP_inf_parse_wifi_data”, 0x148, inplen) 0004ddac log(“tpOneMesh.c:329”, “”%s””, inpbuf) 0004ddc8 memset(s: &inpcpy, c: 0, n: 0x400) 0004dde4 memcpy(dest: &inpcpy, src: inpbuf, n: inplen) // [15] 0004ddfc inpcj = get_cjson_obj_from_inp(&inpcpy) // [16] 0004ddfc
“`

While our data is treated as a JSON string and converted to a CJSON object at [16], we don’t actually need to reach that far as there’s a copy of our ~0x4000 input data into a temporary stack buffer of size 0x400 and offset $sp-0x830 at [15]. This obviously leads to an out of bounds write on the stack and subsequent code execution.

“`
Thread 2.1 “tmpServer” received signal SIGSEGV, Segmentation fault. 0x41414140 in ?? () [^_^] SIGSEGV [o.O]> info reg r0 0x0 0 r1 0x51935 334133 r2 0x3c 60 r3 0x0 0 r4 0x5159c 333212 r5 0x355010 3493904 r6 0x1 1 r7 0x255ec 153068 r8 0x20 32 r9 0x0 0 r10 0x1 1 r11 0x41414141 1094795585 r12 0x1 1 sp 0x7e9a6a18 0x7e9a6a18 lr 0x4de20 319008 pc 0x41414140 0x41414140 cpsr 0xa0000030 -1610612688 fpscr tpidruro [^.^]> bt #0 0x41414140 in ?? () #1 0x0004de20 in ?? () Backtrace stopped: previous frame identical to this frame (corrupt stack?)
“`

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.