中文 English

密码没写错,群晖却不让登录?我拆开 DSM 的 RSA + AES 双层信封,完整复刻 WebAPI 登录

发布时间: 2026-07-11
Synology DSM NAS WebAPI RSA AES Python 密码学 自动化 群晖

先说结论

群晖某些 WebAPI 登录请求并不是简单地把 accountpasswd 直接提交给 auth.cgi。OpenStack Cinder 的 Synology 驱动展示了一套完整兼容流程:先调用 SYNO.API.Encryption.getinfo,取得 RSA 公钥、服务器时间和两个动态字段名;客户端生成一次性随机 passphrase,用 RSA 加密它,再用它派生 AES-256-CBC 的密钥和 IV,对 URL 编码后的登录参数加密,最后把两段 Base64 密文放入服务器指定的 cipherkey 字段。

但必须先划清边界:这是一种 WebAPI 身份认证兼容方式,不是破解密码,也不是绕过权限;拿到 session=DSM 的 API SID,也不等于凭空获得浏览器 DSM 管理页面的完整 Cookie 登录状态。HTTP 参数加密同样不能代替 HTTPS,因为 TLS 还负责服务器身份校验、完整性保护和抵抗中间人攻击。

原创题图:RSA 与 AES 双层信封保护 NAS 登录

为什么密码正确,普通 POST 仍然可能失败

很多人第一次自动化 DSM 登录,会自然地写出下面这种请求:

requests.post(
    "https://nas.example.test:5001/webapi/auth.cgi",
    data={
        "api": "SYNO.API.Auth",
        "version": 6,
        "method": "login",
        "account": account,
        "passwd": password,
        "session": "DSM",
        "format": "sid",
    },
)

在部分版本、接入方式或客户端兼容场景中,这种写法并没有复刻目标实现要求的参数保护流程。于是表现很像“密码错了”:返回登录失败、字段缺失、请求格式不匹配,或者代理层只看到一次普通的 auth.cgi 请求。

最容易走偏的方向有三个:反复重置密码、把 cipherkey 硬编码成 __cIpHeRtExT、复制一次旧的 server_time 长期使用。真正需要复刻的不是某个固定字符串,而是 每次登录前先向服务器领取当次规则

完整请求时序

一手证据来自哪里

本文的算法依据不是猜测,而是 OpenStack Cinder 固定提交中的 Synology 驱动实现。驱动的 AESCipher 明确写出:

真实源码截图:AES-256-CBC 与 OpenSSL 兼容信封

另一段 _encrypt_params 则给出了完整拼装顺序:调用 getinfo,读取 public_keycipherkeyciphertokenserver_time,生成 501 字节随机 passphrase,将时间写入动态 token 字段,然后分别执行 RSA 与 AES 加密。

真实源码截图:RSA 与 AES 密文装入动态字段

这里有一个很重要的细节:Cinder 驱动只在非 HTTPS 分支调用这套参数加密。这说明它主要是对特定协议兼容行为的实现,而不是对 TLS 的替代。今天重新实现时,仍然应优先使用 HTTPS,并开启证书校验。

用“双层快递”理解混合加密

假设你要寄一张写着账号和密码的纸条。

AES 像一把速度很快的小钥匙。它可以迅速锁住整只大盒子,但问题是:怎样把这把钥匙安全地交给 NAS?如果把钥匙明文贴在盒子外面,锁盒子就失去意义。

RSA 像 NAS 提前公开的一只特殊保险信封。任何人都能把东西塞进去并锁上,但只有持有私钥的 NAS 能打开。于是客户端把随机 AES passphrase 放进 RSA 信封;账号、密码、Session 和服务器时间则放进 AES 盒子。最后两个包裹一起送到 NAS。

双层快递类比

这种做法叫混合加密:RSA 擅长安全交换小块秘密,AES 擅长高速加密大量数据。两者分工,比“所有内容都用 RSA 加密”更高效,也比“直接发送 AES 密钥”更安全。

每一步究竟发生了什么

客户端先向 /webapi/encryption.cgi 提交:

