TALOS-2026-2388
CVE-2026-40627
A OS command injection vulnerability exists in the boa formWsc peerRptPin functionality of X2000R (version(s): TOTOLINK-X2000R-Gh-V1.0.0-B20250421.1327). A specially crafted HTTP request can lead to arbitrary code execution. An attacker can make an authenticated HTTP request 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.
X2000R (version(s): TOTOLINK-X2000R-Gh-V1.0.0-B20250421.1327)
X2000R - https://www.totolink.net/home/menu/newstpl/menu_newstpl/products/id/242.html
9.1 - CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H
CWE-78 - Improper Neutralization of Special Elements used in an OS Command (‘OS Command Injection’)
The Totolink X2000R is a popular budget gigabit home router primarly found in eastern markets. Supporting IPV6, WPA3, mesh capabilities among other features, the Totolink X2000R is a solid choice for the price point.
The Totolink X2000R mainly relies on the standalone boa binary for all web administration and subsequent configuration. While normally a security risk in itself due to the age, it appears that the version running on the X2000R has been patched. Regardless, for the current set of vulnerabilities we only pay attention to the Totolink-specific issues, so we start from the point where authentication has already occurred via the /boafrm/formLogin webpage, and then we can successfully interact with another /boafrm/form* page, each of which have a corresponding callback. For the current vulnerability we examine the /boafrm/formWsc page, which leads us into the formWsc() function:
00448054 size_t formWsc(int32_t* arg1)
// [...]
00448168 char* targetAPMac = get_from_postbody(cli: arg1, "targetAPMac", &cgi_var_pref)
00448180 char* targetAPSsid = get_from_postbody(cli: arg1, "targetAPSsid", &cgi_var_pref)
00448198 char* redirect-url = get_from_postbody(cli: arg1, "submit-url", &cgi_var_pref)
004481c0 void* const var_34 = &data_460000
004481c4 int32_t var_48
We can skip looking at most of the code, it’s only really worth noting that the get_from_postbody() function lets the program read parameters that we pass in via HTTP post parameters. Also for this bug in particular, we read in the targetAPSsid HTTP POST parameter in at [1]. Continuing on to the portion of the function where the targetAPSsid is used:
// [...]
004488e4 if (setnewpin == 0) // [2]
00448910 char* targetAPMac_1 = targetAPMac
0044890c char* targetAPSsid_1
0044890c
0044890c if (sx.d(*targetAPMac) != 0) // [...]
0044890c else
00448914 targetAPSsid_1 = targetAPSsid
00448914
00448920 int32_t out_10 = out_5
00448920
0044891c if (sx.d(*targetAPSsid_1) != 0)
00448930 if (strlen(str: targetAPSsid) u>= 0x21) // [3]
00448aa0 puts(str: "invaild SSID Len")
00448930 else
00448944 memset(str: &pin, c: 0, n: 0x80)
00448950 strcpy(dest: &pin, src: targetAPSsid)
00448958 escape_quote_tick_slash(&pin) // [4]
00448970 var_2e8 = &pin
00448974 snprintf(dst: &var_210, 0xc8, format: "iwpriv wlan%d set_mib wsc_specssid="%s" ", wlan_idx, var_2e8) // [5]
0044897c system(string: &var_210) // [6]
Assuming we set the correct HTTP POST parameters to avoid setting a new pin, we enter the branch at [2] and our targetAPSsid variable is looked at. Assuming it’s non-zero, there’s a length check at [3] to verify it’s within the normal 0x20 bounds of a typical SSID. If this check passes, a further validation check occurs within the escape_quote_tick_slash() function at [4], and then the value is inserted into a larger command at [5] which is then run with the call to system at [6]. The crux of this issue lies exactly in how the function at [4] works, so let us take a quick peek:
00429224 char* escape_quote_tick_slash(char* arg1)
0042922c char var_d0[0xcc]
0042922c char (* wptr)[0xcc] = &var_d0
0042923c char* $a3 = arg1
0042923c
00429244 while (true)
00429244 char* rptr = sx.d(*$a3)
00429244
00429248 if (rptr == 0)
00429248 break
00429248
00429264 if (rptr == '\\')
0042926c *wptr = '\\'
00429270 (*wptr)[1] = '\\'
00429274 wptr = &(*wptr)[2]
00429264 else if (rptr == '\"')
00429288 *wptr = '\\'
00429290 (*wptr)[1] = '\"'
00429274 wptr = &(*wptr)[2]
00429280 else if (rptr != '`')
00429298 *wptr = rptr.b
004292ac wptr = &(*wptr)[1]
00429294 else
0042929c *wptr = '\\'
004292a4 (*wptr)[1] = '`'
00429274 wptr = &(*wptr)[2]
00429274
0042927c $a3 = &$a3[1]
0042927c
00429254 *wptr = 0
0042925c return strcpy(dest: arg1, src: &var_d0)
Without giving a step-by-step, it suffices to say that a copy of our input is copied to the stack, except any backslashes, double quotes, or backticks are escaped via backslashes. Critically, for unknown reasons, this function overlooks the fact that busybox also supports subshell commands via the $(...) syntax. As such, we can input any set of commands to be run as long as the length is less than 0x1d (0x20 - 0x3 for $()). Since there’s not really any reason we cannot run this command injection multiple times with multiple requests, this quickly results in arbitrary code execution.
2026-06-16 - vulndev@cisco.com - Initial Vendor Notification
2026-06-23 - vulndiscovery@external.cisco.com - Follow-up sent
2026-06-25 - fae_hardware@zioncom.net - Vendor Acknowledged
2026-06-25 - vulndiscovery@external.cisco.com - Resent all notifications
2026-07-16 - vulndiscovery@external.cisco.com - Follow-up sent
2026-09-15 - vulndiscovery@external.cisco.com - Follow-up sent
Lilith Wyatt of Cisco Talos