6 minute read

I’m excited to explain my process and methodology for finding my first CVE vulnerability (CVE-2025–26529) in an open source project!

Why Moodle?

Moodle is one of the most widely used Learning Management Systems, it’s written in PHP and used by a large part of the educational centers, if you are or have been a student or a teacher in the last years, you have probably used it.

Many of you probably thought about what it would be like to modify the grades, modify submissions and do a lot of hacker stuff. That was one of my motivations to start hacking on this software, and I always wanted to find a 0day and submit it to multiple bug bounty programs.

Where should I start?

It’s important to download the source code and run it locally before starting to test it. I would say that debugging is almost necessary if you want to understand source code, also it’s very easy to debug PHP code.

This was my first time auditing source code, so I tried to create a methodology by trying approaches of other hackers. My purpose was to find a bug from an unauthenticated session, or at least from a guest user.

I started navigating through the application as a normal user, to remember how it was.

Searching for a bug

I decided to search for auth bypass by debugging the normal authentication flow, authentication plugins, Oauth, SSO…

I spent like 20h debugging each authentication mechanism, how the auth plugins communicate with the core application, I learned a lot about how the application handles the authentication but I didn’t find any bug.

I also spent a little time debugging functionalities that caught my attention, even if they were only available to normal authenticated users (student, teacher…).

Sources and syncs

After 30h of hacking, I tried a different approach, searching for sources and syncs.

Sources are the points in the code where external data enters the application. This could be from user input, API endpoints, web endpoints. I would describe them as “entry points” where the user is able to introduce data, read data, modify…

Debugging all the sources of an application can be a pain, so I decided to debug only the unauthenticated or guest sources, I think was a better approach.

I looked for all unauthenticated sources (authentication is also a source) and interesting guest sources, I didn’t find any bug but I took notes of all interesting functionalities and behaviors.

I knew that I could spend a lot of time on each source, but I only wanted a superficial knowledge of each one.

After that, I looked for syncs.

Synks are the points where the untrusted data (from sources) is used in a potentially dangerous way, leading to vulnerabilities such as SQL Injection, XSS, or Remote Code Execution (RCE). Apart from specific functions of each application, each programming language has its own sources. 
In PHP, functions like system(), file_get_contents(), unserialize() or sql queries could be a valid sync

Searching for syncs is the opposite of searching for sources, this approach is based on finding a dangerous function (like system()), and do “backward analysis” until you find how that function is connected with a valid source.

I searched for all dangerous PHP functions, doing backward tracing to any valid source but I didn’t find anything.

After that, I started searching for application specific functions that could be dangerous, after some search, I looked inside the admin logs route /report/loglive/index.php, this route is used to show the description of multiple events that happen in Moodle:

The first thing that came to my mind was finding some way to inject an XSS payload inside the Description field, so I copied one of the Description Values and I searched the text in the source code to trace how the string is created.

I found that this description text:

Was created with this code:

I discovered that there are event types, each event type has a name, a class and each class has a method called “get_description()” which creates a string with variables of the actual object and displays the string in the logs. In this case, the event name was “Dashboard viewed” and the event class was “dashboard_viewed”.

The variables of each event class ($this->userid) are created when a new event object is created. In this case, a “dashboard_viewed” object is created every time a user visits the dashboard.

The source code flow looked like this:

Before looking ways to inject XSS payload inside “get_description()” function, I needed to check if it was vulnerable to XSS, so I replaced the text with a simple XSS payload and I reloaded the page.

It worked, that means that “get_description()” is a good sync to look, so I checked the “get_description()” functions from all event classes

A lot of functions showed up, one for each event class. I started to search for functions where a XSS payload could be injected. I found this after some search.

The string is created with “receivedconfigkey” and “receivedbrowserkey” variables

This event class is created when a user fails the authentication inside the “Safe Exam Browser” functionality.

Before explaining what is “Safe Exam Browser”, I’ll explain in what context is used.

Moodle has a functionality called QUIZ, it’s a kind of “test” with questions that the teachers create for the students.

Teachers can enable an option called “Safe Exam Browser”, this option allows the users to access the quiz with a software called “Safe Exam Browser”, which restricts cheating in the exams.

If this option is enabled, you need to provide a “configKey” and “BrowserExamKey” to do the QUIZ. When those values are invalid, the “access_prevented “ object is created with them. (as you have seen in the previous images).

That means that if we inject the XSS payload inside “configKey” or “BrowserExamKey”, it will create the XSS in the admin logs.

Let’s do it. The payload needs to be injected inside specific headers inside this HTTP request.

This request is used to access to the QUIZ with “Safe Exam Browser” enabled

The source code flow would look like this:

Once that is sent, the event should trigger and we should see the XSS in the admin page.

We have XSS in the admin logs, the problem is that the exploitation requires access to a student account and a quiz with SafeExamBrowser enabled.
So I decided to find a way to exploit this from a guest account.

More impact

SafeExamBrowser is not accessible from the guest account, so I tried to search more events that could trigger the XSS.

I didn’t find any event that could be used to create XSS, the only alternative was some events that created the string using a variable URL

The URL could be injected, but if I created an URL with an XSS payload (web.com/<xss>), the server always urlencoded the payload (web.com/%3Cxss%3E).

I checked my notes and I remembered one endpoint that had a SSRF, the problem with this SSRF was that didn’t allow internal IP’s.

Request
Response

And I remember trying to set my own server URL and redirecting to an internal IP:

Request

Source Code of my server

But this event triggered when redirecting to an internal IP

So, I tried to set the XSS payload in the redirection URL of my server, so when Moodle hits my server and gets redirected to http://127.0.0.1/<img src onerror=alert(1)> it will block the URL because it’s internal, but the event will trigger with the XSS payload.

Source code of my Server

I sent the request, Moodle reached my server and this happened.

It turns out that the URLs are always urlencoded when provided by the user but not when provided by a server.

Using this XSS, I stole the admin’s session by exfiltrating document.cookie Admin Account Takeover.

I used https://moodle.org/mod/page/view.php?id=8722

Now I have my CVE.

https://moodle.org/mod/forum/discuss.php?d=466145#p1871275

By Alvaro Balada on February 23, 2025.

Updated: