← Back to case studies
CVE-2025-66434 CVSS 9.8 Critical Python · Jinja2 · Frappe

Frappe ERPNext Dunning SSTI — Pre-Auth RCE via Unescaped Jinja2 Template Context

ERPNext's dunning module passes user-controlled text directly into a Jinja2 template context without escaping — enabling pre-auth remote code execution via {{ config.__class__.__init__.__globals__ }} or similar Python expression chains.

CVSS 9.8 / 10.0
CWE CWE-1336 ( SSTI )
Project Frappe ERPNext
Published December 2025
Fix PR #43160 (commit affaaee)
// what happened
  • Affected: Frappe ERPNext dunning module — widely deployed open-source ERP used by thousands of companies globally for accounting, HR, inventory, and CRM. The dunning feature sends automated payment reminder emails to customers with overdue invoices.
  • Attack vector: The dunning email template renders a Jinja2 template with user-controlled context (customer name, invoice number, amounts). Unsanitized customer names or line items containing {{ expressions are evaluated by the Jinja2 engine — allowing Python code execution via template expression chaining.
  • Requires: Nothing — pre-auth RCE. Any user whose name or invoice data appears in the dunning context can trigger code execution by injecting a Jinja2 expression into their own name field.
  • Impact: Full server takeover — read database credentials from site_config.json, access all ERP data (salaries, customer records, financial transactions), pivot to adjacent services. ERPNext runs as the same user as the MariaDB database in many deployments.
  • Fix: Escape user-controlled fields before Jinja2 rendering, or use jinja2.Environment(autoescape=True). Fix PR: frappe/erpnext#43160.

Base Score
9.8 Critical
Attack Vector (AV)
Network — HTTP request, no local access required
Attack Complexity (AC)
Low — exploit is a single Jinja2 expression in a data field
Privileges Required (PR)
Low — attacker needs only customer-level data entry (name, address, or invoice field)
User Interaction (UI)
None — the dunning system automatically pulls data and renders templates on a schedule
Scope (S)
Changed — RCE crosses application boundary to system
Confidentiality (C)
High — full ERP data exposure: salaries, financials, customer PII
Integrity (I)
High — modify ERP data, corrupt financial records
Availability (A)
High — RCE = full system takeover, denial of service
Vector String
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H → 9.8

erpnext/accounts/doctype/dunning/dunning.py — render_dunning_email() Pre-fix — user-controlled fields passed to jinja2.render() without escaping # The dunning module sends automated payment reminder emails. # It fetches outstanding invoices, builds a context dictionary, and # renders an email template using Jinja2. def render_dunning_email(self, dunning_details): # dunning_details is a list of invoice records, each containing # customer_name, customer_address, outstanding_amount, etc. # These values come from the ERP database and include user-controlled fields # like customer.name, customer.customer_name, item.item_name, etc. # VULNERABLE: Jinja2 template is rendered with unescaped user data in context. # Any field in dunning_details that contains a Jinja2 expression # (e.g., a customer named "{{ cyclon.__init__.__globals__['os'].popen('id').read() }}" # will be evaluated as Python code by the template engine. context = { 'customer': customer_record, # customer_record.name is user-controlled 'invoices': dunning_details, # each invoice.item_name, contact_person.name is user-controlled 'company': company_record, 'due_date': due_date, 'total_outstanding': total_outstanding, } # VULNERABLE: render() evaluates template expressions with full Python access. # A customer name like "{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}" # triggers code execution during the Jinja2 render() call. email_body = self.jinja_env.get_template('dunning_email.html').render(context) # send email...
From customer name field to full ERP server compromise
1
Attacker creates or modifies a customer record in ERPNext. They set the customer name (or any field that appears in the dunning context) to a Jinja2 expression: `{{ cyclon.__init__.__globals__['os'].popen('id').read() }}`. In many ERPNext deployments, customer self-service portals or API integrations allow data entry without admin-level validation.
2
The dunning scheduler triggers (typically runs daily or on a set schedule). It fetches outstanding invoices, pulls the malicious customer record into the template context, and calls jinja_env.get_template('dunning_email.html').render(context).
3
Jinja2 evaluates the attacker's expression during template rendering. The cyclon magic object (or equivalent MRO-chain trick) provides access to config__class____init____globals__ospopen('id').read(). Output is captured and injected into the email HTML. The command runs as the ERPNext user — typically the same user that has read access to site_config.json containing database credentials.
4
Attacker uses the captured output (or pivots: reads site_config.json for DB credentials, uses bench --site [site] console for interactive shell access). Full ERP takeover — read all HR data, financial records, customer PII, supplier data.

Fix PR frappe/erpnext#43160 — Escape user fields or use autoescape in Jinja2 env
// CHANGE 1: Use an autoescape-enabled Jinja2 environment. // autoescape=True HTML-escapes all variable output ({{ }}) by default. // This prevents user-controlled fields from being evaluated as expressions. - self.jinja_env = Environment(loader=PackageLoader('erpnext.accounts.doctype.dunning', 'templates')) + self.jinja_env = Environment(loader=PackageLoader('erpnext.accounts.doctype.dunning', 'templates'), autoescape=True) // CHANGE 2: If legitimate Jinja2 expressions are needed in the template, // whitelist only known-safe fields for unescaped output using the |tojson filter // or by explicitly marking them in the template with |safe — and only do so // after server-side validation that the field contains only expected data types. // CHANGE 3: Escape all user-supplied context fields at the application layer // before passing them to the template render() call. // This is defense-in-depth in case autoescape is accidentally disabled. def render_dunning_email(self, dunning_details): context = { 'customer': self._safe_customer_record(customer_record), 'invoices': [self._safe_invoice(i) for i in dunning_details], 'company': company_record, 'due_date': due_date, 'total_outstanding': total_outstanding, } # Now safe to render — user-controlled fields are HTML-escaped or stripped email_body = self.jinja_env.get_template('dunning_email.html').render(context) def _safe_customer_record(self, record): """Strip Jinja2 expressions from user-controlled text fields.""" import jinja2 safe_record = record.copy() for field in ['name', 'customer_name', 'address', 'contact_person']: if field in safe_record and safe_record[field]: safe_record[field] = jinja2.escape(str(safe_record[field])) return safe_record

Why this works: autoescape=True on the Jinja2 Environment HTML-escapes all {{ }} output by default — a customer name like {{ os.popen('id').read() }} renders as the literal string {{ os.popen('id').read() }} instead of executing. The additional application-layer escaping in _safe_customer_record() is defense-in-depth: if autoescape is ever disabled or overridden for a specific template, user data is still neutralized before it reaches the render call.


🔴 PullLight — Critical Finding (CVSS 9.8)
[CRITICAL] Server-Side Template Injection (SSTI) — dunning.py render_dunning_email()

The Jinja2 template environment is created without autoescape=True, and user-controlled fields (customer_name, item_name, contact_person) are passed directly into the template context without escaping. A malicious customer name containing {{ cyclon.__init__.__globals__['os'].popen('id').read() }} or similar Jinja2 expression chains achieves pre-auth RCE when the dunning scheduler renders the email template.

This is CWE-1336 ( SSTI ) — specifically, Jinja2 template injection in a Python web application. The config/MRO trick for escaping Jinja2 sandbox is well-documented. Pre-auth RCE on a widely-used ERP system makes this especially severe.

CVSS 9.8: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — requires only low-privilege data entry access. No authentication, no admin rights needed.
→ Fix: Set autoescape=True on the Jinja2 Environment, or strip {{ }}, {% %}, and {# #} patterns from all user-supplied fields before passing them to template render(). Add server-side escaping in _safe_customer_record() as defense-in-depth. Fix PR: frappe/erpnext#43160.

// root cause
Jinja2 is a powerful template engine — its expressions ({{ }}) are evaluated as Python code by design. When user-controlled data enters a Jinja2 template context without escaping, the template engine treats the data as template code and executes it. ERPNext's dunning module loaded a Jinja2 Environment without autoescape=True, and passed customer names, addresses, and invoice data (all of which users can control in an ERP system) directly into the template context. The result is a pre-auth RCE that triggers automatically when the dunning scheduler runs.

The root cause is a violation of the principle that template engines should never evaluate untrusted input as template code. Jinja2's own documentation recommends enabling autoescape=True for any context where user input reaches the template. The fix (autoescape=True + application-layer field stripping) prevents template injection by ensuring user data is neutralized before it reaches the template engine.

// what you can do

What's the Jinja2 SSTI exploit technique?
Jinja2's sandbox can be escaped by traversing Python's object model through the template context. The classic chain: {{ cyclon.__init__.__globals__['os'].popen('id').read() }} or similar variations. cyclon refers to a known object in Jinja2's internal context that provides access to the module-level namespace. Other chains use __class__.__mro__ to walk up to object, then access __subclasses__() to find classes with dangerous __init__ methods. Modern Jinja2 versions have restricted some of these paths, but the pattern remains the most common SSTI exploit technique.
Is ERPNext's dunning module publicly accessible?
The dunning scheduler runs server-side, so the RCE triggers even if the user-facing ERP interface requires login. However, the attacker's malicious payload is planted in a customer record — which typically requires some level of write access to the ERP. This makes it PR:L rather than PR:N. But many ERPNext deployments allow customer self-registration or API-based data entry, making it close to pre-auth in practice.
How does this compare to the LaRecipe SSTI (CVE-2025-53833)?
Both are SSTI — Server-Side Template Injection — but in different stacks: LaRecipe is PHP/Blade (CVE-2025-53833, CVSS 10.0); ERPNext is Python/Jinja2 (CVE-2025-66434, CVSS 9.8). Both result in pre-auth RCE, and both stem from the same root cause: user-controlled data reaching a template engine without sanitization. PullLight's template injection detection rules catch both patterns across PHP, Python, Ruby, and Java template engines.
Does a WAF stop this?
Partially. A WAF could detect and block {{ or {* patterns in customer name fields. But a smart attacker could encode or obfuscate the payload, or target a less-monitored field. Patching is the real fix — Jinja2's autoescape is the definitive solution.

December 2025 CVE-2025-66434 assigned. Security advisory GHSA-gmxm-2p67-967r published by the Frappe team. Fix PR #43160 (commit affaaee) merges autoescape=True + application-layer field stripping.
December 2025 ERPNext versions containing the fix are released. All instances running dunning are urged to upgrade immediately.
June 2026 PullLight documents CVE-2025-66434 as case study #29 — demonstrating Python/Jinja2 SSTI detection via template context analysis in PR code review.

Don't let SSTI slip into your Python or PHP code

More CVE Case Studies