密码没写错,群晖却不让登录?我拆开 DSM 的 RSA + AES 双层信封,完整复刻 WebAPI 登录
先说结论
群晖某些 WebAPI 登录请求并不是简单地把
account和passwd直接提交给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 还负责服务器身份校验、完整性保护和抵抗中间人攻击。

为什么密码正确,普通 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 明确写出:
- 输出以
Salted__开头; - 随机盐长度为 8 字节;
- 密钥长度为 32 字节,也就是 AES-256;
- 模式为 CBC,IV 为 16 字节;
- 密钥和 IV 通过连续 MD5 摘要拼接派生;
- 明文按 16 字节块做 PKCS#7 风格填充。

另一段 _encrypt_params 则给出了完整拼装顺序:调用 getinfo,读取 public_key、cipherkey、ciphertoken、server_time,生成 501 字节随机 passphrase,将时间写入动态 token 字段,然后分别执行 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\":\"...\"}"
}

我对原始示例做了哪些修正
资料中的思路基本正确,但直接用于长期自动化还需要几处加固。
第一,随机 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
Ubuntu 26.04
chmod +x install-ubuntu.sh
./install-ubuntu.sh
macOS 26
chmod +x install-macos.sh
./install-macos.sh
如果使用自签名证书,正确做法是把自己的 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,不把兼容加密误解成权限绕过。
参考资料
- OpenStack Cinder Synology driver 固定提交:https://opendev.org/openstack/cinder/src/commit/efc3217d8b3ce8f4f1a66c7d5b7c940d73a36753/cinder/volume/drivers/synology/synology_common.py
- Python
secrets文档:https://docs.python.org/3/library/secrets.html - cryptography 对称加密 primitives:https://cryptography.io/en/latest/hazmat/primitives/symmetric-encryption/