Talos Vulnerability Report

TALOS-2026-2435

Totolink X2000R batchupg OS command injection vulnerability

October 20, 2026

CVE Number

CVE-2026-10135

Summary

A OS command injection vulnerability exists in the batchupg functionality of X2000R (version(s): TOTOLINK-X2000R-Gh-V1.0.0-B20250421.1327). A specially crafted HTTP request can lead to arbitrary code execution. An attacker can make an authenticated HTTP request 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.

X2000R (version(s): TOTOLINK-X2000R-Gh-V1.0.0-B20250421.1327)

Product URLs

X2000R - https://www.totolink.net/home/menu/newstpl/menu_newstpl/products/id/242.html

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-78 - Improper Neutralization of Special Elements used in an OS Command (‘OS Command Injection’)

Details

The Totolink X2000R is a popular budget gigabit home router primarly found in eastern markets. Supporting IPV6, WPA3, mesh capabilities among other features, the Totolink X2000R is a solid choice for the price point.

The Totolink X2000R mainly relies on the standalone boa binary for all web administration and subsequent configuration. While normally a security risk in itself due to the age, it appears that the version running on the X2000R has been patched. Regardless, for the current set of vulnerabilities we only pay attention to the Totolink-specific issues, so we start from the point where authentication has already occurred via the /boafrm/formLogin webpage, and then we can successfully interact with another /boafrm/form* page, each of which have a corresponding callback. Today’s vulnerability deals with two seperate boa forms, /boafrm/formUploadFile and then /boafrm/formBuploadSetting, and then finally the batchUpg binary itself. To proceed, the formUploadFile() function:

0044ce38    size_t formUploadFile(struct client* cli)
0044ce50        int32_t upload_offset = get_readstart_offset(cli->uploadbuf)   // [1]
0044ce60        char var_d8[0xc8]
0044ce60        
0044ce60        if (upload_offset != 0xffffffff)
0044cf20            uint32_t $v0_2 = isFileExist("/tmp/bzupload")  // [2]
0044cf2c            char* uploadbuf = cli->uploadbuf
0044cf2c            
0044cf28            if ($v0_2 != 0)
0044cf34                get_filename_from_buf(buf: uploadbuf, dst: &upload_filename)
0044cf48                sprintf(dst: &var_d8, fmt: "<b>Upload Successful, file name: %s</b>", &upload_filename)
0044cf5c                write_to_firware_ini(readbuf: cli->uploadbuf, msgsize: cli->message_size_maybe, readoffset: upload_offset, 
0044cf5c                    filename: &upload_filename)
0044cf68                system(string: "killall batchupg >/dev/null 2>&1")
0044cf74                system(string: "batchupg & >/dev/null 2>&1")
0044cf84                return send_302_redirect(cli, redirect-url: "/bzupload.htm")
0044cf84            
0044cfb0            if (handleFirmwareFile(msgbuf: uploadbuf, max_size: cli->message_size_maybe, read_offset: upload_offset) == 0)  // [3]
0044cfbc                get_filename_from_buf(buf: cli->uploadbuf, dst: &upload_filename)                                           // [4]
0044cfd0                sprintf(dst: &var_d8, fmt: "<b>Upload Successful, file name: %s</b>", &upload_filename)
0044cfe4                write_to_firware_ini(readbuf: cli->uploadbuf, msgsize: cli->message_size_maybe, readoffset: upload_offset,  // [5]
0044cfe4                    filename: &upload_filename)
0044cf84                return send_302_redirect(cli, redirect-url: "/bupload.htm")

The general overflow of this function goes as such: we first find the start of our uploaded data file inside of the function at [1], and then check to see if the /tmp/bzuploadfile exists at [2], which doesn’t really matter in the long run and by default does not exist. Thus we hit the branch at [3], which requires us to upload a valid firmware file (which can be sidestepped via a specially crafted binary that side-steps the actual flashing process), and the filename of our file from the mulit-part POST data is copied to a static global at [4] and also copied in to the /tmp/firware.ini configuration file at [5]. With this filename written, we can now look at the /boafrm/formBuploadSetting functionality:

0044d01c    char* formBuploadSetting(struct client* arg1)
0044d03c        char* fw_key = get_from_postbody(cli: arg1, "fw_key", &cgi_var_pref) 
0044d03c        
0044d048        if (sx.d(*fw_key) != 0)
0044d068            inifile_set("/tmp/firware.ini", "firware", "csid", fw_key)
0044d068        
0044d07c        char* fw_ver = get_from_postbody(cli: arg1, "fw_ver", &cgi_var_pref) 
0044d07c        
0044d088        if (sx.d(*fw_ver) != 0)
0044d0a8            inifile_set("/tmp/firware.ini", "firware", "svn", fw_ver) 
0044d0a8        
0044d0b4        system(string: "killall batchupg >/dev/null 2>&1") 
0044d0c0        system(string: "batchupg & >/dev/null 2>&1")       
0044d0d4        char* result = get_from_postbody(cli: arg1, "submit-url", &cgi_var_pref) 
0044d0d4        
0044d0e0        if (sx.d(*result) == 0)
0044d108            return result
0044d108        
0044d0f8        return send_302_redirect(cli: arg1, redirect-url: result) __tailcall

