TALOS-2026-2435
CVE-2026-10135
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.
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)
X2000R - https://www.totolink.net/home/menu/newstpl/menu_newstpl/products/id/242.html
9.1 - CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H
CWE-78 - Improper Neutralization of Special Elements used in an OS Command (‘OS Command Injection’)
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.
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
Lilith Wyatt of Cisco Talos