漏洞

HFS:登录还没完成,签名密钥已经露出线索

HFS 曾用同一个普通随机数发生器生成会话签名密钥和登录编号。研究者从多次登录响应中推算状态,最终伪造管理会话。CVE-2026-61500 在 3.2.1 首次修复,当前应升级到 3.3.4,并让可疑旧密钥退出验签。

浅暖纸上的随机机械轮、输出纸带与会话印章,表现公开输出与签名秘密之间的联系。
文章导航

一个文件分享服务,为什么会因为登录时返回的随机数,让攻击者最终在服务器上执行代码?Horizon3.ai 在 9 月 30 日公开的 HFS 研究,把这条路接了起来。研究者先观察服务器发给客户端的登录编号。编号本来只负责区分一次握手,却暴露了生成会话签名密钥时使用的随机状态。

这项漏洞编号是 CVE-2026-61500,影响 HFS 3.0.0 至 3.2.1 之前的版本。修复早在 7 月 13 日的 3.2.1 中发布,9 月底的新材料详细解释了机制。到本文发布时,稳定版已是 3.3.4,并包含后续安全修复。重新讨论它的价值,在于看清一个常见误区:程序每次启动都会生成新密钥,密钥看起来也足够长,仍然可能无法保守秘密。

HFS 3 使用 Node.js 与 Koa。它把会话内容放在浏览器 Cookie 里,由服务器签名后交给客户端保存。之后浏览器带回内容和签名,服务端检查签名,再恢复其中的会话字段。用户当然可以读取自己收到的 Cookie;安全性取决于他能否修改内容并做出服务器认可的新签名。

这可以从依赖一路核实。HFS 3.2.0 的会话中间件启用 signed: true,没有配置外部会话存储。其锁定的 koa-session 7.0.2 在读取 Cookie时取得内容并解码,在保存会话时把序列化内容编码后写回。Base64 只是表示方式,知道格式的人可以还原字段。

真正要守住的是签名密钥。HFS 3.2.0 的启动代码优先读取环境变量 COOKIE_SIGN_KEYS;没有自定义配置时,调用 randomId(30) 生成默认密钥,再交给 Koa。问题落在这个默认分支上。使用独立生成、足够强的自定义密钥,会避开这里的弱密钥生成路径;自行填写一个可猜的字符串则没有这种效果。

randomId()把长度大于 10 的请求拆开。参数是 30 时,它实际调用三次 Math.random(),把每个结果转成 36 进制,截取小数部分,再拼接起来。把小写 l 换成大写 L 只是方便辨认。字符变多、格式变复杂,都没有把普通随机发生器变成密码学密钥源。

// HFS 3.2.0: default signing key
const keys = process.env.COOKIE_SIGN_KEYS?.split(',')
    || [randomId(30)]

2 密码还没验证,随机编号先发出去了

密钥留在服务器里,客户端从哪里得到推算它的线索?答案在登录的第一步。HFS 的 SRP 登录会分两次交换:服务端先准备握手状态,客户端随后提交证明,服务端才确认登录。SRP 是一种让双方用密码相关材料完成认证的协议;这次分析要看的是保存握手状态的外围代码。

在 loginSrp1() 中,程序先查账号,检查账号能否登录、来源网络是否允许,以及插件是否阻止这次尝试。通过后创建 SRP 服务端状态,执行 const sid = Math.random(),用这个编号把状态存到 ongoingLogins,同时把 { username, sid } 写进客户端会话。这个临时状态有 60 秒清理计时器。

此时客户端还没有提交密码证明。真正核对证明并调用 setLoggedIn() 的代码在后面的loginSrp2()。所以,知道一个可用账号名,并满足第一步的网络和登录条件,就可能收到包含 sid 的 Cookie。签名让客户端无法随意改它,却不会把其中的随机数藏起来。

禁用、过期或缺少登录方式的账号无法通过这一检查;使用插件认证的账号会转向另一条路径,插件事件和访问控制也可能提前阻断请求。公开研究中的账号发现过程,还涉及后来另行修复的账号探测行为。能持续收到这些编号,才有后面观察随机状态的机会。

现在,两个原本不相干的用途连上了。启动时的签名密钥,以及登录第一步的公开编号,都来自同一 JavaScript 随机状态。一次输出只给出有限信息;多次观察则可能逐步限制内部状态的候选范围。研究者随后用已有 Cookie 的签名核对候选密钥,确认自己推回的结果是否正确。

3 看起来随机,为什么仍然能推算

Math.random() 每次给出一个 0 到 1 之间的数。数列适合模拟、抽样和普通随机选择,但生成过程保存在有限状态里:状态按确定规则变化,当前状态决定后续输出。V8 自己的说明也明确提醒,这个接口不适合密码学用途。即使输出通过了统计随机性测试,也不能据此把它用来保守密钥。

沿 HFS 构建所涉及的 Node.js 运行时看,V8 使用两个 64 位状态字段,通过移位和异或推进。Node 24.0.0 所带 V8 的缓存填充代码会预先生成一批值;缓存容量是 64。返回给 JavaScript 的顺序,还受缓存索引递减的实现影响。因此,观察到的先后顺序,不能直接当作状态计算循环的先后顺序。

可恢复的原因还在于具体算法。这里的移位与异或组合可以逆向求解:知道完整状态后,可以追溯前一个状态。公开浮点数只包含状态的一部分信息,但多个相继输出会给同一组未知状态施加约束。密码学随机发生器也可能按确定规则运行,设计却要求从可见输出推回秘密状态在计算上不可行;Math.random() 没有提供这种保证。

