一、先说一说这个漏洞到底是怎么回事

很多企业现在都喜欢用企业微信来做内部办公,然后又在低代码平台上搭各种业务应用。两边要打通,最常用的办法就是企业微信自建应用带着员工的身份去访问低代码平台,让平台知道“当前登录的是谁”。这个过程中,OAuth2.0的授权码模式是绝对的主流,而在这个流程里面,有一个叫 state 的参数,看起来不起眼,却经常被开发者忽略。结果就是,攻击者可以利用这个参数干一些“偷梁换柱”的坏事。

你可能会问:state 不就是随手传一个随机字符串吗?能有什么漏洞?别急,我们一步一步拆开看。

二、OAuth2.0授权码模式和state参数的正常用法

2.1 授权码模式的基本流程

先简单回忆一下OAuth2.0授权码模式是怎么跑的。假设员工小王想在企业微信里打开一个低代码平台上的审批应用,低代码平台需要知道小王是谁。流程大概是:

  1. 企业微信前端页面跳转到低代码平台的登录接口。
  2. 低代码平台发现没登录,就拼一个企业微信的授权链接,把小王引导到企业微信的授权页。
  3. 小王在企业微信里点了“同意授权”。
  4. 企业微信回调低代码平台,带上一个临时的 code
  5. 低代码平台拿着 code 去企业微信的服务端换 access_token 和用户信息。
  6. 拿到用户信息后,低代码平台自己生成一个会话,完成登录。

2.2 state参数用来干什么

state 是OAuth2.0规范里专门用来防CSRF(跨站请求伪造)的。因为在第4步回调的时候,企业微信只会原样把 state 带回来,它自己不校验这个值是什么。正常情况下,低代码平台在生成授权链接的时候,把 state 设置成一个跟当前浏览器会话绑定的随机值,然后在回调时检查 state 是不是跟自己发出去的一致。如果一致,说明这个回调是刚才那个用户发起的,不是别人伪造的。

听起来很简单对吧?但问题恰恰出在“检查”这两个字上。很多低代码平台在对接企业微信自建应用的时候,压根就没做这个检查,或者检查得马马虎虎。

三、漏洞的实际攻击场景

3.1 攻击者怎么利用

假设低代码平台的登录接口是 https://lowcode.example.com/login/wecom,它接收企业微信回调的地址是 https://lowcode.example.com/callback/wecom?code=xxx&state=yyy

攻击者小张想偷偷登录受害者的低代码账号。他先自己走一遍正常的登录流程,拿到一个合法的 code(这个code是他自己的)。然后他构造一个恶意链接:

https://lowcode.example.com/callback/wecom?code=小张的合法code&state=小张随便写的state

接下来,小张想办法让受害者点击这个链接。比如他把这个链接伪装成“查工资”的短链接发到企业微信群里。受害者点击后,低代码平台收到回调,发现 state 压根没校验,直接把 code 拿去换了用户信息。换回来的用户是谁?是小张的。于是低代码平台就以为当前登录的人是“小张”,然后把小张的会话种到受害者的浏览器里。

受害者接下来在这个低代码平台上干的所有事儿,都会被记录在小张名下。如果小张是普通员工,受害者是管理员,那受害者登录后看到的界面是小张的普通员工权限,可能危害不大。但如果反过来,受害者是普通员工,小张是管理员,那么受害者在不知情的情况下,就用自己的账号执行了管理员的权限操作。更关键的是,如果低代码平台上有“绑定手机号”“同步通讯录”这类操作,攻击者可能借受害者之手完成某些数据篡改。

3.2 为什么低代码平台更容易中招

低代码平台的特点是“快速搭建”,很多业务人员拖着组件就把应用做出来了,底层的认证逻辑往往是平台封装的“黑盒”。有些低代码平台为了简化配置,把 code 换用户信息的接口暴露出来,但不要求前端传 state,甚至回调地址都允许自定义。这样一来,只要企业微信应用的管理员在配置时少填一个参数,或者平台没把校验逻辑做在关键路径上,漏洞就出现了。

四、一个完整的漏洞示例(Python + Flask)

下面我们用一个 具体技术栈把这个问题演示一遍。技术栈选择:Python 3.10 + Flask 2.3

4.1 正常的授权跳转接口

先看低代码平台里,跳转到企业微信授权的接口是怎么写的:


# app.py
import os
import requests
from flask import Flask, redirect, request, session
from urllib.parse import urlencode

app = Flask(__name__)
app.secret_key = "change-me-to-a-random-secret"

