接到某物平台这个采集项目时我以为只是一单普通的App爬虫手机装个抓包工具找出商品接口直接requests模拟请求就行。真正动手后才发现光是请求头里的x-sign和x-device-id就把我卡了两天后续还碰到native层签名、设备风控、代理池失效等一系列问题。这篇文章按我当时排查的时间线把整个逆向分析过程整理成一份可复用的总结重点讲三件事加密参数是怎么一步步还原的、请求侧怎么和存储层配合、以及真正遇到风控时该怎么排查。无论你是刚接触爬虫逆向还是已经写过不少脚本想系统梳理方法这篇文章都值得看完。1. 项目最初的两个问题要采什么凭什么能采1.1 数据口径先于代码所有爬虫项目的第一件事不是抓包不是写代码而是把数据口径定下来。我当时拿到的需求是采集某物平台的商品信息包括商品标题、价格、销量、上架时间、历史价格曲线。字段看着不多但上架时间这个字段就折腾了三个小时——接口里根本没有直接返回只有一串SKU里的时间戳还得自己换算。所以我建议所有人在开始逆向之前先把采集字段做成一张清单标注哪些字段来自列表页、哪些来自详情页、哪些需要二次计算。某物这类平台的请求链路大致分三层列表页能拿到商品ID、基础标题和封面详情页能拿到价格、库存、销量明细收藏和人气候选指标又往往藏在另一个聚合接口里。先把三层接口都抓到再回头设计存储表后面写代码时结构会清晰很多。这一步看似和逆向无关实际上决定了后续签名还原后要封装多少个请求函数。如果一开始就把字段口径搞错后面往往是要推倒重来的。我做爬虫的规矩就是宁可先花半天梳理字段也不要边写边猜。1.2 抓包环境的三个准备步骤逆向某物的第一步是抓包。先交代一下我的环境一台Pixel 2Android 10专门跑抓包Charles和Frida常驻主力机不装代理避免影响日常使用。真机比模拟器省事因为某物这类App对模拟器检测挺敏感指纹特征太明显容易被风控重点照顾。抓包准备上我按顺序做三件事缺一不可把Charles的根证书安装为系统证书。Android 7以后用户证书默认不被系统信任很多App直接用系统信任逻辑就能拦下HTTPS但某物在release包里做了证书校验用户级证书根本不生效。我把证书推到/system/etc/security/cacerts/这一步需要Magisk或者adb root权限。配置域名白名单代理。只代理某物自己的域名过滤掉广告、统计、地图等第三方流量这样日志干净很多排查问题时不会在一堆埋点请求里迷路。用Frida启动App挂一个最基础的证书校验绕过脚本。注意先别急着找签名算法目标是把HTTPS流量放出来看整体结构。关于证书固定多说一句现在的App通常不是全部接口都做pinning往往只在登录、支付这些关键链路做。如果只是采集商品列表这种公开数据有些域名甚至没开固定。某物当时是主域名开了pinning图片域名没开静态资源随便抓动态接口就需要Hook。所以实战中先试探别上来就全链路Hook降低复杂度。1.3 从流量里挑出关键接口流量通了以后我一般先把Charles上的请求按域名分组把js/css/图片全部忽略掉只看XHR请求。某物App的接口路径还算有规律列表页、详情页、搜索页分别是三个明显的前缀。第二步是开着App反复滑动列表观察哪些请求每次都触发哪些只在进入详情页才拉取。这里有个关键经验很多新手以为抓到一个接口就能一直用实际上客户端在列表页加载时会同时发出十几个请求但真正承载商品数据的只有一到两个其余是埋点请求、曝光统计、广告请求。埋点里也有大量加密参数看起来吓人其实不影响采集。我在某物项目里就差点被一个叫trace的埋点误导后来通过对比相同操作下的反复请求确认它不是数据主链路。想起以前做traceme.exe逆向入门题时也是先学会区分主逻辑和干扰逻辑爬虫逆向要有同样的直觉。2. 加密参数在流量里的样子抓包见到的第一现场2.1 请求头和Query里的可疑字段把商品列表接口的请求从Charles里复制出来我第一眼看到的关键信息是URL路径固定Query里有platform、version、timestamp、nonce这几个参数Header里有x-sign、x-device-id、x-app-versionbody里是一个JSON对象部分字段是加密后的字符串。和普通App对比核心差异就集中在x-sign、x-device-id、x-app-version这三个头。尤其是x-sign长度64位一时分不清是SHA256还是别的算法。普通参数一般32位大写类似MD5这里64位十六进制第一反应可能是SHA256或HMAC-SHA256。但这里不能急着下结论。逆向分析最怕的就是看到长度就猜算法。64位可能是SHA256也可能是两段32位拼接也可能是RSA签名后的hex甚至可能是对MD5结果做了二次处理。所以第一步我只做记录什么参数会变、什么参数固定、哪几个字段和时间戳相关。2.2 观察参数变化规律接下来在App里做受控实验清空缓存杀掉进程重新启动把同样的请求发三次再手动修改一次请求观察服务端响应差异。实验结论大致是timestamp每次启动都会变nonce是请求时生成的随机值x-device-id在重装前保持不变属于设备维度x-sign会跟着timestamp和nonce变是它们的函数。我把规律归纳成一句话x-sign F(path, query_params, body, timestamp, nonce, device_id)。至于F是什么算法需要去代码里找。这个归纳极其重要它把逆向目标从一串神秘字符串变成了输入输出映射函数之后只要还原F并在本地重新计算和抓包值比对就知道对错。2.3 用已知明文初步试探算法有了输入输出对之后我做了几个快速尝试把Query按字典序排序拼成字符串拼上timestamp和nonce试MD5和SHA256再试HMACkey取device_id再试加盐的MD5。如果运气好Python的hashlib几分钟就能猜中。我在某物上没这个运气但这几次尝试不是无用功——它们帮我排除了签名算法在Java层直接可复现的可能说明要么有特殊盐和拼接顺序要么逻辑在native层。同时我还发现一个细节服务端对timestamp的校验范围大概在±5分钟。手动改请求里的timestamp会直接返回签名失败错误码。这个时间窗口在后面做重放和并发请求时非常有用它意味着我只要让生成签名和实际请求的时间差控制在5分钟内基本不会被时间维度拦下来。3. 定位签名入口的完整排查链路3.1 先把apk解包扫一遍拿到apk后我习惯用jadx直接打开搜索关键词sign、Signature、encrypt、Cipher、MessageDigest、OkHttp Interceptor。某物用的是混淆过的release包类名和方法名基本被打乱直接搜sign会出来一大片效果很差。更有效的是搜索字符串常量。在jadx里输入x-sign、x-device-id这两个Header名基本不会被混淆因为它们是网络协议字段代码里必须明文指定。顺着这个字符串可以找到OkHttp的Interceptor自定义类某物App的网络层就是在这里统一对请求做签名处理的这个类是核心入口。从x-sign字符串定位到拦截器类这个技巧在大多数App上都行得通。找Header常量永远比找方法名靠谱因为Header名要在网络请求里一字不差地发送混淆工具默认不会处理它们。3.2 Frida Hook验证从Java层到Native层静态分析只能定位到大概位置真正确定签名逻辑还得靠动态调试。我用的是frida -U -f com.xxx.xxx -l hook.jshook.js里主要做这几件事Hook所有HashMap的put方法打印请求参数Map的内容Hook javax.crypto.Mac、Cipher、MessageDigest、Base64等密码学相关类记录入参和返回值如果有自定义Interceptor直接把intercept()的参数和返回值打印出来。第一次跑确实在Cipher和MessageDigest上打到了调用记录但入参看起来是一堆已经被处理过的字节流不是明文。这说明签名逻辑在更上游已经先做了预处理。顺着调用栈往上翻栈里出现一个native方法方法名被混淆成类似a.b.c.e()的样子参数是byte[]。看到这里我心里基本有数了签名主体在.so里。3.3 还原一个签名要多少代码到了native层我先把apk里的lib目录拖出来找到对应的so文件用Ghidra打开。某物的核心so是arm64库导出函数不多但字符串表里有AES、CBC、PKCS5Padding的关键词。用Ghidra的字符串交叉引用基本能把流程串出来先对参数明文做一轮AES-CBC加密再做SHA256散列最后转成十六进制字符串。整个过程没用到高级白盒保护属于典型的会者不难。还原算法时用等价替换思路不看so里复杂的位移指令只看它调用哪些密码学标准函数。既然AES和SHA256都是标准库实现那我在Python里用pycryptodome和hashlib就能等价实现。关键是AES的key和IV从哪来。某物没有随机生成key而是写在so的.data段里用Ghidra搜到一段32字节常量再用Frida Hook在内存里验证——Hook Cipher.init()时打印key和iv和静态分析结果一致。最后写了一个很小的验证脚本从抓包里取一组真实的timestamp、nonce、device_id本地按还原的算法计算签名和每次抓包的原始值比对连续20组全部一致。到这里签名这一步就算闭环了。核心还原代码没超过200行Python但中间的排查链路花了两天。写这篇总结时我想把链路本身讲清楚比单纯甩代码重要得多。4. 把逆向结果变成可用的请求服务4.1 签名服务的边界设计签名逻辑还原之后不建议直接塞进每个采集脚本里。更规范的做法是拆成独立的签名微服务本地起一个FastAPI进程接收参数返回签名采集脚本统一走HTTP调用。这样做的原因很简单签名逻辑单独维护不污染业务代码多线程采集时只需要一个签名服务实例避免每个线程各自加载so或重复计算后续如果签名算法更新只改服务内部实现采集端完全不用动。签名服务的接口设计得很简单POST /signbody里带method、url_path、query_params、timestamp、nonce、device_id返回x-sign。服务内部先做参数归一化再跑还原出的算法。调试期我还会加一个debug开关返回签名过程中间的拼接字符串方便和抓包值对比。4.2 请求侧并发模型与代理策略某物列表页数据量不小单线程采集太慢。我第一版用的是requestsThreadPoolExecutor线程数控制在8到16之间跑一晚上大概采了20万条。为什么不用Scrapy因为很多目标接口的安全校验和代理调度需要自己组装Scrapy对这种逆向项目反而有点重。飞快的并发模型在初期意义不大瓶颈通常不在底层协程而在代理和风控上。代理是绕不过去的一环。某物对单一IP的访问频率限得很严一个代理IP连续请求200次左右就会触发验证码。我在请求侧做了一层级联策略每50个请求换一个代理IP每次请求间隔随机0.3到2秒不要固定间隔遇到验证码响应码时把该IP标记为风险IP放进本地队列冷却15分钟连续3次失败就切换到新的代理池批次。这套策略简单有效最大的价值是把风控导致的失败和代码bug导致的失败区分开。如果策略里不处理代理维度遇到大量失败时根本分不清是签名写错了还是IP被限制了。4.3 一版能跑通的采集脚本骨架这里贴一个简化版本的骨架重点看结构不贴完整签名实现import time import random import requests from concurrent.futures import ThreadPoolExecutor SIGN_SERVICE http://127.0.0.1:8000/sign def build_headers(device_id, query, ts, nonce): r requests.post(SIGN_SERVICE, json{ method: GET, path: /api/product/list, query_params: query, timestamp: ts, nonce: nonce, device_id: device_id, }, timeout2).json() return { x-sign: r[sign], x-device-id: device_id, x-app-version: 10.3.1, User-Agent: 某物/10.3.1 (Android 10), } def fetch_page(page, session, device_id, proxy): ts str(int(time.time())) nonce random_hex(32) query {page: page, limit: 20, ts: ts, nonce: nonce} headers build_headers(device_id, query, ts, nonce) resp session.get( https://api.xxx/product/list, paramsquery, headersheaders, proxies{https: proxy}, timeout10, ) if resp.status_code 200: return resp.json()[data] return None这个骨架里最容易被忽略的是session复用。每个线程里单独创建requests.Session连接复用对效率影响很大。另一个坑是timestamp和nonce的生成时机必须和发起请求使用同一个值如果生成后又被其他逻辑修改签名对不上。这个坑我踩了不下五次每次报错都先怀疑签名算法写错了最后发现是时序问题。5. 数据落库SQLAlchemy的使用与后悔药5.1 表设计与唯一键采集回来的数据需要落库。我用SQLAlchemy 2.x PostgreSQL选PostgreSQL是因为后期要跑JSON字段上的查询jsonb更方便。不过这不影响思路换成MySQL也完全没问题。核心商品表结构很简单class Product(Base): __tablename__ products id Column(BigInteger, primary_keyTrue) product_id Column(String(64), uniqueTrue, nullableFalse, indexTrue) title Column(String(255)) price Column(Numeric(10, 2)) original_price Column(Numeric(10, 2)) sales Column(Integer) status Column(String(16)) detail Column(JSONB) created_at Column(DateTime, server_defaultfunc.now()) updated_at Column(DateTime, onupdatefunc.now())唯一键设在product_id上这是最重要的一条设计决策。爬虫是幂等的同一个商品在多次采集中必然重复出现如果没有唯一键去重逻辑就要写在业务代码里既慢又容易漏。数据库层面的唯一约束配合INSERT ON CONFLICT DO UPDATE可以在写入时天然完成增量更新。5.2 批量写入与Session经验刚开始我用session.add(instance)一行行insert一万条数据要跑好几分钟改成批量操作后快了两个数量级。SQLAlchemy做批量写有几个经验优先用session.bulk_insert_mappings传入字典列表绕开ORM实例化开销数据量大时加chunk_size500分批写避免单条SQL过大如果只是导入历史数据可以直接用executemany落到表里不经过ORM。还有一个很少有人注意的细节SQLAlchemy的Session不是线程安全的。用ThreadPoolExecutor采集时千万别让多个线程共享同一个Session对象。正确做法是每个线程创建一个Session或者在线程函数内部用sessionmaker创建会话。我遇到过一个奇怪现象数据总量没少但偶尔报session already begun错误最后发现原因就是共享了Session。5.3 增量更新和看起来成功的坑采集脚本跑了一段时间后我发现数据库里出现大量重复的product_id记录排查下来是早期表结构里唯一键没建好混入了脏数据。那几天重建表、去重、重导浪费了非常多时间。教训是任何爬虫项目第一版表结构就把唯一键设计到位别等数据脏了再补。另外SQLAlchemy和PostgreSQL在事务控制上默认差异很大。有些数据库驱动默认自动提交SQLAlchemy默认不自动提交。批量插入后如果忘记session.commit()进程一退出数据直接丢了而且不会报错看起来一切正常。这种看起来成功的坑比数据库报错更可怕因为等你发现时数据量已经积压很大。我后来在工程里加了一个校验每5000条数据落库后统计库里的总行数和采集成功数是否一致不一致立刻告警。6. 反爬风控遭遇战从一次全量403开始排查6.1 现象与排查顺序项目运行到第三天采集端突然全部403。第一反应是签名失效了因为签名算法可能被服务端更新。但我先做了一件事用抓包里保存的老请求重新放一遍居然能通过说明签名算法没变。那问题就出在其他维度。我把排查顺序理成一张清单确认签名本身没失效放老请求验证确认IP有没有被限制换一个干净代理再试确认设备指纹对不对换一组device_id再试确认User-Agent、App版本等Headers是否完整最后才考虑请求频率过高导致的账号维度处罚。这个顺序很重要。很多人一看到403就直接怀疑签名挂了冲进代码库改半天回头发现换个IP就好了。6.2 设备指纹和IP维度的双重惩罚那次403最终定位到的原因单个device_id凑了太多请求风控直接把它拉黑。IP反而没过线说明某物是双维度风控设备指纹和IP都会累计风险分。即使你每次换IP如果device_id不变高频请求还是触发设备级封禁而且往往比IP封得更狠。解决方案是做多身份轮换机制维护一个device_id池每个设备绑定一套固定的UA和App版本每次请求按设备维度轮换。注意device_id和UA不能随便混搭否则会出现设备与UA不匹配的异常特征反而更容易被判定为脚本。这是我踩过的一个典型反直觉坑你想着伪装身份结果身份之间互相矛盾。6.3 逻辑风控返回200但数据被污染比403更隐蔽的是逻辑风控。某物有一次返回200但数据里的商品ID全部被替换成乱序字符串解析代码拿到了没有意义的SKU。这种风控的目的就是让爬虫看起来成功实际拿到的全是伪装数据。排查方法并不复杂把抓包数据里的字段和App界面显示做抽样对比。如果从第N条开始出现字段错位、ID缺失、价格变成0基本可以确定是逻辑风控。应对思路通常是在解析环节增加字段合法性校验——检查product_id是否匹配预期格式、price是否大于0、title是否为空发现异常就降低当前身份的抓取频率而不是继续硬冲。宁可少采一点也不能拿到一堆脏数据。6.4 兜底手段频率、退避与人工介入我在项目里给采集任务加了三个兜底总开关每分钟请求数超过阈值自动停止采集进入冷却指数退避遇到验证码或错误码等待时间指数增长最多退到半小时人工介入连续失败超过10次微信机器人推一条告警人工确认是否需要更新签名或换代理池。爬虫对抗反爬不是技术越激进越好。越是长期运行的采集项目越需要稳定而克制的频率。我见过太多人为了跑得快把代理池和device_id池疯狂刷结果全被封完项目直接死亡。稳比快要重要得多。7. 复盘这套逆向思路的通用打法7.1 方法论总结整个项目从接到需求到稳定跑通花了一周左右。回顾下来最值得沉淀的不是某个平台的签名算法本身而是从抓包到落库的完整排查链路。我压缩成五个步骤先抓包观察参数随操作的变化规律建立输入输出映射静态扫字符串找Header常量定位签名实现入口动态Hook验证逻辑在Java层还是native层用调用栈定位真实逻辑在本地等价还原算法用抓包样本做签名比对验证请求侧封装签名服务配合代理池和设备池最后落库做唯一键约束。这五步对大多数App逆向都适用。难度差异只在第3和第4步有的App把关键逻辑放在native so里并做混淆有的甚至上白盒加密、指令虚拟化那是另一个层面的对抗但排查思路一脉相承。7.2 几个容易踩的认知误区这里写几个我带新人时反复纠正的误区误区一以为签名是固定不变的。很多签名随timestamp和nonce变化先观察变化规律比直接猜算法重要得多。误区二以为抓包看到签名就够了。签名算法必须和请求参数逐一对上代码里一个字段排序不同结果就完全不一样。误区三以为逆向成功后就能一劳永逸。App升级后签名逻辑可能变化要留定期回归验证的口子。误区四以为多线程疯狂并发就能提升效率。反爬不是线性的实际瓶颈通常先在代理和设备维度爆发。顺便说一句偶尔看到有人在问异步爬虫、分布式爬虫、爬虫管理平台怎么搭。我的建议是先把手里的单机任务跑稳再考虑架构。没有稳定的签名服务和反爬策略再大的分布式也只会在被风控封禁时死得更有节奏感。7.3 关于合规和边界的建议最后必须说清楚逆向分析本身属于技术研究的一部分但使用边界一定要控住。我做这个项目的前提是对方明确承诺只采集公开商品信息不碰账号体系不对用户隐私数据做任何抓取同时遵守平台服务条款。爬虫不是用来搞恶意竞争的采集频率、数据量都要克制。如果你是学习技术建议用自己的测试账号或者找开放接口练手别一上来就拿生产环境的数据集练级。从技术角度看逆向分析最吸引人的地方是把黑盒变成白盒的过程。某物这个项目让我重新梳理了HTTP签名、native函数定位、数据建模和工程调优这些能力之后接其他平台直接复用。经验都是踩坑踩出来的这篇总结写下来也算给自己做一个阶段性的收尾。
