先把结论放在最前面这个项目做完你收获最大的不是“能扫码登录”本身而是完整走了一遍“接口定位 - 参数分析 - 会话维护 - 反爬规避”的链路。这篇文章我会按实际项目推进的顺序来写包含我踩过的坑、走过的弯路以及最后沉淀下来的一套可复用方案。整个项目的核心诉求拆开看其实就三句话拿到一个长期有效的登录态、通过扫码方式完成登录、解决登录后频繁被限制访问的问题。标题里的“某字节某yin”具体是哪个平台不重要重要的是这套思路能平移到几乎所有“扫码登录 Cookie 会话”类型的 Web 应用上尤其适合 App 内嵌 H5 页面、PC 端扫码登录、Web 端扫码授权这种常见场景。我这里全程基于一个完全虚构的平台来演示所有接口名、参数名都做了脱敏但流程和实现思路是完整且可直接复用的。1. 项目整体思路拆解1.1 核心需求解析一开始拿到这个需求我第一反应是把它拆成三个独立又互相依赖的子问题。第一扫码登录怎么实现。二维码从哪来、扫码之后的状态怎么感知、扫码确认之后服务端返回什么。这一步是基础。第二Cookie 信息怎么获取和持久化。登录成功之后服务端下发的那一串凭证是整个会话的核心资产。第三访问太频繁怎么解决。这个是真正让人头疼的因为它不是代码层面的问题而是风控策略层面的博弈。拆完之后我发现这三个问题的优先级是线性递进的没有二维码拿不到登录凭证没有 Cookie 会话无法维持会话有了但请求频率控制不住账号照样被限制。所以项目推进顺序必须严格按 1 - 2 - 3 来走不能跳步否则后面排查问题的时候会分不清是登录态失效还是风控拦截。1.2 方案选型为什么用扫码登录而不是账号密码可能有人会问直接模拟账号密码登录不是更简单吗我在项目初期确实这么想过但很快放弃了。账号密码登录最大的痛点在于密码传输几乎必然是加密的。客户端会把密码用 RSA 公钥加密后再传给服务端公钥可能定期换、可能带时间戳、可能拼接随机串逆向工作量成倍增加。更麻烦的是密码登录往往需要额外处理图形验证码而二维码登录只需要用户用手机 App 扫一下人机校验的成本转移给了 App 端我们这边只需要处理二维码状态流转就行。扫码登录还有一个好处风控特征更接近真实用户。真人每天在电脑上登录某个平台大概率也是扫码而不是手动敲密码。所以扫码登录的请求链路本身就带着“正常用户”的行为特征后续的访问频率控制压力也会小一些。我实测下来扫码登录所生成的会话被风控盯上的概率低很多因为这个动作没法被自动化批量执行天然具备人机校验能力。2. 扫码登录的完整流程与核心原理2.1 整体链路扫码登录的流程可以分为前后两层。第一层是“预登录阶段”。客户端请求服务端生成一个二维码 ID服务端把这个 ID 和某个待确认的会话绑定同时返回二维码内容。客户端把二维码内容渲染成图片用户掏出手机 App 扫一扫App 端弹出确认页用户点头确认App 端向服务端发送确认指令。第二层是“会话建立阶段”。服务端收到确认指令后把二维码 ID 对应的会话标记为已授权此时客户端需要持续轮询二维码状态。一旦轮询到“已确认”状态服务端会返回一个临时凭证通常是 code 或 token客户端再用这个凭证去换取正式的 Cookie。这个过程用生活化的类比来说就像你去前台办事先在取号机拿一个号二维码 ID然后坐在大厅等叫号轮询二维码状态窗口叫你之后你递上身份证扫码确认工作人员给你一张回执临时凭证你拿回执去领证换 Cookie。每一步都有明确的状态流转实现了前后端分离的无感登录。2.2 二维码生成与状态流转二维码生成接口通常长这样POST /api/qr/create Content-Type: application/json { app_id: xxxx, scene: login, extra: {device_id: device_xxx} }返回的 JSON 数据大概长这样{ code: 0, data: { qr_id: qr_20231101_ab12cd, qr_content: https://example.com/login?tokeneyJhbGciOi..., expire_at: 1698836400, poll_interval: 1500 } }这里有个很关键的细节qr_content是一个 URL而不是字符串内容。这意味着我们不能直接把整个 URL 丢给二维码库去生成而是要确认链接里带不带过期时间戳——有些服务端会生成一次性链接过期后扫了也没用。我实际用的生成方式是 Python 的qrcode库配合按需轮询的间隔时间。但这里有一个注意点poll_interval返回的是 1500ms很多教程会直接写死 2 秒轮询一次但如果服务端下发的间隔更短你按更慢的频率轮询会拉长整个登录耗时的感知反过来说如果服务端下发的是 3 秒你按 1 秒去轮询大概率触发频率风控。所以这个值必须用接口返回的动态值。二维码状态流转一般有这几类等待扫码、已扫码待确认、已确认、已过期、已取消。轮询接口返回的状态字段是数字枚举建议自己建一个映射表。我遇到过最坑的情况是界面显示“已扫码”但轮询接口返回的是“等待确认”两个状态语义不一致这通常是因为扫码之后需要用户手动点“确认登录”按钮才推进状态只是扫了码不算授权完成。2.3 Cookie 会话的建立与管理换 Cookie 这个环节是整个项目中价值密度最高的一步。轮询接口在状态变成“已确认”后会返回一个临时授权码常见字段名有code、auth_token、login_ticket等等。拿到这个临时凭证后再请求登录确认接口POST /api/auth/login Content-Type: application/json { qr_id: qr_20231101_ab12cd, ticket: xxxx, device_fingerprint: fp_xxxxxxxx, app_id: xxxx }这个接口的响应头里会包含Set-Cookie字段这就是我们要的核心资产。一般不止一条常见的有 session ID、UID 标识、CSRF Token、设备指纹标识可能还有两条同名的但 PATH 不同的 Cookie。我建议拿到后先按原始顺序记录不要急着清洗因为服务端校验时可能对 Cookie 顺序有隐式依赖。Cookie 的持久化方案我选用的是本地文件存储格式是 JSON 文件里面同时记录 Cookie 内容和过期时间。其实更规范的方案是用浏览器的 localStorage 或者系统的 keychain但作为一个脚本工具文件足够方便排查问题时直接打开看。考虑安全性存的 Cookie 一定要做权限控制不要让其他进程能直接读。写进文件之前先chmod 600这是基本素养。另一个容易忽略的点Cookie 的存活期。有的平台登录后 Cookie 能活 30 天但 Refresh Token 可以续期有的平台 24 小时内无操作就失效。我建议一定记录下两个时间获取 Cookie 的时间、服务端返回的过期时间。方便后续判断是直接复用还是需要重新扫码。2.4 关键参数逆向定位思路接下来就是整个项目技术难度最高的部分逆向定位几个关键参数是怎么生成的。首先是device_fingerprint。这个参数通常是客户端启动时生成的一个唯一设备标识可能基于设备硬件信息 随机数 时间戳经过某种哈希或加密算法生成。它一般不是固定在 App 里的而是 App 首次启动时生成并存储之后每次请求都带上。定位方式有几种抓包看这个取值是否在多个请求中一致如果一致那就去搜它的生成逻辑最常见的是在某个公共函数里。在 App 端先访问一遍登录流程Hook 住JSON.stringify、HashMap.put等关键方法看这个值在哪个时机被塞进请求体。用 Frida 在 Java 层 Hookjava.util.Map的put方法打印调用栈就能回溯到上层赋值逻辑。其次是请求签名。这类大厂的请求体一般都会带X-Signature或者X-Bogus之类的签名头通常是请求参数 时间戳 盐值做 MD5/SHA/HMAC 后得到。注意这个签名未必是服务端验签也可能是防中间人篡改的客户端风控。我这次没硬抗签名而是直接绕过登录接口的签名校验——因为这个接口是“扫码确认”的入口真实用户扫码确认本身就带了人机特征服务端对该接口的风控策略反而偏弱。这个发现也是试出来的第一次我强行破解签名调试了一整天才发现原来只要参数齐全、Cookie 正确签名那头只要保证和服务端策略不冲突就行。不过这不意味着签名不用管。如果后续要批量刷接口签名是绕不过去的坎。我的建议是在项目的第一阶段先别碰签名把整套登录流程跑通、Cookie 拿到手再回头研究签名生成算法这样至少有个可用的基线版本。3. 实操从零开始完整实现扫码登录3.1 抓包与接口定位正式开始写代码前先确认目标接口长什么样。我用的抓包工具是 Fiddler模式是 HTTPS 解密 进程抓包。直接在 PC 端打开目标平台的登录页面扫码页面弹出来之后Fiddler 里会刷出一串请求我们要做的就是过滤出qr、login、auth等关键词相关的接口。这里有个新手容易卡壳的地方现在很多 Web 应用走的是 WebSocket 推送不是传统 HTTP 轮询。如果你发现二维码状态更新的请求在 Fiddler 里根本刷不出来那大概率是 WebSocket 通道。处理方式是切到 WS 标签页看握手请求和后续帧的负载。WebSocket 的好处是服务端主动推送省去了轮询这个动作。还有一种更隐蔽的情况接口用的是 HTTP/2Fiddler 默认可能没开启 HTTP/2 解码导致请求在列表里显示为乱码或者连接错误。这个时候去 Fiddler 的 Connection 设置里勾选 HTTP/2 解析即可。这个细节我印象很深因为排查了快半小时才找到原因。定位到接口之后第一时间做两件事把完整的请求头、请求体、响应体都复制出来存到文本文件里作为文档留档再把关键参数整理成表格标注哪些是固定值、哪些是动态值、哪些看起来像加密值。这个动作能在后期省下大量时间不然代码写一半发现某个参数含义不明还得回头翻包。3.2 核心代码实现我用的技术栈是 Python 3 requests 库整个扫码登录的核心逻辑大概两百行拆成三个模块二维码模块、状态轮询模块、Cookie 持久化模块。先看二维码生成和状态轮询import time import requests import qrcode from loguru import logger class QRLoginSession: def __init__(self, session: requests.Session, app_id: str): self.session session self.app_id app_id def create_qr(self) - dict: url https://api.example.com/api/qr/create payload { app_id: self.app_id, scene: login, extra: {device_id: self._get_device_id()} } resp self.session.post(url, jsonpayload).json() if resp.get(code) ! 0: raise RuntimeError(fcreate qr failed: {resp}) return resp[data] def poll_status(self, qr_id: str, interval: int 1500): url fhttps://api.example.com/api/qr/status?qr_id{qr_id} while True: resp self.session.get(url).json() status resp[data][status] logger.info(fqr status: {status}) if status confirmed: return resp[data].get(ticket) if status in (expired, cancelled): raise RuntimeError(fqr expired or cancelled: {status}) time.sleep(interval / 1000) def login_with_ticket(self, qr_id: str, ticket: str) - dict: url https://api.example.com/api/auth/login payload { qr_id: qr_id, ticket: ticket, device_fingerprint: self._get_device_fingerprint(), app_id: self.app_id } resp self.session.post(url, jsonpayload) return {cookies: resp.cookies, headers: dict(resp.headers)}注意self._get_device_id()和self._get_device_fingerprint()这两个私有方法一个是生成设备 ID一个是生成设备指纹。这两个值如果是固定的那就直接写在配置里如果是动态的问题就大了需要从 App 端逆向出生成逻辑。这里额外提一个我踩过的坑创建二维码之后不要立刻去轮询一定要确保二维码已经在界面上展示出来了。如果用户还没来得及扫你就在疯狂轮询很容易触发服务端的频率风控。我这边做的是生成二维码 - 展示到控制台或图片文件 - 等待 1 秒 - 才开始轮询。节奏感很重要。3.3 设备指纹的获取策略设备指纹device_fingerprint这个参数的逆向难度决定了整个项目的工作量。我这次遇到的情况比较友好它是 App 端在本地生成的一个 UUID 时间戳的拼接拿到后被我直接放到配置文件里持久化。具体做法是先用 Frida Hook 住JSONObject.put方法触发一次扫码登录流程看device_fingerprint字段是在哪个函数被赋值的。日志会打印出调用栈从下往上找到源头。一般来说这种字段会有两种生成方式随机 UUID 首次生成时间拼成固定字符串之后不再变化。这种最省事复制一次就能用。基于设备信息动态生成每次调用都会算一遍但底层输入基本不变。这种要多费点功夫。如果实在找不到生成方式也有一个土办法用抓包工具拦截登录请求直接把请求体里的device_fingerprint值复制下来手动塞进代码里。如果服务端不校验这个值的合法性那就直接可用。如果校验那这个方案不成立只能回到逆向破解的路上。3.4 Cookie 持久化与复用设计Cookie 拿到手之后不能每次登录都重新扫码否则这个项目的价值就少了一半。所以我把 Cookie 存到本地 JSON 文件并做了一套简单的生命周期管理。import json import os from datetime import datetime, timedelta class CookieStore: def __init__(self, path: str): self.path path self.data self._load() def _load(self) - dict: if not os.path.exists(self.path): return {} with open(self.path, r) as f: return json.load(f) def save(self, cookies: dict, expire_in_days: int 30): self.data { cookies: cookies, saved_at: datetime.now().isoformat(), expire_at: (datetime.now() timedelta(daysexpire_in_days)).isoformat() } with open(self.path, w) as f: json.dump(self.data, f, ensure_asciiFalse, indent2) os.chmod(self.path, 0o600) def load(self) - dict | None: if not self.data: return None expire_at datetime.fromisoformat(self.data[expire_at]) if datetime.now() expire_at: return None return self.data[cookies] store CookieStore(session.json) # 首次登录后保存 cookies {k: v for k, v in session.cookies.items()} store.save(cookies) # 后续直接复用 cookies store.load() if cookies: session.cookies.update(cookies) else: # 走扫码流程 pass复用 Cookie 时有一个细节要注意requests 库的 Session 对象在更新 Cookie 时可能会带上你本地已有的其他域名的 Cookie造成污染。建议单独建一个 Session 实例只喂目标域的 Cookie其他域名的直接剔除。另外HttpOnly 属性的 Cookie 在 requests 的cookies.items()里不会体现但这不代表它不重要。实际上很多平台的会话校验核心恰恰就是 HttpOnly Cookie。如果你发现登录后状态还是失效先检查抓包时 Set-Cookie 里所有条目是否都完整带上了特别是那几个带HttpOnly标志的。4. 访问太频繁问题的完整排查与解决4.1 风控触发原因复盘标题里点名的“访问太频繁”问题到我实际跑起来之后确实如期而至了。症状是执行某个操作接口时返回固定的风控提示“操作频繁请稍后再试”或者直接返回 403 状态码。一开始我怀疑是 Cookie 不对排查半天之后才确定是请求频率的问题。这个错误的真名并不重要重要的是理解它的原理。任何平台的风控体系都有三个维度的监控账号维度、设备维度、IP 维度。频繁报错的原因无非就是同一 Cookie 在极短时间里请求了大量接口同一 IP 在短时间内发起大量来自不同账号的请求请求头指纹不符合真实浏览器的行为模式。我这次触发风控的直接原因是拿到 Cookie 后用循环快速跑了 50 次查询接口间隔只有 0.2 秒账号被临时限制 10 分钟。这个事实告诉我们“解决访问太频繁”的核心不是“绕过风控”而是“别触发风控”。4.2 合规的降频策略既然原因清楚了解决方案也就清晰了。我在代码里写了一个请求调度器核心逻辑有三条import time import random import threading class RateLimiter: def __init__(self, min_interval: float 2.0, max_interval: float 5.0): self.min_interval min_interval self.max_interval max_interval self._last_request_time 0 self._lock threading.Lock() def wait(self): with self._lock: now time.time() interval random.uniform(self.min_interval, self.max_interval) wait_time self._last_request_time interval - now if wait_time 0: time.sleep(wait_time) self._last_request_time time.time() limiter RateLimiter(min_interval3.0, max_interval6.0) limiter.wait() resp session.get(https://api.example.com/api/some_operation)第一条是必须在两次请求之间加随机间隔间隔范围要覆盖真实用户的手速范围。3 到 6 秒是比较保守的如果你要跑批量任务可以适当缩短到 1 到 3 秒但不要低于 1 秒。第二条是每次请求前随机化 User-Agent 和部分 Header 字段顺序不要用同一个指纹连续打几十次。第三条是单次任务的总请求量要控制连续操作 20 次之后主动休眠 30 秒。这套调度器落地之后我的报错率立刻降下来了扫码流程也稳定了很多。但这里有一个想提醒大家的点即便加了随机间隔也不等于绝对安全。触发风控后不要立刻重试等 5 到 10 分钟再跑这是最稳妥的。4.3 缓存与增量设计减少不必要的请求除了请求频率控制还有一个思路可以大幅度降低风控风险缓存。不是所有数据都需要每次实时请求。比如某些配置类的接口返回结果是几个小时不变完全可以在本地缓存只在必要时刷新。任何一个接口允许缓存尽量让它命中缓存。我做了一个很简单的带过期时间的缓存字典import time class SimpleCache: def __init__(self, default_ttl: int 300): self.default_ttl default_ttl self._data {} def get(self, key: str): item self._data.get(key) if item and item[expire_at] time.time(): return item[value] return None def set(self, key: str, value, ttl: int | None None): self._data[key] { value: value, expire_at: time.time() (ttl or self.default_ttl) } cache SimpleCache(ttl600) def get_user_info(session, user_id): key fuser_info_{user_id} cached cache.get(key) if cached: return cached resp session.get(fhttps://api.example.com/api/user/{user_id}).json() cache.set(key, resp[data], ttl300) return resp[data]这个设计不是为了炫技而是最直接地减少对服务端的打扰。你真正常用的数据可能就几个固定对象其他都是可缓存的间接关系。把这些缓存好请求量能降一半。4.4 错误码速查表把这次踩过的错误码整理成速查表贴在代码注释里错误码/现象含义解决方案-2345验证码或滑块校验未通过检查请求头中的指纹信息是否一致必要时等待后重新请求-503请求频率过高触发临时限制等待 5-10 分钟降低单账号请求频率随机化间隔Token 失效Cookie 过期或主动失效重新走扫码登录获取新 Cookie-401签名校验失败检查签名参数是否基于最新时间戳计算确认参数参与顺序滑块验证码弹出设备或 IP 风险评分过高切换质量较优的网络出口或更换请求设备指纹二维码过期二维码有效期太短缩短生成到扫码之间的时间差或调整轮询间隔适配过期时间这里想强调一个容易被忽略的细节 “滑块验证码”这个机制的背后逻辑不是拒绝你一次请求而是给你的请求打一个风险评分当评分超过阈值时才会弹出。所以从策略上讲模拟真实用户行为固定间隔 固定操作序列 较低的请求频率就是最有效的降风险方式。它的触发往往是长时间高频请求的累积结果而不是单次请求的问题。5. 工程化落地与合规边界5.1 代码架构模块拆分与配置化项目走到后期我发现如果所有逻辑都堆在一个文件里后续排查问题和修改参数会非常痛苦。所以做了模块拆分login_project/ ├── core/ │ ├── qr_login.py # 扫码登录逻辑 │ ├── cookie_store.py # Cookie 持久化与读取 │ ├── rate_limiter.py # 请求频率控制 │ └── cache.py # 简易缓存 ├── api/ │ ├── client.py # API 客户端统一处理请求和异常 │ └── endpoints.py # 接口地址与参数定义 ├── configs/ │ ├── config.json # app_id、设备指纹等静态配置 │ └── session.json # 生成的 Cookie 文件 └── main.py # 入口逻辑配置化的好处是换一个目标平台时只需要修改config.json和endpoints.py核心代码不用动。这对后续做多平台适配特别有用。5.2 合规红线的个人建议这里必须说清楚整个项目的合法边界是什么。这个项目本质上是模拟用户自己的登录行为只适用于以下三种场景你有一个账号的合法使用权你想自动化完成属于你自己账号的操作你在做安全研究目标是发现和报告漏洞研究对象是你拥有或你有明确授权去测试的系统你在做个人数据备份或辅助工具只操作你自己的账号数据。任何未经授权的访问、批量注册、批量操作他人账号信息、利用漏洞获取未授权数据的行为都不在讨论范围内。我的代码里也做了限制单账号并发数封顶、单次任务请求量封顶、不提供多代理轮换功能这些限制不是技术做不到而是合规上不能做。5.3 后续扩展方向这个项目本身已经是“扫码 Cookie 会话”的完整实现。后续如果继续深入可以往三个方向走一是加入 Refresh Token 自动续期逻辑实现“一次扫码长期在线”二是把设备指纹逆向部分单独做成一个模块支持动态生成彻底摆脱硬编码三是接入验证码识别如果有或手动打码的兜底方案提高系统的鲁棒性。5.4 实战中的几点总结做这个项目我最大的体会是逆向工程的核心不是把代码跑通而是搞清楚服务端信任模型里“哪些参数是可信的、哪些是必须真实存在的”。扫码登录之所以是性价比最高的方案是因为它把设备指纹和 Cookie 的需求都降到了最低——真实用户的扫码动作本身就完成了人机校验。一旦想通了这一点后续优化方向就非常清晰。其次请求频率控制不是“写个 sleep 就完事”这么简单而是要模拟真实用户的节奏感。真实用户不会在 0.5 秒内连续发出 20 个请求也不会每天固定时间疯狂操作。把节奏控制好比你用几百个代理 IP 换着出更有效。最后再分享一个调试技巧遇到“访问太频繁”报错时不要慌着改代码先把最近 10 次请求的行为模式打印出来看一眼。很多时候你会发现问题不在技术上而是你的请求频率/行为模式已经完全不像一个正常用户了。知道这一点很多问题你都能一眼看出解药。