All articles
Security ResearchSeptember 16, 2026 · 13 min read

ERPNext Privilege Escalation: How a Low-Privilege Account Can Take Over the Whole System

A walkthrough of an ERPNext security review covering server-side template injection (SSTI), SQL injection, SSRF, and an IDOR bug, reported and fixed through coordinated disclosure with Frappe.

By PentestPilot · Offensive Security Team

We spent several weeks running a security review against ERPNext, the open source ERP system built on the Frappe framework, and found something worth writing up properly. One of the lowest-privilege roles in the system, the kind of account you'd hand to a junior bookkeeper who just posts invoices and reconciles ledgers, turned out to be enough to become the system's Administrator. No leaked password. No phishing. Just a few lines of text typed into a form field meant for boilerplate legal language.

ERPNext runs accounting, inventory, HR, and purchasing for a lot of companies, so a bug like this isn't just academic. The review targeted ERPNext 16.17.0 on Frappe 16.17.4, though most of the underlying code patterns existed in earlier releases too. We tested everything against a disposable instance, reading through the source and proving each idea worked before reporting it. This post walks through what we found, in the order an attacker would actually hit it: starting from zero access, and ending with full control of the server.

The Findings at a Glance

Here's the whole set before we get into how each one works. The theme running through most of them is that the access an account has on paper turned out to be very different from what it could actually reach.