More configuration gets written into the /tmp/firware.ini file and the program then kills an runs the batchupg binary which is why we need to send this request. Continuing into the batchupg binary, we summarize the general overview of the batchupg setup before examining the function in which the vulnerability is located.

00402c5c    int32_t main(int32_t argc, char** argv, char** envp)
// [...]
00402e74                        
00402e74                        if (pthread_create(&var_38, 0, UnicastReceive, 0) == 0) // [6]
00402f40                            pthread_detach(var_38)
00402f40                        
00402e98                        int32_t var_34
00402e98                        
00402e98                        if (pthread_create(&var_34, 0, PostFileToDevice, 0) == 0)
00402f50                            pthread_detach(var_34)
00402f50                        
00402ea0                        BroadcastReceive()   // [7]
00402ea0                        noreturn
00402ea0                    
00402f30                    perror(str: "bind")
00402f30                
00402f08                printf("[Batch Upgrade:%d]Unicast socket init error!\n", 0x2f8)
00402f08        
00402f20        return 0xffffffff

Alot of socket setup code has been truncated out, but it suffices to say that the program sets up a broadcast udp socket on port 13977 for listening and also a unicast udp socket that can be ignored which is utilized in the UnicastReceive() function [6]. For this vulnerability the broadcast socket is much more important so we look to the BroadcastReceive() function at [7]:

004029f0    void BroadcastReceive() __noreturn
// [...]
00402af8                            if (recvfrom(fd, &msg, len: 0xa000, flags: 0, &srcaddr, &var_a03c) u>= 0x94) // [8]
00402b00                                uint32_t* i = &msg
00402b04                                struct copied copied
00402b04                                uint32_t* x = &copied
00402b04                                
00402b30                                do                        // [9]
00402b10                                    int32_t $a3_1 = i[1] 
00402b14                                    int32_t $a2_1 = i[2]
00402b18                                    int32_t $a1_4 = i[3]
00402b1c                                    *x = *i
00402b20                                    x[1] = $a3_1
00402b24                                    x[2] = $a2_1
00402b28                                    x[3] = $a1_4
00402b2c                                    i = &i[4]
00402b34                                    x = &x[4]
00402b30                                while (i != &msg[0x90])
00402b30                                
00402b3c                                *x = *i
00402b50                                rfd = &readfds

To start, it’s worth just noting that while the program does read in 0xa000 bytes to an appropritately sized stack buffer at [8], only the first 0x90 bytes are copied in the loop at [9] before being looked at further:

00402b4c                                if (copied.magic == 0x26d19a37)   
00402b6c                                    rfd = &readfds
00402b6c                                    
00402b68                                    if (memcmp(s1: &copied.mac, s2: &_00_00_aa_bb_cc_dd, n: 6) != 0)
00402b70                                        int32_t* svn = copied.svn
00402b74                                        uint32_t tftpMode = copied.tftpMode
00402b7c                                        transfer_mode = tftpMode
00402b7c                                        
00402b88                                        if (debug_switch != 0)
00402bb8                                            printf("[%s:%d]csid=%s,tftpMode=%d,svn=%d,fw_csid=%s,fw_svn=%d,\n", "BroadcastReceive", 0x25b, &copied.csid, tftpMode, 
00402bb8                                                svn, &csid, svn)
00402bb8                                        
00402bc8                                        rfd = &readfds
00402bc8                                        
00402bc4                                        if (sx.d(csid) != 0)
00402be0                                            rfd = &readfds
00402be0                                            
00402bdc                                            if (strcmp(str1: &csid, str2: &copied.csid) == 0)
00402be8                                                if (svn == svn)
00402be8                                                    break
00402be8                                                
00402bf4                                                int32_t var_38
00402bf4                                                __builtin_memset(dest: &var_38, ch: 0, count: 0x12)
00402c3c                                                sprintf(dst: &var_38, "%02X:%02X:%02X:%02X:%02X:%02X", zx.d(copied.mac[0]), zx.d(copied.mac[1]), zx.d(copied.mac[2]), 
00402c3c                                                    zx.d(copied.mac[3]), zx.d(copied.mac[4]), zx.d(copied.mac[5]))
00402c4c                                                send_broadcast_resp(&var_38, &copied)  // [10]
00402c58                                                rfd = &readfds 

