CVE-2025-24496
An information disclosure vulnerability exists in the /goform/getproductInfo functionality of Tenda AC6 V5.0 V02.03.01.110. Specially crafted network packets can lead to a disclosure of sensitive information. An attacker can send packets 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.5 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
CWE-288 - Authentication Bypass Using an Alternate Path or Channel
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 common to most home routers, the main management interface for the Tenda AC6 AC1200 is via the web portal on TCP port 80. In the setup httpd_mainloop
function, a series of callbacks is registered to a set of URLs, and these callbacks are accessed in series if they match on the URL of a given HTTP request:
int32_t httpd_mainloop()
// [...]
800b2724 setup_web_server(port: 80, retries: 5)
800b2744 websUrlHandlerDefine(urlPrefix: &NULLSTR, webDir: nullptr, arg: 0, handler: http_auth_control_function, flags: 1) // [1]
800b2764 websUrlHandlerDefine(urlPrefix: "/cgi-bin", webDir: nullptr, arg: 0, handler: cgi_bin_http_handler, flags: 0) // [2]
800b2780 int32_t $s1 = 0
800b277c do_log3(fmt: "-----------%s------------%d-----…", "initWebs", 0x10e)
800b279c websUrlHandlerDefine(urlPrefix: "/goform", webDir: nullptr, arg: 0, handler: websFormHandler, flags: 0) // [3]
800b27bc websUrlHandlerDefine(urlPrefix: &NULLSTR, webDir: nullptr, arg: 0, handler: websHandler, flags: 2)
800b27c4 setup_goform_callbacks() // [4]
800b27cc setup_ate_cb()
800b27ec websUrlHandlerDefine(urlPrefix: &slash, webDir: nullptr, arg: 0, handler: minimal_resp_cb, flags: 0)
To start, the authentication function is registered at [1], which verifies that either a correct password
POST parameter is given, or that the ecos_pw
cookie matches to a logged in admin. Since this registration occurs with a NULL
urlPrefix, it is hit on every HTTP request.
Next up at [2], we can see a callback registered for the /cgi-bin
directory which will only hit if our HTTP request’s URL starts with /cgi-bin
. For the current vulnerability, we only particularly care about the authentication callback at [1], the /goform
callback at [3], and then the particular functions that handle each particular goform which are registered within [4]. Let us first look briefly at the http_auth_control_function
:
800c1814 int32_t http_auth_control_function(webs_t wp, char* uri)
// [...]
800c1968 if (strnstr(s1: uri, needle: "/public", len: 7) != 0 &&
wp->host != 0 &&
strnstr(s1: uri, needle: "/favicon.ico", len: 0xc) != 0 &&
strnstr(s1: uri, needle: "/goform/getproductInfo", len: 0x16) != 0) { // [5]
}
800c25ac return 0
Our requested URL is pointed to by the uri
parameter, and within the line at [5], we clearly see a curious conditional. Assuming we’re requesting the /goform/getproductInfo
URL, we completely bypass the entire authentication process. So, returning back to what we can do with this special URL, we first have to understand what occurs in the /goform
handler in the websFormHandler
function that was registered at [3]:
800b6368 int32_t websFormHandler(webs_t wp, char* urlPref, char* webDir, int32_t arg, char* url, char* path, char* query)
800b639c data_81ee130c += 1
800b6398 void var_110
800b6398 strncpyclr(dst: &var_110, src: path, len: 0xfe)
800b63a4 char path_1[0xff]
800b63a4 char* slash = strchr(&path_1, '/')
800b63a4
800b63ac if (slash != 0)
800b63d4 char* second_slash = strchr(&slash[1], '/')
800b63d4
800b63dc if (second_slash != 0)
800b63e0 *second_slash = 0
800b63e0
800b63ec struct sym_t* $v0_2 = symLookup(0xffffffff, inpstr: &slash[1]) // [6]
800b63ec
800b63f4 if ($v0_2 != 0)
800b63f8 int32_t $t8_1 = $v0_2->name.__bitfieldc.__offset(0x4).d
800b63f8
800b641c if ($t8_1 != 0)
800b6428 $t8_1(wp, &slash[1], query) // [7]
800b63f4 else
800b640c send_http_error_resp(httpreq: wp, 0xc8, errmsg: "Form %s is not defined", &slash[1])
800b63ac else
800b63c0 send_http_error_resp(httpreq: wp, 0xc8, errmsg: "Missing form name", arg)
800b63c0
800b6440 return 1
This function’s sole purpose is to look through all the registered form callbacks and to first see if that callback exists at [6], and then to call that function at [7] with our web request’s query parameters passed in as an argument. Simple enough, but now we have to see where /goform/getProductInfo
has a registered callback within setup_goform_callbacks
at [4]:
800c5b2c int32_t setup_goform_callbacks()
800c5b50 set_goform_http_cb(varname: "setWAN", funcptr: generic_goform_setter)
800c5b60 set_goform_http_cb(varname: "getWAN", funcptr: generic_goform_getter)
800c5b70 set_goform_http_cb(varname: "setWifi", funcptr: generic_goform_setter)
800c5b80 set_goform_http_cb(varname: "getWifi", funcptr: generic_goform_getter)
800c5b90 set_goform_http_cb(varname: "getWifiRelay", funcptr: generic_goform_getter)
800c5ba4 set_goform_http_cb(varname: "getWifiScan", funcptr: webwrites_getWifiScan)
800c5bb4 set_goform_http_cb(varname: "setWifiRelay", funcptr: generic_goform_setter)
800c5bc4 set_goform_http_cb(varname: "getWizard", funcptr: generic_goform_getter)
800c5bd4 set_goform_http_cb(varname: "setWizard", funcptr: generic_goform_setter)
800c5be4 set_goform_http_cb(varname: "getNAT", funcptr: generic_goform_getter)
800c5bf4 set_goform_http_cb(varname: "setNAT", funcptr: generic_goform_setter)
800c5c04 set_goform_http_cb(varname: "setSysTools", funcptr: generic_goform_setter)
800c5c14 set_goform_http_cb(varname: "getSysTools", funcptr: generic_goform_getter)
800c5c24 set_goform_http_cb(varname: "sysReboot", funcptr: generic_goform_setter)
800c5c34 set_goform_http_cb(varname: "sysRestore", funcptr: generic_goform_setter)
800c5c44 set_goform_http_cb(varname: "setMacClone", funcptr: generic_goform_setter)
800c5c54 set_goform_http_cb(varname: "setHomePageInfo", funcptr: generic_goform_setter)
800c5c64 set_goform_http_cb(varname: "getStatus", funcptr: generic_goform_getter)
800c5c74 set_goform_http_cb(varname: "getQos", funcptr: generic_goform_getter)
800c5c84 set_goform_http_cb(varname: "setQos", funcptr: generic_goform_setter)
800c5c94 set_goform_http_cb(varname: "setParentControl", funcptr: generic_goform_setter)
800c5ca4 set_goform_http_cb(varname: "getParentControl", funcptr: generic_goform_getter)
800c5cb4 set_goform_http_cb(varname: "getHomePageInfo", funcptr: generic_goform_getter)
800c5cc4 set_goform_http_cb(varname: "setPowerSave", funcptr: generic_goform_setter)
800c5cd4 set_goform_http_cb(varname: "getPowerSave", funcptr: generic_goform_getter)
800c5ce8 set_goform_http_cb(varname: "setWifiWps", funcptr: webwrites_setWifiWps)
800c5cfc set_goform_http_cb(varname: "loginOut", funcptr: webwrites_loginOut)
800c5d0c set_goform_http_cb(varname: "setIPV6", funcptr: generic_goform_setter)
800c5d1c set_goform_http_cb(varname: "getIPV6", funcptr: generic_goform_getter)
800c5d2c set_goform_http_cb(varname: "getIpv6Status", funcptr: generic_goform_getter)
800c5d50 return set_goform_http_cb(varname: "getproductInfo", funcptr: generic_goform_getter) // [8]
A complete list of all available /goform/
pages is given above, and we see our /goform/getproductInfo
URI registered at [8]. Interestingly, it actually uses the exact same callback as a large set of the other /goform/get*
callbacks, so let us look within generic_goform_getter
:
800c2a68 struct cjvar* generic_goform_getter(webs_t wp)
800c2a9c char* modules = nullptr
800c2a98 struct cjvar* inp = create_cj_var()
800c2a98
800c2aa0 if (inp != 0)
800c2acc int32_t $s5_1 = 0
800c2ad0 modules = websGetVar(wp, needle: "modules", default: &NULLSTR) // [9]
800c2adc label_800c2adc:
800c2adc char* modules_1 = modules
800c2adc
800c2ae0 while (true)
800c2ae0 if (modules_1 == 0)
800c2b5c char* $s0_3 = nullptr
800c2b58 websWrite(httpreq: wp, fmt_str: "HTTP/1.1 200 OK\nContent-type: t…")
800c2b58
800c2b60 if ($s5_1 != 0)
800c2b68 char* $v0_4 = cj_get(inp)
800c2b74 $s0_3 = $v0_4
800c2b74
800c2b70 if ($v0_4 != 0)
800c2b7c write_out_json(wp, $v0_4) // [10]
800c2b7c
800c2b88 check_socket_dead_or_keepalive(wp, http_code: 200)
800c2b90 inp = parsed_json_dtor(inp)
800c2b90
800c2ba0 if ($s5_1 != 0 && $s0_3 != 0)
800c2ba8 return free($s0_3)
800c2ba8
800c2b98 break
800c2b98
800c2ae8 char* $v0_1 = strtok(&modules, U",") // [11]
800c2ae8
800c2af0 if ($v0_1 == 0)
800c2af0 goto label_800c2adc
800c2af0
800c2af8 int32_t $s0_1 = 0
800c2afc struct func_list_2* const modlist = &modules_get_list // [12]
800c2afc
800c2b04 while (true)
800c2b04 if (modlist->idk == 0)
800c2b04 goto label_800c2adc
800c2b04
800c2b0c char* func = modlist->func
800c2b14 modlist = &modlist[1]
800c2b14
800c2b18 if (strcmp($v0_1, func) == 0)
800c2b40 $s5_1 = 1
800c2b3c modules_get_list[$s0_1].field_8(wp, inp, 0) // [13]
800c2b48 modules_1 = modules
800c2b44 break
800c2b44
800c2b1c $s0_1 += 1
800c2bd8 return inp
To summarize, the generic_goform_getter
will look for a modules
POST parameter at [9], and then iterate over the comma separated module strings with the strtok
call at [11]. For each of these modules in the list, assuming that the module name matches one of the hard coded modules found in the static struct array at [12], then then the corresponding module getter function is called at [13], and the information is then sent inside of a json at [10]. To help clarify a little, the static modules struct is partially listed below:
80346b00 struct func_list_2 modules_get_list[0x2d] =
80346b00 {
80346b00 [0x00] =
80346b00 {
80346b00 char* func = data_802df84c {"wanBasicCfg"}
80346b04 uint32_t idk = 0x1
80346b08 void* field_8 = get_wanBasicCfg
80346b0c }
80346b0c [0x01] =
80346b0c {
80346b0c char* func = data_802df858 {"internetStatus"}
80346b10 uint32_t idk = 0x1
80346b14 void* field_8 = get_internetStatus
80346b18 } // [...]
While not listed in its entirety for brevity’s sake, there are 44 different registered getters for different modules. When calling any of the /goform/get*
URLs via the web interface, these module
POST parameters are pre-set for us, and correspond to whatever page is being browsed, such that only the relevant information is gathered and displayed, however keen readers will notice that there’s nothing stopping us from manually requesting all of the getters manually. Since the same generic information getter is called for all /goform/get*
URLs, even though we’re requesting the /goform/getproductInfo
page, which normally just grabs unimportant “productInfo” strings, we can request all the modules at once without any authentication.
This allows us to dump out the plaintext Wifi password, DDNS username and password, and most other configuration information that we could want (although the admin password is missing from this), all without any authentication, resulting in an information disclosure vulnerability.
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.