api=SYNO.API.Encryption
version=1
method=getinfo
format=module

成功响应中的四个字段各有职责:

字段 作用 能否硬编码
public_key RSA 模数,指数固定为 65537 不应该
cipherkey 最终密文参数的字段名 不应该
ciphertoken 放置服务器时间的内部字段名 不应该
server_time 本次加密参数中的时间值 不应该复用

然后生成 501 字节随机 passphrase。之所以是 501,与 4096 位 RSA 和 PKCS#1 v1.5 的最大明文长度有关:512 字节模数减去至少 11 字节填充,正好是 501 字节。

登录参数加入动态时间字段后进行 URL 编码,例如逻辑上类似:

account=reader%40example&passwd=<redacted>&session=DSM&format=sid&synotoken=...

接下来用兼容 OpenSSL EVP_BytesToKey 思路的 MD5 链派生 32 字节 AES 密钥和 16 字节 IV,再用 AES-256-CBC 加密。输出结构是:

Salted__ + 8-byte salt + ciphertext

最后形成动态字段:

{
  "<cipherkey from server>": "{\"rsa\":\"...\",\"aes\":\"...\"}"
}

cipherkey 载荷结构

我对原始示例做了哪些修正

资料中的思路基本正确,但直接用于长期自动化还需要几处加固。

第一,随机 passphrase 不再使用 random.choice,而使用适合安全随机数的 secrets.choice。第二,HTTPS 默认开启证书验证,只有显式传入 --insecure 才关闭,并打印警告。第三,密码默认通过隐藏输入或临时环境变量读取,不写进源码。第四,程序默认只报告“服务器返回了 SID”,不会把 SID 打进日志;只有明确使用 --show-sid 才显示。第五,对 getinfo 缺字段、JSON 异常、HTTP 错误、登录失败和“成功但没有 SID”分别报错。

我还修复了原资料末尾示例中函数调用括号未闭合的问题,并将 host + port + scheme 合并为明确的 --url,减少 HTTP/HTTPS 与端口组合错误。

完整程序可直接下载:

真实离线验证

为了避免在文章里伪造“成功截图”,我使用临时生成的 4096 位 RSA 密钥完成了可逆验证:公钥加密 501 字节 passphrase,私钥解密后逐字节相等;AES 密文以 Salted__ 开头;解密后能够恢复 URL 参数和动态时间字段;最终登录请求中不存在明文 passwd 字段。

真实测试运行截图

下面这张图也是实际运行程序生成的请求结构,而不是手画的示意 JSON。公钥、密码和密文正文均已截断或替换,保留的只有能够证明算法结构的长度和前缀。

真实生成的加密交换截图

测试不能证明每个 DSM 版本都接受同样的私有兼容流程,但可以证明本文代码确实完成了预期的密码学封装,而不是只拼出一个“看起来像”的 JSON。

三个平台的一键脚本

三个脚本都会在当前目录创建独立 .venv,安装固定依赖,然后交互式读取地址、账号和 Session;密码由 Python 的隐藏输入读取,不会出现在命令历史中。

Windows 11

下载同一目录中的全部文件,然后在 PowerShell 执行:

Set-ExecutionPolicy -Scope Process Bypass
.\install-windows.ps1

下载 Windows 11 脚本

Ubuntu 26.04

chmod +x install-ubuntu.sh
./install-ubuntu.sh

下载 Ubuntu 26.04 脚本

macOS 26

chmod +x install-macos.sh
./install-macos.sh

下载 macOS 26 脚本

如果使用自签名证书,正确做法是把自己的 CA 导入系统或 Python 信任链,而不是长期添加 --insecure

人工执行方法

已经有 Python 3 时,也可以手工运行:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python synology_login.py \
  --url "https://nas.example.test:5001" \
  --account "reader" \
  --session "DSM"

程序随后会隐藏输入密码。若自动任务必须使用环境变量,可临时设置 SYNOLOGY_PASSWORD,运行后立即清理,且不要在共享终端、CI 日志或截图中输出环境内容。

Agent 自动配置方法