# 企业微信自建应用的配置
WECOM_CORP_ID = "ww1234567890abcdef"      # 企业的Corp ID
WECOM_AGENT_ID = "1000002"                # 自建应用的AgentId
WECOM_SECRET = "your-app-secret"          # 自建应用的Secret
WECOM_REDIRECT_URI = "https://lowcode.example.com/callback/wecom"

# 授权链接的基础地址
WECOM_AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize"


@app.route("/login/wecom")
def login_wecom():
    """
    引导用户跳转到企业微信授权页面。
    注意:这里完全没有生成state参数,或者生成了但没保存。
    """
    params = {
        "appid": WECOM_CORP_ID,
        "redirect_uri": WECOM_REDIRECT_URI,
        "response_type": "code",
        "scope": "snsapi_base",
        "agentid": WECOM_AGENT_ID,
        # 注意:缺少state参数
        # "state": "random_string",
    }
    auth_url = WECOM_AUTHORIZE_URL + "?" + urlencode(params)
    return redirect(auth_url)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

上面的代码里,state 压根没传。这是第一种低级错误。

4.2 生成了state但没校验

再来看另一种更隐蔽的写法,看起来好像做了安全措施,但其实等于没做:


# app.py(改进版,但还是错的)
import secrets
import requests
from flask import Flask, redirect, request, session
from urllib.parse import urlencode

app = Flask(__name__)
app.secret_key = "change-me-to-a-random-secret"

WECOM_CORP_ID = "ww1234567890abcdef"
WECOM_AGENT_ID = "1000002"
WECOM_SECRET = "your-app-secret"
WECOM_REDIRECT_URI = "https://lowcode.example.com/callback/wecom"
WECOM_AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize"


@app.route("/login/wecom")
def login_wecom():
    """
    这次生成了state,并且塞进了session.
    但回调里忘记校验,或者只校验了一半。
    """
    state = secrets.token_urlsafe(16)   # 生成一个随机的state
    session["wecom_state"] = state      # 存到会话里

    params = {
        "appid": WECOM_CORP_ID,
        "redirect_uri": WECOM_REDIRECT_URI,
        "response_type": "code",
        "scope": "snsapi_base",
        "agentid": WECOM_AGENT_ID,
        "state": state,                  # 这里带上了state
    }
    auth_url = WECOM_AUTHORIZE_URL + "?" + urlencode(params)
    return redirect(auth_url)


@app.route("/callback/wecom")
def callback_wecom():
    """
    回调接口。
    这里犯了一个非常经典的错误:只检查了state是否存在,
    没有校验它和session里的值是否一致。
    """
    code = request.args.get("code")
    state = request.args.get("state")

    # 只判断了state不为空,但没对比
    if not state:
        return "state参数缺失,拒绝处理", 400

    # 直接用code去换用户信息
    # 注意:这里没有校验 state == session.get("wecom_state")!
    token_url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
    token_params = {
        "corpid": WECOM_CORP_ID,
        "corpsecret": WECOM_SECRET,
    }
    token_resp = requests.get(token_url, params=token_params).json()
    access_token = token_resp.get("access_token")

    user_url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"
    user_params = {"access_token": access_token, "code": code}
    user_resp = requests.get(user_url, params=user_params).json()
    user_id = user_resp.get("UserId")

    # 拿到UserId后,直接给浏览器种上登录态
    session["user_id"] = user_id
    return f"登录成功,当前用户:{user_id}"


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

这个版本里,state 是生成了,也在授权链接里带了,但回调里只检查了“有没有”,没检查“对不对”。攻击者自己构造一个带任意 state 的链接,绕过企业微信授权页,直接往回调地址发请求,照样可以登录。

4.3 正确写法

正确的做法必须是“先验证state,再换用户信息”。而且验证要严格使用 hmac.compare_digest 之类的常量时间比较函数,避免时间侧信道攻击。下面是修正后的代码:


# app.py(正确版)
import hmac
import secrets
import requests
from flask import Flask, redirect, request, session, abort
from urllib.parse import urlencode

app = Flask(__name__)
app.secret_key = "change-me-to-a-random-secret"

WECOM_CORP_ID = "ww1234567890abcdef"
WECOM_AGENT_ID = "1000002"
WECOM_SECRET = "your-app-secret"
WECOM_REDIRECT_URI = "https://lowcode.example.com/callback/wecom"
WECOM_AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize"