To save some time, it suffices to say that we want to reach the send_broadcast_resp function at [10], and the following commented python code will generate a buffer that can reach the function:

####################
buf = b""
buf += struct.pack("<I", 0x26d19a37) # magic bytes
buf += struct.pack("<I", 0x1)        # tftp mode
buf += struct.pack("<I", 0x12345678) # svn (just needs to be != fw_ver from above...)  
buf += b"\x11\x22\x33\x44\x55\x66"   # mac addr, needs to be != \x00\x00\xaa\xbb\xcc\xdd               
buf += csid                          # the csid, this needs to match our fw_key HTTP POST param from above
buf += b"\x00"
while len(buf) < 0x94:
    buf += b"A"

With such a buffer generated and everything checking out as valid inside of BroadcastReceive, the program then adds an entry to a global cjson array inside the send_broadcast_resp[10] function. This cjson array constantly checked to see if there’s any entries, and assuming there are, a different pthread then tries to send this batch upgrade firmware file to the device it has received a broadcast from inside of PostFileToDevice():

00401588    void PostFileToDevice() __noreturn
// [...]
00401620        
004016a8        while (true)
004016a8            void* bcast_array_1 = bcast_array
004016ac            int32_t $v0_1
004016ac            void* arp_dst
004016ac            int32_t $s2_3
004016ac            
004016ac            while (true)
004016ac                $v0_1 = cJSON_GetArraySize(array: bcast_array_1)
004016ac                
004016b4                if ($v0_1 s<= 0)
0040186c                    sleep(2)
00401878                    bcast_array_1 = bcast_array
004016b4                else
004016c0                    int32_t time = time(0)
004016d8                    int32_t $v0_2 = cJSON_GetArrayItem(bcast_array, $v0_1 - 1)
004016f8                    arp_dst = websGetVar($v0_2, "ip", &arp_dst_something)
0040173c                    $s2_3 = 2
0040173c                    
00401738                    if (strtol(str: websGetVar($v0_2, "time", &arp_dst_something), endptr: nullptr, base: 0xa) + 4 s< time)
00401738                        break
00401738                    
00401858                    usleep(300000)
00401864                    bcast_array_1 = bcast_array
00401864            
0040175c            int32_t $a1_1
0040175c            
0040175c            while (true)
0040175c                char stack_0x1b8[0x100]
0040175c                sprintf(dst: &stack_0x1b8, "arp -d %s")
00401768                system(string: &stack_0x1b8)
00401768                
00401778                if (debug_switch != 0)
00401790                    printf("[%s:%d]Post file to:%s\n", "PostFileToDevice", 0x28c, arp_dst)
00401790                
004017a4                char (* string)[0x100]
004017a4                
004017a4                if (transfer_mode != 2)
004017c0                    sprintf(dst: &stack_0x1b8, "tftp -l %s -r test.bin -p %s  > /tmp/log.txt 2>&1", &filename, arp_dst)                     // [11]
004017c8                    string = &stack_0x1b8
004017a4                else if (type == 1)
00401688                    sprintf(dst: &stack_0x1b8, "curl --max-time 60 -F 'file=@"/tmp/ubootBatchUpg/%s";filename="uboot"' %s > /tmp/log.txt",  // [12]
00401688                        &filename, arp_dst)
00401694                    string = &stack_0x1b8
00401650                else
00401668                    sprintf(dst: &stack_0x1b8, 
00401668                        "curl --max-time 60 -F 'file=@"/tmp/ubootBatchUpg/%s";filename="firmware"' %s > /tmp/log.txt", &filename, arp_dst)  // [13]
00401674                    string = &stack_0x1b8
00401674                
004017cc                system(string) // [14]

Depending on the transfer mode that was provided by our earlier broadcast above we copy one of the commands at [11], [12], or [13] to the stack with our filename HTTP POST parameter sprintf’ed in and then followed up with a call to system at [14]. Since there is no sanitization on the file name during this entire code flow, from HTTP POST to configuration file to final system command, this quickly results in arbitrary OS command injection.

Timeline

2026-06-16 - vulndev@cisco.com - Initial Vendor Notification
2026-06-23 - vulndiscovery@external.cisco.com - Follow-up sent
2026-06-25 - fae_hardware@zioncom.net - Vendor Acknowledged
2026-06-25 - vulndiscovery@external.cisco.com - Resent all notifications
2026-07-16 - vulndiscovery@external.cisco.com - Follow-up sent
2026-09-15 - vulndiscovery@external.cisco.com - Follow-up sent

Credit

Lilith Wyatt of Cisco Talos