尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

美团mtgsig逆向与补环境:从原型链到风控签名的技术实战

美团mtgsig逆向与补环境:从原型链到风控签名的技术实战 我先把话放在前面这篇文章讨论的是美团外卖 mtgsig 1.2 的逆向分析与补环境思路面向的是安全研究、风控对抗学习和客户端安全评估场景。我不会把完整可用的签名生成代码贴出来因为这东西一旦被滥用伤害的是普通用户和平台生态。但我会把整个分析路径、关键技术选型、补环境的底层逻辑和实战中容易踩的坑讲透。你把这套方法论吃透不管是研究其他 App 的签名方案还是做风控防护思路都是通用的。1. 认识 mtgsig它到底是个什么东西1.1 mtgsig 在美团风控体系里的位置美团外卖的请求链路里几乎所有关键接口都会带上一个叫 mtgsig 的参数。它不是简单的“签名字符串”而是一组结构化数据里面包含了客户端环境信息、请求参数摘要、时间戳、随机数以及经过多种加密算法处理后的校验值。服务端拿到请求后会基于同一套算法计算结果进行比对同时结合设备指纹、行为特征、IP 画像等维度做综合判定。1.2 是 mtgsig 的一个版本标识。不同版本之间字段含义、加密算法、拼接顺序都可能完全不同。这也是为什么很多人在网上找了一堆所谓“逆向教程”跟着操作却始终跑不通——大概率是版本对不上。这个签名的作用通俗点说就是美团的后端需要确认“这个请求真的是从美团外卖 App 里发出的而且是正常用户行为发出的”。一旦签名校验不通过请求直接拒绝连业务逻辑都走不到。1.2 为什么会有逆向需求我接触过不少做这块的人诉求大致分几类安全研究人员做客户端漏洞分析需要理解签名方案的强度和弱点。风控对抗研究者想验证自己的防护策略是否有效。自动化脚本开发者比如外卖比价、优惠监控需要模拟 App 请求于是必须过签名这一关。纯粹的技术爱好者被“怎么生成 mtgsig”这个技术挑战吸引。不管你是哪一类逆向 mtgsig 的技术路径都差不多先定位生成函数再还原算法最后通过补环境让算法能够在非浏览器/非 App 环境下跑起来。这篇文章的重点放在第三条链路补环境。1.3 逆向研究的目标边界做这类事情我建议给自己划两条线第一条是法律线。不要用逆向结果去抓取用户隐私数据、不要攻击线上服务、不要批量注册或刷单。学习研究和技术探索没问题但一旦落地到黑灰产场景性质就变了。第二条是技术线。你不要指望完全还原美团的所有加密细节那工作量巨大且迭代太快。实际工程中更聪明的做法是“让原始代码在你能控制的环境里自己跑起来”这就是补环境的核心价值——你不需要逆向每行算法只需要给代码提供一个它认得的“家”。2. 逆向工程的整体技术路径2.1 抓包定位找到签名出现在哪里拿到目标 App 之后第一步不是急着反编译而是先抓包看流量。用 Charles 或者 Fiddler 配好 HTTPS 代理装好证书然后操作外卖 App 随意浏览几个页面筛选出请求中的 mtgsig 字段。这一步的目的有两个一是确认目标版本不同版本字段格式差异很大二是借助请求参数反推签名覆盖的数据范围。你需要在抓包界面里观察比如 mtgsig 的参数值变化频率、是否和时间戳联动、同一个请求重放后返回值是否变化等。这些观察会在后续算法定位时帮上大忙。2.2 定位生成函数堆栈回溯与关键字搜索拿到样本后常规做法是把 App 包解包搜索关键字。对于 React Native、Flutter、小程序这类跨端方案直接搜“mtgsig”字符串通常就能命中对于纯原生 App需要从 so 层入手用 IDA 或 Ghidra 分析 JNI 导出函数。实操中我最常干的是直接搜字符串常量。mtgsig 作为一个固定 key 名在代码里基本是以字符串字面量或配置文件的形式出现。用 jadx 打开 APK全局搜索“mtgsig”基本能定位到 Java/Kotlin 层的组装逻辑。如果签名核心逻辑在 so 层那这一步只能看到 JNI 调用入口还需要继续下钻。2.3 从防混淆代码中梳理加密流程美团这类体量的 App代码基本都做了混淆和加固。字符串加密、控制流平坦化、指令抽取都是常见操作。逆向时不能指望代码可读性有多好更实际的做法是“动态调试 Hook”。用 Frida 在关键函数上打点打印参数和返回值观察签名生成的中间过程。配合内存读写监控能比较快地摸清加密链条。说实话这个过程非常耗时间和耐心尤其是遇到 VMP 或者其他虚拟化保护时静态逆向基本无解只能靠动态跟踪数据流。2.4 两种路线算法还原 vs 环境模拟加密流程摸清楚之后你会面临一个选择路线 A用 Python 等语言完整重写加密算法。这条路代码最干净、执行效率最高但前提是你能把每一处算法细节都搞清楚包括哈希加盐、加密模式、填充方式、字节序这些细节。任何一个环节不对生成的结果就是废的。路线 B在 Node.js 或其他 JS 运行环境中把原始算法代码跑起来。这条路不需要完全理解算法细节只需要解决“代码在非浏览器环境下无法运行”的问题也就是补环境。这两条路线各有优劣实战中我见过不少人选了路线 A 死磕几周最后发现算法里还有一层基于设备信息的动态因子直接放弃也见过有人走路线 B补环境补到崩溃。正确的做法是前期两条腿走路先动态调试摸清算法大致结构然后重点突破补环境在补环境的过程中逐步反推细节。3. 补环境到底在补什么3.1 为什么代码在 Node.js 里跑不起来美团外卖这类前端代码原本是运行在 App 内置浏览器内核WebView或 JavaScriptCore 环境里的。代码运行时大量使用浏览器或 WebView 环境提供的全局对象、DOM API、事件机制。比如最常见的window、document、navigator、locationlocalStorage、sessionStorage、IndexedDBXMLHttpRequest、fetchcanvas 指纹、WebGL 等图形接口各种事件监听机制这些对象在 Node.js 里统统不存在你把代码拿过来直接 node 执行第一行就报错 ReferenceError: window is not defined。补环境就是把代码依赖的这些浏览器环境对象在 Node.js 里手动创建出来并且尽量做到“足够真实”让代码以为自己还在浏览器里。3.2 为什么不能用 jsdom 一把梭很多人第一反应是装个 jsdom 不就行了jsdom 确实实现了大量 DOM 标准但它和真实浏览器环境还是有差异。对于普通网页爬虫jsdom 够用但对于签名算法这种“会把环境指纹作为参与因子”的场景jsdom 往往不够jsdom 的性能开销大跑一遍完整环境要初始化大量 DOM 结构。jsdom 的很多实现和真实浏览器不完全一致比如某些属性的类型、方法挂载位置、toString 输出。算法代码里可能用到 Web API 的细节比如 Canvas 指纹、AudioContext、WebGLjsdom 并不完全支持。另外很多签名算法会有主动的环境检测比如检查某个属性是否存在、类型是否正确、是否可枚举。jsdom 暴露的接口太“标准”反而容易被检测出来。所以在实战中我倾向于用“最小化手写环境 按需补齐”的方式而不是引入一个沉重的 DOM 模拟库。只有代码里真正用到的对象才去补不用的坚决不补这样既省性能也能减少环境指纹破绽。3.3 原型链是补环境的核心支点补环境的核心操作就是“在对象上挂属性、在原型上挂方法”。JavaScript 这门语言对象的属性查找是沿着原型链往上走的。代码里调用 window.navigator.userAgent实际上会发生找到 window 对象没有 navigator 属性。继续找 window 的原型也没有。再往上层找 Object.prototype还是没有。最后报错。所以我们需要在某个环节把 navigator 挂出来。通常的做法是直接定义在 window 上global.window global; window.navigator { userAgent: Mozilla/5.0 ..., platform: Win32, language: zh-CN, ... };但这里有一个更隐蔽的细节。有些代码不是简单地读 window.navigator而是先判断 window 自身属性里有没有 navigator或者通过各种“奇怪”的方式检测。比如const getNavigator window[navig ator]; const nav Object.getOwnPropertyDescriptor(window, navigator);一旦代码用这种检测方式你直接在 window 上挂一个普通对象属性就有可能会露馅。这个时候就需要通过 Object.defineProperty 去定义属性或者通过修改原型链的方式让属性看起来更像原生实现。所以理解原型链的查找机制是补环境的第一课。4. 核心实操从零搭一套可用的补环境框架4.1 先建立一个浏览器环境基座补环境的第一步是建立一个最小的浏览器全局对象基座。我常用的起步模板大概是这样的// env.ts / env.js const env {}; // window 与 global 互通 env.window env; env.globalThis env; env.self env; // 标准内置对象 env.Object Object; env.Array Array; env.JSON JSON; env.Date Date; env.Math Math; // 浏览器常见的全局函数 env.setTimeout setTimeout; env.clearTimeout clearTimeout; env.setInterval setInterval; env.clearInterval clearInterval; env.requestAnimationFrame (cb) setTimeout(cb, 16); env.cancelAnimationFrame (id) clearTimeout(id); // 挂到 Node.js 的全局对象上 global.window env; global.globalThis env; global.self env;这个基座看起来很简单但已经能解决 50% 以上的 “xxx is not defined” 报错。剩下的就是根据报错信息逐个补齐。4.2 常见浏览器对象的模拟写法接下来我会按照报错频率从高到低把常见的模拟对象列出来并解释关键细节。navigator 对象navigator 是访问频率最高的对象也是环境检测的重灾区。你需要尽量完整地模拟它的属性包括 userAgent、platform、language、languages、appName、appVersion、vendor、product、connection 等。const navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, appCodeName: Mozilla, appName: Netscape, appVersion: 5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., platform: Win32, language: zh-CN, languages: [zh-CN, zh], cookieEnabled: true, onLine: true, product: Gecko, productSub: 20030107, vendor: Google Inc., vendorSub: , hardwareConcurrency: 8, maxTouchPoints: 0, webdriver: false, deviceMemory: 8, connection: { effectiveType: 4g, rtt: 50, downlink: 10, saveData: false } };这里有个非常重要的细节很多签名算法会检测 navigator.webdriver。如果这个属性是 true说明当前浏览器正被自动化工具控制签名算法极有可能拒绝生成签名或者生成一个异常值。补环境时一定要把它显式设置为 false。document 对象document 相对复杂因为它涉及 DOM API。对于签名算法来说用到的功能一般集中在document.cookiedocument.createElement用于创建 canvas 等元素document.documentElement用于获取浏览器宽高document.addEventListener / attachEventdocument.referrer / document.title一个简化但够用的 document 模拟可以这样写const document { cookie: , referrer: , title: , readyState: complete, documentElement: { clientWidth: 1920, clientHeight: 1080 }, createElement(tag) { if (tag canvas) { return createCanvasMock(); } return {}; }, addEventListener() {}, removeEventListener() {} };canvas 与 WebGL 指纹这是很多签名算法里最难模拟的一块。canvas 指纹的生成原理是在 canvas 上绘制一段文本通常带特定字体、颜色、背景然后调用 toDataURL 或 getImageData 导出像素数据再算哈希。不同的浏览器渲染引擎字体渲染结果有细微差异这个差异就构成了指纹。模拟 canvas 指纹有两种思路思路一自己构造一个 canvas mock 对象toDataURL 返回一个固定的 base64 字符串。这个方案简单但风险是算法可能在多台设备上跑出一样的值一旦服务端按设备维度分析很容易看出异常。思路二提前在真实浏览器里采集 canvas 指纹数据然后写死在 mock 里。这个方案更接近现实但需要准备多套指纹数据。WebGL 的情况类似主要是通过 WebGLRenderer 获取渲染器名称、硬件信息等。这个可以用固定数据 mock。location 对象location 提供当前 URL 信息。签名算法一般会读取 location.href 或 location.host 作为参考因子。const location { href: https://waimai.meituan.com/, protocol: https:, host: waimai.meituan.com, hostname: waimai.meituan.com, port: , pathname: /, search: , hash: , origin: https://waimai.meituan.com };4.3 原型链修改的通用模式前面说的都是普通对象模拟但有些代码会用到更“原生”的方法比如 Object.prototype.toString.call(window) 应该返回 [object Window]。这就涉及修改原型链。一个常见的模式是用自定义构造函数创建对象然后修改它的 Symbol.toStringTag 属性或者直接修改原型的 toString 方法。// 让 toString 返回 [object Navigator] const Navigator function() {}; Navigator.prototype[Symbol.toStringTag] Navigator; const nav new Navigator(); Object.setPrototypeOf(nav, Navigator.prototype); // 把 navigator 暴露到 window Object.defineProperty(window, navigator, { value: nav, configurable: true, writable: true });另一个常见模式是在模拟对象上挂一个方法但这个方法可能在调用时被检测了 toString 输出。原生函数 toString 输出是以 “function xxx() { [native code] }” 结尾的如果你挂的是一个普通 JS 函数toString 输出会暴露其实现细节。解决办法有两种用 Function.prototype.toString 的覆写能力Function.prototype.toString function() { if (this.__isNativeMock__) { return function ${this.name || native}() { [native code] }; } return Function.prototype.toString.call(this); };更简单粗暴的做法是直接在模拟对象的原型上重新定义 toString让它返回原生格式。这些操作本质上都是在改原型链。理解这个逻辑之后你会发现补环境的本质就是在外人看来你的环境对象和真实浏览器应该有一模一样的“长相”和“指纹”。4.4 从报错到补全的迭代循环补环境不是一蹴而就的是一个反复迭代的过程。我习惯的做法先用 node 跑目标代码看看第一个报错是什么。根据报错在环境基座上补上缺失对象。再跑看下一个报错。重复以上过程。这个循环看起来很蠢但却是最有效的。因为代码的报错顺序往往就是它的执行顺序你补环境的过程就是在模拟它的完整执行路径。有一个小技巧在环境代码里加上日志打印每次对象访问的 key。比如用 Proxy 包一层在 get 和 set 时输出日志。这样你就能看到代码到底访问了哪些属性而不是靠猜。function logAccess(obj, path) { return new Proxy(obj, { get(target, prop) { console.log([get] ${path}.${String(prop)}); return Reflect.get(target, prop); }, set(target, prop, value) { console.log([set] ${path}.${String(prop)} ${value}); return Reflect.set(target, prop, value); } }); }我给这个技巧起了个名字环境探针。它能让你从“黑盒猜补环境”变成“白盒精准补环境”效率翻倍。5. 深入原理为什么环境检测这么难绕过5.1 环境指纹的“面数”签名算法里的环境检测核心理念是“采集很多个环境特征组成一个高维指纹”然后把指纹哈希进签名里。单个属性容易模拟但几百个属性的组合就很难全部模拟到位。这些特征包括但不限于User-Agent 字符串屏幕分辨率、色深、窗口尺寸浏览器语言、时区Canvas 像素指纹WebGL 渲染器名称字体列表设备内存、CPU 核心数电池状态触控支持情况AudioContext 指纹每个特征之间的关联性也很重要。比如如果你的 User-Agent 是 Win10 桌面版 Chrome但 maxTouchPoints 是 10触屏设备的特征这就是一个明显的自相矛盾服务端很容易识别出来。补环境必须关注特征的“自洽性”而不能只盯着单个对象。5.2 原型链检测的常见手段前面说了原型链是核心支点反过来检测方也会在原型链上做文章。常见的检测手段有检查属性是自有属性还是来自原型链。检查属性的描述符descriptor比如 enumerable、configurable、writable 是否和原生环境一致。用 Object.getOwnPropertyNames 枚举对象的所有自有属性看是否出现异常属性。用 Proxy 包裹环境对象追踪代码对属性的访问顺序判断是否有“本来不该被访问到”的属性暴露出来。所以在补环境时我建议不要把环境对象做成一个“巨大的垃圾桶”什么属性都往上放。每加一个属性都要问自己一句这个属性在真实浏览器里真的存在吗它的描述符是什么样的5.3 动态与静态检测的结合分析签名算法的环境检测往往分两层静态层在 JS 代码执行前先跑一段环境检测逻辑收集环境指纹。动态层在签名生成过程中再次读取环境信息和静态层的结果做交叉验证。如果两次检测结果不一致比如第一次模拟得好好的第二次被篡改了就会判定为可疑环境。这就是为什么补环境不能只补一次。你需要确保环境对象在整个 JS 执行周期内是稳定的且不能有明显的“被修改”痕迹。建议在初始化环境之后用 Object.freeze 或 Object.defineProperty 把关键对象锁死防止代码运行中误改或者检测方篡改。6. 常见问题与排查技巧实录6.1 报错“xxx is not defined”却搜不到这个 x这种情况通常是代码里用了动态变量名或字符串拼接。比如const api window[env ironment];你搜“environment”搜不到因为代码是拼接出来的。解决办法就是打日志看具体访问路径。6.2 签名永远校验失败但代码没有报错这是最让人崩溃的情况。代码能跑通、签名能生成但服务端就是不认。原因大概率出在环境指纹不一致比如 UA 和 Canvas 指纹来自不同的设备。参与签名的参数有遗漏比如某个 header、时间戳、随机数没有正确带上。算法内部有动态因子比如基于设备 ID、安装时间、缓存数据生成的 token没有被正确初始化。排查顺序建议是先比对请求参数和抓包样本是否一致再检查环境指纹自洽性最后逐步 hook 算法内部中间值。6.3 Node.js 运行时间异常有些签名算法会加入“时间锁”或者“执行时间检测”。如果算法在补环境环境下执行时间比真实环境长太多就会触发超时保护。解决办法是优化环境基座减少不必要的 Proxy 和日志输出。这也是为什么我建议正式跑签名时把探针日志关掉。6.4 常见问题速查表现象可能原因排查方法window is not defined基座未初始化检查 global.window 赋值navigator.toJSON is not a functionnavigator 模拟太粗糙补上 toJSON 方法canvas.toDataURL is not a functioncanvas mock 不完整补 canvas 绘图 APICannot read property appendChild of undefineddocument.body 缺失补 body 相关 DOM 结构签名被拒绝环境指纹自洽性不足对齐 UA、canvas、webgl 数据签名被拒绝请求参数不完整比对 mtgsig 生成时的入参执行超时探针日志导致性能下降关闭 Proxy 日志6.5 一个实战案例的复盘我之前研究过一个类似签名当时卡了整整三天。现象是代码在浏览器里一切正常但搬到 Node.js 补完环境后签名值跟浏览器里的完全不同。后来我用探针日志一跑发现代码在签名前读取了 document.cookie。我一开始没补 cookie结果它拿到的是 undefined拼接进加密串后签名自然对不上。解决了这个之后又发现代码读取了 performance.now() 来生成随机数而 Node.js 的性能计时起点和浏览器完全不同。我把 performance 对象补上、对齐时间戳后签名终于稳定了。这给我一个很大的教训不要忽略任何一个小对象。签名算法里的每一个输入都可能成为左右结果的关键变量。7. 工具选型与效率提升建议7.1 Frida 与 Node.js 的配合逆向定位阶段Frida 几乎是标配。它可以实时 hook Java 层和 Native 层函数打印参数、返回值、调用栈。特别是在定位 so 层签名入口时Frida 能直接把 JNI 参数和返回结果捞出来省去大量静态逆向时间。补环境阶段Node.js 是主力。配合 v8 引擎和 Chrome DevTools 协议可以在 Node.js 里跑浏览器代码并且 debug 起来也很方便。Node.js 还有一个好处部署在服务器上时几乎零依赖不像 Puppeteer 那样要拉起一个完整浏览器。7.2 高效迭代的工程化方法补环境做久了你会发现大部分代码逻辑是可复用的。我经常建议朋友维护一套私有的“环境基座代码”里面包含了导航器、文档、画布、WebGL、地理位置等常见对象的完整模拟并且通过配置文件切换不同指纹组合。这样做的价值在于每次遇到新的签名算法你不需要从零开始补环境只需要跑目标代码看报错按需扩展基座即可。长期积累下来补一套环境的效率可以提升一个量级。7.3 不要放弃堆栈信息很多人在补环境报错时第一反应是看错误信息但忽略了堆栈。其实堆栈信息非常关键它能告诉你代码从哪个函数走进了哪个函数。结合源码搜索你往往能在几秒钟内定位到具体是哪个模块需要补环境而不是满世界猜。8. 最后一些经验做逆向工程和补环境这几年我最大的感触是技术本身是中性的关键在于人怎么用它。mtgsig 这类签名方案本质上是平台保护自身数据和服务的一种手段。研究它的攻防可以帮助安全工程师更好地理解客户端防护的弱点也可以帮助平台方持续改进风控策略。如果你是因为工作需要或者纯粹出于技术好奇心来做这件事那我很支持但如果你是想着怎么绕过风控搞爬虫、刷单、薅羊毛那我建议你收敛一下。这类行为的法律风险远高于收益尤其是近几年黑灰产打击力度持续加大一旦踩线后果可能不只是封号那么简单。回到技术上补环境的方法论在别的场景也很有用。比如你接了一个老旧的 Web 项目需要在 Node.js 里做服务端渲染但代码里全是浏览器全局依赖又比如你想在 Node.js 里跑一些前端加密 SDK用来做自动化测试。这些场景下掌握原型链补环境的思路都能让你的工作效率提升不少。如果你正准备开始研究 mtgsig我的建议是从我上面讲的最小基座开始先跑通“环境探针 → 补齐对象 → 生成签名”的基本链路再逐步处理环境指纹的一致性问题。别一开始就追求完整复刻浏览器环境那样只会把自己淹没在细节里。一步步来把这个“猜谜游戏”玩明白你会发现自己对 JavaScript 原型链的理解会有一个质的飞跃。
返回列表