CVE-2025-20393
2025 年 12 月 10 日,Cisco 发布了关于 Cisco Secure Email Gateway 和 Cisco Secure Email and Web Manager 的未授权远程命令执行漏洞预警公告,此漏洞影响这些系统的 Spam Quarantine 功能。本次我们根据有关信息对该漏洞进行复现分析。
基本信息
Cisco 发布的公告提到该漏洞仅影响系统中的 Spam Quarantine web 服务,这是邮件安全网关中用于拦截与展示垃圾邮件的功能,漏洞 CWE 为 CWE-20 属于输入验证不当,除此之外公告中没有给出更加具体的信息。
该漏洞公告发布于 2025 年 12 月 10 日,补丁于 2026 年 1 月 15 日更新,并且该漏洞在修复之前就已存在在野利用。
漏洞分析
Cisco 在更新 Cisco Secure Email Gateway 和 Cisco Secure Email and Web Manager 补丁的同时将所有旧版本系统做下架处理。通过其它渠道我只能获取到 Cisco Secure Email Gateway 的 16.0.1.017 和 16.0.4.016 两个版本,因此暂时只能使用这两个版本进行补丁对比。你可以在这里获取环境:下载链接
环境部署
将两个版本的镜像导入虚拟机启动,提示登录时输入默认账户 admin/ironport,执行 ifconfig 命令可以查看网卡信息,访问 IP:443 进入 Web 管理页面,使用 admin 账户登录后会提示完成一些基础配置。
系统不直接提供 root shell 所以要手动获取权限,完成基本配置之后关机,将硬盘挂载到另一个 FreeBSD 系统上,分析发现关键文件都位于 /dev/da1p9 分区中。
通过搜索登录 CLI 后输出的一些信息,定位到 CLI 程序 bin/cli.sh,我们直接修改这个脚本文件,在头部添加命令 /bin/sh -i,并且修改系统中 python 的权限为 4777。
保存修改开机,使用 admin 账户登录即可进入普通权限的 linux shell,再执行 python 并 setuid(0) 获得 root 权限。业务逻辑相关的代码都位于 /data/release/current 目录下,分别将两个版本的 current 目录下载到本地。
Spam Quarantine 功能需要在系统后台手动开启,在 Monitor -> Spam Quarantine 选项下面打开垃圾邮件隔离功能,然后在 Network -> IP Interfaces 选项下面修改 Management 网卡配置,启用 Spam Quarantine,该服务默认监听 83 端口。
代码解密
查看端口信息看到是 euq_webui 监听 83 端口,简单逆向分析发现这实际上是一个使用 Python Frozen 打包的 ELF 程序,通过字符串定位到使用的 python 版本为 2.6.13。
打包在 ELF 程序中的模块位于 _PyImport_FrozenModules 数组中,它的结构为:
1 | struct _frozen { |
code 是该模块对应的 marshal 序列化代码对象,注意这部分不是完整的 pyc 文件格式。
我们编写 IDAPython 脚本(dump_code.py)将嵌入的模块代码分离出来,同时补充 pyc magic 和文件日期等 header 数据。
得到嵌入模块的 pyc 代码之后,可以使用 uncompyle6 反编译,主要关注 __main__.pyc 文件,反编译后得到代码:
1 | # uncompyle6 version 3.9.2 |
main 函数中会调用 ipoe.imputil.register_ipoe 注册包加载器,这个函数定义在 ipoe.imputil.pyc 文件中:
1 | def register_ipoe(): |
ipoe.imputil.pyc 实现了一个自定义的 Python 软件包加载器,正常的 Python 软件包是蛋(egg)格式,这里引入了 ipoe 格式。
ipoe 文件本身是 ZIP 格式,但是它内部包含的 python 代码文件都被加密了,在加载 ipoe 包的过程中,会调用 ipoe.encryption.pyc 中实现的算法进行解密:
1 | # uncompyle6 version 3.9.2 |
对加密文件会先使用 HMAC-SHA1 进行完整性检查,然后再用 AES 算法解密。Key 是按上述代码生成的,IV 从加密文件中读取。
参考以上代码在本地编写解密脚本(handle_ipoe.py),批量处理所有 ipoe 包之后,可以开始补丁对比。
补丁对比
我们使用 Beyond Compare 比较,主要比较 lib/python2.6_13_amd64_nothr/site-packages 目录,因为大部分 ipoe 包都在这个目录下。
比较时有一些需要特别注意的地方,由于 python 代码是反编译得到的,而 uncompyle6 在反编译时会向文件头添加源文件编译时间等信息,导致 Beyond Compare 将一些没有任何变化的文件识别为不同,另外还有很多文件只是版本字符串发生了变化。为了缩小比较范围,可以在 Beyond Compare 中设置例外规则,将这些变动设置为不重要的文本:
1 | # Compiled at.*\s |
这样对比范围就变小了很多,在 enduser_site_packages/aquarium/screen/AppController.py 找到了一处关键变动:
1 | # 16.0.1.017 |
新版本代码检测到 serial 参数会直接将它删除,而 auth 参数则需要通过 is_md5Hash 的验证:
1 | def is_md5Hash(s): |
对参数新增检查说明这可能就是漏洞点所在,而且这段代码位于登录功能的入口,无需任何权限即可调用。具体分析逻辑,当请求中存在 auth 参数时进入这段代码,旧版本直接调用 specialLogin 函数,最终会调用到 CoroSessionContainer.py 中的 checkHash 函数:
1 | def specialLogin(self): |
传入 checkHash 的 hash 和 serial 都是可控的,代码接着调用 commandd 的 call 函数,它定义在 command_client.py 中的 command_client 类:
1 | def call(self, modules, method, args, destination=None, timeout=0, tag=None): |
注意到这个函数的第四个参数是 destination,而代码中其它位置使用 cc.call 的调用方式为:
1 | topin = self._cc.call(('hermes.imh', ), 'topin', (num_hosts,))[0][1] |
checkHash 里面直接将 serial 作为 destination 参数使用,这可能会导致一些问题。
call 函数调用 send_message 函数,部分代码如下:
1 | def send_message(self, commandment_msg, data='', source=None, destination=None, ttl=0, expected=AUTO_EXPECT, timeout=0, tag=None): |
代码又调用了 CommandMessage.send_message 函数,注意 data 参数已经是经过 cPickle 序列化之后的数据。
1 | # CommandMessage.send_message |
CommandMessage.send_message 函数使用 struct 模块打包 header 再拼接后面的数据,最后会调用 self._socket.send 将数据发送出去,考虑到 command_client 的初始化过程,数据会被发送给 /tmp/commandd.mainsock
这个函数存在一处问题,在打包 header 时对于 destination 的长度使用了 B,而 destination (即 serial)实际没有长度限制。如果传入一个长度超过 255 字节的 destination,struct.pack 会发生整数溢出,令 destination 的长度字段回绕成较小值。
CommandServer.py 监听 /tmp/commandd.mainsock,它会调用 read_message 函数读取输入的数据:
1 | while 1: |
而 CommandMessage.read_message 的实现如下:
1 | def read_message(read_method, timeout=0): |
首先解析 header 部分,获取到各个字段的长度,然后依次读取出这些字段。考虑 Command 协议的实现,一个正常的请求结构如下:
1 | +--------+--------+--------+--------+ -+ |
由于 CommandMessage.send_message 函数在打包 header 时,没有限制 destination 的长度,而且 DST_LEN 使用了 B,如果我们传入一个长度大于 0xff 的 destination,假如 len(destination) = 0x100,那么 CommandMessage.read_message 读取数据时,会认为传入的请求中不包含 destination (pack 之后 DST_LEN = 0x00),于是继续读取 msg_len 长度的数据作为 message 使用,但这里实际上读取的是 0x100 长度的 destination 所在位置。
解析并读取数据后,代码会检查 source 和 destination 是否与系统序列号相同,source 是调用 tags.serial() 获取的,可以通过验证。destination 由于 length 等于 0,会直接跳过检查逻辑。
接着代码根据请求中的 msg_type 调用 MSG_CALL 对应的函数,并传入 message 作为参数:
1 | def call(self, message, source, destination, txn_tag): |
在 MSG_CALL 对应的函数中,直接调用 cPickle.loads 反序列化 message 数据,正常情况下 message 是 cPickle.dump 得到的,但由于对 header 长度解析问题导致此处反序列化的实际上是可控数据,造成反序列化漏洞。
漏洞利用
我们已经知道了漏洞的成因,那么利用此漏洞也比较简单。向登录接口发送一个请求,请求中应该包含 auth 和 serial 参数,且 auth 参数的长度和反序列化 payload 的长度相等。
serial 参数的长度应该是 0x100 的整数倍,这样可以导致发往 commandd.mainsock 的 DST_LEN 为 0,同时 serial 的构成为:反序列化 payload + padding 到 0x100 整数倍任意数据
这样 read_message 就会读取到错误的 message,然后在后续的 call 中反序列化了攻击者控制的数据,由于 CommandServer 是以 root 权限运行的,反序列化导致的命令执行也是 root 权限,这和漏洞公告相符。
由于该漏洞存在在野利用情况,建议受影响用户尽快更新到最新版本,并检查系统是否已经被攻击。
AI 自动分析(🤖)
本次分析使用了 Claude Code + deepseek-reasoner,目录结构为
1 | CVE-2025-20393 --+ |
输入提示词(无进一步辅助信息输入):
1 | 我正在尝试复现 Cisco 邮件安全网关最近出现的未授权远程代码执行漏洞 CVE-2025-20393,该漏洞的描述如下: |
输出文档:
1 | # CVE-2025-20393 Vulnerability Analysis Report |
看起来 AI 定位到了漏洞点,但是没有进一步深入找到命令执行的方法(根本原因),它根据新版本对参数添加的检查认为该参数可能存在命令注入。
如果采取更换性能更强的模型、设计更好的 Agent 架构或工具、优化提示词等办法,AI 也许可以自动给出正确且完整的报告。
本次分析消耗 66k token,耗费 ¥1.30,AI 自动编写了用于 diff 的 Python 脚本:
1 | #!/usr/bin/env python3 |
参考文章
附件
dump_code.py
1 | import idaapi |
handle_ipoe.py
1 | import hmac |
- Title: CVE-2025-20393
- Author: Catalpa
- Created at : 2026-02-05 00:00:00
- Updated at : 2026-02-05 17:09:04
- Link: https://wzt.ac.cn/2026/02/05/CVE-2025-20393/
- License: This work is licensed under CC BY-SA 4.0.