逆向闲鱼App签名算法:HmacSHA256加密与frida动态Hook实战
1. 项目概述与核心挑战最近在做一个数据采集项目需要从闲鱼上抓取一些商品信息。一开始我以为这活儿挺简单毕竟闲鱼是个公开的App数据应该不难拿。但真正上手写代码才发现闲鱼的反爬虫机制比想象中要复杂得多尤其是那个签名算法和时间戳校验直接把我给拦在了门外。如果你也打算写闲鱼爬虫或者对如何逆向分析移动端App的API接口感兴趣那这篇实战总结或许能帮你少走不少弯路。这篇文章主要面向有一定Python和网络基础想挑战复杂反爬虫场景的开发者。我会详细拆解我是如何定位、分析并最终绕过闲鱼的关键校验机制的并附上可以直接运行的Python代码。整个过程就像一场“攻防战”充满了技术细节和思考咱们一步步来。闲鱼作为一款国民级二手交易应用其数据价值不言而喻无论是做市场分析、价格监控还是其他研究都很有吸引力。然而它的接口防护也相当严密。最核心的难点在于它的API请求不是简单的GET或POST一个URL就能完成的。每个请求都必须携带几个关键的参数其中最重要的是sign签名和timestamp时间戳。服务器会校验这些参数的合法性如果校验失败直接返回错误你连一个字节的数据都拿不到。这和我们常见的、通过分析网页HTML结构的爬虫思路完全不同它要求我们必须深入到App的网络请求层面去理解其客户端与服务器通信的“暗号”。2. 逆向工程前的准备工作与环境搭建2.1 核心工具链选型与配置工欲善其事必先利其器。逆向分析App的API我们需要一套趁手的工具。经过对比和实测我最终确定了以下工具链它们各自扮演着不可替代的角色抓包工具Charles/Fiddler作用这是我们的“眼睛”用于捕获手机App发出的所有HTTP/HTTPS请求和响应。我选择Charles因为它对HTTPS流量的解密支持非常友好界面也清晰。配置要点在电脑上安装Charles并启动。手机和电脑需要在同一Wi-Fi网络下。在手机Wi-Fi设置中手动配置代理服务器地址为电脑的IP端口为Charles默认的8888。最关键的一步在手机浏览器中访问chls.pro/ssl下载并安装Charles的根证书。对于iOS还需要在“设置”-“通用”-“关于本机”-“证书信任设置”中完全信任该根证书。这一步是为了让Charles能够解密HTTPS流量否则你看到的全是乱码。逆向分析工具jadx-gui作用这是我们的“手术刀”用于反编译闲鱼App的Android安装包APK将其中的Java/Kotlin代码还原成可读性较高的形式。我们最终的目标签名算法很可能就藏在混淆后的Java代码里。使用直接从GitHub下载release版本打开后把闲鱼的APK文件拖进去即可。它会自动进行反编译和代码分析。动态调试工具frida作用这是我们的“X光机”和“遥控器”。当静态分析代码逻辑过于复杂或混淆严重时frida可以动态注入JavaScript脚本到运行中的App进程实现Hook钩子函数、打印参数、修改返回值等操作。这对于验证签名算法的计算逻辑至关重要。环境搭建电脑端pip install frida-tools。手机端需要一台已Root的Android手机或者使用模拟器如夜神、雷电并开启Root权限。然后在设备上安装对应架构的frida-server并运行。开发环境Python 3.8作用最终实现爬虫脚本。我们将用requests库模拟请求用json处理数据。库安装pip install requests注意整个逆向分析过程需要耐心和细心。抓包和逆向APK本身不违法但获取的数据必须用于合法合规的个人学习与研究目的绝对禁止用于大规模爬取、商业用途或对闲鱼服务器造成压力。在开始前请务必阅读并理解闲鱼的robots.txt协议虽然App接口不遵循此协议但体现了平台态度和用户协议尊重平台规则。2.2 目标接口的定位与初步分析环境准备好后我们开始“侦查”。打开手机上的闲鱼App进行我们想要爬取的操作比如搜索某个关键词“iPhone 13”。开启Charles抓包清空之前的记录。在闲鱼App中执行搜索。观察Charles的请求列表。你会看到大量请求我们需要找到那个返回商品列表数据的核心接口。通常这类接口的URL会包含明显的路径比如/search/、/item/等并且返回的数据是JSON格式。筛选和定位通过查看Response的内容类型Content-Type: application/json和预览返回数据我定位到了类似https://acs.m.taobao.com/gw/mtop.taobao.idle.search/1.0/这样的接口。这就是我们的目标。初步分析请求参数 查看这个请求的Query String或Form Data你会发现一堆参数例如api: mtop.taobao.idle.search v: 1.0 ttid: 10003355xxxx_android_10.10.10 data: {key:iPhone 13,page:1,...} // 实际的搜索条件经过URL编码 t: 1646123456789 // 一个13位的时间戳毫秒 sign: v1a1b2c3d4e5f6... // 一串长长的、看起来像加密后的字符串这里t就是时间戳sign就是签名。我们的任务就是搞清楚这个sign是怎么由其他参数特别是data和t计算出来的。3. 签名算法逆向分析与破解这是整个项目最硬核、也最有趣的部分。我们无法直接从服务器拿到算法只能从客户端App入手。3.1 静态代码分析寻找线索用jadx-gui打开闲鱼的APK文件。由于代码经过了混淆类名和方法名都变成了a,b,c这种无意义的字符直接搜索“sign”可能结果太多。技巧1从网络库入手。大型App通常会使用统一的网络请求库签名逻辑往往封装在其中。在闲鱼中阿里系应用广泛使用mtopSDK。我们可以尝试搜索“mtop”、“sign”、“签名”等中文或拼音关键词。有时关键的映射文件mapping可能没有被完全混淆会留下一些线索。技巧2搜索关键常量。在抓包中我们看到签名以v1开头。在jadx中搜索这个字符串v1可能会直接定位到生成签名的代码附近。技巧3分析参数结构。观察请求参数如api,v,data,t等都是生成签名的可能输入。在代码中搜索这些字段名如“api”、“data”、“t”看看它们在哪里被组装和处理。经过一番搜索和跟踪调用关系我最终定位到了一个疑似进行签名计算的方法。它的内部可能调用了MessageDigestMD5、SHA、MacHmac等Java加密类并且方法的输入参数中包含了我们看到的那些请求参数。3.2 动态Hook验证让代码自己“说话”静态分析看到的是“死”代码逻辑可能很绕。这时就需要frida出场进行动态验证。我们的思路是Hook住我们怀疑的那个签名计算方法当App发起请求时这个方法会被调用我们的脚本就能打印出它的输入参数和返回值。下面是一个简化的frida脚本示例用于Hook一个名为a的类中的b方法实际类名方法名需要替换为你分析出来的Java.perform(function () { // 替换成你分析出的实际类名和方法名 var targetClass Java.use(com.xxx.xxx.a); var targetMethod targetClass.b.overload(java.lang.String, java.lang.String, java.lang.String); // 根据方法签名重载 targetMethod.implementation function (arg1, arg2, arg3) { console.log(\n[] Hook到签名方法被调用); console.log( 参数1: arg1); // 可能是拼接后的参数字符串 console.log( 参数2: arg2); console.log( 参数3: arg3); // 调用原方法获取结果 var result this.b(arg1, arg2, arg3); console.log( 返回值(sign): result); // 把结果返回不影响App正常运行 return result; }; });操作步骤将上述脚本保存为hook_sign.js。在电脑上启动fridafrida -U -f com.taobao.idlefish -l hook_sign.js(假设闲鱼包名是com.taobao.idlefish)。操作手机闲鱼App进行搜索。观察电脑终端输出。如果Hook成功你就能清晰地看到生成签名所需的原始字符串arg1, arg2...和计算出的签名结果result。关键发现通过多次Hook不同请求搜索不同关键词、翻页对比输入和输出我发现了规律。签名算法并不是简单的MD5而是一个类似HmacSHA256的算法。其输入通常是一个按特定规则如字母序排序并拼接所有请求参数包括api,v,data,t等后形成的字符串然后用一个密钥Secret进行加密。这个密钥是硬编码在App中的另一个常量。3.3 算法还原与Python实现一旦通过动态Hook拿到了清晰的输入输出对甚至看到了密钥还原算法就水到渠成了。假设我们分析出的逻辑是将请求参数不包括sign本身按键名按字母顺序排序。将所有键值对拼接成key1value1key2value2...的格式。将拼接后的字符串与一个固定密钥secret使用HmacSHA256算法进行计算。将计算结果进行Base64编码或者转换为十六进制字符串并在前面加上v1前缀。那么Python实现就非常简单了import hashlib import hmac import time import urllib.parse def generate_sign(params, secret): 生成闲鱼API签名 :param params: dict, 请求参数不包括sign :param secret: str, 从App中逆向出的密钥 :return: str, 计算得到的sign值 # 1. 参数按Key排序 sorted_params sorted(params.items(), keylambda x: x[0]) # 2. 拼接成 keyvalue 格式 str_to_sign .join([f{k}{v} for k, v in sorted_params]) # 3. 使用HmacSHA256计算签名 hmac_obj hmac.new(secret.encode(utf-8), str_to_sign.encode(utf-8), hashlib.sha256) # 4. 获取十六进制摘要并添加前缀根据实际情况调整 sign v1 hmac_obj.hexdigest() return sign # 示例使用 secret_key YOUR_REVERSED_SECRET # 这里需要替换成你逆向出来的真实密钥 params { api: mtop.taobao.idle.search, v: 1.0, data: {key:iPhone 13,page:1}, # 注意data本身是个JSON字符串 t: str(int(time.time() * 1000)), # 当前13位时间戳 ttid: 10003355xxxx_android_10.10.10, # ... 其他必要参数 } signature generate_sign(params, secret_key) print(f生成的签名: {signature})实操心得密钥secret是核心机密不同版本的App可能会更换。上述算法描述和代码是一个高度简化的示例真实情况可能更复杂可能涉及多个密钥、对data字段的特殊处理如先MD5、或者额外的盐值salt。必须通过你的动态Hook结果来精确还原。此外ttid、uid等设备/用户标识参数也可能参与签名需要一并收集。4. 完整爬虫代码实现与关键逻辑掌握了签名生成编写爬虫就变成了标准的HTTP请求操作。下面是整合了签名生成、请求发送和数据处理的核心代码框架。4.1 核心请求类封装import requests import json import time from urllib.parse import quote_plus # 假设我们已经有了上面定义的 generate_sign 函数和正确的 secret_key class XianYuSpider: def __init__(self): self.session requests.Session() # 设置合理的请求头模拟Android闲鱼客户端 self.headers { User-Agent: MTOPSDK/3.1.1.7 (Android;11;Xiaomi M2007J3SC), Content-Type: application/x-www-form-urlencoded;charsetUTF-8, Host: acs.m.taobao.com, } self.base_params { api: mtop.taobao.idle.search, v: 1.0, ttid: 10003355xxxx_android_10.10.10, # 需要根据实际情况调整 appKey: xxxx, # 可能需要根据抓包结果定 data: , t: , sign: , } self.secret YOUR_REVERSED_SECRET_HERE # 核心密钥 def _make_params(self, search_keyword, page1): 构造请求参数字典 # 构造data字段的JSON data_dict { key: search_keyword, page: page, pageSize: 20, # 通常一页20条 sortType: default, # 排序方式 # 可能还有其他固定参数从抓包中复制 } # 将data字典转为JSON字符串并进行URL编码重要 data_json json.dumps(data_dict, separators(,, :), ensure_asciiFalse) encoded_data quote_plus(data_json) params self.base_params.copy() params[data] encoded_data params[t] str(int(time.time() * 1000)) # 当前时间戳 # 生成签名注意签名时可能使用未编码的data_json也可能用encoded_data需根据Hook结果确定 # 这里假设签名使用未编码的原始参数字符串不含sign自身 sign_params params.copy() # 通常sign参数本身不参与签名计算所以先删除 if sign in sign_params: del sign_params[sign] # 注意这里传递给generate_sign的params字典其data字段的值是encoded_data还是data_json必须与Hook观察到的逻辑一致 sign generate_sign(sign_params, self.secret) params[sign] sign return params def search(self, keyword, max_pages5): 执行搜索爬取多页数据 all_items [] for page in range(1, max_pages 1): print(f正在爬取第 {page} 页...) try: params self._make_params(keyword, page) # 注意闲鱼接口通常是POST请求参数放在Form Data中 resp self.session.post( https://acs.m.taobao.com/gw/mtop.taobao.idle.search/1.0/, dataparams, headersself.headers, timeout10 ) resp.raise_for_status() # 检查HTTP错误 result resp.json() # 解析返回的JSON提取商品列表 if result.get(ret) and isinstance(result.get(ret), list) and SUCCESS in result[ret][0]: data_field result.get(data, {}) item_list data_field.get(resultList, []) if not item_list: print(f第 {page} 页无数据可能已到底部。) break all_items.extend(item_list) print(f 获取到 {len(item_list)} 条商品。) # 礼貌性延迟避免请求过快 time.sleep(2) else: print(f请求失败或返回错误: {result.get(ret, 未知错误)}) print(f响应内容: {result}) break # 如果签名或参数错误通常会在这里返回失败 except requests.exceptions.RequestException as e: print(f网络请求异常: {e}) break except json.JSONDecodeError as e: print(fJSON解析异常: {e}) print(f原始响应: {resp.text[:500]}) break return all_items def parse_item(self, item): 解析单个商品信息按需提取字段 # 这是一个示例解析函数实际字段需要根据返回的JSON结构调整 return { item_id: item.get(itemId), title: item.get(title), price: item.get(price, {}).get(priceText), # 价格可能是一个对象 location: item.get(location), seller_nick: item.get(seller, {}).get(nick), view_count: item.get(viewCount), detail_url: item.get(detailUrl), } # 使用示例 if __name__ __main__: spider XianYuSpider() keyword 机械键盘 items spider.search(keyword, max_pages3) print(f\n总共爬取到 {len(items)} 条商品信息。) for item in items[:3]: # 打印前3条看看 parsed spider.parse_item(item) print(parsed)4.2 时间戳校验的应对策略你可能注意到我们在_make_params方法里生成了一个当前毫秒时间戳t。服务器会校验这个时间戳如果与服务器时间相差太大比如超过几分钟请求也会被拒绝。这防止了请求被无限重放。应对策略很简单使用当前的真实时间戳即可。Python的time.time() * 1000就能得到13位的毫秒时间戳。只要你的系统时间基本准确就不会有问题。不需要去“绕过”这个校验因为它本身是一个合理的、需要被满足的约束条件。重要注意事项密钥与算法版本secret和签名算法如v1还是v2可能随App版本更新而改变。你的爬虫可能需要定期维护。参数完整性务必确保构造请求的参数与抓包看到的完全一致包括看似可有可无的字段。少一个参数都可能导致签名错误。编码问题data字段的JSON字符串是否需要URL编码、编码前后哪个参与签名必须通过Hook精确验证这里是常见的坑点。请求头User-Agent、Host等请求头也很重要尽量模拟原App的请求。请求频率务必添加延迟如time.sleep(2)控制爬取速度这是对目标网站最基本的尊重也能避免你的IP被快速封禁。5. 常见问题排查与实战技巧即使按照上述步骤操作在实际编写和运行爬虫时你仍可能会遇到各种问题。下面是我在实战中遇到的一些典型问题及解决方法。5.1 签名验证失败 (ret: [“FAIL_SYS_TOKEN_EXOIRED::令牌过期”] 或类似)这是最常见的问题意味着服务器认为你的sign不合法。排查步骤检查密钥确认你使用的secret是否正确且是最新的。重新Hook验证一次。检查参数顺序确认参与签名计算的参数字符串拼接顺序是否与App内一致通常是字母序。检查参数完整性对比你的参数字典和抓包看到的参数是否遗漏了某个参数如appKey、sid等。特别注意有些参数可能只在首次请求或特定条件下出现需要动态获取。检查编码确认data字段在签名前是原始JSON字符串还是URL编码后的字符串。用frida脚本打印出App内部用于计算签名的原始字符串与你代码中拼接的字符串进行逐字对比。检查算法确认使用的哈希算法HmacSHA256和输出格式十六进制/Base64是否正确。5.2 返回数据为空或只有少量数据可能原因data参数构造错误搜索关键词、分页参数page,pageSize、排序参数sortType等未按接口要求构造。仔细检查抓包中data字段的JSON结构。IP或设备标识被限制如果请求过于频繁服务器可能暂时限制当前IP或设备标识ttid、uid等。解决方案是降低请求频率增加随机延迟或者考虑使用代理IP池需谨慎合法使用。接口已更新目标接口的路径或参数可能已经变更。重新抓包确认最新的接口地址和参数格式。5.3 请求被重定向或返回非JSON数据可能原因请求头不完整缺少必要的请求头如Referer、X-Requested-With等。尽量完整复制抓包中的请求头。Cookie或Session某些接口可能需要登录态。检查抓包请求是否带有Cookie。如果爬虫需要登录后才能访问的数据你需要先模拟登录流程获取有效的Cookie并在requests.Session()中保持。闲鱼的登录逆向极其复杂涉及多个加密步骤这通常是另一个更大的挑战。5.4 动态Hook时frida连接失败或脚本不生效排查步骤设备Root/ADB权限确保Android设备已Root且frida-server已成功运行adb shell后输入su然后运行/data/local/tmp/frida-server 。进程名使用frida-ps -U确认闲鱼App的正确进程名。有时App有多个进程需要Hook主进程。脚本语法检查JavaScript脚本语法特别是Java方法签名overload是否正确。混淆后的方法可能有多个重载需要指定正确的参数类型。反调试/反Hook一些加固后的App会检测frida等调试工具。这可能需要进行更复杂的对抗如隐藏frida特征、使用定制版的frida等这属于更高级的逆向范畴。5.5 法律与道德风险再强调我必须再次强调技术探索的边界是法律和道德。遵守robots.txt与服务条款虽然robots.txt主要针对搜索引擎但它表明了网站运营者对爬虫的态度。大规模、高频的爬取行为很可能违反闲鱼的用户协议。限制爬取速度与数量务必在代码中设置足够的延迟例如每页请求间隔3-5秒并且只爬取必要的最小数据量。不要试图在短时间内抓取海量数据。数据用途将获取的数据仅用于个人学习、研究或非商业的统计分析。绝对禁止用于任何商业竞争、骚扰用户、发布到其他平台等用途。尊重隐私如果爬取到用户昵称、头像等个人信息应妥善处理避免公开传播。逆向工程和爬虫开发是锻炼技术能力的绝佳途径它要求你对网络协议、编程语言、加密算法和系统运行原理有深入的理解。通过这个闲鱼爬虫项目我们不仅学会了一套具体的绕过签名校验的方法更重要的是掌握了一套“定位问题 - 静态分析 - 动态验证 - 代码还原”的通用技术研究思路。这套思路可以应用到分析其他App或网站的加密逻辑上。最后请始终牢记技术是一把双刃剑用它来学习和创造而不是破坏和掠夺。在实际操作中如果遇到无法解决的问题多看看网络上的技术社区分享有时候一个关键的提示就能打开思路。