@app.route("/login/wecom")
def login_wecom():
    """
    生成state并存入session,然后带着state跳转。
    """
    state = secrets.token_urlsafe(32)   # 生成一个足够随机的state
    session["wecom_state"] = state      # 存到服务端会话

    params = {
        "appid": WECOM_CORP_ID,
        "redirect_uri": WECOM_REDIRECT_URI,
        "response_type": "code",
        "scope": "snsapi_base",
        "agentid": WECOM_AGENT_ID,
        "state": state,
    }
    auth_url = WECOM_AUTHORIZE_URL + "?" + urlencode(params)
    return redirect(auth_url)


@app.route("/callback/wecom")
def callback_wecom():
    """
    回调接口。严格校验state,确认没问题后才换用户信息。
    """
    code = request.args.get("code")
    state = request.args.get("state")

    # 第一步:检查state是否存在
    if not state:
        abort(400, description="state参数缺失")

    # 第二步:检查session里有没有存过state
    saved_state = session.get("wecom_state")
    if not saved_state:
        abort(400, description="本地state不存在,可能Session过期")

    # 第三步:常量时间比较,确保完全一致
    if not hmac.compare_digest(state, saved_state):
        abort(400, description="state校验失败,疑似CSRF攻击")

    # 第四步:校验通过,删除session里的state(一次性使用)
    session.pop("wecom_state", None)

    # 第五步:用code换用户信息
    token_url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
    token_params = {
        "corpid": WECOM_CORP_ID,
        "corpsecret": WECOM_SECRET,
    }
    token_resp = requests.get(token_url, params=token_params).json()
    access_token = token_resp.get("access_token")
    if not access_token:
        abort(502, description="获取企业微信access_token失败")

    user_url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"
    user_params = {"access_token": access_token, "code": code}
    user_resp = requests.get(user_url, params=user_params).json()
    user_id = user_resp.get("UserId")
    if not user_id:
        abort(502, description="获取用户UserId失败")

    # 这里可以继续从通讯录拉取用户详情,然后建立低代码平台自己的会话
    session["user_id"] = user_id
    return f"登录成功,当前用户:{user_id}"


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

注意,上面的 code 本身就是一次性且有效期的,但攻击者可以提前用自己的code。所以在校验逻辑上,state 必须是第一道关卡,code换用户信息必须是第二步。

五、企业微信自建应用和低代码平台的特殊性

5.1 低代码平台里的“连接器”概念

很多低代码平台提供“企业微信连接器”,让开发者拖拽一个组件就能实现免登。这些连接器内部封装好了OAuth2.0的步骤,但暴露出来的配置项可能只有 CorpIDAgentIdSecret回调地址,没有单独的 state 配置。这导致开发者根本没机会插入自己的 state 校验逻辑。平台的连接器如果不帮你生成和校验 state,那这个漏洞就是平台层面的。

所以作为低代码平台的开发者,你需要去看平台文档里有没有“安全设置”或者“回调参数校验”的开关;作为企业内使用低代码平台的员工,你很难直接改平台源码,但你可以向平台方反馈,或者在企业微信自建应用的“网页授权及JS-SDK”配置里,设置可信域名,并启用“跳转域名校验”,尽可能减少攻击面。

5.2 企业微信里code的特殊性

企业微信的OAuth2.0跟普通网站的OAuth2.0有一点不同:它的 code 是跟 AgentId 绑定的。也就是说,你用一个应用的 code 去另一个应用的接口换用户信息,是会失败的。但这个特点不能缓解CSRF,因为攻击者使用的 code 是同一个应用下的合法code。

另外,企业微信的回调地址可以是动态的,只要在应用的“网页授权及JS-SDK”里设置可信域名,企业微信会要求回调域名在可信域名列表里。这能挡掉一部分“任意回调”的攻击,但挡不住“合法回调地址下state缺失”的问题。

5.3 低代码平台多租户场景下的风险放大

低代码平台往往是多租户的,一个平台上有几十上百家企业。如果A企业配置了企业微信自建应用,B企业也配置了,那么平台的回调地址可能是同一个,比如 https://lowcode.example.com/callback/wecom。平台需要根据 codestate 来区分是哪个企业、哪个应用的回调。如果 state 不仅没有防CSRF,还没用来绑定企业ID,那么攻击者就可以构造一个A企业的回调链接,让B企业的用户点,导致B企业用户登录到A企业的工作空间里。这是更严重的数据越权。

六、怎么彻底堵住这个漏洞

6.1 强制生成随机state并绑定会话

使用平台内置的安全随机数生成器,比如Python的 secrets、Node.js的 crypto.randomBytes、Java的 SecureRandom,不要用时间戳、自增ID、固定字符串。生成的 state 必须跟当前用户会话绑定,存到服务端 session 或Redis里。

6.2 校验state时做到三件事