恢复时还要对齐两个输出之间额外消耗的随机数、缓存边界,以及启动密钥所在的位置。候选状态满足已知输出后,再按 HFS 的 36 进制转换和截取规则生成候选密钥,用已有 Cookie 签名核对。签名相符,才把推算结果与真实会话密钥接上。

Horizon3 的公开演示记录使用 12 次观测,并讨论了一个 52 位输出模型。报告没有完整列出该次运行的 HFS 构建与 Node/V8 版本。我们核对的 Node 22.16.0 ToDouble()采用 52 位尾数构造;Node 24.0.0 的相应实现则保留 53 位整数再缩放,恢复模型也要随之变化。

12 次观测对应那份演示的环境。额外随机调用、进程重启或请求落到不同工作进程,都可能改变序列对齐;具体部署的成功率和所需样本量还取决于运行时。下文按源码继续追踪密钥失守后的权限变化,本文没有执行状态恢复或会话伪造。

同一随机状态分别用于启动签名密钥和公开登录编号;多次输出约束状态,补丁将两个用途改为密码学随机接口。
图 1:图中箭头表示信息与调用关系。观测输出、恢复状态和核对密钥之间仍有运行时及序列对齐条件,不能从一个编号直接跳到任意部署的密钥。

4 签名一旦能伪造,后面的权限就接上了

得到签名密钥以后,攻击者可以修改客户端保存的会话内容,并生成匹配签名。HFS 的prepareState()会从会话的 username 找到服务端账号。账号的权限仍由服务器保存;攻击者需要冒用一个确实存在、能够登录且拥有相应权限的账号,不能凭空写个名字就创造管理员。

会话中的时间和 IP 字段也需要放在这个前提下理解。旧版确实检查失效时间,并在IP 变化时清除会话用户名。这些字段同样由签名保护;签名密钥失守后,检查所依据的客户端内容也能被重新制作。部署在服务端或网络上的独立访问限制,仍有单独的拦截作用。

管理 API 还有自己的关口。ctxAdminAccess()先检查管理网络限制,再按服务端账号判断管理权限。accountCanLoginAdmin()要求账号可登录且有管理员属性;admin_net可以阻止不在允许范围的远程来源。完整代码执行链因此还取决于管理账号和管理入口的可达性。

一旦以管理身份通过,HFS 本来提供的服务器扩展功能便成为最后一环。set_config把修改交给配置系统,配置变化会触发对应的编译处理。server_code 的处理函数用 new Function 加载脚本,并把 require 交给它。代码在 HFS 服务进程的权限下执行,能接触什么文件和系统能力,取决于该进程身份及运行环境。

这条路径没有再要求一次内存破坏。前面的会话伪造,把攻击者带到了原本留给管理员的可编程功能。因此,调查若已经确认管理身份被冒用,就要继续检查服务器代码配置、插件和进程可访问的文件;只改文件分享密码,会漏掉已经发生的服务器端变化。

5 修复随机源,也要让旧密钥退场

修复补丁同时改了两个位置。默认签名密钥改用 randomBytes(32),然后编码成 Base64url;登录编号改用 randomUUID()。前者由 Node 的密码学随机接口生成 32 字节,后者也不再公开 Math.random() 的状态输出。普通 randomId() 并没有从项目里全部删除,它可以继续服务于不要求保密或不可预测的用途。

const keys = process.env.COOKIE_SIGN_KEYS?.split(',')
    || [randomBytes(32).toString('base64url')]
const sid = randomUUID()

两个修改各自切断了一段联系。修好密钥生成后,即使普通随机数在别处可观察,也无法据此推出新签名密钥;替换公开的握手编号,又移除了这里的普通随机输出。把代码中的 Math.random() 一律换掉会掩盖这种区别。评审时更应问清楚:这个值只需要不易重复,还是必须在别人看过相关输出后仍然不可预测?

3.2.1标记首次修复边界。现在部署应选择3.3.4这一当前稳定版,阅读其后续安全变更,并在可信备份上检查既有配置和插件兼容性。默认密钥在进程启动时生成,更新并重启后会换成新的安全随机密钥,旧会话应失效。如果升级失败,先限制服务入口,再用兼容且已修复的版本恢复;3.2.1 是本漏洞的历史下限,并不保证覆盖之后发现的其他问题。

自定义 COOKIE_SIGN_KEYS 的部署要多看一步:更新程序不会自动替换环境变量里的秘密。若旧密钥来源不可靠或疑似泄露,应使用独立生成的强密钥替换,并撤掉旧值。依赖的 Keygrip 1.1.0用列表第一个密钥签名,却遍历整个列表验签。只把新密钥放在前面、把可疑旧密钥留在后面,会继续接受旧密钥签出的内容。正常平滑轮换时这很方便,处理密钥失守时则需要让旧信任真正失效。

修复后有三项检查直接对应这次机制:确认实际运行的版本和密钥来源,旧会话在撤换密钥后被拒绝,新的正常登录与文件操作仍然成功。曾经出现异常管理行为的环境,还需核对 server_code、插件和文件变更,必要时从可信程序与经过检查的配置重建。升级能消除继续伪造新会话的这一来源,已经运行过的代码及其后果仍须单独调查。

HFS 这次错误最有启发性的地方,是两个局部上看似合理的选择碰到了一起:启动时随机生成秘密,登录时随机分配编号。把它们放在同一个可推算状态上,公开编号就给秘密留下了线索。以后审查令牌、密码重置链接或会话密钥时,除了看长度和签名算法,还值得沿随机源多走一步:同一状态的其他输出,究竟会交给谁。