How I got Code Execution on the server behind my classes
or, how a `sort` parameter turned into a Manager account and eventually RCE
I use Moodle pretty regularly. It's the platform my institution uses for courses, assignments, grades, and pretty much everything else related to classes.
What started as poking around with CVEs ended with me taking over a Manager account and executing code on the server.
Ever since day one, I noticed "TurnKey Linux" as a watermark, and that it was self-hosted. On a particular day however, I got distracted by it again - and, remembering that I was playing with some old image file extensions at home - I decided to check how old the version is.
I wasn't completely surprised to find out it's a ~2021 version.
I wasn't going to go the easy route and submit a malicious PDF and wait for someone to click "view all submissions"; I tried to be silent
The entire chain involved two publicly known vulnerabilities:
- CVE-2021-36393 for SQL injection in Moodle's "recent courses" functionality.
- CVE-2024-43425 for remote code execution through Moodle's "Calculated Questions" functionality.
The beginning
The Moodle instance was running version 3.9.4, which was already pretty outdated. (~2021)
I only had a regular Student account, and I tried the SQL injection on this AJAX endpoint:
/lib/ajax/service.php
Moodle uses this endpoint for various backend service calls. One of those services was core_course_get_recent_courses, which retrieves courses recently accessed by a user.
The request accepted a few arguments, including a sort parameter.
Normally, it would look something like this:
{
"methodname": "core_course_get_recent_courses",
"args": {
"sort": "id",
"limit": 0,
"offset": 1
}
}
Nothing unusual so far.
Except the value of sort ended up being incorporated into an SQL ORDER BY clause without proper validation.
And, well, you can see where we're going.
Making the database wait
The problem with this injection was that it didn't directly return database contents or SQL errors.
I couldn't simply execute a query and have the result appear on the page.
However, the database supported time-based expressions, which meant that I could make its response time depend on whether a particular condition was true.
A normal request completed in approximately 0.4 seconds.
When I tested a conditional database delay, the difference was pretty obvious:
Normal request: ~0.4s
Condition is false: ~0.4s
Condition is true: ~9.0s
The interesting thing was that even though the delay was configured for one second, the request took around nine seconds.
This suggested that the expression was being evaluated repeatedly across the rows involved in the query.
At this point, I had a working time-based blind SQL injection.
There were some filtering restrictions, but they were surprisingly inconsistent. Functions and operators such as LIKE, SUBSTRING, and COUNT worked, while a few keywords including length, version, and load_file were blocked.
Luckily, none of those restrictions prevented the information I was interested in from being inferred :)
Extracting a database one character at a time
Since the application wasn't returning the results of my SQL expressions, every piece of information had to be inferred through timing differences.
I could determine whether a condition matched the data, but I couldn't just ask the database to print a table name.
So I started enumerating the database schema.
Moodle installations normally use the mdl_ table prefix, although administrators can configure a different one.
This instance wasn't using the default prefix.
While enumerating the table names, I started getting results:
a
as
ass
assi
assig
assign
assign_
Meh, probably bullshit. I don't recall how they ended, but there was a reason I skipped it.
Next!
s
se
ses
sess
sessi
session
sessions
Now we're talking :)
Moodle can store active session information in its database, depending on the configured session handler.
And if I could read those sessions, there was a possibility that I could get access to every account.
Finding someone interesting
The next step was understanding how the session information related to actual Moodle users.
After enumerating the relevant tables and fields.
The particularly interesting ones were:
userfor Moodle user account information.sessionsfor session identifiers associated with users.role_assignmentsfor associations between users and their assigned roles.
Moodle's default role identifiers include roleid = 1 for the Manager role.
And as it turned out, there was an active session associated with a privileged Manager account 🎉
Rather than immediately going for the session identifier, I wanted to confirm whose account I was looking at.
So I started extracting the username.
j
jo
jos
josh
josh_
(The real username has been changed)
Oh I know that guy! Lol
Either way, there was an active session belonging to someone with significantly more permissions than my Student account.
And I could read its database record.
26 characters between me and a Manager account
The sessions table contained a field called sid. "Probably session identifier," I thought.
On this deployment, the session identifiers were stored as readable values in the database.
Drifting off-topic to say you should not be storing session tokens in your database - if you have a leak, all accounts are compromised. As I started making more websites after Lyntr, I stopped doing this.
Moodle uses the MoodleSession cookie to associate a browser with an authenticated session, so obtaining a valid session identifier was a pretty big deal.
If you're curious about passwords, they were stored in MD5 according to the server admin (we'll get to it later don't worry). I don't have much experience in decryption, but I know that MD5 and base64 are the best encryption algorithms out there. ✌️
Regardless, who needs to crack passwords when we have a 24-character session ID right there?
Of course, I still had the same problem: blind SQL injection.
I couldn't just retrieve the entire identifier in a single response.
Instead, I extracted it incrementally, using timing-based checks to determine its contents.
It took ~20-30 minutes, and it failed the first time because the guy logged out.
But eventually, I had recovered the full 26-character session identifier.
After validating the recovered session in the browser, I navigated to /my/.
And it worked! 🎉🎉🎉
I was now browsing Moodle under the Manager account.
Okay, now what?
By this point I got home and started contemplating - will I get into trouble? Surely they won't check the database logs to see my SQL queries, it looks like normal student activity. Should I escalate to my original objective - full server access?
A bit of a personal detour, but let's say I got indirectly challenged by a Linux larper two years ago to get server access. So, naturally, I continued :)
Having a Manager account gave me access to functionality that wasn't available through my original account. In other words, more CVEs :D
Until now, most of the interesting functionality had been inaccessible because of my limited permissions.
With the Manager account, that changed.
One possible approach would have involved installing a malicious Moodle plugin. But as I mentioned in the beginning, the point here was to be silent. I didn't want to disrupt services or be detected before I could report this properly.
Instead, one CVE stood out - Calculated Questions.
Apparently, maths can execute PHP
Moodle supports calculated quiz questions.
The idea is that, instead of writing a question with fixed numerical values, teachers can define formulas containing variables.
Moodle substitutes values into those formulas to calculate the correct answer.
You can, again, probably see where we're going.
The vulnerable versions of Moodle evaluated those expressions using PHP's... eval()... after applying insufficient sanitization.
That is an interesting design choice.
The vulnerability (CVE-2024-43425) requires permission to create or modify questions, which is why the initial Student account wasn't sufficient.
But the Manager session gave me access to the necessary functionality on this deployment.
To be silent, I chose the oldest course the guy had (2022), and created a quiz. (This entire thing took 3 minutes total, and would only had been visible if they were in the same tab)
I imported a calculated question through Moodle's XML question-import functionality, using a specially crafted formula:
{readfile(base64_decode($_GET[chr(98)]))}
Btw, the result appeared directly in the Moodle interface.
And now it's time for the classic /etc/passwd:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
My excitement was immeasurable :)
Aftermath
I reported the vulnerability which gave me both teacher account access and code execution on the server. We got it fixed a few hours later, and I confirmed the SQLi could no longer be triggered.
Conclusions
Update your services!
A few weeks ago, we had an RCE vuln on our Plausible instance (analytics.outpoot.com) as well. Thankfully, it was contained and wasn't able to escape, thus nothing leaked and they were only able to probably run code in the already-limited Docker container. Funnily enough, they planted a backdoor. I appreciate the souvenir :)
Thanks for the read.