逆向分析API签名与加密机制:从抓包到算法还原实战

📅 2026/7/6 21:36:53
逆向分析API签名与加密机制:从抓包到算法还原实战
1. 项目概述与背景最近在分析一个翻译平台的接口时遇到了一个典型的“签名加密”双重防护机制。简单来说就是客户端在发送请求时不仅对请求体比如你要翻译的文本进行了加密还额外生成了一个名为sign的签名参数用于验证请求的完整性和合法性。服务器收到请求后会按照同样的规则重新计算一遍sign如果对不上就直接拒绝请求。这种设计在涉及核心业务、数据安全或反爬虫的场景中非常普遍比如电商下单、内容发布、数据查询等接口。我拿到的这个案例请求体是经过某种方式加密的同时请求参数里还带了一个sign。我们的目标很明确逆向分析出sign的生成算法以及请求体的解密方法。最终目的是能够脱离原客户端用我们自己的脚本或程序模拟构造出合法的请求实现自动化调用。这个过程业内通常称为“脱机”或“协议还原”。对于开发者而言理解这套机制不仅能用于安全研究测试自家接口的健壮性也能在合法合规的前提下实现一些自动化工具比如批量翻译、数据同步等。整个分析过程我会从最基础的抓包开始带你一步步定位关键代码分析加密逻辑并用代码复现。我会尽量还原我当时踩过的坑和解决问题的思路而不仅仅是扔给你一个最终的结果。毕竟授人以鱼不如授人以渔掌握这套分析方法以后遇到类似的加密你也能自己搞定。2. 逆向分析的核心思路与工具准备逆向分析这类网络协议核心思路可以概括为“由外而内动态追踪”。我们不是去硬啃编译后的二进制代码那太痛苦了而是利用工具在程序运行时去“窥探”它内部的关键函数调用和数据处理过程。2.1 核心思路拆解抓包定位目标首先我们需要用抓包工具如 Charles、Fiddler、mitmproxy捕获到目标应用发出的网络请求。这一步是为了确认加密和签名的存在并拿到最原始的请求样本包括加密后的请求体data或body和sign等参数。寻找加密/签名入口点请求是从客户端代码的某个地方发出的。我们需要找到负责网络请求的类和方法。通常一个规范的应用会有一个统一的网络请求库或工具类比如叫HttpClient、NetworkManager、ApiService。我们的目标是找到这个类里最终组装请求、计算sign、加密body的地方。动态调试与Hook这是最关键的一步。我们使用动态调试工具对于iOS是frida、MonkeyDev对于Android是frida、Xposed来“钩住”Hook我们怀疑的加密或签名函数。当应用调用这些函数时我们的钩子代码就能截获传入的参数和返回的结果。通过对比抓包数据和我们Hook到的数据就能反推出算法逻辑。静态分析辅助如果动态Hook不顺利或者想更深入地理解代码结构就需要进行静态分析。对于iOS应用可以用class-dump导出头文件查看类和方法名用IDA或Hopper进行反编译阅读伪代码。对于Android应用则用jadx或JEB进行反编译。静态分析像是看地图动态调试像是开着导航走路两者结合效率最高。算法还原与复现一旦通过Hook拿到了加密前的明文、密钥或者签名前的原始字符串剩下的工作就是用代码如Python、JavaScript还原这个算法。通常需要模拟参数排序、字符串拼接、加盐salt、然后进行MD5、SHA256等哈希运算或者AES、RSA等加密操作。2.2 工具选型与配置工欲善其事必先利其器。以下是本次分析会用到的核心工具及其作用抓包工具 (Charles)用于拦截和查看HTTPS流量。需要给测试设备安装Charles的根证书并在设备上信任该证书才能解密HTTPS内容。这是所有分析的起点。逆向分析框架 (frida)这是一个跨平台的动态代码插桩工具。我们编写一段JavaScript脚本注入到目标进程中可以Hook几乎任何函数查看参数、返回值甚至修改逻辑。它比传统的Logos仅限iOS更通用和强大。反编译工具iOS:class-dump导出Objective-C头文件IDA Pro或Hopper Disassembler反汇编并生成伪代码。Android:jadx-gui免费且强大的Java反编译工具JEB商业级反编译效果更好。编程环境 (Python)用于编写算法还原脚本和最终的模拟请求代码。requests库用于发送HTTP请求hashlib用于哈希计算pycryptodome或cryptography用于处理AES等加密算法。注意所有分析工作必须在你自己拥有完全控制权的设备和应用上进行或者针对明确授权可以进行安全测试的目标。未经授权对他人软件进行逆向工程可能涉及法律风险。2.3 目标请求样本分析通过Charles抓包我们捕获到翻译接口的一次请求样本POST https://api.example-translate.com/v1/translate Headers: Content-Type: application/json User-Agent: TranslateClient/1.0.0 ... (其他头部) Body (加密后): { data: U2FsdGVkX12w4bH6Lm9z...很长一串Base64编码的字符串, sign: a7d83f4c6e2b9a1d5c8f7e0b3a2d1c4e, timestamp: 1685432100000, appKey: test_2023 }观察这个样本我们可以得到几个关键信息主要的业务数据待翻译文本、目标语言等被加密后放在了data字段。存在独立的sign、timestamp、appKey字段它们很可能是明文用于签名验证。sign看起来是一个32位的十六进制字符串极有可能是MD5或SHA1的结果。timestamp是毫秒级时间戳常用于防止请求重放Replay Attack。我们的突破口很可能就在sign上。因为sign的生成规则一旦被破解我们就能构造出合法的签名。同时sign的生成过程很可能引用了data的明文或密文这能为我们解密data提供线索。3. 动态Hook定位签名函数动态分析是最高效的手段。我们假设目标应用是iOS应用Android思路类似工具换为frida-android。我们的首要目标是找到计算sign的函数。3.1 使用frida进行函数追踪首先我们需要将frida-server安装到越狱的iOS设备上并通过frida-ps -U命令找到目标应用的进程名例如com.example.translateapp。然后我们编写一个Frida脚本用于追踪所有可能计算MD5或哈希的函数。因为sign看起来像MD5我们可以从常见的哈希函数入手。// find_hash.js Java.perform(function() { // 如果是Android可以Hook Java的MessageDigest类 // var MessageDigest Java.use(java.security.MessageDigest); // 这里以iOS的Objective-C为例我们需要Hook CommonCrypto中的函数或自定义的包装函数 // 但更通用的方法是Hook所有方法调用过滤出我们感兴趣的 // 先尝试Hook NSData的MD5相关方法如果应用自己实现了 // 或者Hook一个常见的第三方加密库比如CryptoSwift、OpenSSL的封装 // 这里我们采用一个更暴力的方法Hook所有Objective-C方法打印包含“md5”、“sign”、“encrypt”等关键词的调用 var className “你的网络请求类名”; // 例如 “ZXHttpRequest”这需要从class-dump或猜测获得 var hookClass ObjC.classes[className]; if (hookClass) { var methods hookClass.$ownMethods; for (var i 0; i methods.length; i) { var methodName methods[i].toString(); if (methodName.toLowerCase().includes(sign) || methodName.toLowerCase().includes(md5) || methodName.toLowerCase().includes(hash)) { console.log(“[] Found potential method: “ methodName); // 动态Hook这个方法 Interceptor.attach(ObjC.classes[className][methodName].implementation, { onEnter: function(args) { console.log(“\n*** [Enter] “ methodName “ ***”); // 打印参数args[0]是self, args[1]是selector, args[2]开始是参数 for (var j 2; j 5; j) { // 假设我们只看前几个参数 try { var arg args[j]; if (arg ! null) { var argStr arg.toString(); if (argStr.length 500) { // 防止打印过长数据 console.log(arg[${j}]: ${argStr}); } else { console.log(arg[${j}]: (data too long)); } } } catch(e) {} } }, onLeave: function(retval) { try { var retStr retval.toString(); if (retStr.length 500) { console.log([Leave] Return: ${retStr}); } else { console.log([Leave] Return: (data too long)); } } catch(e) {} console.log(“*** [Leave] “ methodName “ ***\n”); } }); } } } else { console.log(“[-] Class not found: “ className); } });运行脚本frida -U -l find_hash.js com.example.translateapp。然后在手机上操作应用触发一次翻译请求。观察控制台输出。3.2 分析Hook结果与定位关键函数理想情况下我们会看到类似下面的输出[] Found potential method: generateSignWithParams:timestamp: *** [Enter] generateSignWithParams:timestamp: *** arg[2]: { “text” : “Hello World”, “to” : “zh-CN”, “from” : “en” } arg[3]: 1685432100000 [Leave] Return: a7d83f4c6e2b9a1d5c8f7e0b3a2d1c4e *** [Leave] generateSignWithParams:timestamp: ***Bingo我们一下子就找到了生成sign的函数generateSignWithParams:timestamp:并且看到了它的输入参数一个参数字典和timestamp返回值正是我们抓包看到的sign值。如果第一次没有成功可能需要调整Hook的类名。我们可以通过class-dump导出应用的所有头文件搜索包含 “Http”, “Network”, “Request”, “Sign”, “Encrypt” 等关键词的类名然后逐一尝试。3.3 深入Hook获取完整签名串找到了签名函数但我们还不知道它内部是如何计算的。它可能只是调用了另一个底层的MD5计算方法。我们需要继续深入。修改脚本直接Hook常见的MD5计算函数。在iOS中应用可能会使用CC_MD5来自CommonCrypto库或者自己实现或者用第三方库。// hook_md5.js // 拦截 CC_MD5 函数 (这是一个C函数需要用到Module.findExportByName) var cc_md5 Module.findExportByName(null, ‘CC_MD5’); if (cc_md5) { Interceptor.attach(cc_md5, { onEnter: function(args) { // CC_MD5(const void *data, CC_LONG len, unsigned char *md) this.dataPtr args[0]; this.length args[1].toInt32(); this.mdPtr args[2]; if (this.length 1024) { // 只处理较短的输入避免打印过多数据 var inputData Memory.readByteArray(this.dataPtr, this.length); console.log(“\n[CC_MD5 Input]”); console.log(hexdump(inputData, { offset: 0, length: this.length, ansi: true })); // 也可以尝试转成字符串 try { var inputStr Memory.readUtf8String(this.dataPtr); console.log(“As String: “ inputStr); } catch(e) {} } }, onLeave: function(retval) { var outputData Memory.readByteArray(this.mdPtr, 16); // MD5输出16字节 console.log(“[CC_MD5 Output] “ hexdump(outputData)); } }); }再次运行脚本并触发请求。这次我们很可能看到CC_MD5被调用并打印出计算MD5之前的原始字符串。这个字符串就是生成sign的关键。假设我们Hook到的输入字符串是appKeytest_2023dataU2FsdGVkX12w4bH6Lm9z...timestamp1685432100000secretxyz789那么签名规则就非常清晰了将appKey,data,timestamp按字母顺序或固定顺序拼接成keyvalue的形式最后追加上一个密钥secret然后对这个整个字符串取MD5。实操心得在实际操作中可能一次Hook不到CC_MD5因为应用可能使用了其他哈希函数如CC_SHA256或者使用了静态链接库函数符号名不同。这时需要结合反编译工具查看generateSignWithParams:timestamp:方法内部调用了哪个哈希函数再用Frida去Hook那个具体的函数地址。4. 静态分析验证与解密函数定位动态Hook给了我们明确的线索但为了确保万无一失并且找到请求体data的解密方法我们需要进行静态分析来验证和深化理解。4.1 使用class-dump导出头文件对于iOS应用将.ipa文件解压找到其中的主二进制文件通常位于Payload/xxx.app/xxx。使用class-dump命令导出头文件class-dump -H TargetApp -o ./headers/在输出的头文件中我们可以搜索之前Hook到的类名和方法名比如generateSignWithParams:timestamp:。找到对应的.h文件查看其完整定义了解参数和返回类型。这能帮助我们确认动态分析的结果。4.2 使用IDA进行反编译与逻辑分析class-dump只能看到接口看不到实现。我们需要用IDA加载主二进制文件进行反编译。用IDA打开二进制文件分析完成后在左侧的Functions窗口搜索generateSignWithParams或我们找到的方法名。找到函数后按F5键生成伪代码。IDA的伪代码虽然有些晦涩但结合我们动态分析的结果就能看懂。在伪代码中我们会看到字符串拼接的逻辑很可能使用了stringByAppendingFormat:或stringWithFormat:以及最终的哈希函数调用如CC_MD5或[SomeClass md5:]。关键点在伪代码中寻找硬编码的字符串比如拼接规则中的分隔符、以及最重要的secret或salt盐值。这个盐值可能就是我们在Hook中看到的xyz789。它通常以字符串常量的形式存储在代码段里。4.3 定位请求体解密函数sign解决了接下来是data的解密。我们同样从抓包知道data是Base64编码的密文。解密函数可能叫decryptData:、aesDecrypt:或decodeBody:等。我们可以用类似HookCC_MD5的方法去Hook常见的对称解密函数比如AES相关的CCCrypt。但更有效的方法是在IDA中搜索与加密相关的字符串常量如 “AES”, “decrypt”, “key”, “iv”, “mode” 等。或者直接查看网络请求类我们之前找到的在发送请求前对请求体做了什么处理。通常在设置HTTPBody之前会有一步加密操作。假设我们在IDA中跟踪代码发现了一个函数调用[EncryptionUtil aesEncrypt:plainText key:key iv:iv]。那么解密函数很可能就是对应的aesDecrypt:cipherText key:key iv:iv。4.4 获取解密密钥Key和初始化向量IV这是整个解密环节最核心的一步。密钥和IV可能硬编码在代码中这是最简单也最不安全的情况。我们直接在IDA的伪代码或字符串窗口中搜索可能的密钥字符串。从服务器动态获取应用启动时或首次使用加密功能前从服务器获取一个密钥然后保存在内存或本地如Keychain。这种情况需要Hook获取密钥的网络请求或存储函数。由固定种子生成使用appKey、设备ID等固定信息通过一个算法生成。这就需要我们逆向这个生成算法。在我们的案例中假设通过HookaesEncrypt:key:iv:函数我们得到了以下信息onEnter: arg[2] (plainText): {text:Hello World,to:zh-CN} onEnter: arg[3] (key): 0123456789abcdef0123456789abcdef (32字节16进制字符串形式) onEnter: arg[4] (iv): 0000000000000000 (16字节) onLeave: retval (cipherText Base64): U2FsdGVkX12w4bH6Lm9z...至此我们拿到了AES解密所需的全部要素算法AES、模式需要结合代码判断常见CBC、密钥key、初始化向量iv。data字段的内容就是明文JSON经过AES-CBC-PKCS7Padding加密后再做Base64编码的结果。5. 算法还原与Python代码实现掌握了所有规则和参数后我们就可以用Python来还原整个请求的构造过程了。5.1 还原签名Sign生成算法根据动态Hook和静态分析的结果我们确认签名规则如下将所有待签名参数appKey,data,timestamp按参数名的ASCII码升序排列。将排序后的参数拼接成key1value1key2value2...的格式。在拼接后的字符串末尾加上一个固定的secret盐值。对最终字符串进行MD5哈希32位小写十六进制。import hashlib import time import json from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 def generate_sign(params, secret): 生成请求签名 :param params: dict, 待签名的参数字典 :param secret: str, 密钥盐值 :return: str, 32位小写MD5签名 # 1. 按key排序 sorted_keys sorted(params.keys()) # 2. 拼接 keyvalue sign_str ‘’.join([f“{k}{params[k]}” for k in sorted_keys]) # 3. 末尾加盐 sign_str secret # 4. MD5哈希 m hashlib.md5() m.update(sign_str.encode(‘utf-8’)) return m.hexdigest() # 测试签名 test_params { “appKey”: “test_2023”, “data”: “U2FsdGVkX12w4bH6Lm9z...“, # 这里是加密后的data签名时用的就是它 “timestamp”: “1685432100000” } secret “xyz789” # 从逆向分析中获取的盐值 calculated_sign generate_sign(test_params, secret) print(f“Generated sign: {calculated_sign}“) # 应该等于抓包中的 a7d83f4c6e2b9a1d5c8f7e0b3a2d1c4e5.2 还原请求体Data加解密算法我们确认加密算法是AES-256-CBC密钥为32字节IV为16字节零向量使用PKCS7填充。class TranslateCrypto: def __init__(self, key_hex, iv_hex‘00000000000000000000000000000000’): self.key bytes.fromhex(key_hex) # 32字节 for AES-256 self.iv bytes.fromhex(iv_hex) # 16字节 def encrypt(self, plaintext_dict): 加密请求体 # 将字典转为JSON字符串 json_str json.dumps(plaintext_dict, ensure_asciiFalse, separators(‘,’, ‘:’)) # AES-CBC加密 cipher AES.new(self.key, AES.MODE_CBC, self.iv) # PKCS7填充 padded_data pad(json_str.encode(‘utf-8’), AES.block_size) ciphertext cipher.encrypt(padded_data) # Base64编码 encrypted_b64 base64.b64encode(ciphertext).decode(‘utf-8’) return encrypted_b64 def decrypt(self, encrypted_b64): 解密响应体如果需要 ciphertext base64.b64decode(encrypted_b64) cipher AES.new(self.key, AES.MODE_CBC, self.iv) padded_plaintext cipher.decrypt(ciphertext) # 去除PKCS7填充 # 注意这里使用简单的切片生产环境应用使用unpad函数 pad_len padded_plaintext[-1] plaintext padded_plaintext[:-pad_len] return json.loads(plaintext.decode(‘utf-8’)) # 测试加解密 crypto TranslateCrypto(key_hex‘0123456789abcdef0123456789abcdef’) # 加密 request_body { “text”: “Hello World”, “from”: “en”, “to”: “zh-CN” } encrypted_data crypto.encrypt(request_body) print(f“Encrypted data: {encrypted_data}“) # 解密 (模拟服务器响应) # 假设服务器返回的data字段也是同样方式加密的 # response_encrypted_data ‘...‘ # decrypted_response crypto.decrypt(response_encrypted_data) # print(f“Decrypted response: {decrypted_response}“)5.3 组装完整请求现在我们可以将签名生成和请求体加密结合起来模拟一个完整的合法请求。import requests def build_translate_request(text, from_lang, to_lang, app_key, secret, aes_key_hex): “”“构建一个完整的翻译请求参数”“” crypto TranslateCrypto(key_hexaes_key_hex) # 1. 构造业务参数并加密 business_params { “text”: text, “from”: from_lang, “to”: to_lang } encrypted_data crypto.encrypt(business_params) # 2. 构造待签名参数 timestamp str(int(time.time() * 1000)) # 毫秒时间戳 sign_params { “appKey”: app_key, “data”: encrypted_data, “timestamp”: timestamp } # 3. 生成签名 sign generate_sign(sign_params, secret) # 4. 组装最终请求参数 final_payload { “data”: encrypted_data, “sign”: sign, “timestamp”: timestamp, “appKey”: app_key } return final_payload # 配置参数从逆向分析中获得 APP_KEY “test_2023” SECRET “xyz789” AES_KEY_HEX “0123456789abcdef0123456789abcdef” # 构建请求 payload build_translate_request( text“Hello, how are you?”, from_lang“en”, to_lang“zh-CN”, app_keyAPP_KEY, secretSECRET, aes_key_hexAES_KEY_HEX ) print(“Final request payload:“) print(json.dumps(payload, indent2)) # 发送请求 headers { ‘Content-Type’: ‘application/json’, ‘User-Agent’: ‘TranslateClient/1.0.0 (Simulated)’ } try: # 注意这里的URL是示例实际需要替换为真正的接口地址 response requests.post(‘https://api.example-translate.com/v1/translate‘, jsonpayload, headersheaders, timeout10) print(f“\nStatus Code: {response.status_code}“) print(f“Response: {response.text}“) # 如果响应中的data字段也是加密的可以尝试解密 # resp_json response.json() # if ‘data’ in resp_json: # decrypted_resp crypto.decrypt(resp_json[‘data’]) # print(f“Decrypted response data: {decrypted_resp}“) except requests.exceptions.RequestException as e: print(f“Request failed: {e}“)6. 常见问题、排查技巧与进阶思考在实际操作中你几乎一定会遇到各种问题。下面是我总结的一些常见坑点和排查技巧。6.1 常见问题与解决方案问题现象可能原因排查思路与解决方案Hook不到目标函数1. 类名/方法名不对。2. 函数是C函数或静态链接符号名不同。3. 应用有反调试/反Hook机制。1. 用class-dump或jadx仔细核对类名方法名注意大小写。2. 在IDA中查看函数调用关系找到底层C函数用Module.findExportByName或Module.enumerateImports查找。3. 尝试在应用启动早期注入frida脚本或者使用frida的-f参数在应用启动时附着。对于反调试可能需要使用更隐蔽的Hook方式或patch反调试代码。签名计算正确但服务器不认可1. 参数顺序不对。2. 拼接规则有误如是否包含URL是否对value进行了URL编码。3. 盐值 (secret) 不对或动态变化。4. 使用了不同的哈希算法如SHA256。5. 签名包含的字段不全可能还包含了HTTP头部的某些字段。1. 仔细对比Hook到的原始字符串和抓包参数确认拼接顺序。常见顺序是ASCII升序但也可能是固定顺序。2. 查看服务器端验证代码如果有或通过大量请求样本归纳规则。3. 检查secret是否是动态从服务器获取的。Hook网络请求或本地存储函数。4. Hook其他哈希函数如CC_SHA256。5. 尝试将User-Agent、Content-Type等头部字段也纳入签名计算。解密失败提示Padding错误1. 密钥 (key) 错误。2. 初始化向量 (iv) 错误或不是全零。3. 加密模式不对如不是CBC可能是ECB。4. 填充方式不对如不是PKCS7。5.data字段在Base64解码前可能还有其他处理如去掉了某些字符。1. 反复确认Hook到的密钥值。2. 确认IV值可能来自某个固定字符串或由key衍生。3. 在IDA中查看加密函数调用确认模式常量如kCCOptionPKCS7Padding。4. 尝试其他填充方式如pad函数使用不同的标准。5. 检查抓包到的data字符串看是否有\n、空格等确保Base64解码前的字符串纯净。请求返回“签名过期”或“重放错误”服务器端有防重放机制缓存了已使用的sign或timestamp。确保每次请求的timestamp是新的当前毫秒时间戳。如果服务器时间窗很小如5秒需要保证客户端时间与服务器同步。算法复杂无法直接Hook签名或加密算法被混淆、加固或使用了自定义的汇编实现。1. 尝试使用模拟执行如Unicorn引擎来执行关键的算法代码片段。2. 如果算法是标准算法如AES RSA尝试在黑盒测试中通过输入输出对来推断模式和密钥难度较大。3. 关注算法的输入输出如果无法破解算法本身可以尝试将核心算法代码可能是C/汇编通过frida的Interceptor在内存中dump出来或者用CModule直接调用。6.2 进阶技巧与思考自动化Hook脚本不要每次都手动写Hook脚本。可以编写一个通用的脚本自动枚举所有类和方法过滤出包含“crypt”, “encode”, “decode”, “sign”, “md5”, “sha”, “aes”, “des”等关键词的方法进行Hook并打印日志大大提高发现关键函数的效率。关注密钥管理现代应用越来越倾向于不硬编码密钥。密钥可能来自服务器下发、本地文件加密存储、设备指纹派生、代码混淆后动态解密。要找到它们需要关注应用启动流程、网络请求拦截、以及文件IO操作。协议不止于加密除了签名和加密还要注意其他防护措施如Token机制、滑动验证码、请求频率限制、设备指纹绑定等。逆向是一个系统工程需要多维度考虑。合法合规与道德再次强调所有逆向分析工作必须用于合法目的如安全研究、兼容性开发、自动化测试等。尊重软件著作权和服务条款。6.3 关于“脱敏版”的说明你提供的标题是“脱敏版”这在实际工作中非常重要。在分享技术细节时必须去除所有敏感信息包括但不限于真实的域名、API地址、AppKey、Secret、密钥、IV、公司名称、个人数据等。用示例值如example.com,test_key,0123...代替。本文中的所有代码和参数均为演示生成的示例不指向任何真实平台。通过以上步骤我们完成了一次完整的“sign和请求体解密逆向分析”。从抓包观察到动态Hook定位再到静态分析验证最后用代码还原算法。这套方法论不仅适用于翻译平台对于绝大多数采用类似签名加密机制的移动端或Web端API都具有普适的参考价值。核心在于耐心、细致的观察和逻辑推理以及熟练使用工具的能力。