TALOS-2026-2438
CVE-2026-10138
A OS command injection vulnerability exists in the boa formUpload() 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. While today’s vulnerability is within a seperate binary entirely, in order to get that binary to run we must examinne the /boafrm/formUpload functionality:
0044c8a8 size_t formUpload(struct client* cli)
0044c8e4 char str[0xc8]
0044c8e4 memset(&str, c: 0, n: 0xc8)
0044c8f4 char var_2d0[0xc8]
0044c8f4 memset(str: &var_2d0, c: 0, n: 0xc8)
0044c904 int32_t out = 1
0044c908 char _deviceMac
0044c908 memset(str: &_deviceMac, c: 0, n: 0x99)
0044c910 char* uploadbuf = cli->uploadbuf
0044c914 int32_t read_offset = get_readstart_offset(uploadbuf) // [1]
0044c914
0044c924 if (read_offset != 0xffffffff)
0044c9e8 int32_t dmac_slot_1 = handleFirmwareFile(msgbuf: uploadbuf, max_size: cli->message_size_maybe, read_offset) // [2]
0044c9f4 int32_t dmac_slot = dmac_slot_1
0044c9f4
0044c9f0 if (dmac_slot_1 == 0)
0044ca08 sprintf(dst: &str, fmt: "Upload successfully (size = %d bytes)!<br><br>Firmware update in progress.",
0044ca08 cli->message_size_maybe)
0044ca20 void* _AutoReset = sessionCheck(cli, varname: "AutoReset", &cgi_var_pref, nullptr, 1)
0044ca30 apmib_get(MIB_DO_Mcli, &out)
0044ca38 int32_t MeshEnabled = out
0044ca3c char* redirect-url
0044ca3c
0044ca3c if (MeshEnabled != 1) // [3]
// [...]
0044ca3c else
0044ca54 printf("===========================Start mesh upgrade function: %s\n", &upload_filename)
0044ca60 get_filename_from_buf(buf: cli->uploadbuf, dst: &upload_filename)
To start, the program finds the offset of the file that we are uploading at [1] via parsing HTTP POST data, and then at [2] treats this uploaded file as a firmware file at [2] with the handleFirmwareFile function. If we wish to sidestep actually flashing anything, we can use and the following commented python code to generate a buffer that’s considered “valid firmware” but doesn’t flash.
####################
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"
Continuing on, assuming that our device is currently in easy mesh mode [3], or we set that configuration ourselves via other HTTP requests, we can then continue on within the formUpload() function:
0044ca94 void* _controller_upgrade = sessionCheck(cli, varname: "controller_upgrade", &cgi_var_pref, nullptr, &data_490000)
0044cab8 int32_t _deviceNum = atoi(str: sessionCheck(cli, varname: "deviceNum", &cgi_var_pref, nullptr, &data_490000)) // [4]
0044cacc printf("devicenum :%d\n", _deviceNum)
0044cadc char* format = "enable%d"
0044cae8 char* str2 = &ON
0044cae8
0044caf0 while (_deviceNum s>= MeshEnabled)
0044cc3c char varname[0x64]
0044cc3c snprintf(dst: &varname, 0x14, format, MeshEnabled) // [5]
0044cc3c
0044cc64 if (strcmp(str1: sessionCheck(cli, &varname, &cgi_var_pref, nullptr, &data_490000), str2) != 0)
0044cc68 MeshEnabled += 1
0044cc64 else
0044cc78 snprintf(dst: &varname, 0x14, format: "devicemac%d", MeshEnabled) // [6]
0044cc8c char* _devicemac%d = sessionCheck(cli, &varname, &cgi_var_pref, nullptr, &data_490000)
0044cc8c
0044cc9c if (sx.d(*_devicemac%d) == 0)
0044cca0 MeshEnabled += 1
0044cc9c else
0044ccb0 printf("device mac:%s\n", _devicemac%d)
0044ccb0
0044ccbc if (dmac_slot s< 3)
0044ccd4 strcpy(dest: &(&_deviceMac)[dmac_slot * 0x33], src: _devicemac%d) // [7]
The program goes back to reading our HTTP POST parameters, most importantly the deviceNum parameter at [4], which determines how many times we enter the loop further down. For each loop iteration, we look first for generated enable%d parameter, where %d is the loop iteration. Assuming this HTTP POST parameter is found and is non-zero, then we hit the line at [6] and try to look for a devicemac%d parameter as well. If this is found, then we copy the value to an array slot with strcpy at [7]. Continuing on to where these dmac_slot array values are used:
0044cafc if (zx.d(_deviceMac) != 0)
0044cb0c int32_t $v0_6 = sx.d(*_AutoReset)
0044cb08 char dmac_slot_2
0044cb08 char dmac_slot_3
0044cb08
0044cb08 if (zx.d(dmac_slot_2) == 0)
0044cd54 char* __dmac
0044cd54 int32_t mclinum
0044cd54
0044cd54 if ($v0_6 == 0)
0044cd84 mclinum = 2
0044cd8c __dmac = &_deviceMac
0044cd54 else
0044cd60 mclinum = 3
0044cd64 __dmac = &_deviceMac
0044cd64
0044cd70 sprintf(dst: &var_2d0, fmt: "%s %d %s &", "Mcli", mclinum, __dmac) // [8]
0044cb08 else if (zx.d(dmac_slot_3) == 0)
0044cd08 char* _dmac
0044cd08 char* var_2e4_2
0044cd08 int32_t $a3_5
0044cd08
0044cd08 if ($v0_6 == 0)
0044cd40 $a3_5 = 2
0044cd44 var_2e4_2 = &dmac_slot_2
0044cd50 _dmac = &_deviceMac
0044cd08 else
0044cd14 $a3_5 = 3
0044cd18 var_2e4_2 = &dmac_slot_2
0044cd20 _dmac = &_deviceMac
0044cd20
0044cd2c sprintf(dst: &var_2d0, fmt: "%s %d %s %s &", "Mcli", $a3_5, _dmac, var_2e4_2) // [9]
0044cb14 else
0044cb1c char* dmac
0044cb1c char* var_2e4_1
0044cb1c char* var_2e0_1
0044cb1c int32_t $a3_2
0044cb1c
0044cb1c if ($v0_6 == 0)
0044ccec $a3_2 = 2
0044ccf0 var_2e0_1 = &dmac_slot_3
0044ccf8 var_2e4_1 = &dmac_slot_2
0044cd04 dmac = &_deviceMac
0044cb1c else
0044cb28 $a3_2 = 3
0044cb2c var_2e0_1 = &dmac_slot_3
0044cb34 var_2e4_1 = &dmac_slot_2
0044cb3c dmac = &_deviceMac
0044cb3c
0044cb48 sprintf(dst: &var_2d0, fmt: "%s %d %s %s %s &", "Mcli", $a3_2, dmac, var_2e4_1, var_2e0_1) // [10]
0044cb50 system(string: &var_2d0) // [11]
To summarize most of this code, depending on how many device macs there are, we generate a longer or shorter Mcli command string at [8], [9], or [10] and then run that command with a system call at [11]. Since there’s no sanitization on the individual device mac input parameters, we’re left with a command injection vulnerability and subsequent arbitrary commands that can be run by an attacker.
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