Talos Vulnerability Report

TALOS-2025-2161

Tenda AC6 V5.0 Firmware Signature Validation firmware update vulnerability

August 20, 2025
CVE Number

CVE-2025-31355

SUMMARY

A firmware update vulnerability exists in the Firmware Signature Validation functionality of Tenda AC6 V5.0 V02.03.01.110. A specially crafted malicious file can lead to arbitrary code execution. An attacker can provide a malicious file 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.

Tenda AC6 V5.0 V02.03.01.110

PRODUCT URLS

AC6 V5.0 - https://www.tendacn.com/product/ac6v5.html

CVSSv3 SCORE

7.2 - CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

CWE

CWE-494 - Download of Code Without Integrity Check

DETAILS

The Tenda AC1200 AC6 is an IPv6 smart wifi router that supports multiple configuration types for home connectivity options. Extremely popular and affordable in online sellers, the Tenda AC1200 AC6 sees large usage in the home-networking space.

As with most embedded devices, after logging into the web portal, whether or not there’s a password set for it, there’s a UI tab for ‘Administration’. Therein, the logged in user is able to either provide a local file to upgrade the firmware or to attempt an online upgrade. In examining the latest firmware for the device US_AC6V5.0re_V02.03.01.110_multi_TDE01.bin, and also in examining the functions that handle this upgrade functionality, we can reason out the basic header structure of the ‘.bin’ file to essentially be as follows:

struct fw_header {
    uint32_t magic_bytes;
    uint32_t file_size;
    uint32_t crc;
    uint32_t fw_flash_offset_flag;
    [...]
}

This struct is located at the beginning of the firmware file, and if we look at US_AC6V5.0re_V02.03.01.110_multi_TDE01.bin, we can see it as such:

$ hexdump -C US_AC6V5.0re_V02.03.01.110_multi_TDE01.bin | head
00000000  52 54 4b 30 00 10 16 00  94 f9 e5 ae 10 00 01 00  |RTK0............|

So 0x304b5452 will be our magic byte string, 0x161000 is the firmware size, 0xaee5f994 is the CRC, and we also have 0x00010010 for our firmware flags. We can confirm the size matches up to the actual file as well:

$ ls -la US_AC6V5.0re_V02.03.01.110_multi_TDE01.bin 
-rw-rw-r-- 1 1445888 US_AC6V5.0re_V02.03.01.110_multi_TDE01.bin

With this header structure in mind, we can head into the actual code that handles the firmware validation on the device itself:

800be0c8    int32_t check_upgrade_file(char* data, int32_t inpsize, int32_t* out)

800be104        char* fw_bytes = *(data + 0xc) + size
800be124        do_log3(fmt: "[%c][%c][%c][%c][%c]\n", sx.d(*fw_bytes), sx.d(fw_bytes[1]), sx.d(fw_bytes[2]), sx.d(fw_bytes[3]), sx.d(fw_bytes[4]))
800be134        struct fw_header fw_header
800be134        memcpy(dst: &fw_header, src: fw_bytes, cnt: 0x1c)  // [1]
800be140        uint32_t magic = fw_header.magic
800be140        
800be148        if (magic == 0x304b5452)   //[2]
800be178            uint32_t file_size = fw_header.file_size
800be178             
800be190            if (get_internal_flash_size() - 0x30000 u>= file_size && file_size u>= 0x1c)  // [3]
800be1a0                do_log3(fmt: "trx->len: %x\n", file_size)
800be1bc                void* inpsize_wo_header= 0xfffffff4 - inpsize + *(data + 8)

While our fw_header struct is actually 0x1c bytes in size, as shown by the copy at [1], only the first 0x10 bytes are ever accessed in the validation function. Continuing in the function, we see that our file’s magic bytes match that which are looked for in the code at [2]. Assuming that the device’s internal flash has enough space for our file [3], the code then does a quick hexdump of the file and it checks the firmware CRC:

800be220                void* i = file_size - 0xc - inpsize_wo_header
800be224                void* gened_crc_1 = do_fw_crc_checking(inp: *(data + 0xc) + inpsize + 0xc, insize: inpsize_wo_header, IV: 0xffffffff) // [4]
800be22c                char* $s1_2 = *data
800be230                void* gened_crc = gened_crc_1
800be230                
800be234                while (i != 0)
800be268                    if ($s1_2 == 0)
800be268                        break
800be268                    
800be270                    void* filesize_1 = *($s1_2 + 8)
800be270                    
800be27c                    if (filesize_1 u>= i)
800be27c                        filesize_1 = i
800be27c                    
800be288                    i -= filesize_1
800be28c                    gened_crc = do_fw_crc_checking(inp: *($s1_2 + 0xc), insize: filesize_1, IV: gened_crc)  // [5]
800be294                    $s1_2 = *$s1_2