第一,确认请求里有 state;第二,确认服务端对应的 state 存在;第三,用常量时间比较算法确保两个字符串完全一致。比较通过后,马上删除这个 state,避免重放攻击。

6.3 校验state要在换code之前做

这听起来是废话,但确实有平台把顺序写反了:先拿code去换用户信息,换完再校验state。如果换用户信息时发生错误,或者网络超时,攻击者就能通过时间差或者报错信息判断code的有效性。而且如果顺序反了,即使state校验失败,code已经被消费了一部分,可能导致用户被半登录。正确顺序一定是:先校验state,再处理code。

6.4 统一封装OAuth2.0客户端

在低代码平台内部,建议统一封装一个“企业微信授权客户端”,把生成state、保存state、校验state、换取用户信息这四个步骤封装成一个函数。任何业务页面需要使用企业微信登录时,只调用这个函数,不允许业务代码自己拼接授权URL和处理回调。这样可以避免不同页面之间出现“有的校验有的不校验”的不一致情况。

6.5 回调地址加签和绑定

企业微信自建应用的回调地址,平台侧要校验签名。企业微信官方其实支持在回调URL带上 msg_signature 参数用来验证消息真实性,但在OAuth2.0授权回调里没有这个签名机制。所以平台要在自己的回调接口里加一个自定义的签名参数,比如 sign,值为 HMAC-SHA256(app_secret, code + state),这样即使攻击者拿到了一个合法的code,因为没有app_secret,也无法伪造签名。

七、技术优缺点分析

7.1 直接使用OAuth2.0授权码模式的优点

标准成熟,社区资料多,绝大多数企业微信应用都支持,调试方便。它是目前最安全的OAuth2.0授权模式之一,因为code是一次性的,且需要配合client_secret才能换取token,不暴露长期凭证给前端。

7.2 它的缺点

流程比较长,需要多次跳转,用户会看到企业微信的授权中间页(不过微信内通常是静默授权)。另外,开发者需要自己处理好 state 的存储和校验,否则就容易出漏洞。对于低代码平台这种“追求可视化搭建”的场景,开发者往往不是专业安全工程师,很容易踩坑。

7.3 对比其他模式(极简介绍)

授权码模式有个“隐式授权”模式(implicit),它不返回code,直接返回token,就更不安全了。企业微信目前也不推荐用隐式模式。还有一种“设备码”模式适合无浏览器的场景,但不适合企业微信内部应用。所以授权码模式仍然是唯一合理的选择,关键就是要把细节做到位。

八、注意事项总结

  1. 不要把 state 设成一个固定值,比如“123456”或者 "state" 字符串,那样完全没用。
  2. 不要把 state 放在Cookie里就算了,一定要服务端保存,因为Cookie可能被第三方脚本读取。
  3. 校验 state 时不要使用 == 或者 equals,在Python里要使用 hmac.compare_digest,在其他语言里也要用对应的恒定时间比较函数。
  4. code 换用户信息的接口必须走服务端,绝不能让前端拿 code 自己去换,否则 code 可能被抓包。
  5. 企业微信自建应用的回调地址建议使用HTTPS,防止 statecode 在传输过程中被中间人截获。
  6. 处理完一次授权后,立即从会话中删除 state。如果用户的会话过期,state 也就失效了,这是一个很好的安全策略。
  7. 低代码平台如果支持自定义回调处理器,要在文档里醒目标注“必须校验state参数”,并且提供现成的校验代码片段。
  8. 定期查看企业微信后台的“网页授权”日志,如果出现大量异常回调,比如同一个state被多次使用,就要警惕攻击。

九、文章总结

企业微信自建应用对接低代码平台时,OAuth2.0授权码模式里的 state 参数漏洞本质上就是一个CSRF漏洞。它的危害程度取决于攻击者和受害者的角色关系,轻则错乱登录身份,重则导致管理员账号被冒用、多租户数据越权。最让人头疼的是,很多低代码平台把授权流程封装成了可视化组件,开发者根本看不到底层代码,这时候你需要去检查平台是否在回调阶段做了严格的 state 校验。

如果你是自己开发对接代码,请务必按照“生成随机state、服务端存state、回调校验state、校验通过后删state、再换用户信息”这个铁律来写。如果你只是低代码平台的使用者,建议及时联系平台客服或技术负责人,确认他们是否处理过这个漏洞。安全无小事,一个不起眼的参数,可能就是整个系统防线崩塌的起点。

最后,把这段经验记在团队的知识库里:任何第三方授权登录,state 校验不是可选项,而是必选项。不要因为“企业微信官方文档没强制要求”或者“低代码平台没提供配置入口”就放弃安全。