逆向解析携程App私有协议:从抓包失效到数据采集的完整实战

📅 2026/7/26 14:31:58
逆向解析携程App私有协议:从抓包失效到数据采集的完整实战
1. 项目缘起当通用抓包工具在携程App前“失灵”作为一名长期和数据打交道的人我经常需要从各类App中采集公开的、非敏感的商业数据用于市场分析、价格监控或者竞品研究。携程作为国内领先的在线旅游平台其酒店、机票、火车票的动态价格信息无疑具有极高的分析价值。然而当我像往常一样打开熟悉的抓包工具比如Charles或Fiddler设置好手机代理准备对携程App进行常规的HTTP/HTTPS流量捕获时却碰了一鼻子灰。你会发现App内的列表加载正常搜索结果也能返回但抓包工具里却一片“寂静”或者只能看到一些无关紧要的、像是心跳包一样的零星请求核心的搜索、列表、详情数据一个都抓不到。这不是网络问题也不是代理设置错误而是你遇到了现代App尤其是大型互联网公司核心App的“标配”防御——私有通信协议。携程App与服务器之间的数据交换很可能已经不再完全依赖于标准的HTTP/HTTPS明文或TLS加密而是封装在了一套自研的二进制或自定义编码的协议里。这意味着传统的基于HTTP代理的抓包方式在协议解码这一关就直接失效了。这种困境催生了“逆向工程”的需求。我们得绕到App的背后去弄清楚它究竟是如何与服务器“对话”的。这不仅仅是“抓个包”那么简单而是一场从应用层协议到网络层封装的全面解析。逆向解析携程App的私有协议目标就是穿透这层自定义的“外壳”重新让数据以我们可以理解的形式呈现出来进而实现稳定、准确的数据采集。这个过程融合了移动端逆向、网络协议分析、密码学等多个领域的知识充满了挑战但也正是其技术魅力所在。2. 核心思路与技术选型从黑盒到白盒的探索路径面对一个闭源的、通信被混淆的App我们的核心思路是从外部观察动态分析和内部解构静态分析两个方向协同推进最终目标是复现其完整的网络请求生成逻辑。技术选型上需要一套组合拳。2.1 动态分析捕捉最原始的数据流动态分析是在App运行过程中进行监控和调试。首要任务是绕过SSL Pinning证书绑定。SSL Pinning是App防止中间人攻击MITM的技术也会让我们的抓包工具无法解密HTTPS流量。对于Android平台主流方案是使用Xposed框架、LSPosed模块或者Frida注入工具配合JustTrustMe、SSLUnpinning等模块来Hook掉证书验证的逻辑。对于iOS平台则通常需要在越狱环境下使用Cydia Substrate或Frida配合类似的插件。在搞定SSL Pinning后我们依然可能抓不到预期的HTTP请求这强烈暗示了私有协议的存在。此时我们需要更底层的抓包工具。tcpdump和Wireshark是网络层的利器它们可以捕获设备上所有进出的TCP/IP数据包。我们可以在Root后的Android设备上运行tcpdump或者通过路由器的流量镜像功能将手机流量导到电脑上用Wireshark分析。这样得到的是最原始的二进制网络流量但缺乏进程关联信息。为了将网络数据包和App进程关联起来strace系统调用跟踪或Frida的Interceptor.attach功能就派上用场了。我们可以Hook住App中所有与网络相关的系统调用如socket,send,recv,SSL_write,SSL_read打印出调用时的参数内存地址、长度和返回值。结合Wireshark抓到的原始流量通过时间戳、端口号、数据长度等多维度信息进行关联比对就能精准定位到携程App发送和接收的每一个数据块。这是逆向私有协议最关键的第一步——获取到未经解密的协议载荷Payload。2.2 静态分析逆向协议结构与算法拿到二进制Payload后我们需要知道如何解析它。这就需要静态分析App的安装包APK或IPA探查其内部实现。对于Android AppAPK我们可以使用apktool进行反编译得到smali中间代码和资源文件。但smali可读性较差更常用的工具是jadx或GDA它们能将Dex文件反编译成可读性更高的Java代码。我们的搜索目标很明确所有与网络通信相关的类。关键词包括“socket”、“protocol”、“encode”、“decode”、“packet”、“request”、“response”、“加密”、“解密”等。重点关注那些自定义的XXXProtocol、XXXEncoder、XXXDecoder类或者继承/实现了OutputStream、InputStream并进行重写的类。很多时候核心的加密和压缩算法可能以Native代码C/C的形式实现存放在lib目录下的.so文件中。这时就需要用到逆向分析Native代码的工具链IDA Pro、Ghidra或radare2。通过分析JNIJava Native Interface的调用关系找到Java层调用的Native函数然后深入so库进行逆向还原出加密算法如AES、RSA、自定义混淆、压缩算法如zlib、snappy以及整个协议包的结构包头、包体、校验码等。2.3 协议复现从分析到采集分析的目的在于复现。当我们基本摸清了协议格式例如前4字节为包长接着2字节为命令字然后是经过某种加密和压缩的JSON数据体最后2字节为CRC校验并逆向出了加解密、压缩解压缩的算法及密钥后就可以开始用编程语言如Python来模拟这个过程。复现的流程通常是先用Python构造出业务请求的原始数据例如一个包含城市、入住日期、关键词的JSON对象然后按照逆向出来的算法流程依次进行压缩、加密、添加协议头等操作生成一个二进制字节流最后通过socket或requests库模拟TCP连接将这个字节流发送到我们分析出来的携程服务器地址和端口。接收响应后再反向进行解包、解密、解压最终得到可读的响应数据。这个过程中Frida扮演着“验证器”的角色。我们可以在App运行时用Frida脚本Hook住关键的编码/加密函数直接打印出函数的输入和输出。然后我们用Python模拟生成的字节流与Frida Hook到的真实字节流进行逐字节比对任何差异都意味着我们的逆向还有遗漏或错误需要反复调试修正。注意整个逆向分析与数据采集活动必须严格限定在技术学习与研究范畴仅针对公开、非敏感、非个人的聚合数据。任何涉及用户隐私、身份信息、未公开商业秘密的数据抓取都是违法违规的。务必遵守robots.txt协议控制请求频率避免对目标服务器造成负载压力。3. 实战拆解定位与逆向携程App的网络模块理论讲完了我们来点“干货”模拟一下实战中可能的关键步骤和发现。请注意以下内容基于常见的移动端逆向模式进行推演和举例并非针对特定版本携程App的真实逆向结果。3.1 初步探测与协议特征发现首先在配置好Frida绕过SSL Pinning后使用常规HTTP代理抓包确认核心业务请求如酒店搜索/api/hotel/search缺失。接着在Root的Android设备上运行tcpdump抓取原始流量同时使用Frida Hooklibc的send和recv函数。通过Frida脚本我们可以过滤出携程App进程如ctrip.android.view的所有网络收发操作并打印出目标IP、端口和数据长度。很快我们可能发现App频繁与某个或某几个特定IP的某个非80/443端口例如443端口但非HTTP或8000、9000等自定义端口通信且收发的是长度不规则的二进制数据块这很可能就是私有协议通道。3.2 关键类的定位与反编译有了目标我们使用jadx打开携程的APK文件。全局搜索与上述IP、端口相关的字符串可能一无所获因为这类信息可能被加密或混淆。更有效的方法是搜索网络库相关关键词。大型App通常会使用统一的网络层封装。我们可以搜索“OkHttp”、“Retrofit”、“Volley”的调用痕迹或者搜索自定义的类名如“NetworkClient”、“HttpEngine”、“ProtocolHandler”。一个常见的发现是App内可能存在一个名为com.ctrip.android.network.protocol.XXXProtocol的类。通过反编译查看其Java代码我们可能会看到类似下面的伪代码结构public class CtripBinaryProtocol { private OutputStream mOutputStream; private InputStream mInputStream; private Cipher mEncryptCipher; private Cipher mDecryptCipher; public void sendRequest(RequestData request) throws IOException { byte[] jsonData request.toJsonString().getBytes(UTF-8); // 1. 压缩 byte[] compressedData ZlibUtils.compress(jsonData); // 2. 加密 (例如AES CBC模式) byte[] encryptedData mEncryptCipher.doFinal(compressedData); // 3. 组包长度(4字节) 命令字(2字节) 加密数据体 ByteBuffer buffer ByteBuffer.allocate(4 2 encryptedData.length); buffer.putInt(encryptedData.length 2); // 总长度数据体长命令字长 buffer.putShort((short) request.getCommandId()); buffer.put(encryptedData); // 4. 发送 mOutputStream.write(buffer.array()); mOutputStream.flush(); } public ResponseData readResponse() throws IOException { // 1. 读包头长度 byte[] lenBytes new byte[4]; mInputStream.read(lenBytes); int pkgLen ByteBuffer.wrap(lenBytes).getInt(); // 2. 读命令字和加密体 byte[] pkgBody new byte[pkgLen]; mInputStream.read(pkgBody); // 3. 解密、解压、解析JSON... // ... } }这段伪代码揭示了一个典型的私有协议结构长度头 命令字 加密压缩后的业务数据体。我们需要逆向的关键点变成了ZlibUtils.compress的具体参数、mEncryptCipher的初始化方式算法、模式、填充、密钥来源。3.3 密钥与算法的追踪密钥往往不会硬编码在代码中。它可能来自首次登录或握手协议App启动时与服务器进行一次密钥协商如Diffie-Hellman后续通信使用协商出的会话密钥。固定密钥动态盐值有一个固定的基础密钥存储在so库或经过复杂混淆每次请求会结合一个服务器下发的随机数盐值生成当次加密密钥。从资源文件或配置服务器加载密钥被加密后放在assets文件或通过某个公开接口获取运行时解密。我们需要顺着Cipher.getInstance(“AES/CBC/PKCS5Padding”)和cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)这样的代码回溯keySpec密钥和ivSpec初始化向量的来源。这个过程可能需要在jadx中不断跳转引用最终可能会追踪到一个Native方法例如getEncryptKeyFromNative()。这时就必须分析Native库了。用IDA Pro打开libctripsecurity.so找到对应的Java_com_ctrip_android_network_security_KeyManager_getEncryptKeyFromNative函数分析其汇编或反编译的C代码可能发现它是在做一系列固定的字节数组变换或者从某个内存地址读取。这就是逆向中最耗时、最需要耐心的部分可能需要动态调试用Frida的NativeHook或IDA的远程调试来观察内存数据的变化。3.4 使用Frida进行动态验证在静态分析有初步猜想后Frida的动态Hook是验证真理的唯一标准。我们可以编写Frida脚本直接Hook住上述CtripBinaryProtocol类的sendRequest方法或者Hook更底层的加密函数。// Frida脚本示例Hook 加密函数并打印输入输出 Java.perform(function() { var CipherClass Java.use(‘javax.crypto.Cipher’); var ByteString Java.use(‘com.android.okhttp.okio.ByteString’); CipherClass.doFinal.overload(‘[B’).implementation function(input) { console.log(“[Cipher.doFinal] called“); console.log(“Input data (hex): “ ByteString.of(input).hex()); var result this.doFinal(input); console.log(“Output data (hex): “ ByteString.of(result).hex()); console.log(“---“); return result; }; });运行这个脚本在App中触发一个酒店搜索动作我们就能在控制台看到加密前的压缩数据可能是乱码但长度可见和加密后的输出。同时用Wireshark捕获到对应TCP流将加密后的输出与网络包中的应用层数据部分进行比对如果一致就证明我们Hook的点是正确的并且看到了“明文”到“密文”的转换过程。4. 数据采集脚本的核心实现与调试当我们成功逆向出协议格式、加密算法和密钥后就可以着手编写数据采集脚本了。这里以Python为例展示核心流程。4.1 构建请求原型首先我们需要知道业务请求的原始JSON结构。除了逆向App还可以通过抓包其他未加密的接口如一些图片、配置加载接口、或者分析Web端携程官网的相同功能请求来推测。假设我们逆向出酒店搜索的commandId是0x1001请求体是一个JSON包含cityId,checkInDate,checkOutDate,keyword等字段。import json import zlib from Crypto.Cipher import AES from Crypto.Util.Padding import pad import struct import socket def build_search_request(city_id, check_in, check_out, keywordNone): 构建酒店搜索请求的原始JSON request_body { “header“: { “version“: “1.0“, “channel“: “android“, “token“: ““, // 通常需要登录后获取公开数据采集可能用不到或使用匿名token }, “payload“: { “cityId“: city_id, “checkInDate“: check_in, # 格式: “2023-10-01“ “checkOutDate“: check_out, “pageIndex“: 1, “pageSize“: 50, “sortType“: “default“, } } if keyword: request_body[“payload“][“keyword“] keyword return json.dumps(request_body, separators(‘,‘, ‘:‘)) # 紧凑格式避免空格差异4.2 实现协议编码接着实现我们逆向出来的协议编码过程压缩 - 加密 - 添加协议头。def encrypt_data(plain_data, key, iv): AES-CBC加密PKCS7填充 cipher AES.new(key, AES.MODE_CBC, iv) padded_data pad(plain_data, AES.block_size) encrypted_data cipher.encrypt(padded_data) return encrypted_data def pack_protocol_data(command_id, json_str): 按照协议格式打包数据 # 1. JSON字符串转字节 json_bytes json_str.encode(‘utf-8‘) # 2. 压缩 (假设是zlib) compressed_bytes zlib.compress(json_bytes, levelzlib.Z_BEST_SPEED) # 3. 加密 (密钥和IV需从逆向中获取此处为示例) # key bytes.fromhex(‘你的16/24/32字节密钥hex字符串‘) # iv bytes.fromhex(‘你的16字节IV hex字符串‘) # encrypted_bytes encrypt_data(compressed_bytes, key, iv) # 为演示假设加密后数据不变实际必须替换为真实加密 encrypted_bytes compressed_bytes # 临时替换实际需加密 # 4. 组包: 总长度(4) 命令字(2) 加密体 total_length 2 len(encrypted_bytes) # 命令字占2字节 packet struct.pack(‘I H‘, total_length, command_id) encrypted_bytes return packet4.3 网络通信与解码然后通过Socket发送请求并解析响应。def send_request(host, port, packet): 发送协议包并接收响应 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.settimeout(15) sock.connect((host, port)) sock.sendall(packet) # 接收响应头长度 header sock.recv(4) if len(header) 4: raise ConnectionError(“Failed to receive response header“) resp_total_len struct.unpack(‘I‘, header)[0] # 接收剩余响应体 received bytearray() while len(received) resp_total_len: part sock.recv(4096) if not part: break received.extend(part) return bytes(received) def unpack_protocol_data(resp_bytes): 解包协议响应 if len(resp_bytes) 6: # 长度头(4)命令字(2) raise ValueError(“Invalid response length“) # 解析命令字 (理论上应与请求命令字对应或为响应命令字) resp_command_id struct.unpack(‘H‘, resp_bytes[4:6])[0] encrypted_body resp_bytes[6:] # 解密 (逆向过程此处省略) # decrypted_body decrypt_data(encrypted_body, key, iv) decrypted_body encrypted_body # 临时替换 # 解压 try: decompressed_body zlib.decompress(decrypted_body) except zlib.error: # 可能未压缩或压缩算法不同 decompressed_body decrypted_body # 解析JSON resp_json json.loads(decompressed_body.decode(‘utf-8‘)) return resp_command_id, resp_json4.4 集成与调度最后将上述函数集成并加入错误处理、重试机制和速率限制。import time from typing import Dict, Any class CtripPrivateProtocolClient: def __init__(self, server_host, server_port, encrypt_key, encrypt_iv): self.host server_host self.port server_port self.key encrypt_key self.iv encrypt_iv # 可以在这里初始化一个连接池或持久连接 def search_hotels(self, city_id, check_in, check_out, max_retries3): 酒店搜索主函数 request_json build_search_request(city_id, check_in, check_out) packet pack_protocol_data(0x1001, request_json) # 假设0x1001是搜索命令 for attempt in range(max_retries): try: resp_bytes send_request(self.host, self.port, packet) cmd_id, resp_data unpack_protocol_data(resp_bytes) # 处理响应数据提取酒店列表 hotel_list self._parse_hotel_list(resp_data) return hotel_list except (socket.timeout, ConnectionError, json.JSONDecodeError) as e: print(f“Attempt {attempt 1} failed: {e}“) if attempt max_retries - 1: raise time.sleep(2 ** attempt) # 指数退避 return [] def _parse_hotel_list(self, resp_data: Dict[str, Any]) - List[Dict]: 解析响应JSON提取核心酒店信息 hotels [] # 根据实际逆向出的响应结构解析 # 例如: resp_data[‘data‘][‘hotelList‘] hotel_items resp_data.get(‘data‘, {}).get(‘hotelList‘, []) for item in hotel_items: hotel { ‘hotelId‘: item.get(‘hotelId‘), ‘name‘: item.get(‘name‘), ‘score‘: item.get(‘score‘), ‘price‘: item.get(‘price‘), # 可能是一个对象包含实际价格、货币等 ‘address‘: item.get(‘address‘), ‘latitude‘: item.get(‘lat‘), ‘longitude‘: item.get(‘lng‘), } hotels.append(hotel) return hotels # 使用示例 if __name__ “__main__“: # 关键参数需要从逆向中获取 client CtripPrivateProtocolClient( server_host‘api.ctrip.com‘, # 示例非真实地址 server_port9443, encrypt_keybytes.fromhex(‘...‘), encrypt_ivbytes.fromhex(‘...‘) ) try: hotels client.search_hotels(‘0101‘, ‘2023-12-25‘, ‘2023-12-26‘) for hotel in hotels[:5]: # 打印前5条 print(f“{hotel[‘name‘]} - 价格: {hotel.get(‘price‘, {}).get(‘amount‘)}“) except Exception as e: print(f“数据采集失败: {e}“)5. 逆向与采集过程中的典型问题与解决实录私有协议逆向和数据采集是一条布满荆棘的路以下是我在类似项目中总结的一些常见“坑”及其应对策略。5.1 抓不到任何TCP流量问题即使使用了tcpdump在触发App操作时也看不到目标端口的任何数据包。排查协议类型首先确认是否是TCP协议。有些App可能使用UDP甚至是基于QUICHTTP/3的私有封装。在Wireshark中过滤udp或quic看看。本地SocketApp可能使用Unix Domain Socket与一个本地守护进程通信然后由该进程统一对外转发。检查App是否启动了本地服务netstat -anp | grep app_pid并尝试Hooksocket系统调用关注AF_UNIX族。WebSocket/长连接私有协议可能建立在WebSocket之上。抓取标准的HTTP Upgrade请求然后后续的二进制数据都在这个WebSocket连接里传输。Hookokhttp3.WebSocket相关的类。5.2 协议结构频繁变动问题今天逆向成功的协议明天就失效了服务器返回了未知错误码。策略版本锁定采集脚本固定使用某个特定版本的App APK/IPA并在模拟器或专用设备上运行该版本避免自动更新。动态适配在协议包头中增加版本号字段。逆向时找到版本号判断的逻辑。在采集脚本中可以通过Hook某个获取版本号的函数或者解析App启动时与服务器的第一次握手响应来动态获取当前协议版本和对应的加密参数。心跳与保活私有长连接通常有心跳机制。模拟心跳包keep-alive是维持连接的必要条件。逆向时需找到心跳包的commandId和发送间隔。5.3 密钥动态生成或定期轮换问题静态逆向出的密钥只能用一段时间之后所有请求都因解密失败而被服务器拒绝。解法握手协议逆向重点分析App启动后或登录前与服务器最初的几次通信。这很可能是一次密钥协商过程如ECDH。用Frida Hook所有密钥相关对象的创建和赋值过程找到最终用于业务加密的会话密钥存储在内存的哪个对象里。运行时窃取不追求完全逆向密钥生成算法。而是编写一个Frida持久化脚本在App运行时一旦检测到密钥被设置到Cipher或类似对象中就将其dump下来并通过网络发送到我们的控制服务器。采集脚本则定期从控制服务器拉取最新的有效密钥。这是一种“绕开”而非“破解”的思路。白盒加密最复杂的情况是使用了白盒加密技术将密钥与算法深度融合无法分离。应对此情况需要深厚的密码学和二进制分析功底可能需要对白盒加密库进行深度逆向提取出等效的加密查表工作量巨大。5.4 请求被风控返回空数据或验证码问题协议模拟成功也能收到响应但返回的数据是空的或者提示需要验证。风控对抗要点设备指纹App会上传大量设备信息IMEI、Android ID、OAID、设备型号、系统版本、屏幕分辨率、字体列表、传感器信息等作为指纹。模拟请求时需要构造合理的设备指纹并保持一致性。可以使用android-emulator或device-farm工具来模拟真实设备。行为模式模拟正常用户的操作间隔不要以固定毫秒级间隔疯狂请求。加入随机延时模拟“浏览-点击-查看”的行为序列。网络环境避免使用明显的机房IP。使用优质代理IP并注意IP的纯净度是否被其他爬虫滥用过。签名与校验除了加密请求可能还有签名Signature。签名算法通常使用HMAC-SHA256等密钥可能不同。需要逆向出签名参数的拼接顺序和算法。任何参数的顺序、大小写、额外空格JSON格式化差异都可能导致签名校验失败。妥协策略对于核心数据如果风控极其严格可能需要考虑更低频的采集或者结合其他数据源如公开的API、合作伙伴数据进行补充而非强攻一点。5.5 性能与稳定性优化连接复用频繁创建和销毁TCP连接开销很大。应该实现一个连接池复用长连接来处理多个请求。异步与并发使用asyncio或gevent实现异步IO提高采集效率。但务必注意控制并发度过高的并发本身就是异常行为极易触发风控。完善日志与监控记录每个请求的耗时、响应状态、数据量。设置异常报警当连续失败或返回空数据比例过高时能及时通知便于排查是协议变更、密钥失效还是被风控。数据去重与校验设计去重逻辑避免因重试机制导致数据重复。对采集到的数据进行基础校验如字段完整性、价格合理性及时发现解析逻辑错误。逆向解析私有协议并实现数据采集是一个系统工程考验的不仅是逆向技术还有工程化、风控对抗和运维能力。它没有一成不变的解决方案需要分析人员根据目标App的具体实现灵活组合各种工具和方法在不断试错和调试中前进。整个过程务必保持对法律边界的清醒认识将技术用于正当的学习与研究目的。