Talos Vulnerability Report

TALOS-2026-2440

Totolink X2000R boa Firmware Update Unsigned Firmware vulnerability

October 20, 2026

CVE Number

CVE-2026-10141

Summary

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.

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-347 - Improper Verification of Cryptographic Signature

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. 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.

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