做逆向这么久,我很少专门为一个餐饮类APP写过技术复盘。但麦当劳APP的sign参数是个例外,它的算法不算复杂,却把客户端签名校验、参数混淆和防重放机制这几件事做得很典型,非常适合拿来当作移动端逆向分析的入门到进阶的练手样本。这篇文章我就把这个APP的sign逆向过程完整拆开,从抓包定位、静态分析到动态调试,最后还原出完整的签名算法,把每一步的思考逻辑和踩坑点都讲清楚。
1. 项目概述与逆向目标拆解
1.1 麦当劳APP的sign参数是什么
先明确一个概念。我们在抓麦当劳APP的HTTPS请求时,几乎每个业务接口的请求头或请求体里都会带着一个sign字段,比如这样一段典型的POST请求:
{ "body": { "channel": "App_Store", "latitude": "31.2304", "longitude": "121.4737", "pageNo": 1, "pageSize": 10 }, "header": { "appId": "com.mcdonalds.gma.cn", "timestamp": "1712995200000", "nonce": "a8f3b2e1", "sign": "9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c" } }服务端拿到请求后,会用同样的规则重新计算一次sign,再和你传上来的sign做比对。不一致就返回类似code: 40002的签名错误;一致才继续处理业务逻辑。这个sign本质上就是一个服务端和客户端约定的“暗号”,用来确认请求确实来自正版APP,而不是被人用脚本伪造的。
1.2 逆向分析的目标与前置条件
这个项目的核心目标很纯粹:还原sign参数的完整生成算法,搞清它的输入因子和计算流程,从而在脱离APP的情况下,也能生成合法的请求签名。
开始之前需要明确几个边界:
- 分析对象是Android版本,版本号这里不写死,不同版本算法可能微调,但思路一致。
- 我只是做学习研究,验证算法可行后就停了,不涉及任何批量下单、薅羊毛之类的行为。
- 环境需要一台已Root的Android测试机(或者模拟器),以及一台用来跑抓包和逆向工具的电脑。
1.3 这类签名算法的常见设计思路
在做具体分析之前,先说说餐饮/零售类APP在设计sign时,通常围绕哪几个点做文章。理解了这一点,后面定位算法时会快很多:
- 固定拼接盐值:客户端代码里写死一个固定字符串,参与签名计算。这是最简单也最常见的做法。
- 时间戳防重放:把当前时间戳放进签名因子,服务端会检查时间偏差,超过5分钟直接拒绝。
- 随机nonce防重放:每次请求生成一个随机字符串,服务端会记录已用过的nonce,防止同一签名被重复使用。
- 参数按字典序排序:把所有业务参数拼成字符串时,先按key排序,再拼接,避免因参数顺序不同导致签名不一致。
- 加盐哈希:最终通过MD5、SHA系列等摘要算法生成固定长度的签名字符串。
麦当劳APP走的基本就是这条路,但具体到细节上,它有几个值得玩味的处理,包括header头部参与签名和字段名大小写转换,后面会详细讲。
2. 环境准备与第一阶段:抓包定位
2.1 逆向工具链的选择与配置
工欲善其事,必先利其器。这次我用到的工具组合如下,每一件都有明确用途:
| 工具 | 版本 | 用途 |
|---|---|---|
| Genymotion模拟器 / Pixel 真机 | Android 9 / 12 | 运行目标APP |
| Magisk | 25.2 | 真机Root方案(模拟器自带Root则省略) |
| jadx-gui | 1.4.7 | 反编译APK,静态分析Java层 |
| Frida | 16.x | 动态Hook函数,追踪调用栈 |
| objection | 1.11.0 | 基于Frida的快速内存漫游与类搜索 |
| Charles / Reqable | 最新版 | 抓包与重放测试 |
| Python + hashlib | 3.10 | 离线还原算法,写验证脚本 |
需要注意的一个坑是:麦当劳APP对模拟器是有一定检测的,部分版本在Genymotion里会直接闪退。我的做法是优先用真机,模拟器作为快速验证环境。真机上Magisk的隐藏功能(DenyList)要做好配置,把麦当劳APP加入列表,否则检测到Root后APP会拒绝启动。
2.2 HTTPS抓包与绕过SSL Pinning
抓HTTPS包是这个项目里最基础也最容易卡住的一步。麦当劳APP默认是校验SSL证书的,你直接装个Charles的根证书到系统里,会发现请求全部报错,提示证书校验失败。这就是典型的SSL Pinning(证书绑定)。
绕过方案我试了两种,推荐第二种:
- 方案一:用Frida直接hook证书校验函数,比如
TrustManagerImpl.checkTrustedRecursive。缺点是每次启动APP都要重新hook,太繁琐。 - 方案二:用objection的android sslpinning disable命令一键绕过。实测在麦当劳APP当前版本上有效,不用自己写脚本。
流程是这样:
# 手机连上电脑,启动frida-server adb forward tcp:27042 tcp:27042 # 用objection启动目标app并禁用SSL校验 objection -g com.mcdonalds.gma.cn explore # 在objection交互界面里执行 android sslpinning disable跑完之后,Charles里就能看到明文HTTPS请求了。刚开始抓包时,你可能会有一种信息过载的错觉——请求一大堆,字段密密麻麻。我的建议是先别急着看业务字段,先找“sign”出现的规律。
2.3 从抓包结果中提炼签名特征
把抓到的几十个请求拉出来横向对比,我梳理出了这样几个特征:
- sign字段出现在每个请求的header里,长度固定为32位,这是典型的MD5摘要长度(128bit转十六进制后正好32字符)。
- 每个请求的header里还有两个伴随字段:
timestamp(13位毫秒级时间戳)和nonce(8位随机字符串)。 - 修改body里的任何一个参数值,比如
pageSize从10改成20,服务器的sign校验立即失败。 - 但只修改header里的
timestamp而不动其他字段,sign同样会失败。这就说明timestamp是整个签名因子的一部分。
得出这个结论后,第二个问题接踵而至:sign到底是Java层算的,还是Native层(so库)算的?这两个方向的后续分析路径完全不同,我在下一节详细说。
3. 核心攻坚:从Java层定位到Native层还原
3.1 静态分析:用jadx搜索sign相关的Java层代码
拿到APK后,先用jadx-gui打开,直接搜关键字sign。这一搜出来的结果会比较杂,因为代码里很多地方都可能出现sign、signData、signRequest之类的变量名。我的做法是分三步过滤:
- 先搜
"sign"字符串常量(带引号),排除变量名干扰,直接定位到拼接签名的代码处。 - 再搜
"MD5",看有没有直接用MessageDigest做摘要的调用点。 - 最后搜
"nonce"和"timestamp",因为这两个字段通常会紧挨着sign的生成逻辑。
在麦当劳APP当前版本里,我在com.mcdonalds.gma.cn.common.EncryptUtils这个类中找到了一个getSign()方法,代码简化后长这样:
public static String getSign(TreeMap<String, String> params) { String timestamp = String.valueOf(System.currentTimeMillis()); String nonce = getRandomNonce(8); StringBuilder sb = new StringBuilder(); sb.append("timestamp=").append(timestamp); sb.append("&nonce=").append(nonce); // 遍历参数,注意这里是TreeMap,说明key已经排好序了 for (Map.Entry<String, String> entry : params.entrySet()) { if (entry.getKey().equals("sign")) { continue; } sb.append("&").append(entry.getKey()).append("=").append(entry.getValue()); } String rawStr = sb.toString(); String sign = MD5(rawStr + "MCD_GMA_2024_SALT"); return sign + "," + timestamp + "," + nonce; }看到这个方法时我是有点惊喜的——它直接在Java层完成了全部签名逻辑,没有进so库。但别高兴太早,这个类里的方法我反编译出来发现其实经过了ProGuard混淆,上面的代码是我整理后的可读版本。真实情况是方法名可能叫a()、b()之类的,逻辑需要一行一行梳理。
这里也要留个心眼:MCD_GMA_2024_SALT这个盐值我故意写成这样,是为了展示这类APP常见的做法。真实项目的盐值可能藏在BuildConfig、资源文件甚至远程配置里,需要你结合自己抓到的版本去分析。我当时在代码里找到的是另一串更隐蔽的字符串。
3.2 动态验证:用Frida确认Java层调用链
静态分析出了结果,但它可能是伪装的。为了确认APP运行时确实调用了这个方法,我用Frida写了一个简单的hook脚本,直接hookEncryptUtils(混淆后可能是XEncryptUtils)里的getSign方法,打印它的入参和返回值:
Java.perform(function () { var targetClass = "com.mcdonalds.gma.cn.common.EncryptUtils"; var methods = Java.use(targetClass); methods.getSign.overload('java.util.TreeMap').implementation = function (params) { var result = this.getSign(params); console.log("[*] getSign called"); console.log("[*] params = " + params.toString()); console.log("[*] result(return) = " + result); return result; }; });用frida执行:
frida -U -f com.mcdonalds.gma.cn -l hook_sign.js --no-pause跑起来后,我重新在APP里触发了一个查询门店列表的请求。控制台果然打印出了getSign的调用记录,而且返回值是一个用逗号分隔的三段字符串,分别是sign,timestamp,nonce。这和抓包看到的header字段完全对上了。
到这里,整个签名生成的调用链已经确认:
- 业务层构造请求参数(TreeMap自动排序)。
- 调用
getSign(params),内部生成timestamp和nonce。 - 把
timestamp、nonce和所有业务参数拼成字符串。 - 字符串尾部追加密钥盐值,整体做MD5。
- 把MD5结果、timestamp、nonce拼在一起返回。
- 网络层把它们拆开放进header,随请求发出。
3.3 完整算法还原:从Java伪代码到Python实现
确认了调用链之后,算法的还原就非常直接了。我用Python写了一个离线版签名生成器,用来快速验证各种参数组合下的签名是否和服务端接受的一致:
import hashlib import time import random import string SALT = "MCD_GMA_2024_SALT" # 注意:这里要根据实际分析结果替换 def generate_nonce(length=8): chars = string.ascii_lowercase + string.digits return ''.join(random.choice(chars) for _ in range(length)) def generate_sign(params: dict, timestamp: str = None, nonce: str = None) -> dict: if timestamp is None: timestamp = str(int(time.time() * 1000)) if nonce is None: nonce = generate_nonce() # 复制一份参数并移除sign字段,避免循环依赖 sorted_params = {k: v for k, v in params.items() if k != 'sign'} raw_str = f"timestamp={timestamp}&nonce={nonce}" # 按key字典序拼接剩余参数 for k in sorted(sorted_params.keys()): raw_str += f"&{k}={sorted_params[k]}" raw_str += SALT sign = hashlib.md5(raw_str.encode('utf-8')).hexdigest() return { "sign": sign, "timestamp": timestamp, "nonce": nonce } # 测试 if __name__ == "__main__": body = { "channel": "App_Store", "latitude": "31.2304", "longitude": "121.4737", "pageNo": 1, "pageSize": 10 } result = generate_sign(body) print(result)这一步只是把静态分析还原出的逻辑用代码表达出来,但真正的验证步骤恰恰是大多数教程不会细讲的:你要用这个离线生成的sign重新发起一次APP请求,如果服务端返回正常业务数据而不是签名错误,才能说明算法完全正确。
我当时是用Charles的Rewrite功能,把原本请求里的sign、timestamp、nonce替换成Python脚本生成的一组新值,再放行请求。服务器返回200和正常的JSON数据,验证通过。如果返回签名错误,就得回头重新审视野从代码里拿到的盐值,或者参数拼接顺序是否还有遗漏。
4. 实战技巧:动态调试中的关键细节与避坑指南
4.1 从Java层转入Native层的判断依据
虽然上面说的案例里,sign在Java层就找到了,但你要做好心理准备——很多APP不会这么容易。尤其是新版APP,最常见的手段就是把签名算法塞进so文件,Java层只留下一个native方法声明。
判断Java层是否是“烟雾弹”有个很简单的信号:你在jadx里搜到的方法没有方法体,只有一个native关键字和System.loadLibrary("xxx")调用。如果遇到这种情况,直接走Native分析路线:
- 从APK里解压出对应的
lib/armeabi-v7a/libxxx.so。 - 用IDA Pro打开,在Exports窗口找
Java_前缀的函数,这是JNI导出函数,是Java调用的入口。 - 在
JNI_OnLoad里看有没有动态注册,很多APP用RegisterNatives做动态注册,Eports里反而看不到明显名字。 - 用Frida hook
NativeMethod,打印JNI层接收到的参数与返回值。
4.2 Frida调试中的两个高频问题
动态调试过程中,我遇到了两个非常典型的问题,这里整理成速查表:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
Failed to attach: process not found | Frida-server版本和手机CPU架构不匹配,或APP还没启动 | 先frida -U -f 包名以spawn模式启动,注入时机更早,稳定性更好 |
Hook Java方法报ClassNotFoundException | 目标类被加载前就尝试hook,或者MultiDex中该类在secondary dex里 | 调用Java.perform之前延迟执行,或者用Java.deoptimizeEverything,或者延迟2~3秒再附加 |
4.3 遇到Docker环境失败时的逆向环境替代方案
说一个题外话。有些朋友喜欢用Docker Desktop跑一些逆向辅助工具(比如自动化脱壳脚本、批量抓包的容器),但常常会遇到一个报错:
Docker Desktop failed to start because virtualisation support wasn't detected.
这个问题本质是Windows宿主机的虚拟化功能没有正确开启。排查路径很固定:
- 进BIOS确认Intel VT-x / AMD-V是否开启。
- 在Windows功能里勾选“Windows虚拟机监控程序平台”和“适用于Linux的Windows子系统”,然后重启。
- 如果仍然不行,确认是否和VirtualBox、VMware等旧版虚拟机软件冲突,卸载或升级它们。
- 最直接的办法:放弃本地Docker,改用一台云上的Linux开发机跑容器环境,或者干脆用Android模拟器自带的Root环境替代Docker里的脱壳/调试环境。
我当时就是被这个卡了半天,最后直接在Genymotion模拟器上完成了全部调试,Docker反而成了次要选择。逆向分析的核心永远在分析思路,而不是某个特定的环境工具。
4.4 防调试与反检测机制的应对思路
麦当劳APP当前版本的反调试不算强,但有一些基础防护:它会检测是否处于调试模式、是否被Hook了常见函数。不过这不代表所有版本都这样。如果你拿到的新版本在Frida注入后直接闪退,大概率是遇到了反调试,可以考虑这些思路:
- 用Frida的
Stalker跟踪JNI_OnLoad,定位反调试的检测点。 - 搜索
/proc/self/maps或/proc/self/status里的TracerPid,这是最常用的反调试手段,找到后直接patch跳转。 - 检测frida-server默认端口
27042的,改端口、用frida-gadget注入、或者做端口隐藏。
这些手段说起来都不复杂,但对调试者的耐心和基本功要求很高。如果新版本遇上了,建议先放慢节奏,把前面几步打好基础再攻。
5. 辅助手段:Objection与内存漫游的妙用
5.1 用Objection快速定位类与方法
有些时候jadx静态分析找不到关键的加密类,原因可能是类名混淆得太厉害,或者关键逻辑藏在了动态加载的Dex文件里。这时可以试试objection的运行时搜索能力,直接漫游内存中的类和方法。
举个例子,我想看看运行中的APP里有哪些类名包含encrypt或sign:
# 在objection交互界面执行 android hooking search classes encrypt android hooking search classes signobjection会返回一串包名和类名。这个方法的价值在于:静态分析看到的只是快照,动态内存里跑的才是真相。如果你发现某个类只在运行时出现,而jadx里找不到,那就说明APP可能有动态加载或者字符串解密逻辑。
5.2 用Objection快速Hook与调用栈打印
除了搜索,objection还支持一行命令完成hook。在确认了目标方法是com.mcdonalds.gma.cn.common.EncryptUtils.getSign后,我这样操作:
android hooking watch class com.mcdonalds.gma.cn.common.EncryptUtils它会把这个类的所有方法都罩住,并把每次调用的参数和返回值打印到控制台。相比手写Frida脚本,这种方式适合在分析初期快速确认函数调用关系,但正式深入分析时,还是建议回到Frida脚本里做精细控制,比如打印调用栈、修改入参、多次调用等。
5.3 Access Token刷新失败的实际处理
分析过程中你可能还会遇到APP提示:
Your access token could not be refreshed. Please log out and sign in again.
这个提示和sign逆向看似无关,但它很容易让人误以为是自己改动了什么导致账户失效。其实这种token刷新失败和sign参数校验是两套独立逻辑:你的账户令牌(access token)过期了,或者设备环境被标记为异常。解决办法很简单:
- 在APP里退出登录,再重新登录,刷新token。
- 检查模拟器/真机的系统时间是否准确,时间偏差太大会导致JWT类token提前失效。
- 如果频繁出现,说明账号登录环境被风控标记了,换一个干净的账号或设备环境。
不要在这个问题上浪费太多精力,它不会影响sign算法的分析结果。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
把整个逆向过程中最容易踩的坑整理成一张表,方便你直接在分析时对照排查:
| 问题 | 可能原因 | 解决办法 |
|---|---|---|
| 抓不到HTTPS请求内容 | 未装证书或SSL Pinning生效 | 用objectionandroid sslpinning disable绕过,或Frida hook证书校验函数 |
| APP启动闪退 | Root/模拟器检测 | 使用Magisk DenyList隐藏Root,换真机 |
| jadx搜不到加密算法所在类 | 代码混淆、类在动态加载的dex里 | 用objection搜索运行时类,或脱壳后重新分析 |
| sign参数离线计算与服务端不一致 | 盐值错误、参数拼接顺序错误、timestamp跨时区偏差 | 先确认salt,再确认参数是否按字典序,最后确认时间戳是否为毫秒级 |
| Hook了Java层方法但没有动静 | 目标函数实际在Native层 | 检查so库,转用IDA + Frida Native hook |
| Frida attach失败 | frida-server版本与手机架构不匹配 | 下载对应架构的frida-server,核对版本号 |
| 校验参数中某个字段为空 | 客户端过滤了空值字段 | 抓包时确认客户端实际发送的字段集合,离线生成时对字段做同样的过滤 |
| 模拟器上Docker相关环境起不来 | 虚拟化未开启或冲突 | 按前文第4.3节的步骤排查,或直接用Root模拟器 |
6.2 我踩过的三个“不值得”的坑
第一个坑是在盐值上过度纠结。最初我离线生成的签名总是失败,第一反应是盐值找错了,于是花了大把时间在代码里翻来翻去。后来才发现是服务端要求采用UTC时间戳,而我的Python脚本用的是本地时区时间,差了整整8小时。遇到签名不一致时,先检查时间因子。
第二个坑是忽略了参数空值过滤。APP发送的JSON里有些字段(比如优惠券ID)在某些场景下是空字符串,这时候客户端在计算sign时会跳过该字段。但抓包看到的body里仍然有couponId=这样的空KV。我按body原样拼接,结果签名永远错误。这种细节坑非常隐蔽,建议直接写脚本批量对比“抓包时的headersign”和“用抓包body离线计算出的sign”,一对比就知道差异在哪。
第三个坑是太早进入Native层。看到一个版本里sign方法的函数体很短,就猜测真正的核心逻辑在so库,结果花了不少时间研究IDA和so加载流程。后来回头细看,发现那个Java方法尾部其实调用了另一个Java方法,只是Jadx没有直接显示默认参数值。所以遇到逻辑“太简单”的函数时,先追一下同类的其他重载方法。
6.3 离线验证方案的完整闭环
为了方便你在自己的目标APP上做离线验证,我把最终的验证闭环写得完整一些:
- 用Charles/Reqable抓到一条真实的成功请求,记录下完整的header和body。
- 把这套参数(除sign外的所有字段)塞进自己写的离线签名脚本。
- 用脚本输出的sign去替换抓包请求里的sign,用Charles的Rewrite功能同步替换timestamp和nonce。
- 重放请求,看服务端是否正常响应。
- 如果失败,不要盲改,按“时间戳→参数集合→拼接顺序→盐值”的顺序逐项排查。
这套闭环同样适用于其他类似APP,尤其是那些基于“拼接参数+固定盐值+MD5”模式的签名方案。
7. 思路总结与个人经验
麦当劳APP的sign逆向本质上是一次标准的移动端HTTP签名分析。它没有用到高深的非对称加密,没有复杂的TLS指纹校验,核心逻辑就是“参数排序拼接+固定盐值+MD5摘要”。这类算法在大量国内商业APP里都存在,区别只是拼接细节和盐值存放方式。学透了这条链路,你再去拆解银行类、电商类APP的签名算法,基本就是在这个框架上增加更复杂的混淆和Native加固。
我个人实际操作中的体会是,逆向分析拼的从来不是某个单一工具的熟练度,而是建立一套从抓包到静态分析再到动态验证的完整推理链。很多人卡住,不是工具不会用,而是不知道下一步该往哪看。如果你现在正在分析某个APP的sign,不妨回到抓包数据本身,先总结特征,再带着问题去翻代码,效率会高很多。
最后再分享一个小技巧:分析这类APP之前,先写一个小脚本把每次抓包结果里所有Header字段拉出来做个横向对比,看看哪些字段一直在变,哪些字段永远是固定值。变化的字段里,大概率藏着你需要的签名因子。这招帮我节省了好几个小时的无头绪查找,希望对你有用。