VulnerabilityWho can exploit itWhat it gives an attackerStatus
SSTI in Terms and ConditionsAccounts User (low privilege)Full Administrator takeover, plus server-side code execution on self-hosted setupsFixed (PR #37924)
SSTI in Request for QuotationPurchase ManagerThe same admin takeover, reached through a different fieldFixed (PR #37924)
User list field leak (IDOR)Any logged-in Desk userEvery other user's API key and session metadataFixed (PR #40248)
SQL export filter bypassAnyone who reaches an SSTI surfaceArbitrary file write on self-hosted installsCVE-2026-47199, fixed
SSRF via request helpersAnyone who reaches an SSTI surfaceServer-side requests to internal services and cloud metadataFixed (PR #37924)
SQL injection in budget validationAccount Manager plus Accounts UserDatabase reads through error messagesFixed
Host header poisoning in email loginUnauthenticated (conditional)Account takeover through a hijacked login linkCVE-2026-47194, fixed
Guest record creation with HTML injectionUnauthenticatedCRM spam and phishing rendered inside the Desk UIFixed

What an Unauthenticated Visitor Can Already Do

Before you even create an account, ERPNext already gives you something to work with. The public website has a contact form, and the endpoint behind it looks like this:

@frappe.whitelist(allow_guest=True)
def send_message(sender, message, subject="Website Query", ...):
    frappe.get_doc({"doctype": "Lead", "email_id": sender, ...}).insert(ignore_permissions=True)
    frappe.get_doc({"doctype": "Communication", "content": message, ...}).insert(ignore_permissions=True)

Two things stand out there. allow_guest=True skips the login check completely, and ignore_permissions=True means the write happens no matter who's asking. On its own that's mostly a spam magnet, except the message field lets a sliver of HTML through the sanitizer. Scripts get stripped, but <img> and <form> tags survive. So a stranger can drop a tracking pixel that fires the moment a staff member opens the message in the CRM, or embed a fake "your session expired, log in again" form that renders inside the company's own trusted Desk interface. Nobody expects a phishing attempt to come from inside the tool they already trust, which is exactly why it works.

There's a second bug at this same access level, and it's more conditional. ERPNext has an optional feature that emails people a one-time login link:

key = frappe.generate_hash()
frappe.cache.set_value(f"one_time_login_key:{key}", email, ...)
return get_url(f"/api/method/.../login_via_key?key={key}")

get_url() trusts whatever Host header shows up on the request, unless it's explicitly told not to, and here nobody told it. Send a login-link request with a forged Host header, and the email that goes out points at your own domain instead of the real one. Click the link and the one-time key lands on the attacker's server instead. Two things have to line up for this to work: an admin has to have switched the email-link login on, since it ships disabled, and the site can't have its canonical hostname pinned in config. Both of those are the normal state for a site that opts into the feature, so in practice a forged header is enough. What makes this one interesting is that Frappe had already fixed the exact same mistake on the password reset flow, one file over, by adding a single argument: allow_header_override=False. That fix just never made it across to this sibling feature.

Neither bug gets you logged in on its own. They're a nuisance and a conditional trap. The real story starts once you have an account, even a low-value one.

The Low-Privilege Bug That Leads to Full Admin Access

Every ERPNext account with basic Desk access (anyone who can log into the backend at all, not an admin) can pull up the user list and read fields that are supposed to be locked to System Administrators: API keys, last login IPs, even a hash confirming whether someone's password reset is currently active. This is a type of access-control bug often called IDOR, short for insecure direct object reference, and here the cause is a shortcut meant for the framework's own internal bookkeeping:

if doctype in CORE_DOCTYPES:   # "User" happens to be on this list
    return valid_columns        # every column, regardless of permission level

User got swept into a list of doctypes the framework trusts unconditionally, so fields that should require System Administrator access get handed to anyone with the lowest tier of login. On its own it's a privacy leak. Paired with what comes next, it hands you half of a working credential.

And here's the part that mattered most in this whole review: one of the lowest operational roles in ERPNext, the one you'd give someone who only manages payment terms and invoice boilerplate, can plant a working Administrator login from scratch. Not guess it, not reset it. Create a brand new one, with a password of your choosing.

That role can author "Terms and Conditions" templates, the standard legal text attached to a quotation or invoice. Those templates get processed by a templating engine (the same kind of technology behind most "Dear {{customer_name}}" emails you've ever received), and the code that renders one looks harmless enough:

return frappe.render_template(terms_and_conditions.terms, doc)

Here's the actual bug. terms_and_conditions.terms is free text the accounts-clerk role can edit, and it's being used as the template itself, not as a value dropped into one. The doc argument is context data the caller controls too, but that isn't even where the danger is. The real problem is what the template gets to reach once it runs. Frappe hands every template a set of built-in helpers, and that set includes live, callable access to its own ORM and database layer, so frappe.get_doc and frappe.db are sitting right there inside the sandbox. This class of bug is usually called server-side template injection, or SSTI, and instead of filling in a customer's name, you can write something like this:

{{ frappe.get_doc("User", "Administrator")
     .update({"api_key": "…", "api_secret": "…"})
     .save(ignore_permissions=True) }}

Save that as a terms template, have any logged-in user render it (or trigger it yourself), and the system executes it server-side without hesitation. As far as the templating engine is concerned, this isn't an exploit, it's just a function call it was explicitly handed. A moment later, that API key and secret work as valid credentials for the real Administrator account. No session, no cookie, no idea what the actual admin password is. Change that password tomorrow and the planted key keeps working, because it lives in a completely different part of the database than the password does.

There's no clever exploit chain here in the traditional sense. No memory corruption, no obscure bypass. A low-trust role got handed the same power as the application's own code, through a field that was only ever meant to hold legal text.

From Database Access to Remote Code Execution

Once you can run arbitrary instructions inside that templating engine, the obvious next question is whether it stops at the database or reaches further, down to the operating system itself.

On our test setup, it reached further. The same templating context that reads and writes any record can also run raw SQL, which is supposed to be restricted to read-only queries by a check like this:

if query.strip().lower().startswith(("select", "explain")):
    return True   # a query ending "...INTO OUTFILE '/path'" still starts with SELECT

That check only looks at the first word of the query. Whether it actually writes a file depends on the database setup, not on ERPNext. The write needs the MySQL or MariaDB account to hold the FILE privilege and a permissive secure_file_priv value, which is common on self-hosted installs but locked down on the managed cloud version. Where those conditions hold, the gap is enough to write an arbitrary file to disk, including a small Python file dropped straight into the framework's own application folder.

From there, one more piece falls into place. ERPNext has a Scheduled Job feature for background tasks, and the code that runs one looks like this:

def execute(self):
    frappe.get_attr(self.method)()   # no permission check anywhere above this line

get_attr resolves that string using Python's real import system, the same one the framework itself uses, completely outside the restricted templating sandbox. Point a scheduled job at the file you just wrote and trigger it once. You're no longer manipulating application data. You're running commands as the server's own operating system user.

Three individually modest weaknesses, a permissive template, a query filter that only checks the first word, and a background job feature that trusts whoever calls it, stack into full server compromise. And it still only takes that same junior-level role to get there.

A Second Route to the Same Vulnerability

The very first thing we found, before any of the above, was a near-identical bug in a completely different feature: the message sent to suppliers when requesting a quotation. It needs a slightly higher role, a purchasing manager rather than an accounts clerk, but it's the same admin-takeover trick, just sitting behind a different door.

What makes this one worth its own mention is a single line sitting right above the vulnerable call:

# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
rendered_message = frappe.render_template(message_for_supplier, doc_args)

That comment tells the project's own automated security scanner to skip this exact spot. Someone had already been warned. The scanner flagged it correctly, and a developer silenced the alert instead of fixing what it pointed at. It's a good reminder that the hardest bugs to catch aren't always the ones nobody saw coming. Sometimes they're the ones somebody already saw and moved past.

A SQL Injection Bug That Doesn't Escalate, But Still Hurts

Not everything in this review chains into the same outcome. Separately, we found a classic SQL injection sitting in the budget approval workflow. An account name gets pasted straight into a database query with no escaping at all:

condition = f"expense_account = '{params.expense_account}'"

Give an account a name with a single quote in it, and that quote closes the string early and spills out into the surrounding query. A role that combines purchasing and accounting permissions can use this to pull data out of the database through the resulting error messages. It's a real, working bug on its own, it just doesn't lead anywhere further.

What's more interesting than the exploit itself is who else can trip it by accident. Plenty of real businesses have account names with apostrophes in them, think "Director's Loan" or "Owner's Equity." Any of those breaks this exact query on every single transaction posted against that account, completely by accident. This is a security bug and a live correctness bug wearing the same coat, and it was almost certainly already firing in production systems that had never heard of this review.

Why the Sandbox Held Up (and Why It Barely Mattered)

Credit where it's due: the templating engine itself is genuinely well built. We spent real time trying the usual tricks for escaping a sandboxed template environment, reaching into an object's internal Python machinery, abusing string formatting quirks that have broken other frameworks' sandboxes in the past. Every attempt got blocked. There's no path from clever template syntax alone to raw code execution here.

It didn't matter, because the sandbox was never the weak point. The weak point was what the sandbox was configured to allow on purpose. A template didn't need to break out of its cage. It was handed the keys on the way in: live database access, the ability to save any document as any user, even a function for making arbitrary outbound web requests. That last one deserves its own mention, since it's a class of bug called SSRF, short for server-side request forgery. From inside that same low-privilege template context, you could point it at your cloud provider's internal metadata endpoint, the address every major cloud platform uses to hand out temporary credentials to whatever's running on that machine. On a cloud-hosted deployment, that's a straight line from "junior accounting role" to "valid cloud API token," and none of it required a traditional exploit.

A sandbox that stops people from breaking out, while quietly handing every dangerous capability to whoever's already inside, hasn't actually contained much.

How Frappe Responded to the Reports

We reported each of these to Frappe's security team individually, with proof-of-concept steps and suggested fixes, over several weeks in mid-2026. To their credit, they engaged with all of them. Two have since been assigned official CVE identifiers through the public disclosure process: CVE-2026-47194 for the host header issue (rated High) and CVE-2026-47199 for the SQL export trick (rated Low, since it only works against self-hosted installs with permissive database settings). Both are patched in Frappe v16.18.3 and v15.108.0 and later. The guest contact-form bug and the budget SQL injection are also documented publicly now, though without individual CVE numbers attached.

The turnaround wasn't consistent across the board. The host header and SQL export fixes shipped within a few weeks. The templating engine rework, covered below, was a bigger project and took closer to a month once it was properly scoped. The access control fix for the user list leak took the longest by a wide margin. The patch that closes it is six lines long, and even so it spent more than two months sitting as an open pull request before merging, about three and a half months from the day we reported it to the day it shipped. Small, low-drama fixes have a habit of losing the queue to whatever's currently on fire.

What the Actual Code Fix Looked Like

The templating engine fix, once it landed, wasn't a narrow patch aimed at the two vulnerable fields. It was a real architectural change, close to 300 new lines across seven files, rebuilding what a template is allowed to touch from scratch.

Two changes matter most here. First, frappe.get_doc(), the function our admin-takeover trick relied on, no longer hands a template a live, callable document. It hands back an inert copy instead:

def get_doc_as_dict(doctype, name):
    return frappe.get_doc(doctype, name).as_dict()   # a dict, no .save(), no .update()

You can still read a document's fields from a template. You just can't call .save() on one anymore, because what you're holding was never the real object in the first place. Second, frappe.db, the object that gave templates raw SQL access, got removed from that context altogether instead of being patched method by method. Both tricks, the one that turned "read some data" into "become Administrator" and the one that turned it into "write a file to disk," stopped working for the same underlying reason. The template just isn't handed live application internals anymore.

That rework caught its own near miss along the way, which is worth mentioning. An early draft of the outbound request guard tried to block internal addresses with a plain string check:

if resolved_ip.startswith(("127", "10", "192", "172")):
    return

That happens to miss 169.254.0.0/16 entirely, the exact range every major cloud provider uses for its metadata service. Someone caught it before merge and replaced it with a proper range check using Python's ipaddress module instead of string prefixes.

The user list leak got a smaller, more surgical fix: one added condition, repeated at each of the two places the old shortcut got checked.

if doctype in CORE_DOCTYPES and doctype != "User":
    return valid_columns

It's small enough to read in five seconds, which is probably part of why it sat in review for months instead of weeks. It also wasn't a clean, blanket lockdown. One of the fields the old code leaked, user_type, turned out to be quietly load-bearing for an unrelated autocomplete feature elsewhere in the product, so the fix carves out an explicit exception for it rather than breaking that feature just to close the leak.

None of this is something you have to take our word for. The advisories are public, with CVE numbers where they were assigned, and the fixes themselves sit in Frappe's open source commit history for anyone to read:

If You Run ERPNext, Here's What to Do

If you're running ERPNext or Frappe, the single most important step is to update. Every issue described here is fixed in current releases, and the two that carry CVEs are patched in Frappe v16.18.3 and v15.108.0. Anything older than that is exposed.

Self-hosted installs deserve a second look beyond the version bump, because the jump from reading the database to running commands on the server rests entirely on database configuration you control. That step needs the MySQL or MariaDB account to hold the FILE privilege along with a permissive secure_file_priv setting. Revoke FILE from the application's database user and point secure_file_priv at a directory the app can't reach, and the file-write step falls apart even on an unpatched version. It's worth doing on its own merits regardless of this report.

A few smaller steps are worth the few minutes they cost. Pin your canonical hostname in the site config so no incoming header can override it, which closes the host-header trick independently of any patch. If you don't use the email login-link feature, leave it switched off. And if you maintain custom apps or print formats, search your own code for two things: render_template calls that take a user-editable value as the template itself, and any nosemgrep comments that quiet the SSTI rule. Those are the exact two mistakes at the root of the worst bugs here, and they're easy to repeat in your own code.

Key Takeaways From This ERPNext Security Review

None of this needed a zero-day in any meaningful sense. It needed someone to notice that "junior role" and "harmless role" aren't the same claim, that a warning silenced with a code comment is still a warning, and that a sandbox is only as safe as whatever you decide to put inside it. The most dangerous permission in this entire chain wasn't a missing access check. It was a legal-text field doing exactly what it was built to do.

Want a source code audit?

A source code audit is how you find this class of bug — SSTI, IDOR, SQL injection, and SSRF — in your own code before an attacker does. We review your code the way an attacker would, prove what's reachable, and hand your developers fixes they can ship.