XSS对通行密钥是致命的:无证明的隐藏风险

Lobsters Hottest 新闻

摘要

本文解释了一个单一的XSS漏洞如何能够在证明设置为'none'的情况下破坏通行密钥的抗钓鱼能力,允许攻击者注册自己的通行密钥并实现持久账户接管。文章呼吁关注这一被忽视的威胁并提出了防御措施。

<p><a href="https://lobste.rs/s/k8mkgs/xss_is_deadly_for_passkeys_hidden_risk">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/20 20:31

# XSS 对通行密钥是致命的:无认证的隐藏风险 来源:https://scotthelme.co.uk/xss-is-deadly-for-passkeys-the-hidden-risk-of-attestation-none/ 一个单一的 XSS 漏洞就能将通行密钥从一种抗钓鱼的登录机制转变为持久的账户劫持后门。如果恶意 JavaScript 能在你的页面上运行,它就可能为攻击者控制的通行密钥注册到受害者的账户中。用户什么也看不到,网站记录了一次成功的注册,而攻击者则带着一个有效的身份验证后门走开了。 [](https://report-uri.com/?utm_source=scotthelme.co.uk)对于一个组织来说,这不仅仅意味着"有人发现了 XSS";它意味着身份被入侵、持久化、审计线索模糊、监管风险暴露,以及一项安全控制措施看似正常工作,实际上却在暗中为攻击者提供便利。 令人不安的真相是,尽管通行密钥带来了惊人的好处,而且我认为每个人都应该使用它们,但在威胁模型中存在一个危险的缺口,几乎我交谈过的所有人都忽视了它。这篇博文解释了这种风险,演示了它是如何实现的,以及有效的防御措施是什么样子的。 ### 引言 在开始之前,如果你想快速了解通行密钥的工作原理,可以跳转到我写的的[通行密钥 101 博文](https://scotthelme.co.uk/passkeys-101-an-introduction-to-passkeys-and-how-they-work/?utm_source=scotthelme.co.uk),那里我解释了基础知识。在本博文中,我将假设你理解通行密钥的概念,我们将更详细地探讨它们的工作方式。 我们还需要建立一些术语,以便更容易理解本篇博文: **依赖方**:存储和验证用户通行密钥凭证以进行身份验证的网站或应用程序。 **验证器**:创建、存储和使用私钥向依赖方证明用户身份的用户设备或密码管理器。 **认证机制**:验证器在注册期间可以用来证明创建凭证的硬件类型的机制。 ### 通行密钥的注册方式 当向类似 Report URI 这样的依赖方注册通行密钥时,JavaScript 会发出请求以获取所需的数据: ``` const optRes = await fetch('/passkeys/register_get_options/' + getCsrfToken(), { method: 'POST' }); ``` ``` POST /passkeys/register_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1 Host: report-uri.com Cookie: session=... Content-Length: 0 ``` 依赖方会返回一个类似下面的响应,其中包含 `publicKey` 对象: ``` HTTP/1.1 200 OK Content-Type: application/json { "publicKey": { "rp": { "name": "Report URI", "id": "report-uri.com" }, "user": { "id": "Yi8kP1xqd0Jx3mWZ8Q2vK7nR4tH6sLpA9dF1gE0wXc=", "name": "[email protected]", "displayName": "[email protected]" }, "challenge": "kQ7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J", "pubKeyCredParams": [ { "type": "public-key", "alg": -8 }, { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ], "timeout": 60000, "authenticatorSelection": { "requireResidentKey": true, "residentKey": "required", "userVerification": "required" }, "attestation": "none", "excludeCredentials": [ { "type": "public-key", "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc...", "transports": ["usb", "nfc", "ble", "hybrid", "internal"] } ] } ``` 现在你的设备有了所需的信息,它可以创建新的通行密钥并保存,很可能会显示某种需要 PIN、FaceID、TouchID 等确认的提示。这是通过下面的 JavaScript API 调用完成的,该调用将触发与你验证器的交互: ``` const cred = await navigator.credentials.create({ publicKey }); ``` 如果你完成该过程,你的验证器将存储你的新通行密钥。然后 JavaScript 将构建要发送回依赖方的响应,以确认所有事情已完成,并将新通行密钥保存到用户的账户: ``` const payload = { name: nameInput?.value?.trim() || '', password: passwordInput.value, id: cred.id, rawId: cred.rawId, type: cred.type, clientDataJSON: cred.response.clientDataJSON, attestationObject: cred.response.attestationObject, }; const finRes = await fetch('/passkeys/register_finish/' + getCsrfToken(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); ``` `attestationObject` 包含重要信息,其他内容大多是元数据。以下是 `attestationObject` 的内容,其中公钥是关键部分: ``` attestationObject (CBOR) ├─ fmt ← 认证格式,例如 "none" / "apple" ├─ authData ← 验证器数据 │ ├─ rpIdHash ← 依赖方 ID 的 SHA-256 哈希 │ ├─ flags ← UP/UV/AT/ED 标志等 │ ├─ signCount ← 签名计数器 │ └─ attestedCredentialData │ ├─ aaguid ← 类型/型号 ID,同步通行密钥时无效 │ ├─ credentialIdLength │ ├─ credentialId ← 凭证 ID,也暴露为 id/rawId │ └─ credentialPublicKey ← COSE 编码的公钥 └─ attStmt ← 认证声明;格式为 "none" 时为空 ``` 依赖方现在可以将公钥保存到用户记录中,我们知道这是一个通行密钥,用户将来可以用它进行身份验证。存储的记录可能像这样: ``` { "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc", "name": "Jane's MacBook", "pem": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...\n-----END PUBLIC KEY-----\n", "counter": 0, "created": "2026-05-16T14:22:07+00:00" } ``` ### 通行密钥的身份验证方式 登录过程同样简单,只需几个步骤即可成功使用通行密钥进行身份验证。首先,JavaScript 必须从依赖方获取身份验证所需的信息。 ``` const optRes = await fetch('/passkeys/login_get_options/' + getCsrfToken(), { method: 'POST', credentials: 'same-origin' }); ``` ``` POST /passkeys/login_get_options/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1 Host: report-uri.com Cookie: session=... Content-Length: 0 ``` 依赖方将响应一个包含所需信息的 `publicKey` 对象: ``` HTTP/1.1 200 OK Content-Type: application/json { "publicKey": { "challenge": "Vk7nR4tH6sLpA9dF1gE0wXc2vK7mZ8Q2Yi8kP1xqd0J", "timeout": 20000, "rpId": "report-uri.com", "userVerification": "required", "allowCredentials": [ { "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc", "type": "public-key", "transports": ["usb", "nfc", "ble", "hybrid", "internal"] } ] } } ``` 你必须用某种方式告诉依赖方哪个用户/账户正在尝试登录,Report URI 依赖于用户已经在第一步中填写了电子邮件地址和密码,但有些网站只会询问你的电子邮件地址。返回的响应必须已经查找到了用户账户(本例中为 `[email protected]`),现在提供了一组 `allowCredentials`,它们是之前注册的通行密钥的 `id` 值。如果你查看前面的注册步骤,你会发现我们注册了一个 `id` 值为 `AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc` 的通行密钥,现在在登录时作为允许的凭证返回给我们。我们可以现在将其传递给验证器,使用以下 JavaScript API 调用: ``` const assertion = await navigator.credentials.get({ publicKey }); ``` 此时,你的验证器可能会要求你输入 PIN、FaceID、TouchID 或类似的东西,然后验证器将使用之前注册时存储的关联私钥对挑战进行签名,该私钥由提供的 `id` 标识。这个签名的挑战随后可以返回给依赖方,以证明你拥有私钥: ``` const payload = { id: assertion.id, rawId: assertion.rawId, type: assertion.type, clientDataJSON: assertion.response.clientDataJSON, authenticatorData: assertion.response.authenticatorData, signature: assertion.response.signature, userHandle: assertion.response.userHandle || '', }; const finRes = await fetch('/passkeys/login_finish/' + getCsrfToken(), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); ``` ``` POST /passkeys/login_finish/8f3c1a9e4b2d7f60c5a1e8d2b9f4a7c3 HTTP/1.1 Host: report-uri.com Content-Type: application/json Cookie: session=... { "id": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc", "rawId": "AX9k2mZ8Q2vK7nR4tH6sLpA9dF1gE0wXc==", "type": "public-key", "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVms3blI0dEg2c0xwQTlkRjFnRTB3WGMydks3bVo4UTJZaThrUDF4cWQwSiIsIm9yaWdpbiI6Imh0dHBzOi8vcmVwb3J0LXVyaS5jb20iLCJjcm9zc09yaWdpbiI6ZmFsc2V9", "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA==", "signature": "MEUCIQD3...base64 of the ECDSA/EdDSA signature...AiEA9k2m", "userHandle": "T3xq2mP9kZ8Q2vK7nR4tH6sLpA9dF1gE0wXcYi8kP1w=" } ``` 如果依赖方随后能够使用注册时存储的公钥成功验证此载荷中的签名,则尝试登录的用户就证明了自己拥有与该公钥关联的私钥。这意味着他们已经使用通行密钥完成了身份验证,你可以授予他们账户的访问权限。 ### 理解认证机制 认证机制是件大事,但如果你回头看看客户端调用 `/passkeys/register_get_options` 时的注册过程,你会注意到依赖方发回的响应中有以下内容: ``` { ... "attestation": "none", ... } ``` 认证机制允许你的应用程序(依赖方)回答"我在与哪种验证器打交道"这个问题,并且是在硬件级别回答该问题并得到一个可验证的答案。这听起来很棒,那么为什么 Report URI 不要求这样做呢? 为了使认证机制生效,你首先需要获取所有能产生通行密钥的已注册验证器的证书。你可以从 [FIDO 联盟](https://fidoalliance.org/metadata/?utm_source=scotthelme.co.uk) 的元数据服务(MDS3)中获取这些信息,只需要下载文件、验证其签名,然后解析出所有证书。你大约需要每月做一次以保持最新,然后你就可以在验证器向你的应用程序注册通行密钥时要求认证。 认证机制随后是验证器的一种签名,证明它是一个来自特定制造商的真正验证器,比如说 YubiKey。我们的应用程序可以使用上面获取的证书来验证该签名,然后我们就可以确信我们正在与一个真正的 YubiKey 打交道。在注册流程中,验证器会提供一个包含 `attStmt` 的 `attestationObject`,如下所示: ``` "attStmt": { "alg": -7, // 签名算法的 COSE 标识(例如 -7 = ES256) "sig": h'3045022100...', // 对 (authData ‖ SHA-256(clientDataJSON)) 的签名 "x5c": [ // 认证证书链,叶子证书在前 h'308202bd30820...', // 叶子:验证器的认证证书 h'30820336308...' // (可选)中间 CA 证书 ] } ``` 那么,为什么我们在向应用程序注册通行密钥时不要求认证机制呢? ### 便利性 没人提及的权衡!验证器以加密方式证明其硬件设备类型的能力无论如何都不能不被视为一项重大的安全胜利。但这种胜利是有代价的。 我们拉取了当前的 MDS3 列表来看看里面有什么,我们看到的有 Yubico、Feitian、Thales、Ledger、平台 TPM/Hello 验证器等等。问题在于我们没有看到的。1Password、LastPass、Bitwarden、Dashlane、iCloud 钥匙串、Google 密码管理器、Chrome 内置的商店……这不是这些公司的疏忽,而是设计选择。 通行密钥最初的构想是私钥应该保留在单一设备上,位于安全存储中,如安全隔区、TPM 或类似载体。我会在我的在线账户上注册一个通行密钥,并将其保存为"Scott 的笔记本电脑",这个通行密钥将永远留在我的笔记本电脑上,安全地存储在 TPM 中(我是 Windows 用户)。这是一种巨大的安全超能力,但也是有代价的。如果我丢失了笔记本电脑、咖啡洒在上面,或者它严重故障并且[魔法烟雾](https://en.wikipedia.org/wiki/Magic_smoke?utm_source=scotthelme.co.uk)跑出来了,那我就麻烦大了。我现在需要在其他地方有另一台设备,上面已经注册了我的账户的通行密钥,这样我才能从那台设备登录,否则我就惨了。这种需要为每台设备注册和管理单独的通行密钥才能访问在线账户的想法,将我们推向了另一个方向。 ### 同步通行密钥 同步通行密钥在架构上与可认证的硬件凭证截然相反。不是将通行密钥保存在安全存储介质(如安全隔区或 TPM)中,而是使用 1Password,它将私钥存储在我的 1Password 保险库中。然后我的保险库同步到我的所有设备——Windows 桌面、iPhone、MacBook Pro、iPad 等等。这给了我极大的便利,因为我只需要向依赖方注册一次通行密钥,然后就可以在所有设备上使用该通行密钥登录,而不需要从每台设备分别注册。但问题就在这里……我们无法在这个过程中进行有意义的硬件认证来告诉我们正在与什么类型的硬件验证器打交道,因为"这是什么类型的设备?"这个问题的答案总是"取决于情况"。对于像 1Password 和其他软件验证器来说,没有普遍有用的方法来进行认证,这就是为什么我们在 Report URI 上不要求它的原因——因为如果我们要求了,我们绝大多数用户将无法使用他们偏好的方法注册和验证通行密钥。 这种张力——设备认证 vs. 同步通行密钥——实际上是整篇博文的核心。 ### 一切崩塌之处 我们现在有了所有拼图,让我们把它们组合起来,看看哪里出了问题。大多数在线服务不会要求认证机制,因为那会迫使许多用户无法以他们偏好的方式使用通行密钥。但认证机制允许依赖方知道自己正在与一个由硬件支持的正牌验证器对话。没有认证机制,我们只是在与软件对话,与代码对话。事实证明,网页运行着代码…… 我们之前走过的完整通行密钥注册和认证流程都是由 JavaScript 驱动的。要注册一个新通行密钥,页面会调用 `navigator.credentials.create()` 并与验证器交互,来回传递数据。要使用通行密钥进行认证,页面会调用 `navigator.credentials.get()` 并与验证器交互,来回传递数据。如果我们把认证机制排除在外,你可以仅用 JavaScript 完成整个流程,甚至不需要涉及验证器。让我们一步步来: 1. JavaScript 正常调用 `/passkeys/register_get_options/` 开始注册流程。 2. 通常,JavaScript 现在会调用 `navigator.creden

相似文章

匿名凭证:图解入门(第二部分)

Hacker News Top

图解入门系列的第二部分,介绍 Privacy Pass 和 Google 年龄验证提案等真实世界的匿名凭证系统,重点讲解如何防止凭证克隆,并在不牺牲用户隐私的前提下实现富有表现力的证明。

揭露CBSE屏幕标记门户的关键漏洞

Hacker News Top

一名安全研究人员发现了CBSE屏幕标记门户中的关键漏洞,包括硬编码的主密码和认证绕过,这可能导致完全接管账户并篡改考试评估。

Dashlane 披露攻击者如何成功下载加密密码库

Ars Technica

Dashlane 披露了一起有组织的暴力破解攻击事件:威胁行为者滥用设备注册 API,同时向数千个账户发送一次性验证码,在攻击被制止前,已成功下载了不足 20 名用户的加密密码库。