TALOS-2026-2440
CVE-2026-10141
A Unsigned Firmware vulnerability exists in the boa Firmware Update functionality of X2000R (version(s): TOTOLINK-X2000R-Gh-V1.0.0-B20250421.1327). A specially crafted firmware binary can lead to arbitrary firmware update. 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-347 - Improper Verification of Cryptographic Signature
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. For today’s vulnerability, we can look at either the /boafrm/formUpload or /boafrm/formUploadFile page, since both end up dealing with how this device handles firmware files. Since formUpload is quicker, let us start there:
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, 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)
0044cda4 if (sx.d(*_AutoReset) != 0)
0044cdb0 system(string: "flash set UPGRADE_RESET 1")
0044cdb0
0044cdd4 seems_like_set_on_need_to_reboot = 1
0044cdd0 strcpy(dest: &submit-url_0x64, src: "/status.htm")
0044cde4 data_48cd20 = 0xbe
0044cdec redirect-url = "/countDownPage.htm"
The above code section of the formUpload function constitutes the update process from start to finish for the totolink router when not configured for mesh mode. The code finds the offset of our uploaded file in our HTTP POST request form data at [1] and then passes this to the actual firmware image checking at [2]. But before looking at the actual firmware checking code, let us quickly examine the intended structure of a given firmware file such that the code is easier to understand:
struct fw_partition __packed
{
char magic[0x4];
__padding char _4[4];
uint32_t mtd_seek_offset;
uint32_t fw_size;
uint8_t firmware_img[fw_size][
};
struct fw_file __packed
{
struct fw_partition[];
char version_signature[0x14];
};
To summarize: a given firmware file that we upload can contain multiple partitions of firmware to flash, and then at the end of the array of partitions we find a version signature for our given router model, for this particular router we will see “TOTOLINK-X2000R-Gh-V1”. Continuing in the handleFirmwareFile functio at [2]:
0046084c int32_t handleFirmwareFile(void* msgbuf, int32_t max_size, int32_t read_offset)
// [...]
004608c4
00460d30 while (check_size(size_1: bytes_read, max_size) != 0) // [3]
004608d4 int32_t var_134_1 = 0
004608e0 struct fw_file* fwbuf = msgbuf + bytes_read
004608f0 uint32_t fw_size = fwbuf->fw_size
00460940 int32_t firmware_size = fw_size << 0x18 | (fw_size s>> 8 << 0x10 & 0xff0000) | zx.d((fw_size s>> 0x10 << 8).w) | fw_size u>> 0x18
00460950 int32_t bufsize = firmware_size + 0x10
00460950
00460978 if (memcmp(s1: msgbuf + bytes_read, s2: "cr6c", n: 4) != 0)
004609bc if (memcmp(s1: msgbuf + bytes_read, s2: "w6cg", n: 4) != 0)
00460a00 if (memcmp(s1: msgbuf + bytes_read, s2: "r6cr", n: 4) != 0)
00460a20 uint32_t $v0_24 = p2_get_fw_model_string() // "TOTOLINK-X2000R-Gh-V1" // [4]
00460a28 int32_t $v0_29
00460a28
00460a28 if ($v0_24 != 0)
00460a68 $v0_29 = strncmp_lol(msgbuf + bytes_read, get_fw_model_string(), len: strlen(str: get_fw_model_string()))
00460a68
00460a70 if ($v0_24 != 0 && $v0_29 != 0)
00460a7c fw_type = 4
00460a70 else if (is_valid != 1)
00460aa0 log("Invalid file format.")
00460aa8 return 0xffffffff
To start, we continuously loop until we run out of bytes in the provided firmware image at [3]. Within this loop the webserver first checks the first four bytes for the “cr6c”, “w6cg”, or “r6cr” magic bytes which determines what type of partition image this is supposed to be. The only differences between the magic byte types are whether we flash to /dev/mtdblock0, which is the bios/kernel partition or /dev/mtdblock1, which is where the squashfs rootfs lives, and also if the CRC check occurs over two or four byte chunks of data. Assuming that we’ve read some bytes that do not correspond to any of the three magic byte options, then the code checks to see if we’ve reached the end of the firmware file by looking for the firmware model string at [4]. Continuing on in the function:
00460acc if (fw_type == 1 || fw_type == 3) // cr6c, r6cr
00460af4 if (do_image_checsum_1(readoffset: msgbuf + bytes_read + 0x10, fwsize: firmware_size) == 0) // [5]
00460b04 log("Image checksum mismatched.")
00460b0c return 0xffffffff
00460acc else if (do_image_checsum_2(roffset: msgbuf + bytes_read + 0x10, fwsize: firmware_size) == 0) // [6]
00460b48 // w6cg, max 0x800000 bytes
00460b48 log("Image checksum mismatched.")
00460b50 return 0xffffffff
00460b50
00460b64 char* partition_name
00460b64 partition_name = fw_type != 3 ? "/dev/mtdblock0" : "/dev/mtdblock1"
00460b64
00460b94 uint32_t mtd_seek_offset_1 = fwbuf->mtd_seek_offset
00460be8 int32_t mtd_seek_offset = mtd_seek_offset_1 << 0x18 | (mtd_seek_offset_1 s>> 8 << 0x10 & 0xff0000)
00460be8 | zx.d((mtd_seek_offset_1 s>> 0x10 << 8).w) | mtd_seek_offset_1 u>> 0x18
00460be8
00460bf4 if (fw_type == 3)
00460c00 if (mtd_seek_offset != 0)
00460c08 mtd_seek_offset = 0
00460c08
00460c10 var_134_1 = 0x10
00460c1c bufsize -= 0x10
00460c1c
00460c48 strcpy(dest: &(&static_fw)[image_cnt], src: partition_name)
00460c74 (&static_fw)[image_cnt].mtd_seek_offset = mtd_seek_offset
00460c9c (&static_fw)[image_cnt].bufsize = bufsize
00460cd0 (&static_fw)[image_cnt].fw_buf_offset = bytes_read + var_134_1
00460d00 (&static_fw)[image_cnt].shm_addr = get_shm_addr()
00460d0c image_cnt += 1
00460d20 bytes_read = firmware_size + bytes_read + 0x10
00460d54 if (is_valid != 1)
00460d64 log("No valid image.")
00460d6c return 0xffffffff
00460d6c
00460d90 if (p2_get_fw_model_string() != 0 && fw_type != 4)
00460da0 log("Invalid firmware.")
00460da8 return 0xffffffff
00460da8
00460dc8 create_buf_with_contents(file: "/var/fwd.conf", buf: &static_fw, len: 0x90)
00460dd0 sync()
00460dd8 return 0 Further examination of the `handleFirmwareFile` shows that the only other real check for firmware validity is at the image checksum functions at [5] and [6], which either add every uint16_t in the firmware partition together or every uint32_t together and then checks for 0x0. Aside from that, there's really nothing else that the program checks for, and most importantly is missing any sort of signature check. Thus, given a firmware file that passes all the checks listed above, any arbitrary unsigned firmware image can be flashed to the device.
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