CVE-2024-39608
A firmware update vulnerability exists in the login.cgi functionality of Wavlink AC3000 M33A8.V5030.210505. A specially crafted HTTP request can lead to arbitrary firmware update. An attacker can send an unauthenticated message 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.
Wavlink AC3000 M33A8.V5030.210505
Wavlink AC3000 - https://www.wavlink.com/en_us/product/WL-WN533A8.html
10.0 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
CWE-306 - Missing Authentication for Critical Function
The Wavlink AC3000 wireless router is predominately one of the most popular gigabit routers in the US, in part due to both its potential wireless and wired speed capabilities and extremely low price point (costing at the time of this writing ~$60 USD). Among the configuration options, it’s also able to act as a standalone wireless gateway, a basic network router, or a wireless repeater.
When interacting with and configuring the Wavlink AC3000 wifi router, as is typical of most wifi routers, an administrator logs in via some web portal and configures appropriate options via the HTTP interface. In the case of this particular router, and in another somewhat common execution pattern, these HTML pages can invoke .cgi binaries due to how the lighttpd server is configured. Since all of these .shtml and .cgi files are located in the web root, anyone with network access to the device doesn’t actually need to log in to the device to interact with these .cgi files, and it usually is the responsibility of the .cgi binary to check if the authentication is completed successfully. For the upload.cgi
binary, we see no such authentication, so keeping that in mind, let us examine the main()
function:
004009f0 int32_t main(int32_t argc, char** argv, char** envp)
00400a34 void var_230
00400a34 memset(&var_230, 0, 0x100)
00400a50 int32_t var_tmpFW_fd = fopen("/var/tmpFW", &w)
00400a68 int32_t $v0 = check_csrf_referer()
00400a74 if ($v0 != 0)
// [...]
00400a74 else
00400a7c if (var_tmpFW_fd == 0)
00400f24 return $v0
00400a8c int32_t content_len = getenv("CONTENT_LENGTH")
00400a9c int32_t content_len_1 = content_len
00400a98 if (content_len == 0)
00401014 content_len_1 = 0x4030c4
00400ab8 int32_t content_len_2
// [...]
00400acc int32_t content_len_int = strtol(content_len_1, 0, 0xa)
00400af4 if (content_len_int == 0x7ffffffe || (content_len_int != 0x7ffffffe && content_len_int + 1 s> 0 && content_len_int != 0))
We post this section just to show that there is no authentication. Continuing:
00400c08 memset(line_malloc, 0, line_len + 1)
00400c24 memcpy(delim, readbuf, line_len)
00400c40 char* second_newline = strstr(newline_loc + 2, &newline)
00400c54 if (second_newline == 0)
// [...]
00401264 do_system("echo 4 > /tmp/firmware &")
00400c54 else if (strncasecmp(newline_loc + 2, "content-disposition: form-data;", 0x1f) != 0)
// [...]
00401194 do_system("echo 5 > /tmp/firmware &")
00400c74 else
00400c84 int32_t semicolon_loc = strchr(newline_loc + 0x22, ';')
00400c90 if (semicolon_loc == 0)
// [..]
00400c90 else if (strncasecmp(semicolon_loc + 2, "filename=", 9) != 0)
// [...]
00400cb0 else
00400cc4 int32_t end_of_filename_line = strstr(&second_newline[2], &newline)
00400cd0 if (end_of_filename_line == 0)
// [...]
00400cd0 else
00400ce0 int32_t start_of_data = strstr(end_of_filename_line + 2, &newline)
00400cec if (start_of_data == 0)
// [...]
00400cec else
Again, this section is mainly just to show that the binary is only looking for the start of the data of our HTTP POST request, it doesn’t even particularly care about the name of the file, which gets discarded. Continuing on:
00400d14 int32_t data_len = memmem(haystack: start_of_data + 2, haystacklen: readbuf - (start_of_data + 2) + content_len_int + 1, needle: delim, needlelen: line_len) - 2 - (start_of_data + 2)
00400d28 fwrite(ptr: start_of_data + 2, size: 1, nmemb: data_len, stream: var_tmpFW_fd) // [1]
00400d48 void errno
00400d48 if (check(fname: "/var/tmpFW", data_offset: 0, datalen: data_len, errno: &errno) == 0) // [2]
// [...]
00400d48 else
00400d54 write_flash_kernel_version()
00400d74 if (mtd_write_firmware(s_flag: "/var/tmpFW", o_flag: 0, l_flag: data_len) != 0xffffffff) // [3]
00400d84 do_system("echo 0 > /tmp/firmware &")
00400da8 if (access("/tmp/web_log", 0) == 0)
00400f38 int32_t fstream_1 = fopen("/dev/console", &data_40300c)
00400f44 if (fstream_1 != 0)
00400f70 fprintf(fstream_1, "%s:%s:%d:Done...rebooting\n", "upload.c", "main", 0x1d4, content_len_2)
00400f88 fclose(fstream: fstream_1)
00400db0 webFoot()
00400dc8 system("sleep 3 && reboot &")
00400de0 exit(status: 0)
00400de0 noreturn
00400dfc if (access("/tmp/web_log", 0) == 0)
00400fa8 int32_t fstream_2 = fopen("/dev/console", &data_40300c)
00400fb4 if (fstream_2 != 0)
00400fe0 fprintf(fstream_2, "%s:%s:%d:mtd_write fatal error! The corrupted imag…", "upload.c", "main", 0x1c7, content_len_2)
00400ff8 fclose(fstream: fstream_2)
00400e0c do_system("echo 11 > /tmp/firmware &")
After our data is written to /var/tmpFW
at [1], the UBOOT magic bytes, header CRC, and data CRC are all validated within the check
function at [2]. This serves as the only validation of our input data at all within this entire function. Assuming these checks pass, then we call mtd_write_firmware
at [3] and then the device reboots. In total, a completely unauthenticated firmware flash via sending an HTTP request to the device.
2024-07-25 - Initial Vendor Contact
2024-07-29 - Requesting reply from vendor
2024-07-30 - Vendor confirms receipt
2024-07-30 - Vendor Disclosure
2024-07-30 - Vendor confirms receipt
2024-09-02 - Status update request sent
2024-10-15 - Status update request. Upcoming expiration date announced.
2024-10-22 - Vendor replies product has been discontinued, but patches are being worked on
2024-11-04 - Status update request for patch release dates
2024-11-12 TALOS advisory release date announced
2025-01-14 - Public Release
Discovered by Lilith >_> of Cisco Talos.