
1. 项目缘起一个看似简单的数据抓取需求最近在做一个数据分析的小项目需要获取某家航空公司的航班动态和票价信息。我的第一反应是这还不简单找个公开的API或者直接模拟请求抓取网页数据就行了。毕竟现在很多数据都是通过HTTP接口以JSON格式返回的解析起来很方便。然而当我打开浏览器的开发者工具切换到Network网络标签然后刷新那个航空App的页面时情况有点出乎意料。我看到了一大堆的请求但无论是请求体Request Payload还是响应体Response都是一串串毫无规律、看起来像乱码的字符。比如请求体可能是dataU2FsdGVkX1%2F...这种以U2FsdGVkX1开头的Base64编码字符串而响应体则是一长串十六进制的字符像7b22726573756c74223a5b7b22666c696768744e6f...这样的。这明显不是普通的明文JSON。经验告诉我这大概率是数据被加密了。在移动互联网时代为了保障数据传输安全和防止数据被轻易爬取App与服务器之间的通信进行加密是常规操作。其中AESAdvanced Encryption Standard高级加密标准因其安全性和效率是最常见的对称加密算法之一。看到这种“乱码”我基本可以断定我遇到了AES加密。所以这个“从零还原”的任务就摆在了面前我需要搞清楚这个App用的是AES的哪种模式比如CBC、ECB、密钥Key是什么、初始向量IV是什么以及数据是如何被编码比如Base64、Hex的。只有还原出完整的加密流程我才能解密出原始的JSON数据进而进行我的数据分析。这个过程在安全研究领域常被称为“逆向分析”或“协议分析”但我们的目的并非攻击或破坏而是为了在合规的前提下例如用于个人学习、研究或获取公开数据进行分析理解其数据交互机制。这就像拿到一个上了锁的盒子加密数据我们需要找到正确的钥匙密钥和开锁方法算法模式才能看到里面的东西明文数据。2. 逆向分析前的准备工作与核心思路在开始动手之前做好充分的准备和建立一个清晰的思路至关重要。盲目地翻代码就像大海捞针效率极低。2.1 工具准备我们的“手术刀”工欲善其事必先利其器。对于移动App的逆向分析尤其是Android平台一套顺手的工具链是成功的一半。目标App安装包APK这是我们的分析对象。可以从官方应用商店下载或者使用一些第三方APK下载网站获取特定版本。建议选择一个较新但不是最新的版本因为太旧的版本可能协议已变更而最新的版本可能加固措施更强。反编译与代码查看工具Jadx-GUI这是我的首选。它是一个开源工具能够将APK文件中的DEX字节码反编译成可读性非常高的Java代码。它的图形化界面使得浏览包结构、搜索关键词、查看方法调用关系变得异常方便。相比于老一辈的dex2jarjd-gui组合Jadx在代码还原度和易用性上都有巨大优势。Apktool这个工具用于反编译APK的资源文件如图片、布局XML和AndroidManifest.xml更重要的是它能将APK解包成smali代码。smali是Dalvik虚拟机字节码的一种人类可读的表示形式。当Jadx反编译出的Java代码逻辑混乱或遇到混淆时我们可能需要查看smali代码来理解更底层的逻辑。动态调试与流量抓包工具Fiddler / Charles经典的HTTP/HTTPS代理抓包工具。我们需要在电脑上运行它们并将手机的网络代理设置到电脑从而拦截所有App发出的网络请求和接收的响应。这是观察加密前后数据最直接的方式。Postman / Insomnia用于手动构造和发送HTTP请求测试我们推导出的加密算法是否正确。mitmproxy一个更强大的、支持命令行和脚本化的中间人代理工具适合自动化测试和复杂流量分析。辅助分析与搜索工具文本编辑器VS Code / Sublime Text用于查看和搜索反编译后的大量代码文件。Python环境我们将用Python来模拟实现加密算法因为它有丰富的加密库如pycryptodome和便捷的脚本能力。2.2 核心分析思路由外而内顺藤摸瓜我的逆向分析通常遵循一个“由外而内”的流程抓包观察外部行为首先不关心内部实现只观察现象。用抓包工具记录下App启动、登录、查询航班等关键操作产生的网络请求。重点记录URL请求的接口地址。请求头Headers特别是Content-Type、User-Agent以及任何自定义的头部如X-App-Version,X-Encrypt-Data等。请求体Body就是那一串“乱码”记下它的样子。响应体Body同样是一串“乱码”。猜测与假设经验判断根据“乱码”的特征做初步判断。如果以U2FsdGVkX1开头这很可能是OpenSSL格式的加密数据或者经过某种特定处理的Base64。如果是一长串纯粹的十六进制字符0-9, a-f那可能就是AES加密后的字节数组直接转成了Hex字符串。观察请求URL或头部有时会直接包含aes、cbc、key等字样这能极大缩小搜索范围。静态代码分析内部实现将APK拖入Jadx开始搜索。搜索关键词是关键。我会尝试搜索算法相关AES、Cipher、encrypt、decrypt、CBC、PKCS5Padding、SecretKeySpec、IvParameterSpec。网络库相关如果App使用了OkHttp、Retrofit等网络库可以搜索它们的拦截器Interceptor类加密逻辑常常封装在这里。常量关键词搜索抓包看到的URL片段、自定义Header的名字。找到使用这些常量的地方就能定位到发起网络请求的代码附近加密逻辑很可能就在其前后。字符串模糊搜索有时密钥或IV会以字符串常量形式硬编码在代码中虽然这不安全但确实存在可以搜索一些看起来像密钥的字符串如16、24、32位的随机字符串。动态验证还原算法根据代码分析找到的疑似加密函数、密钥和IV用Python编写脚本尝试对一段已知的明文比如{action:query}进行加密看结果是否和抓包到的请求体一致。这是一个反复试错和验证的过程。完整还原当加密请求成功模拟后再用同样的逻辑去解密服务器的响应确保能拿到可读的JSON数据。注意整个分析过程必须在法律允许和个人授权的设备上进行。仅用于学习与研究目的切勿用于非法爬取、侵犯隐私或破坏系统。3. 实战拆解定位加密逻辑与关键参数现在我们进入实战环节。假设目标APK文件名为airline_app.apk。3.1 第一步抓包建立直观认识我打开Fiddler设置好代理并在手机上安装Fiddler的根证书以便解密HTTPS流量。然后打开航空App进行一次航班查询。在Fiddler中我找到了对应的请求URL:https://api.xxx-air.com/flight/search请求方法: POST请求头:Content-Type: application/x-www-form-urlencoded请求体:data7b5d727a...很长一串Hex字符串响应体:0a3c8f1b...同样是一串Hex字符串很好请求体和响应体都是纯粹的十六进制字符串。这强烈暗示着数据经过加密后将字节数组直接转换成了Hex字符串进行传输。Content-Type是常见的表单格式说明data这个参数名是固定的。3.2 第二步静态分析大海捞针找到“钥匙”将airline_app.apk用 Jadx-GUI 打开。面对成千上万个类直接找加密逻辑如同大海捞针。我们需要用关键词来缩小范围。策略一搜索网络接口地址在Jadx的搜索框里搜索URL的一部分比如flight/search。运气不错我们找到了一个类com.xxxair.network.api.FlightApiService。这里通常使用Retrofit的注解定义接口。public interface FlightApiService { POST(flight/search) CallResponseBody searchFlights(Field(data) String str); }这证实了我们的抓包观察接口需要一个名为data的字段。但加密逻辑不在这里Retrofit只是声明。策略二搜索加密相关类和关键词接下来搜索AES。出现了几十个结果。我们需要有策略地筛选。优先查看那些名称中包含Encrypt、Decrypt、Crypto、Security、HttpInterceptor的类。我点开了一个名为com.xxxair.security.AESCryptoUtil的类。Bingo这看起来就是我们要找的核心类。public class AESCryptoUtil { private static final String AES_MODE AES/CBC/PKCS5Padding; private static final String CHARSET UTF-8; private static final String HEX 0123456789ABCDEF; private static byte[] iv hexStringToBytes(00000000000000000000000000000000); private static byte[] key hexStringToBytes(0123456789ABCDEF0123456789ABCDEF); public static String encrypt(String plainText) { // ... 加密实现 } public static String decrypt(String cipherText) { // ... 解密实现 } private static byte[] hexStringToBytes(String s) { // ... Hex转换 } }关键发现算法模式AES/CBC/PKCS5Padding。这是AES的CBC模式使用PKCS5填充。密钥Key0123456789ABCDEF0123456789ABCDEF。这是一个32字节256位的Hex字符串意味着使用的是AES-256。初始向量IV00000000000000000000000000000000。这是一个16字节全零的IV。这是一个重要的安全隐患点静态的、可预测的IV会降低CBC模式的安全性但在逆向分析中这让我们松了口气因为IV是固定的。编码方式从类名和HEX常量看加密后的字节数组被转换成了十六进制字符串。encrypt和decrypt方法应该就是处理这个流程的。3.3 第三步深入加密方法理解完整流程我们双击encrypt方法查看其具体实现。public static String encrypt(String plainText) { try { Cipher cipher Cipher.getInstance(AES_MODE); SecretKeySpec secretKeySpec new SecretKeySpec(key, AES); IvParameterSpec ivParameterSpec new IvParameterSpec(iv); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] encryptedBytes cipher.doFinal(plainText.getBytes(CHARSET)); return bytesToHex(encryptedBytes); // 关键转成Hex } catch (Exception e) { e.printStackTrace(); return null; } }逻辑非常清晰获取一个AES/CBC/PKCS5Padding模式的Cipher实例。用找到的key和iv构造SecretKeySpec和IvParameterSpec。初始化Cipher为加密模式。将明文字符串按UTF-8编码成字节数组然后进行加密。将加密后的字节数组通过bytesToHex方法转换成十六进制字符串返回。decrypt方法是逆过程接收Hex字符串先转回字节数组然后用Cipher解密最后用UTF-8解码成字符串。至此我们已经从代码层面完全掌握了加密算法、密钥、IV和编码方式。但还有一个问题这个AESCryptoUtil是在哪里被调用的数据在发送前是如何被组织并加密的3.4 第四步定位调用链理解数据封装我们需要找到哪里调用了AESCryptoUtil.encrypt()。在Jadx中可以右键点击encrypt方法选择“查找用例”Find Usage。用例显示它被一个名为com.xxxair.network.HttpEncryptInterceptor的类调用。这听起来像一个OkHttp的拦截器Interceptor正是我们猜测的加密发生的地方。打开这个拦截器类public class HttpEncryptInterceptor implements Interceptor { Override public Response intercept(Chain chain) throws IOException { Request originalRequest chain.request(); RequestBody originalBody originalRequest.body(); // 只对特定请求进行加密例如包含敏感信息的POST请求 if (originalBody ! null shouldEncrypt(originalRequest.url())) { // 1. 将RequestBody的内容读取出来通常是JSON字符串 Buffer buffer new Buffer(); originalBody.writeTo(buffer); String plainJson buffer.readUtf8(); // 2. 使用AESCryptoUtil进行加密 String encryptedData AESCryptoUtil.encrypt(plainJson); // 3. 构建新的表单请求体只有一个字段“data” FormBody newBody new FormBody.Builder() .add(data, encryptedData) .build(); // 4. 创建新的请求替换掉原来的请求体 Request newRequest originalRequest.newBuilder() .method(originalRequest.method(), newBody) .build(); return chain.proceed(newRequest); } // 不需要加密的请求直接放行 return chain.proceed(originalRequest); } private boolean shouldEncrypt(HttpUrl url) { // 判断逻辑例如某些特定路径的POST请求需要加密 return url.encodedPath().contains(/flight/) || url.encodedPath().contains(/user/); } }豁然开朗这个拦截器完美解释了我们在抓包中看到的一切App内部构造了一个标准的JSON请求体明文。在请求发出前被这个HttpEncryptInterceptor拦截。拦截器判断如果URL需要加密如/flight/search就将JSON字符串用我们找到的AESCryptoUtil加密成Hex字符串。然后它构建一个新的FormBody只包含一个键值对data加密后的Hex字符串。最后用这个新的请求体替换原请求体继续发送。服务器端收到data参数后会用相同的密钥和IV进行解密得到原始JSON。处理完业务逻辑后再将响应JSON加密成Hex字符串返回。App收到响应后再由拦截器或某个地方调用AESCryptoUtil.decrypt()解密。整个通信链路至此完全清晰。4. 算法还原与Python模拟实现理论分析完毕现在需要用代码来验证我们的发现。我们将使用Python的pycryptodome库来模拟整个加密过程。首先安装必要的库pip install pycryptodome然后编写我们的模拟加密脚本aes_simulate.pyfrom Crypto.Cipher import AES from Crypto.Util.Padding import pad import binascii class AirlineAppCrypto: 模拟航空App的AES-CBC加密解密 基于静态分析结果 算法: AES-256-CBC 密钥: 0123456789ABCDEF0123456789ABCDEF (32字节 Hex) IV: 00000000000000000000000000000000 (16字节 Hex) 填充: PKCS7 (PKCS5) 输出: 加密后字节数组的十六进制(Hex)字符串表示 输入: 明文为UTF-8编码的字符串 def __init__(self): # 将Hex字符串转换为字节数组 self.key binascii.unhexlify(0123456789ABCDEF0123456789ABCDEF) self.iv binascii.unhexlify(00000000000000000000000000000000) # 确保密钥和IV长度正确 if len(self.key) not in [16, 24, 32]: raise ValueError(fInvalid key length: {len(self.key)} bytes) if len(self.iv) ! AES.block_size: # AES.block_size 16 raise ValueError(fInvalid IV length: {len(self.iv)} bytes) def encrypt(self, plaintext: str) - str: 加密字符串 - Hex字符串 # 1. 将明文字符串编码为UTF-8字节 plaintext_bytes plaintext.encode(utf-8) # 2. 创建AES-CBC加密器 cipher AES.new(self.key, AES.MODE_CBC, self.iv) # 3. 对明文进行PKCS7填充并加密 # PKCS7填充确保数据长度是16字节的倍数 ciphertext_bytes cipher.encrypt(pad(plaintext_bytes, AES.block_size)) # 4. 将加密后的字节数组转换为十六进制字符串 ciphertext_hex binascii.hexlify(ciphertext_bytes).decode(utf-8).upper() return ciphertext_hex def decrypt(self, ciphertext_hex: str) - str: 解密Hex字符串 - 字符串 # 1. 将Hex字符串转换回字节数组 ciphertext_bytes binascii.unhexlify(ciphertext_hex) # 2. 创建AES-CBC解密器 cipher AES.new(self.key, AES.MODE_CBC, self.iv) # 3. 解密 decrypted_padded_bytes cipher.decrypt(ciphertext_bytes) # 4. 去除PKCS7填充 from Crypto.Util.Padding import unpad plaintext_bytes unpad(decrypted_padded_bytes, AES.block_size) # 5. 解码为UTF-8字符串 plaintext plaintext_bytes.decode(utf-8) return plaintext # 测试代码 if __name__ __main__: crypto AirlineAppCrypto() # 模拟一个查询请求的JSON test_json {depCity:PEK,arrCity:SHA,depDate:2023-10-01,adultNum:1} print(f原始明文: {test_json}) # 加密 encrypted_hex crypto.encrypt(test_json) print(f加密后Hex: {encrypted_hex}) # 解密验证 decrypted_json crypto.decrypt(encrypted_hex) print(f解密后明文: {decrypted_json}) # 验证加解密一致性 assert test_json decrypted_json, 加解密验证失败 print(加解密验证成功) # 尝试解密一个抓包到的响应假设我们有一个 # captured_response_hex 0a3c8f1b... # 从Fiddler复制过来的Hex串 # try: # decrypted_response crypto.decrypt(captured_response_hex) # print(f服务器响应解密结果: {decrypted_response}) # except Exception as e: # print(f解密响应时出错: {e})运行这个脚本如果我们的分析完全正确那么加密test_json得到的encrypted_hex字符串其格式应该和我们抓包看到的data参数值类似都是大写Hex。将这个encrypted_hex再解密回来应该得到原始的test_json。我们可以做一个更直接的验证从Fiddler中复制一个真实的请求data参数值那一长串Hex替换掉脚本中的test_json先用decrypt方法解密看看得到的是不是一段有意义的JSON。如果能成功解密出JSON并且JSON结构符合航班查询请求的格式那就证明我们的还原工作100%正确。5. 逆向过程中的常见问题与解决策略在实际操作中很少能像上面这个例子一样顺利。以下是几个我经常遇到的“坑”以及应对策略。5.1 代码混淆名字都变成了a,b,c很多商业App会对代码进行混淆类名、方法名、字段名都变成了无意义的a、b、c、aa等。这大大增加了阅读难度。应对策略搜索字符串常量密钥、IV、API地址等硬编码的字符串通常不会被混淆。直接搜索这些字符串是突破口。例如即使类名是a但里面的字符串AES/CBC/PKCS5Padding依然可以搜到。关注方法签名和调用关系虽然名字变了但方法的参数类型、返回值类型以及它被谁调用、调用了谁的关系还在。在Jadx中可以查看方法的“调用关系图”帮助理解逻辑。结合抓包动态分析如果静态分析太难可以尝试使用动态调试工具如Frida挂钩Hook关键API。例如可以Hook Android的Cipher.getInstance()、Cipher.init()、Cipher.doFinal()等方法直接打印出运行时使用的参数算法、密钥、IV。这属于更高级的技巧但非常有效。5.2 密钥非硬编码动态获取或来自服务器更安全的应用不会将密钥硬编码在代码里。它们可能从服务器动态获取App启动时或首次使用加密功能前从服务器获取一个临时的密钥或密钥种子。本地计算生成通过一个固定的种子如设备ID、App版本号加上某种算法如HMAC在本地计算生成密钥。应对策略搜索网络请求在代码中搜索获取密钥的API地址。可以搜索key、secret、token等关键词或者搜索在App启动时如Application类的onCreate方法或登录后发起的网络请求。分析密钥生成函数如果密钥是本地生成的搜索MessageDigest、HMAC、PBKDF2等关键词找到密钥派生函数。需要逆向这个函数的逻辑。动态Hook同样使用FridaHook密钥生成函数或网络请求返回处直接获取运行时产生的密钥值。5.3 加密模式或填充方式不同除了AES/CBC/PKCS5Padding还可能遇到AES/ECB/PKCS5PaddingECB模式不需要IV安全性较低。AES/CFB/NoPadding流加密模式。AES/GCM/NoPadding带认证的加密模式除了密钥和IV在GCM中常称为Nonce还可能有一个认证标签Tag。应对策略仔细查看Cipher.getInstance()的参数这是最直接的证据。观察数据特征ECB模式下相同的明文块会产生相同的密文块。如果抓包发现密文中有重复的片段可能是ECB。GCM模式通常输出密文和认证标签两部分传输格式可能不同。查阅官方文档或SDK如果App集成了某个第三方安全SDK如某盾、某加固其加密方式可能有公开文档或已知模式。5.4 编码或传输前的额外处理有时加密后的数据不会直接作为Hex或Base64发送可能还会进行自定义的编码或混淆如字节顺序打乱、与某个固定字节异或。拼接上时间戳、版本号等其他信息再进行整体编码。应对策略对比分析用我们模拟加密的结果和抓包的真实数据对比。如果长度一致但内容不同或者长度就不对说明还有额外处理。逆向bytesToHex或类似方法仔细查看将加密字节数组转换成最终输出字符串的那个方法里面可能藏有额外的处理逻辑。Hook加密函数的输出用FridaHookCipher.doFinal()方法获取最原始的加密字节数组然后和App最终发送的字符串对比就能发现中间的转换步骤。6. 从还原到应用构建可用的数据获取脚本成功还原加密算法后我们的目标就达成了大半。接下来我们可以构建一个完整的Python脚本来模拟App的行为自动获取数据。这个脚本的核心流程如下构造请求明文按照App的接口文档或通过分析其他请求推断出的格式构造JSON请求体。加密使用我们还原的AirlineAppCrypto类对JSON字符串进行加密得到Hex字符串。发送请求使用requests库以application/x-www-form-urlencoded格式发送data加密后Hex字符串的POST请求。解密响应收到服务器的Hex响应后用同一个AirlineAppCrypto类解密得到JSON明文。解析数据解析JSON提取所需的航班信息。下面是一个简化的示例框架flight_fetcher.pyimport requests import json from aes_simulate import AirlineAppCrypto # 导入我们之前写的加密类 class FlightFetcher: def __init__(self, base_urlhttps://api.xxx-air.com): self.base_url base_url self.crypto AirlineAppCrypto() self.session requests.Session() # 设置必要的请求头模拟真实App self.session.headers.update({ User-Agent: AirlineApp/5.1.0 (Android 12; Mobile), Content-Type: application/x-www-form-urlencoded, Accept-Encoding: gzip, }) def search_flights(self, dep_city, arr_city, dep_date): 查询航班 # 1. 构造请求JSON request_data { header: { appVersion: 5.1.0, deviceId: simulated_device_id_123, token: # 如果需要登录这里需要有效的token }, body: { depCityCode: dep_city, arrCityCode: arr_city, depDate: dep_date, flightType: OW, # One Way adultCount: 1, childCount: 0, infantCount: 0 } } plain_json json.dumps(request_data, separators(,, :)) # 紧凑格式和App保持一致 print(f请求明文: {plain_json}) # 2. 加密 encrypted_data self.crypto.encrypt(plain_json) print(f加密后数据: {encrypted_data[:50]}...) # 打印前50位 # 3. 发送请求 post_data {data: encrypted_data} try: response self.session.post( f{self.base_url}/flight/search, datapost_data, timeout10 ) response.raise_for_status() # 检查HTTP错误 except requests.exceptions.RequestException as e: print(f网络请求失败: {e}) return None # 4. 解密响应 response_hex response.text.strip() # 假设响应体就是纯Hex字符串 try: decrypted_response self.crypto.decrypt(response_hex) result_json json.loads(decrypted_response) return result_json except (ValueError, json.JSONDecodeError, KeyError) as e: print(f解密或解析响应失败: {e}) print(f原始响应Hex: {response_hex[:100]}...) return None def parse_flight_info(self, result_json): 解析航班信息简单示例 if not result_json or result_json.get(code) ! 0: print(f请求失败: {result_json}) return flights result_json.get(data, {}).get(flightList, []) for flight in flights: print(f航班号: {flight.get(flightNo)}) print(f 起飞: {flight.get(depTime)} - 到达: {flight.get(arrTime)}) print(f 价格: {flight.get(adultPrice, {}).get(salePrice)}) print(- * 30) if __name__ __main__: fetcher FlightFetcher() # 模拟查询北京到上海2023-10-01的航班 result fetcher.search_flights(PEK, SHA, 2023-10-01) if result: fetcher.parse_flight_info(result)重要提醒与心得请求头Headers除了Content-TypeUser-Agent、X-Requested-With、Referer等头部也可能被服务器用于校验请求来源。需要从抓包中完整复制否则可能收到403或400错误。Token/Session管理很多涉及用户数据的接口需要登录后的token。你需要先模拟登录流程通常也是一个加密的接口获取token或sessionId并在后续请求的Header或Body中带上。频率限制与反爬航空公司对这类请求通常有频率限制。你的脚本需要加入合理的延时如time.sleep并处理可能出现的验证码这通常需要更复杂的图像识别或第三方打码平台超出了本文范围。数据解析服务器的响应JSON结构可能非常复杂需要仔细分析其嵌套结构才能准确提取出票价、航班状态、舱位等信息。法律与道德边界务必确保你的数据获取行为符合该航空公司的服务条款并且仅用于个人学习、研究或法律允许的范围内。大规模、高频率的爬取可能对服务器造成压力并引发法律风险。通过以上步骤我们完成了一次完整的“AI逆向实战”。从观察现象、提出假设到静态分析、定位关键代码再到算法还原、代码模拟最后整合成可用的工具。这个过程不仅锻炼了技术能力更重要的是培养了一种系统性的问题解决思路。在面对任何未知的协议或加密时这套“由外而内、动静结合、大胆假设、小心验证”的方法论都是非常有效的。