The device first checks the CRC of the amount of bytes passed into the upgrade function which calls check_upgrade_file, which is gleaned from size of the input HTTP packets. So in the first check at [4], it finds the CRC of the entire upgrade file minus the header. This generated CRC is then passed as an IV into a second iteration of CRC checking at [5]. Continuing on within check_upgrade_file for a moment we can see that these are actually CRC checks based off of error messages:

800be240                if (gened_crc == crc)
800be29c                    if (zx.d(fw_header.fw_flash_offset_flag.w) == 0x10)
800be2d4                        *out = 0x20000
800be2dc                        do_log3(fmt: "%s: Upgrade file offset %x in fl…", "do_upgrade_check", 0x20000)
800be2e4                        return 0
800be2e4                    
800be2ac                    crc_1 = 0x233
800be2b0                    gened_crc_2 = "do_upgrade_check"
800be2b4                    fmt = " %s %d  TRX_IMAGE_TAG  error!  \n"
800be240                else
800be24c                    do_log3(fmt: "http_put_file: Bad CRC\n")
800be258                    crc_1 = crc
800be25c                    gened_crc_2 = gened_crc
800be264                    fmt = "crc=%08lx trx.crc=%08lx\n"
800be264                
800be2b8                do_log3(fmt, gened_crc_2, crc_1)
800be2c4                return 0xfffffffd

Being pretty self-evident, we don’t particularly need to discuss the above, but it is worth diving into the do_fw_crc_checking function to see if there is any cryptographic signing going on before continuing:

800bdbfc    int32_t do_fw_crc_checking(char* inp, int32_t insize, int32_t IV)

800bdc08        int32_t out = IV
800bdc08        
800bdc04        if (insize u>= 4)
800bdc14            char* aligned_inp = &inp[3] & 0xfffffffc
800bdc1c            insize += inp - aligned_inp
800bdc1c            
800bdc2c            while (inp u< aligned_inp)
800bdc34                inp = &inp[1]
800bdc54                out = *(((zx.d(*(inp - 1)) ^ out.b) << 2) - 0x7fd0f348) ^ out u>> 8
800bdc54            
800bdc5c            if (insize u>= 4)
800bdc78                while (inp u< &inp[insize & 0xfffffffc])
800bdc80                    int32_t $a2_2 = *inp
800bdc84                    inp = &inp[4]
800bdc9c                    uint32_t x = *(((zx.d(out.b) ^ $a2_2.b) << 2) - 0x7fd0f348) ^ out u>> 8
800bdcc0                    uint32_t y = *(((zx.d(x.b) ^ (($a2_2 u>> 8).b & 0xff)) << 2) - 0x7fd0f348) ^ x u>> 8
800bdce0                    uint32_t z = *(((zx.d(y.b) ^ (($a2_2 u>> 0x10).b & 0xff)) << 2) - 0x7fd0f348) ^ y u>> 8
800bdd00                    out = *(((zx.d(z.b) ^ ($a2_2 u>> 0x18).b) << 2) - 0x7fd0f348) ^ z u>> 8
800bdd00        
800bdd14        while (true)
800bdd14            int32_t $v1_9 = inp u< &inp[insize & 3] ? 1 : 0
800bdd1c            inp = &inp[1]
800bdd1c            
800bdd18            if ($v1_9 == 0)
800bdd18                break
800bdd18            
800bdd40            out = *(((zx.d(*(inp - 1)) ^ out.b) << 2) - 0x7fd0f348) ^ out u>> 8
800bdd40        
800bdd48        return out

In its entirety, we can see that this function only takes our input bytes and returns a 4-byte CRC, nothing hidden or cryptographic about it. As such, we can summarize all the above as follows: the device will accept any firmware update as long as there’s magic bytes, a correct size field, and a CRC that matches what the device generates. Thus, with just having web-portal credentials an attacker can flash any arbitrary firmware onto the device since there’s no trace of firmware signing located anywhere within the device, resulting in arbitrary and persistent code execution.

TIMELINE

2025-04-29 - Initial Vendor Contact
2025-04-30 - Vendor Disclosure
2025-05-05 - Vendor Feedback Request
2025-05-08 - Vendor Feedback Request
2025-05-12 - Vendor Feedback Request
2025-06-11 - Vendor Feedback Request
2025-07-07 - Feedback Request / Announcement Of Upcoming Release Date
2025-07-23 - Feedback Request / Announcement Of Upcoming Release Date
2025-08-19 - Announcement Of Upcoming Release Date
2025-08-20 - Public Release

Credit

Discovered by Lilith >_> of Cisco Talos.