news 2026/10/3 8:00:39

麦当劳APP sign参数逆向还原:从抓包到签名算法复现全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
麦当劳APP sign参数逆向还原:从抓包到签名算法复现全解析

做逆向这么久,我很少专门为一个餐饮类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
Magisk25.2真机Root方案(模拟器自带Root则省略)
jadx-gui1.4.7反编译APK,静态分析Java层
Frida16.x动态Hook函数,追踪调用栈
objection1.11.0基于Frida的快速内存漫游与类搜索
Charles / Reqable最新版抓包与重放测试
Python + hashlib3.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之类的变量名。我的做法是分三步过滤:

  1. 先搜"sign"字符串常量(带引号),排除变量名干扰,直接定位到拼接签名的代码处。
  2. 再搜"MD5",看有没有直接用MessageDigest做摘要的调用点。
  3. 最后搜"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字段完全对上了。

到这里,整个签名生成的调用链已经确认:

  1. 业务层构造请求参数(TreeMap自动排序)。
  2. 调用getSign(params),内部生成timestamp和nonce。
  3. 把timestamp、nonce和所有业务参数拼成字符串。
  4. 字符串尾部追加密钥盐值,整体做MD5。
  5. 把MD5结果、timestamp、nonce拼在一起返回。
  6. 网络层把它们拆开放进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分析路线:

  1. 从APK里解压出对应的lib/armeabi-v7a/libxxx.so。
  2. 用IDA Pro打开,在Exports窗口找Java_前缀的函数,这是JNI导出函数,是Java调用的入口。
  3. 在JNI_OnLoad里看有没有动态注册,很多APP用RegisterNatives做动态注册,Eports里反而看不到明显名字。
  4. 用Frida hookNativeMethod,打印JNI层接收到的参数与返回值。

4.2 Frida调试中的两个高频问题

动态调试过程中,我遇到了两个非常典型的问题,这里整理成速查表:

问题现象根本原因解决方案
Failed to attach: process not foundFrida-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宿主机的虚拟化功能没有正确开启。排查路径很固定:

  1. 进BIOS确认Intel VT-x / AMD-V是否开启。
  2. 在Windows功能里勾选“Windows虚拟机监控程序平台”和“适用于Linux的Windows子系统”,然后重启。
  3. 如果仍然不行,确认是否和VirtualBox、VMware等旧版虚拟机软件冲突,卸载或升级它们。
  4. 最直接的办法:放弃本地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 sign

objection会返回一串包名和类名。这个方法的价值在于:静态分析看到的只是快照,动态内存里跑的才是真相。如果你发现某个类只在运行时出现,而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)过期了,或者设备环境被标记为异常。解决办法很简单:

  1. 在APP里退出登录,再重新登录,刷新token。
  2. 检查模拟器/真机的系统时间是否准确,时间偏差太大会导致JWT类token提前失效。
  3. 如果频繁出现,说明账号登录环境被风控标记了,换一个干净的账号或设备环境。

不要在这个问题上浪费太多精力,它不会影响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上做离线验证,我把最终的验证闭环写得完整一些:

  1. 用Charles/Reqable抓到一条真实的成功请求,记录下完整的header和body。
  2. 把这套参数(除sign外的所有字段)塞进自己写的离线签名脚本。
  3. 用脚本输出的sign去替换抓包请求里的sign,用Charles的Rewrite功能同步替换timestamp和nonce。
  4. 重放请求,看服务端是否正常响应。
  5. 如果失败,不要盲改,按“时间戳→参数集合→拼接顺序→盐值”的顺序逐项排查。

这套闭环同样适用于其他类似APP,尤其是那些基于“拼接参数+固定盐值+MD5”模式的签名方案。

7. 思路总结与个人经验

麦当劳APP的sign逆向本质上是一次标准的移动端HTTP签名分析。它没有用到高深的非对称加密,没有复杂的TLS指纹校验,核心逻辑就是“参数排序拼接+固定盐值+MD5摘要”。这类算法在大量国内商业APP里都存在,区别只是拼接细节和盐值存放方式。学透了这条链路,你再去拆解银行类、电商类APP的签名算法,基本就是在这个框架上增加更复杂的混淆和Native加固。

我个人实际操作中的体会是,逆向分析拼的从来不是某个单一工具的熟练度,而是建立一套从抓包到静态分析再到动态验证的完整推理链。很多人卡住,不是工具不会用,而是不知道下一步该往哪看。如果你现在正在分析某个APP的sign,不妨回到抓包数据本身,先总结特征,再带着问题去翻代码,效率会高很多。

最后再分享一个小技巧:分析这类APP之前,先写一个小脚本把每次抓包结果里所有Header字段拉出来做个横向对比,看看哪些字段一直在变,哪些字段永远是固定值。变化的字段里,大概率藏着你需要的签名因子。这招帮我节省了好几个小时的无头绪查找,希望对你有用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 7:59:56

项目是经营单元:读《华为项目管理之道》第1章

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:59:26

crazyswarm与Crazyflie集群实验平台搭建:资料索引与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:59:25

西门子S7-1500T凸轮同步与CAMIN指令详解

1. 为什么是1500T&#xff1a;普通1500做不了凸轮同步这件事先聊个很多工程师问过我的问题&#xff1a;同样是S7-1500&#xff0c;为什么凸轮同步非要用1500T系列&#xff1f;普通1500加个高速计数模块能不能凑合&#xff1f;答案很直接&#xff1a;凑合不了&#xff0c;或者说…

作者头像 李华
网站建设 2026/10/3 7:59:18

STM32 USART串口通信实战:从原理到代码,带你避开所有坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:58:56

图书馆管理信息系统数据库课设全攻略:从ER建模到并发事务

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:57:56

信噪比SNR全面解析:从ADC采集到ImageJ图像计算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华