可以把下面的任务说明交给本机 Agent。不要把真实密码粘进对话:

在本机为 Synology WebAPI 登录工具创建隔离 Python 虚拟环境。
只使用当前目录中的 synology_login.py 和 requirements.txt。
从操作系统环境变量 SYNOLOGY_PASSWORD 读取密码,不打印变量值、SID、Cookie 或完整响应。
使用我确认过的 HTTPS URL 和账号执行一次 session=DSM 登录。
保持 TLS 校验开启;如果证书失败,停止并报告证书链问题,不得自动添加 --insecure。
最后只报告 HTTP 阶段、是否返回 SID、耗时和脱敏错误码。

Agent 的价值是减少重复安装和参数输入,不是替你降低安全标准。任何“为了方便”把密码写进脚本、关闭 TLS、把 SID 贴回聊天的行为,都应该拒绝。

失败时如何排查

登录失败排障图

先检查网络和 TLS:URL、端口、反向代理路径、证书主机名和 CA 链是否正确。再检查动态字段:是否每次登录前重新调用 getinfo,是否把 ciphertoken=server_time 放入 AES 明文,是否错误地缓存了 cipherkey。然后检查账号策略:账号是否锁定、是否启用 2FA、Session 名是否匹配、调用方是否具备对应 API 权限。最后检查版本:SYNO.API.Auth 的可用版本应以设备的 SYNO.API.Info 查询结果为准。

不要只看到“登录失败”就断定密码错误。它更像快递被退回:可能是地址错了、包装方式不对、标签过期,也可能才是收件人信息错误。

安全边界与容易误解的地方

AES-CBC 本身不提供认证标签,这套格式也不是现代协议设计的推荐模板。它的意义是兼容既有实现,而不是建议新系统照抄。MD5 在这里用于历史兼容的密钥派生,不代表 MD5 重新变得适合密码哈希或数字签名。

即使 HTTP 请求参数经过 RSA + AES 封装,中间人仍可能替换 getinfo 返回的公钥,从而让客户端把秘密加密给攻击者。HTTPS 的服务器身份校验正是用来解决这个问题。因此,“参数已经加密,可以放心关闭证书校验”是危险结论。

此外,WebAPI SID 是临时会话凭据,拿到后应像密码一样保护并在不需要时注销。本文只适用于你拥有或明确获授权管理的设备。

Q&A

cipherkey 一定是 __cIpHeRtExT

不要假设。服务端通过 getinfo 返回字段名,客户端应动态读取。把观察到的一次值写死,会让代码对版本变化非常脆弱。

把 Session 改成 DSM 就能直接登录浏览器管理界面吗

不能这样等同。它决定的是 WebAPI 会话命名空间。浏览器 UI 还可能涉及 Cookie、CSRF、设备令牌、2FA 和前端自己的初始化流程。

为什么 RSA 还使用 PKCS#1 v1.5,而不是 OAEP

因为目标是兼容上游实现。新协议一般更倾向 OAEP,但客户端不能单方面更换填充方式,否则服务器无法解密。

为什么 passphrase 恰好是 501 字节

固定实现使用 4096 位 RSA。PKCS#1 v1.5 加密允许的最大明文是模数长度减 11 字节,即 512 - 11 = 501。

DSM 开了 2FA 怎么办

需要按照设备和 API 版本支持处理 OTP 或设备令牌。本文基础工具没有假装绕过 2FA;遇到相关错误应停止并扩展合法的二次验证流程。

最后的结论

这次分析最有价值的不是“写出了一个登录脚本”,而是建立了正确的阅读顺序:先看服务器发回的动态规则,再看上游客户端怎样消费这些字段,最后用可逆测试证明每层封装都正确。

群晖的这套流程可以浓缩成一句话:RSA 保护一次性 AES 钥匙,AES 保护 URL 参数,动态 cipherkey 负责把两只包裹交给服务器。

真正安全的落地还要再加一句:始终优先使用经过正确校验的 HTTPS,不记录密码和 SID,不把兼容加密误解成权限绕过。

参考资料