1. 项目概述与核心目标
最近在研究移动端的数据抓取和接口分析,发现很多主流App,尤其是像今日头条这样体量的应用,对核心业务接口的保护越来越严密。传统的抓包工具往往只能看到一堆加密的乱码,这让很多数据分析、竞品研究或者自动化脚本的开发者感到头疼。我手头正好有一台越狱的iPhone,就想着能不能以“今日头条”的搜索接口为靶子,完整走一遍iOS逆向分析流程,把它的加密逻辑给扒出来。这个项目的目的很纯粹:不是教你做坏事,而是通过一个真实的、有代表性的案例,掌握一套分析iOS应用网络通信加密的通用方法论。无论你是安全研究员、爬虫工程师,还是对移动端底层原理感兴趣的开发者,这套从环境搭建、静态分析到动态调试的完整思路,都能让你在面对一个“黑盒”App时,知道从哪里下手。
整个实战会围绕“搜索”这个高频功能展开。当你打开今日头条App,在顶部的搜索框输入关键词时,App会向服务器发送一个请求来获取搜索结果。我们的目标就是拦截这个请求,搞清楚它发送的数据(比如搜索词、页码、设备信息等)是如何被加密的,以及服务器返回的数据又是如何被解密的。最终,我们希望能在电脑上用Python或任何其他语言,模拟出这个完整的加密解密过程,从而能够脱离App本身,自由地调用这个搜索接口。这个过程会涉及到越狱环境配置、砸壳获取可分析二进制文件、静态分析寻找关键函数、以及使用Frida进行动态Hook验证等多个环节,我会把每个环节的细节、踩过的坑和心得都详细记录下来。
2. 逆向环境与工具链搭建
工欲善其事,必先利其器。iOS逆向对环境的依赖度比较高,一个稳定、齐全的工具链是成功的一半。下面我会分步骤说明如何搭建这个“作战指挥部”。
2.1 设备与系统准备
首先,你需要一台已越狱的iOS设备。这是iOS逆向的物理基础。我使用的是iPhone 7,系统是iOS 14.4。选择这个版本是因为其越狱方案(如unc0ver)相对成熟稳定。不建议使用最新版本的iOS,因为越狱工具和兼容的逆向工具往往有滞后性。设备越狱后,最重要的就是安装包管理器Cydia(或它的现代替代品,如Sileo)。通过Cydia,我们可以安装后续所需的各种命令行工具和框架。
在电脑端,我使用的是macOS系统,因为很多iOS开发工具链(如Xcode命令行工具)在macOS上集成得最好。Windows用户也可以通过虚拟机安装macOS,或者尝试一些跨平台的替代工具,但可能会遇到更多环境配置问题。
2.2 核心工具安装与配置
接下来,通过Cydia在越狱设备上安装以下核心工具:
- OpenSSH:允许我们通过SSH协议从电脑远程登录到iPhone,在终端里执行命令。安装后务必立即修改默认密码(
alpine),否则你的设备在联网状态下将极度不安全。 - Frida:这是我们本次实战的“王牌”。它是一个动态插桩工具,可以让我们在App运行时,注入自己的JavaScript脚本,来Hook(挂钩)特定的函数,查看、修改参数和返回值。在Cydia中添加Frida的官方源(
https://build.frida.re),然后安装Frida包。 - Cydia Substrate(或它的继任者libhooker,取决于你的越狱工具):这是许多注入工具(包括老版本的Frida)的底层框架。虽然新版Frida可以独立工作,但安装它能确保更好的兼容性。
- adv-cmds:包含
ps等命令,方便查看系统进程。
在电脑(macOS)上,我们需要安装:
- Python 3:通过Homebrew安装即可:
brew install python3。 - Frida客户端:在电脑终端执行
pip3 install frida-tools。安装完成后,运行frida-ps -U命令,如果能看到你手机上的进程列表,说明电脑与手机的Frida连接成功。 - 砸壳工具:从App Store下载的App都是经过苹果加密的(俗称“加壳”),我们无法直接分析。需要砸壳。推荐使用
frida-ios-dump。这是一个基于Frida的砸壳脚本。将其克隆到本地:git clone https://github.com/AloneMonkey/frida-ios-dump.git,然后按照其README安装依赖(主要是pip3 install -r requirements.txt)。之后需要配置dump.py脚本中的SSH连接信息(你的手机IP和密码)。 - 反汇编与静态分析工具:
- Hopper Disassembler或IDA Pro:强大的反汇编工具,可以将二进制文件转换成可读的汇编代码,并尝试生成伪代码。Hopper有体验版,对于初学者足够。
- class-dump:专门用于导出iOS应用中Objective-C类的头文件。这对于快速了解App的类结构非常有帮助。通过Homebrew安装:
brew install class-dump。 - MachoOView:一个查看Mach-O文件(iOS可执行文件格式)结构的图形化工具,可以方便地查看加密状态、加载命令等信息。
注意:环境搭建过程可能因iOS版本和越狱工具不同而略有差异。如果遇到问题,多查阅相关工具在GitHub上的Issue页面,通常都能找到解决方案。一个常见的坑是Frida连接失败,请确保手机和电脑在同一个局域网,并且电脑端的Python环境是Python 3。
3. 目标应用分析与砸壳
环境准备好后,我们正式向“今日头条”App开刀。第一步是获取一个可以静态分析的可执行文件。
3.1 定位与砸壳操作
首先,在越狱手机上打开今日头条App。然后,在电脑终端通过SSH连接到手机:ssh root@[你的手机IP]。连接成功后,输入ps -A | grep News(今日头条的Bundle Identifier通常包含News)来找到今日头条的进程ID(PID)。记下这个PID。
接着,我们使用之前配置好的frida-ios-dump进行砸壳。在电脑的frida-ios-dump目录下,运行python3 dump.py [PID]。脚本会自动完成内存dump和修复,最终在当前目录生成一个名为[App名称].ipa的文件。这个.ipa文件本质上是一个压缩包,解压后,在Payload/[App名称].app/目录下,就能找到我们梦寐以求的、已砸壳的主二进制可执行文件(通常名字就是App的名称)。
如何验证砸壳成功?用MachoOView打开这个二进制文件,查看Load Commands部分,找到LC_ENCRYPTION_INFO或LC_ENCRYPTION_INFO_64。如果其中的CryptID字段值为0,恭喜你,砸壳成功。如果为1,则表示仍然加密,需要检查砸壳过程。
3.2 初步静态分析:类结构探查
拿到砸壳后的二进制文件,先不急着深入汇编代码。我们可以用class-dump来快速窥探一下App的内部类结构,这能给我们提供宝贵的方向。
在终端执行:class-dump -H [二进制文件路径] -o [输出头文件目录]。这个命令会将二进制文件中所有Objective-C类的头文件导出到指定目录。浏览这些头文件,我们可以搜索与“搜索”、“网络”、“加密”、“请求”相关的关键词,如Search,Request,API,Encrypt,Decrypt,HTTP,Manager等。
例如,我们可能会发现名为TTSearchRequestModel、TTNetworkManager、TTCryptoUtility之类的类。这些类名就像地图上的路标,为我们后续的逆向分析指明了潜在的关键区域。尤其是那些看起来像是管理网络请求或执行加密解密的类,需要重点标记。
4. 动态分析与加密逻辑定位
静态分析提供了线索,但加密逻辑往往在运行时才完全展现。动态分析是我们破解加密的核心手段,Frida在这里大显神威。
4.1 抓包确定目标接口
首先,我们需要知道今日头条搜索接口的具体地址和大致参数结构。在电脑上启动抓包工具(如Charles或mitmproxy),并在手机上配置好代理。然后,在今日头条App内进行一次搜索操作。
抓包结果可能会显示一个类似https://is.snssdk.com/api/search/...的请求。查看其请求体(Request Body),你很可能看到的不是明文的keyword=xxx,而是一串看似随机的Base64编码字符串,或者直接是二进制数据。同时,请求头(Headers)里可能会包含一些自定义字段,如X-SS-STUB、X-Gorgon、X-Khronos等,这些通常是签名或加密相关的参数。我们的目标就是找出生成这个加密请求体和这些神秘请求头的代码在哪里。
4.2 使用Frida进行函数Hook
假设我们从抓包看到请求体是Base64编码的,解密后可能是一个JSON。那么,在App中,必然有一个函数在发送请求前,将原始的搜索参数(字典或对象)转换成了这个Base64字符串。同样,也必然有函数在生成X-Gorgon这类签名。
我们可以利用Frida来Hook那些常见的、用于Base64编码和网络请求的函数。
第一步:编写Frida脚本创建一个名为search_hook.js的文件,内容如下:
// 首先,Hook常见的Base64编码方法 var base64Encode = ObjC.classes.NSString['- base64EncodedString']; if (base64Encode) { Interceptor.attach(base64Encode.implementation, { onEnter: function(args) { // args[0]是self,args[1]是selector,args[2]才是函数参数(如果有) // 这个函数没有显式参数,但我们可以打印调用栈和self的内容 console.log(`[+] NSString base64EncodedString called!`); // 打印调用栈,帮助定位上层函数 console.log(`Backtrace:\n${Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n')}`); // 尝试打印self的内容(原始的NSString) var selfStr = ObjC.Object(args[0]).toString(); console.log(`Self (input string): ${selfStr.substring(0, 100)}...`); // 只打印前100字符 }, onLeave: function(retval) { // retval是编码后的Base64字符串 var encodedStr = ObjC.Object(retval).toString(); console.log(`Return (encoded string): ${encodedStr.substring(0, 100)}...`); } }); } // 其次,Hook网络层。今日头条很可能使用AFNetworking或自研网络库。 // 我们可以尝试Hook NSURLSession的任务创建或请求设置方法。 var NSURLSession = ObjC.classes.NSURLSession; // 尝试Hook一个常用的创建任务的方法,例如 dataTaskWithRequest: if (NSURLSession && NSURLSession['- dataTaskWithRequest:completionHandler:']) { var dataTaskWithRequest = NSURLSession['- dataTaskWithRequest:completionHandler:']; Interceptor.attach(dataTaskWithRequest.implementation, { onEnter: function(args) { // args[0]: self, args[1]: _cmd, args[2]: request, args[3]: completionHandler var request = new ObjC.Object(args[2]); var url = request.URL(); var headers = request.allHTTPHeaderFields(); var httpBody = request.HTTPBody(); // 过滤出搜索相关的请求 if (url.toString().indexOf('search') !== -1) { console.log(`\n========== 捕获到搜索请求 ==========`); console.log(`URL: ${url}`); console.log(`Headers: ${JSON.stringify(headers)}`); if (httpBody) { var bodyStr = ObjC.classes.NSString.alloc().initWithData_encoding_(httpBody, 4); // 4 for NSUTF8StringEncoding console.log(`HTTP Body: ${bodyStr}`); } // 同样打印调用栈,找到是谁发起的这个请求 console.log(`Backtrace:\n${Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n')}`); } } }); }第二步:注入并运行脚本在电脑终端,运行:frida -U -f com.ss.iphone.article.News -l search_hook.js --no-pause。这条命令会启动今日头条App(-f指定Bundle ID)并注入我们的脚本。
第三步:触发搜索并观察在手机App上进行一次搜索。此时,电脑终端会疯狂刷出日志。我们需要在日志中寻找与我们抓包看到的Base64字符串或特殊Header(如X-Gorgon)相关的输出。
当Hook到生成我们看到的那个Base64字符串的函数时,Frida会打印出调用栈(Backtrace)。调用栈就像一份“犯罪现场”的足迹记录,从下往上读,显示了是哪些函数一步步调用了这个Base64编码函数。栈顶附近的函数名,很可能就是今日头条内部封装加密逻辑的关键函数。例如,你可能会在调用栈中看到[TTSearchRequestEncoder encodeRequest:]这样的函数名,这几乎就是我们要找的“加密器”了。
4.3 定位关键加密函数
通过动态Hook,我们可能定位到了一个或多个可疑的函数,比如encodeRequest:、generateSignatureWithParams:、encryptBody:等。接下来,我们需要回到静态分析工具(Hopper或IDA),加载砸壳后的二进制文件,直接搜索这些函数名。
在Hopper中,按Cmd+G(跳转到地址)或直接搜索函数名。找到函数后,可以查看它的汇编指令,更强大的是使用Hopper的伪代码生成功能(按F5键)。伪代码虽然不完全准确,但极大提升了可读性,能让我们以近似C语言的逻辑来理解这个函数做了什么。
分析这个encodeRequest:函数,你可能会发现它:
- 接收一个包含搜索参数(关键词、位置、时间戳等)的字典作为输入。
- 按照某种固定格式(可能是JSON)序列化这个字典。
- 调用一个
AESEncrypt或CCCrypt(iOS系统加密API)函数对序列化后的字符串进行加密。 - 将加密后的二进制数据转换成Base64字符串。
- 同时,可能还会调用另一个函数
generateGorgon,利用时间戳、设备ID、加密后的Body等参数,通过HMAC-SHA256等算法计算出一个签名,赋值给X-Gorgon请求头。
至此,加密的核心逻辑和位置就被我们找到了。
5. 加密算法逆向与模拟实现
找到关键函数后,最硬核的部分来了:逆向其具体的加密算法和密钥。
5.1 分析加密模式与密钥
在Hopper的伪代码中,仔细查看调用加密函数(如CCCrypt)的部分。这个函数调用会包含几个关键参数:
op:操作类型,kCCEncrypt(加密)或kCCDecrypt(解密)。alg:算法,如kCCAlgorithmAES。options:选项,这里藏着加密模式(如kCCOptionPKCS7Padding)和模式(如kCCModeCBC)。kCCOptionPKCS7Padding | kCCModeCBC就表示CBC模式,PKCS7填充。key和keyLength:密钥和其长度。iv和ivLength:初始化向量(IV)和其长度(如果是CBC等模式)。
密钥和IV的来源是重点。它们可能是:
- 硬编码在二进制文件里:在伪代码中可能是一个字符数组,如
char key[] = “1234567890abcdef”;。你可以尝试在二进制文件中搜索字符串(在Hopper中按Cmd+Alt+S),查找可能的密钥。 - 从某个服务器接口动态获取:这就需要你Hook网络请求,找到获取密钥的接口。
- 由其他参数通过固定算法生成:例如,用设备ID的MD5值的前16位作为密钥。
在我们的动态Hook日志中,如果之前打印了调用栈,栈中加密函数附近的函数可能会揭示密钥的生成逻辑。有时,密钥甚至就作为参数传递给了加密函数,在onEnter回调中可以直接打印出来。
5.2 使用Frida主动调用与验证
在不确定算法细节时,Frida的Interceptor还有一个强大功能:不仅能在函数被调用时打印信息,还能修改函数的参数或返回值。但更直接的方法是使用Frida的NativeFunction来主动调用我们找到的加密函数。
假设我们已经确定了加密函数encryptWithKey:的地址(可以通过Module.findExportByName获取),并且知道了它的参数和返回值类型(通过分析伪代码的签名猜测),我们可以写一个脚本,在App运行时,直接调用这个函数,传入我们构造的明文,看它返回的密文是否和我们抓包得到的一致。
// 假设我们找到了加密函数的地址偏移是 0x123456 (来自Hopper) var baseAddr = Module.findBaseAddress('今日头条'); // 获取ASLR偏移后的基地址 var encryptFuncAddr = baseAddr.add(0x123456); // 根据伪代码猜测函数原型:void* encrypt(void* data, size_t dataLen, void* key); var encryptFunc = new NativeFunction(encryptFuncAddr, 'pointer', ['pointer', 'size_t', 'pointer']); // 准备测试数据 var testStr = “hello world”; var testData = Memory.allocUtf8String(testStr); var keyStr = “my_test_key_16b”; // 假设的密钥 var keyData = Memory.allocUtf8String(keyStr); // 调用函数 var resultPtr = encryptFunc(testData, testStr.length, keyData); // 读取结果...如果调用成功且结果符合预期,那就证明我们找对了函数和密钥。这个过程可能需要反复尝试,调整函数签名和参数。
5.3 使用Python还原算法
一旦我们通过静态分析和动态验证,完全掌握了加密算法(例如AES-128-CBC,PKCS7填充,固定的密钥和IV),以及签名算法(例如对“URL路径+时间戳+加密Body”的HMAC-SHA256),就可以在电脑上用Python(或其他语言)进行复现了。
import base64 import json import time import hashlib import hmac from Crypto.Cipher import AES from Crypto.Util.Padding import pad class ToutiaoSearchCracker: def __init__(self): # 这些密钥和IV是通过逆向分析得到的,此处为示例,并非真实值 self.aes_key = b'1234567890abcdef' # 16字节密钥 self.aes_iv = b'abcdefghijklmnop' # 16字节IV self.sign_secret = b'your_sign_secret_here' # 签名密钥 def encrypt_body(self, params_dict): """模拟App内对请求体的加密过程""" # 1. 序列化参数为JSON字符串 json_str = json.dumps(params_dict, separators=(',', ':'), ensure_ascii=False) # 确保是bytes data = json_str.encode('utf-8') # 2. AES-128-CBC加密 cipher = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv) encrypted_data = cipher.encrypt(pad(data, AES.block_size)) # 3. Base64编码 encrypted_b64 = base64.b64encode(encrypted_data).decode('utf-8') return encrypted_b64 def generate_gorgon(self, url_path, timestamp, encrypted_body): """模拟生成X-Gorgon签名""" # 假设签名算法为:HMAC-SHA256(secret, url_path + str(timestamp) + encrypted_body) message = f"{url_path}{timestamp}{encrypted_body}".encode('utf-8') signature = hmac.new(self.sign_secret, message, hashlib.sha256).hexdigest() # 可能还会经过一些格式化,比如取前几位等 return signature.upper()[:16] # 示例,实际格式需分析 def build_search_request(self, keyword, page=1): """构建一个完整的搜索请求""" # 构造原始参数 raw_params = { "keyword": keyword, "offset": (page - 1) * 20, "count": 20, "source": "search_tab", # ... 其他必要参数 } # 加密请求体 encrypted_body = self.encrypt_body(raw_params) # 生成签名所需参数 timestamp = int(time.time()) url_path = "/api/search/v1/" # 生成签名 x_gorgon = self.generate_gorgon(url_path, timestamp, encrypted_body) # 构造最终请求头 headers = { "Content-Type": "application/x-www-form-urlencoded", # 可能是其他类型 "X-Gorgon": x_gorgon, "X-Khronos": str(timestamp), # ... 其他固定Header } # 构造请求数据(加密后的Body可能以特定字段名传输) request_data = f"encrypted_data={encrypted_body}" return url_path, headers, request_data # 使用示例 cracker = ToutiaoSearchCracker() url, headers, data = cracker.build_search_request("逆向工程") print(f"URL: {url}") print(f"Headers: {headers}") print(f"Data: {data}") # 接下来就可以用requests库发送这个请求了6. 常见问题与排查技巧实录
在实际操作中,你几乎一定会遇到各种问题。下面是我踩过的一些坑和解决方法。
6.1 Frida注入失败或脚本不生效
- 现象:
frida-ps -U能看到设备,但注入时提示Failed to spawn: unable to find process with name ‘xxx’或脚本无任何输出。 - 排查:
- 检查Bundle ID:确保
-f参数后的Bundle ID正确。可以用frida-ps -U | grep News来确认。 - 检查Frida版本:确保手机端和电脑端的Frida版本兼容。尽量使用相近版本。
- 重启Frida服务:在手机端,可以通过Cydia重新安装
Frida包,或使用命令killall frida-server后重新启动它(如果它以服务形式运行)。 - 关闭App沙盒限制:有些越狱环境或App有更强的反调试。可以尝试使用
frida -U --no-pause -f com.xxx.xxx中的--no-pause,或在脚本中使用setTimeout延迟Hook,避免在App启动早期注入失败。
- 检查Bundle ID:确保
6.2 Hook不到目标函数
- 现象:脚本成功注入,App也能运行,但搜索时没有打印出我们预期的加密或网络请求日志。
- 排查:
- 函数签名错误:Objective-C的函数名(Selector)包含冒号。
base64EncodedString和base64EncodedStringWithOptions:是两个不同的函数。在Hopper或class-dump的头文件里确认准确的函数名。 - 调用时机问题:加密可能发生在子线程,或者你的Hook代码执行时,某些类尚未加载。可以尝试Hook类的
+load方法或_objc_init,然后在回调中再动态去Hook目标函数。 - App使用了自定义加密或第三方库:App可能没有使用系统的
NSString的Base64方法,而是用了自己的实现或第三方库(如OpenSSL)。你需要扩大搜索范围,在静态分析时关注那些看起来像加密操作的函数调用(如CC_SHA256,EVP_EncryptUpdate等)。
- 函数签名错误:Objective-C的函数名(Selector)包含冒号。
6.3 静态分析伪代码难以理解
- 现象:Hopper生成的伪代码逻辑混乱,充斥着大量临时变量和指针操作,看不懂。
- 技巧:
- 重命名变量和函数:在Hopper中,你可以双击一个变量或函数,给它起一个有意义的名字(如
inputString,encryptionKey)。这能极大提升代码可读性。 - 关注核心系统调用:不要试图理解每一行汇编。重点关注对已知系统API的调用,如
CCCrypt,CC_SHA256,malloc,memcpy等。这些是理解算法逻辑的锚点。 - 动态调试辅助:在Frida脚本中,除了打印参数,还可以打印某个对象的所有属性(
ObjC.Object(args[0]).$ivars)或调用其方法,来辅助理解数据结构。
- 重命名变量和函数:在Hopper中,你可以双击一个变量或函数,给它起一个有意义的名字(如
6.4 算法还原后请求仍被服务器拒绝
- 现象:你用Python模拟的请求,加密和签名都看似正确,但服务器返回错误码(如403、412)。
- 排查:
- 参数完整性:检查你的请求是否包含了所有必要的参数。对比抓包到的原始请求,一个都不要少,包括那些看似无关的字段(如
_rticket,device_id,iid等)。有些参数可能参与签名计算。 - 编码与格式:确认JSON序列化的格式(空格、缩进、键序)是否与App完全一致。有些签名算法对字符串的格式极其敏感。可以尝试将App加密前的原始字符串通过Frida dump出来,与你生成的字符串进行逐字节比较。
- 签名算法细节:签名算法可能比你想象的复杂。例如,参与签名的字符串可能经过了URL编码或特定的规范化处理。再次仔细分析生成签名的函数,看是否有额外的步骤(如对某些字段先进行MD5哈希)。
- 时间戳同步:
X-Khronos通常是秒级时间戳。确保你的服务器时间与今日头条的服务器时间大致同步,误差不要太大(如几分钟内)。 - TLS指纹或证书绑定:高级别的防护可能会检查客户端的TLS指纹(如JA3)或使用SSL Pinning。这种情况下,简单的
requests库会被识别。你需要使用更底层的库(如curl_cffi)来模拟iOS的TLS指纹,或者使用frida在App内直接调用其网络层函数来发送请求,完全绕过客户端模拟。
- 参数完整性:检查你的请求是否包含了所有必要的参数。对比抓包到的原始请求,一个都不要少,包括那些看似无关的字段(如
逆向工程是一场与开发者斗智斗勇的持久战。今日头条的加密机制也可能随时更新。今天有效的方法,明天可能就失效了。因此,掌握这套分析思路和工具链,比记住某个具体的密钥或算法更为重要。当加密方式改变时,你可以快速地重复上述流程,定位新的加密函数,分析新的逻辑。这个过程本身,就是对iOS系统、网络协议和安全攻防的深刻学习。