逆向解析携程App私有协议:从抓包困境到数据采集实战
1. 当抓包工具失效时如何破局
第一次尝试用Charles抓取携程App的数据包时,我遇到了一个令人沮丧的情况——所有的请求都显示为乱码或者直接无法捕获。这种情况在分析采用标准HTTP协议的App时很少见,但携程显然使用了某种私有协议。于是我把工具换成了Wireshark,这个决定成为了整个逆向工程的转折点。
Wireshark的原始数据包显示让我确认了一个重要事实:携程App使用的是基于TCP的自定义协议,他们内部称之为SOTP协议。这种协议不像HTTP那样有明文的请求头和响应体,所有数据都是二进制格式传输。更麻烦的是,数据在传输过程中还经过了压缩和加密处理,这解释了为什么Charles无法直接解析。
面对这种情况,我总结出三个关键突破口:
- 协议识别:通过抓包确认协议类型和基本特征
- 请求构造:分析如何构建合法的协议请求
- 响应解析:理解服务器返回数据的格式和编码方式
在实际操作中,我发现携程的协议实现主要集中在SOTPConnection这个类中。通过反编译代码可以看到,所有的网络请求最终都会调用sendRequest方法。这个方法有几个值得注意的特点:首先它会检查一个神秘的ASMUtils接口,这可能是某种动态加载机制;其次它会处理请求的重试和超时逻辑;最重要的是它会通过socket.getOutputStream()直接发送二进制数据。
2. 逆向工程实战:从反编译到协议还原
使用jadx打开携程App的APK文件后,我首先搜索了与网络相关的关键词。很快,ProtocolHandle类引起了我的注意。这个类像是整个App的协议处理中枢,里面定义了几种不同的序列化方式:
public enum CommEncodingType { None, Normal, UTF8, PB, Json, SotpPB, SotpJson, PBSotp, PBJson, JsonSotp, JsonPB, GraphQL }这个枚举类透露了几个重要信息:携程同时使用了Protobuf(PB)和Json两种序列化方式,而且还有它们的各种组合变体。更复杂的是,不同功能模块可能使用了不同的序列化组合,这意味着我们需要针对不同的API接口做差异化处理。
在分析酒店价格查询功能时,我发现请求和响应都采用了"SotpPB"格式——也就是先用Protobuf序列化,再套上SOTP协议的封装。这种设计既保持了Protobuf的高效,又增加了私有协议的安全层。为了验证这个发现,我写了一个简单的Python脚本来模拟这个流程:
import socket import gzip from google.protobuf import message def send_sotp_request(host, port, pb_message): # 1. Protobuf序列化 pb_data = pb_message.SerializeToString() # 2. Gzip压缩 compressed = gzip.compress(pb_data) # 3. 添加SOTP头 header = b'\x02\x00\x00\x00' # 示例头部 full_data = header + compressed # 4. 建立TCP连接并发送 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect((host, port)) s.sendall(full_data) return s.recv(10240)这个脚本虽然简化了很多细节,但它展示了SOTP协议处理的基本流程。在实际操作中,每个步骤都需要更精确地还原App中的实现,特别是头部的构造和压缩算法参数。
3. 攻克加密难题:深入native层分析
当分析到数据加密环节时,遇到了更大的挑战。在EncodeUtil类中,关键的加密方法是以native方式实现的:
public native byte[] cd(byte[] bArr, int i); // 加密 public native byte[] ce(byte[] bArr, int i); // 解密通过查看静态初始化块,可以确定这些方法实现在libctripenc.so中。这意味着我们需要分析ARM汇编代码,这对大多数Java开发者来说是个陌生的领域。我采取的方法是:
- 使用IDA Pro打开so文件,定位到cd和ce函数的实现
- 分析函数的控制流和关键算法特征
- 识别使用的标准加密算法(如AES、RSA等)
- 尝试用Java或Python重新实现相同的算法
经过反复调试,确认携程使用的是AES加密,但有几个自定义改动:
- 密钥不是固定的,而是根据设备信息动态生成
- 使用了非标准的CBC模式初始化向量(IV)处理方式
- 在加密前后还进行了额外的字节变换操作
这些发现解释了为什么直接使用标准AES库无法正确解密数据。最终的解决方案是使用Frida框架挂钩这些native方法,直接获取输入输出,而不用完全逆向算法细节:
Interceptor.attach(Module.findExportByName("libctripenc.so", "Java_com_ctrip_EncodeUtil_cd"), { onEnter: function(args) { console.log("加密输入:"); console.log(hexdump(args[2], { length: parseInt(args[3]) })); }, onLeave: function(retval) { console.log("加密输出:"); console.log(hexdump(Memory.readPointer(retval), { length: Memory.readInt(retval.add(8)) })); } });这种方法虽然取巧,但在面对复杂加密时能节省大量时间。不过要注意的是,过度依赖hook可能会影响性能,在生产环境中还是建议实现完整的算法。
4. 构建可靠的数据采集客户端
掌握了协议细节和加密方式后,就可以着手构建完整的数据采集客户端了。以酒店价格查询为例,完整的流程应该包含以下步骤:
- 构造Protobuf请求消息
- 序列化并压缩数据
- 添加SOTP协议头
- 加密请求体
- 发送TCP请求
- 接收响应并逆向处理
这里给出一个Java版的完整实现框架:
public class CtripClient { private static final String HOST = "api.ctrip.com"; private static final int PORT = 443; public HotelRoomListResponse queryHotelPrices(int hotelId, String checkInDate, String checkOutDate) throws Exception { // 1. 构造Protobuf请求 HotelRoomListRequest request = buildRequest(hotelId, checkInDate, checkOutDate); // 2. 序列化并压缩 byte[] pbData = request.toByteArray(); byte[] compressed = compress(pbData); // 3. 添加协议头 byte[] sotpData = addSotpHeader(compressed); // 4. 加密 byte[] encrypted = encrypt(sotpData); // 5. 发送请求 byte[] response = sendRequest(encrypted); // 6. 解密和解析 byte[] decrypted = decrypt(response); HotelRoomListResponse result = new HotelRoomListResponse(); ProtobufUtil.mergeFrom(decrypted, result); return result; } private byte[] sendRequest(byte[] data) throws IOException { try (Socket socket = new Socket(HOST, PORT); OutputStream out = socket.getOutputStream(); InputStream in = socket.getInputStream()) { out.write(data); out.flush(); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] chunk = new byte[4096]; int bytesRead; while ((bytesRead = in.read(chunk)) > 0) { buffer.write(chunk, 0, bytesRead); } return buffer.toByteArray(); } } }在实际部署时,还需要考虑几个工程化问题:
- 连接池管理:避免频繁创建TCP连接
- 异常处理:网络波动和协议变更的容错
- 限流机制:防止请求过于频繁被服务器封禁
- 数据缓存:减少重复请求
我建议使用类似Apache HttpClient的连接池实现,并设置合理的超时参数。对于大规模采集任务,可以考虑引入消息队列来控制请求速率。
5. 应对反爬机制的实用技巧
在长期运行数据采集客户端的过程中,我发现携程会不定期更新其反爬策略。以下是几个常见的反爬手段及应对方法:
设备指纹验证携程客户端会在请求中携带设备唯一标识,这个标识由多个设备参数组合生成。解决方案是在采集客户端中模拟真实的设备参数,包括:
- 合理的Android ID和IMEI
- 真实的屏幕分辨率和DPI
- 常见机型型号
请求签名关键API请求会包含动态生成的签名,通常由请求参数+时间戳+密钥通过特定算法生成。通过反编译可以找到签名的生成位置,通常是某个Utils类中的sign方法。用Python实现的签名算法示例:
def generate_sign(params, timestamp, key): param_str = '&'.join(f'{k}={v}' for k,v in sorted(params.items())) raw = f'{param_str}&{timestamp}&{key}'.encode('utf-8') return hashlib.md5(raw).hexdigest()行为检测服务器会分析请求模式,异常的访问频率和时间间隔会被识别为爬虫。建议:
- 随机化请求间隔(1-5秒)
- 模拟真实用户的浏览路径
- 使用多个IP地址轮询
我在实际项目中维护了一个IP代理池,配合User-Agent轮换,有效降低了封禁率。同时建议实现自动检测机制,当发现请求失败率升高时自动切换代理或暂停采集。
6. 协议变更的监控与适配
私有协议最大的挑战在于随时可能发生变化。经过多次实战,我总结出一套协议变更的监测和应对流程:
首先建立基线测试用例,保存典型的请求响应样本。然后设置自动化脚本定期运行这些测试用例,检查以下几个方面:
- 协议头格式是否变化
- 加密解密是否仍然有效
- 关键API的响应结构是否改变
当检测到变更时,按以下步骤处理:
- 使用diff工具对比新旧版本APK
- 重点关注网络相关类的修改
- 必要时重新挂钩native方法获取最新算法
- 更新客户端实现并验证
为了快速定位变更点,我通常会为网络模块的关键类建立映射表,记录每个版本中的类名和方法签名变化。这个工作虽然繁琐,但能在协议变更时大大缩短调试时间。
