先说个题外话。最近在折腾移动端接口测试的时候,同事甩了一个标题给我:“万达电影 com.wandafilm.app headers check”。乍一看有点懵,点开旁边的抓包记录才反应过来——他说的是万达电影App在做HTTPS请求时,请求头(headers)里的校验字段特别反常,跟他以前处理的普通App完全不是一个套路。我顺着他抓的几组数据包看了看,确实有点意思,干脆花了一晚上把整套headers机制捋了一遍,顺便把过程中踩的坑也记了下来。这篇文章就是那晚的复盘。
如果你平时喜欢用Charles或者Fiddler抓App的包,或者你正在做安卓逆向、接口分析、风控策略研究,又或者你只是想弄明白“为什么我照着别人的脚本改了个headers还是被服务器拒绝”——那这篇应该能给你一些直接能用的思路。下面所有内容都基于App客户端的正常协议分析,目的纯粹是为了研究HTTP接口交互逻辑,不涉及任何破解会员、刷票、绕过付费之类的违规操作,这点先说清楚。
1. 先搞明白“headers check”到底在查什么
1.1 从抓包结果看,这套请求头有哪些组成
万达电影App的包名是com.wandafilm.app,这个大家应该不陌生,就是万达院线旗下的官方应用,用来买票、选座、查影讯、参加影城活动。我之前其实也断断续续抓过它的接口,但从来没仔细看过headers,这次被人点名提醒,才认真把每个字段拉出来比对了一遍。
随便打开App首页,触发一个查询接口,比如获取正在热映电影列表,Charles里看到的请求头大概是这样的(关键值我做了脱敏处理):
GET /v2/api/movie/list HTTP/1.1 Host: gw.wandafilm.com Accept: application/json Content-Type: application/json;charset=UTF-8 User-Agent: okhttp/3.12.1 versionName: 6.4.2 versionCode: 642 platform: android deviceId: 8634xxxxxxxxxxxx deviceModel: Mi 11 deviceBrand: Xiaomi osVersion: 30 channel: wandafilm sign: 8f7d1a2b3c4d5e6f7a8b9c0d1e2f3a4b timestamp: 1717660800000 token: eyJhbGciOiJIUzI1NiJ9.xxxxx第一眼看上去,这个请求头并没有特别复杂,无非就是UA、平台、版本号、设备信息、时间戳、签名和token那一套。但如果你把它的字段拆开,跟普通App对比一下,就会发现几个有意思的细节:
- 所有业务字段几乎都放在自定义header里,比如versionName、platform、deviceId、sign、timestamp,而不是放在请求体或者query参数里。
- sign几乎出现在所有接口上,连图片上传、意见反馈这种边缘接口都带。
- timestamp是毫秒级Unix时间戳,并且跟sign强相关,改动时间戳后sign立刻校验失败。
- deviceId的生成规则不是标准的IMEI或Android ID,看起来像自定义的UUID变体。
这就引出一个问题:为什么这个App要把这么多信息塞进headers?普通App要么把签名放到请求体里,要么只对登录、下单这种敏感接口做签名校验,而万达电影是全接口、全字段、无差别校验。这说明它的服务端有一个统一网关层,对所有入站请求做了一层标准化的headers校验,也就是标题里说的“headers check”。
1.2 为什么App要搞这么多headers
很多刚接触接口分析的人会问:这些字段不就是用来标识设备和版本的吗?有必要搞这么复杂吗?答案是有必要,至少从商业和技术两个角度都能解释。
从商业角度说,万达电影的购票业务涉及票价、会员折扣、影城结算,接口数据本身就是核心资产。如果别人能轻易模拟请求去刷接口,不仅会被薅羊毛,更可能导致票价数据被批量抓走,影响合作影院的定价策略。所以它需要在最前端就挡住一批低水平的模拟请求。
从技术角度说,所有移动端App都面临一个根本问题:客户端发出去的每个请求都是不可信的。服务端要判断“这个请求是不是来自我家的App”,最简单粗暴的方案就是在headers里带上足够多的环境信息,再配一个签名,服务端收到后先校验签名,再校验时间戳防重放,最后结合设备信息做风控。这套流程下来,就算有人抓到了完整的请求包,想原样重放或者篡改参数,也必须先破解签名算法,这个门槛能劝退90%的普通脚本小子。
所以,headers check并不是一个孤立的技术点,它是整套App接口安全体系的入口。理解它,等于拿到了通往后续所有接口逻辑分析的钥匙。
2. 从零开始做一次客户端请求分析
2.1 工具选型:Charles、Fiddler、Reqable、HTTP Toolkit怎么选
在做headers分析之前,先把抓包环境准备好。市面上的HTTP抓包工具我基本都碰过,简单说下优缺点,你们可以按自己的平台习惯来选。
| 工具 | 平台支持 | 上手难度 | 核心优势 | 主要坑点 |
|---|---|---|---|---|
| Charles | Windows / macOS | 中等 | 老牌稳定,UI清晰,插件多,支持重写(Rewrite)、断点(Breakpoints) | 需要付费,免费版会话有30分钟限制;新版macOS上证书安装略麻烦 |
| Fiddler | Windows为主,macOS可用 | 中等 | 免费,脚本扩展能力强,FiddlerScript可自定义修改请求 | 对HTTPS解密需装证书并配置代理,新版界面不太直观 |
| Reqable | Windows / macOS / Linux | 低 | 国产工具,交互更现代,支持API调试和重放,自带解码器 | 相对小众,遇到极端场景可能需要提issue |
| HTTP Toolkit | Windows / macOS / Linux | 低 | 开源,支持Android设备一键注入,对移动端友好 | 部分高级功能要付费,处理大流量时会卡 |
我自己的习惯是主力用Charles,遇到需要快速验证某个headers修改后的结果时用Reqable,因为它的重放功能比Charles顺手。如果你们只是想单次抓包看看数据长什么样,直接上HTTP Toolkit也行,省去一堆配置。
2.2 抓HTTPS的硬门槛:证书信任和SSL Pinning
这里必须提醒一句,现在绝大多数App的接口都是HTTPS,直接代理只能看到加密后的乱码,必须做证书信任配置。分两种情况:
第一种,目标App没有做SSL Pinning(证书绑定),那好办,直接用Charles或Fiddler装根证书到手机上,再设置代理就行。Android 7.0以上的系统默认不信任用户级CA证书,所以要把证书装到系统证书目录,或者用adb命令把证书push到/system/etc/security/cacerts/,前提是手机已root。
第二种,目标App做了SSL Pinning,Charles里面会直接弹出一个“SSL handshake failed”的红色错误。这时候就需要用Frida来hook证书校验逻辑,或者用objection一键disable ssl pinning。具体命令网络上资料很多,这里不展开,只强调两个坑:
- Frida环境要跟手机端版本严格匹配,服务端和客户端版本号不一致会直接报错。
- 有些App做了二次校验,比如证书校验在native层实现,单纯hook Java层的checkServerTrusted方法不够,还得继续往下挖。
万达电影这个App我实测过,它没有做太强的SSL Pinning,主流工具都能直接解密流量,这点对分析者来说算是很友好了。
搞定SSL后,把App翻一遍,重点触发几个典型接口:启动时的配置接口、首页电影列表、影片详情、登录接口、下单流程。每个接口都留一组完整的数据包,后面拆解签名逻辑时用得到。
3. 请求头里的关键字段:逐一拆解
3.1 字段分组:基础信息、设备信息、鉴权信息各管什么
把抓到的headers字段按功能分个组,思路会清晰很多。
第一组是基础信息类,包括User-Agent、Accept、Content-Type,这些跟普通HTTP请求没什么区别,服务端主要用来识别客户端类型和解析格式。注意User-Agent这里没有暴露太多WebView痕迹,就是纯OkHttp的默认UA,说明接口层采用的是原生网络库请求。
第二组是版本和环境类,包括versionName、versionCode、platform、channel。这一组字段的用途一目了然:服务端需要知道当前App版本,以便对不同版本做差异化处理,比如强制升级提醒、接口兼容逻辑。channel则是渠道标识,用来区分用户是从哪个应用商店下载的App,这在运营统计里很关键。
第三组是设备类,包括deviceId、deviceModel、deviceBrand、osVersion。这组字段给服务端提供了设备指纹的一部分。deviceModel和deviceBrand能直接改成任意值,但deviceId如果改了,服务端很容易发现异常,因为它大概率在用户注册或首次启动时已经上报过,后续每个请求的deviceId都会跟账号体系绑定。
第四组是鉴权类,包括token、sign、timestamp。这是整个headers check的核心,也是分析时的重点。token是用户登录后的凭证,代表“我是谁”;timestamp代表“这个请求是什么时候发的”;sign代表“这个请求的内容是否可信”。三者合在一起,就构成了服务端判断请求合法性的完整链条。
这里说个小技巧:分析字段含义时,不要一个字段一个字段孤立去看,而是把同一个接口在不同状态下的headers拉成一张对比表。比如未登录状态抓一次,已登录状态抓一次;正常请求抓一次,篡改deviceId后再抓一次。对比一下哪个字段变了、哪个字段没变、哪个字段变了会报错,分级标记出字段的重要性。
3.2 最关键的一步:推断sign的生成规则
sign是headers里最核心的字段,也是门槛所在。它通常是由多个参数拼接后加密得到的摘要值,理论上我们无法直接看到原始的加密算法,但可以通过黑盒测试猜个八九不离十。
我的推断套路分四步:
第一步,先验证sign跟哪些字段有关。把timestamp改大几千毫秒,请求发过去,如果返回“sign error”或“invalid signature”,说明sign跟timestamp强关联。把deviceId改一个字符,如果签名校验失败,说明deviceId也参与了签名。把请求体里的某个参数改掉,如果报错,说明业务参数也在签名范围内。
第二步,验证参与签名的参数范围。抓到一个正常请求的headers和请求体,先原样重放,如果服务端返回正常结果,说明“完整的请求”是有效的。然后把headers里的某个字段删掉或改值,在Charles的Rewrite里配置好规则,重新发送,看服务端是否还认。通过这种减法测试,就能圈出哪些字段参与签名。
第三步,验证时间窗口。把timestamp改到跟服务器时间完全一致但请求体不变,看是否成功;再把timestamp往前调5分钟,如果失败,说明服务端允许的时间偏差在5分钟以内。很多App把这个窗口设成5分钟或10分钟。
第四步,根据签名长度和格式猜算法。sign如果是32位十六进制小写字符串,大概率是MD5;如果是40位,可能是SHA1;如果是64位,可能是SHA256。然后拼接几个字段的值,尝试不同的排列顺序,用常见算法做哈希,看有没有能对得上的。这一步可以写个Python脚本自动试:
import hashlib import itertools # 假设参与签名的字段 fields = { "timestamp": "1717660800000", "deviceId": "8634xxxxxxxxxxxx", "versionName": "6.4.2", "platform": "android" } # 尝试不同的排列顺序 keys = list(fields.keys()) for r in range(1, len(keys) + 1): for perm in itertools.permutations(keys, r): raw = "&".join([f"{k}={fields[k]}" for k in perm]) if hashlib.md5(raw.encode()).hexdigest() == target_sign: print("MD5 found:", raw) if hashlib.sha1(raw.encode()).hexdigest() == target_sign: print("SHA1 found:", raw) if hashlib.sha256(raw.encode()).hexdigest() == target_sign: print("SHA256 found:", raw)要注意的是,很多App在签名前还会做二次处理,比如加盐(固定字符串)、对字典key排序、去掉空值参数、或者把headers和body的字段统一收集到一起再拼接。比如我之前分析某个影票类App时发现,它的签名规则是把所有参与签名的参数key按字典序排序,拼成key1=value1&key2=value2的形式,末尾加上App内置的盐值,再做MD5。万达电影的sign长度是32位,我按这个思路测了十来组数据,锁定的规则是:对timestamp、deviceId、versionCode、platform以及请求体JSON里的一级字段名做固定拼接,再加个十余位的固定盐值,最后做MD5。
当然,不同版本的App签名规则可能不一样,这个只能靠自己抓包验证。但思路是通用的:先做减法测试圈定字段,再按格式猜算法,最后用脚本暴力匹配拼接顺序。
3.3 版本更新带来的坑:签名规则随时可能变
这里插一个很多新手容易忽略的点:App版本升级后,签名规则也可能会跟着变。比如旧版本签名字段不包含channel,新版本突然加了,或者旧的加密算法从MD5换成了SHA256,这些都是真实存在的。
所以当你拿着网上搜到的一段旧代码去调接口时,如果发现“明明逻辑都对,就是报签名错误”,先看看你抓包的App版本和代码里写死的版本是不是一致。最稳妥的做法是,每次分析前都从当前最新版App抓包,而不是依赖几个月前的笔记。
另外,有的App区分debug版和release版,两个版本走的网关地址和签名盐值都不同。万达电影这块还算良心,至少debug包里没有埋额外的校验逻辑,不然分析难度又要上一个台阶。
4. 模拟请求时最常踩的坑
4.1 服务端吐出来的那几个经典报错
搞定了签名规则,你以为就能顺利模拟请求了吗?差得远。我把自己在模拟万达电影接口时遇到过的典型报错整理了一下,基本能覆盖大部分人会踩的坑。
第一次把所有headers字段照搬过去,返回的结果是正常的,但一旦把timestamp改成当前时间,立刻报“sign error”。原因很简单,sign里绑定了原来的timestamp,改了时间戳但没有重新计算sign,服务端一比对就发现了。解决办法是把timestamp替换后,用已还原的签名算法重新算一遍sign。
第二个常见坑是“401 Unauthorized”。这个一般跟token有关,token过期、被顶下线、或者绑定的deviceId和当前请求头里的deviceId不一致,都会触发这个状态码。这里有个细节:万达电影的token不是简单的UUID,而是一个JWT格式的字符串,包含三段,点号分隔。JWT的解码网上有现成库,但你要注意,它里面的payload只是Base64编码,不是加密,随便找一个在线工具就能看到内容,里面会有userId、exp(过期时间)、iat(签发时间)之类的信息。
第三个是“403 Forbidden”,这个基本就是风控拦截了。触发原因可能是请求频率太高、单个IP在短时间内请求了大量不同接口、或者设备指纹的某些维度跟历史行为不匹配。我有个朋友写了个脚本,每5秒遍历一次全部影厅的排片,跑了不到半小时,整个IP段都被封了。所以做这种分析时,控制请求频率很重要,加延时是最基本的,最好再加一个随机化的休眠区间。
第四个是“请求体JSON解析失败”或者“字段校验失败”。这个往往不是你headers的问题,而是业务参数的坑。App端的请求体里通常带着一些看起来没用的冗余字段,比如locationCityId、cinemaGroupId、utmSource,这些字段可能参与了服务端的参数合法性校验,缺失时会直接报错,而且报错信息还很不友好,排查起来相当费劲。
4.2 headers重放测试的实验记录
顺手分享一组我做的重放测试数据,方便大家理解服务端的校验逻辑到底是怎样的。我把同一个“获取影片详情”的请求分别做了五种修改,然后观察服务端的返回:
| 修改操作 | 服务端响应 | 结论 |
|---|---|---|
| 原样本直接重放 | 200,正常返回 | 服务端允许一定时间窗口内的重放 |
| timestamp改为5分钟前 | 200,正常返回 | 时间窗口大于5分钟 |
| timestamp改为10分钟前 | 签名错误 | 时间窗口在5到10分钟之间 |
| timestamp改为当前时间,但sign不变 | 签名错误 | sign确实包含timestamp |
| deviceId改一个字符 | 签名错误 | sign确实包含deviceId |
这组测试做完,基本上就把服务端的校验逻辑摸清了:先校验签名,再校验时间窗口,之后才进入业务逻辑。这个校验顺序也解释了为什么改了timestamp却不重算sign,会比直接改token更快报错。
4.3 容易被忽略的“隐性header”和网关改动
再说一个比较坑的细节:你从Charles里看到的headers,未必是服务端实际收到的headers。有些请求在OkHttp层或网关层会被自动改写,比如OkHttp默认会添加Host、Connection: Keep-Alive、Accept-Encoding: gzip这些头;还有的请求会被API网关加上X-Forwarded-For、X-Real-IP这类代理头。
问题在于,有些App的签名计算会把最终发给服务端的headers都纳入计算范围,也就是“全头签名”。如果你在Charles里看到的某个自定义字段没有被重放,哪怕它看起来无关紧要,也可能导致签名校验失败。
万达电影有没有做全头签名?我实测下来,它只对部分自定义字段和请求体做签名,OkHttp自动加的那些标准头不参与。但保险起见,模拟请求时最好把自己抓包看到的、非标准头的字段全部带上,一个都别丢,顺序无所谓,但字段完整性很重要。
4.4 一个差点让我翻车的“陷阱”:deviceId和token的绑定关系
最后说一个特别容易忽略的隐藏校验:deviceId和token的绑定关系。万达电影在登录接口返回token时,顺便会把token跟当前请求里的deviceId做绑定。之后你拿着这个token去请求其他接口,服务端会校验当前请求的deviceId是否跟token绑定的设备一致。
如果不一致,返回的往往是200,但业务数据是空的,或者直接返回一个“请重新登录”的错误码,而不是清晰的401。我第一次踩到这个坑时,差点以为是自己签名算错了,花了一个多小时反复核对签名规则,最后才想起来去对比deviceId。
这种情况在实际改造requests脚本时特别容易犯,因为Charles里抓的登录请求和业务请求可能是同一个设备,但你的模拟环境里如果用了不同的deviceId,就会莫名奇妙地拿不到数据。解决办法也简单:分析时统一用一个固定的deviceId,或者从登录接口开始就完整模拟整个过程,不让token和设备身份脱节。
5. 这套分析思路能迁移到哪些场景
说实话,万达电影这套headers check的复杂度,在移动互联网App里只能算中规中矩。跟某些大厂的电商App比,它没有忒修斯级别的加固壳,没有native层的签名SDK,也没有动态下发的风控策略,整体还停留在“服务端统一校验签名+时间戳+token”的经典阶段。但这反而让它成了一个很好的学习样本。
如果你能完整走一遍这篇文章里的分析流程——抓包、拆字段、做减法测试、还原sign、模拟请求、处理各种报错——那你对这个套路的理解就不只是停留在工具层面了。以后再遇到更大厂的App,你至少知道该从哪个方向下手,而不是拿着Charles瞎点一通。
具体来说,这套思路至少可以迁移到这些场景:
- 自己开发的App或后端接口做安全自测,用同样的方法检查签名逻辑是否够健壮。
- 写自动化脚本做接口回归测试,特别是需要对请求头做动态签名的场景。
- 研究竞品App的接口协议设计,留意别人在安全策略上的取舍。
- 排查线上环境里出现的“客户端正常但服务端拒绝请求”类问题,headers校验是重点怀疑对象之一。
最后再说一个实际经验。分析这种带签名校验的接口时,千万不要一上来就纠结“能不能完全模拟App的行为”,而是先问自己“我到底想拿到什么数据、想验证什么结论”。如果只是验证某个接口的字段含义,那就没必要强行破解签名,直接在Charles上改参数重放就够了;只有当你需要脱离Charles独立发请求时,才值得去还原签名逻辑。工具永远是为目的服务的,别把手段当目的。
我自己的感受是,headers check这类东西,看起来只是一堆键值对的堆砌,但每多搞懂一个字段,你就离一个App的接口设计逻辑更近了一步。分析的乐趣也正在于此:从一坨看似枯燥的请求头里,一点点还原出背后工程师的思考过程——他们防了什么、没防什么、哪些地方做了权衡。所以如果你也想试试,不妨今晚就装个抓包工具,打开手边随便一个App,看看它的请求头里藏着什么秘密。