CVE-2025-31355
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.
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
AC6 V5.0 - https://www.tendacn.com/product/ac6v5.html
7.2 - CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H
CWE-494 - Download of Code Without Integrity Check
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.
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
Discovered by Lilith >_> of Cisco Talos.