Talos Vulnerability Report

TALOS-2024-2036

Wavlink AC3000 login.cgi Unauthenticated Firmware Upload vulnerability

January 14, 2025
CVE Number

CVE-2024-39608

SUMMARY

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.

CONFIRMED VULNERABLE VERSIONS

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

PRODUCT URLS

Wavlink AC3000 - https://www.wavlink.com/en_us/product/WL-WN533A8.html

CVSSv3 SCORE

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

CWE

CWE-306 - Missing Authentication for Critical Function

DETAILS

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.

TIMELINE

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

Credit

Discovered by Lilith >_> of Cisco Talos.