尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Android Native逆向实战:从LoopCrypto看循环加密算法分析与解密

Android Native逆向实战:从LoopCrypto看循环加密算法分析与解密 1. 项目概述逆向工程中的“循环加密”挑战最近在复盘攻防世界CTF的移动安全进阶区题目其中一道名为“LoopCrypto”的题目给我留下了挺深的印象。这道题的核心正如其名围绕着一个“循环加密”算法展开。对于刚接触移动端逆向特别是Android Native层逆向的朋友来说这类题目是个非常好的练手材料。它不像一些纯Java层的题目那样用Jadx、JEB等工具反编译后逻辑一目了然LoopCrypto将关键逻辑藏在了.so动态链接库里你需要真正去理解一段用C/C编写的、带有循环和异或操作的加密函数并写出对应的解密脚本。整个过程从APK解包、定位关键函数、静态/动态分析到最终写出解题脚本完整地走了一遍移动端逆向的典型流程。如果你对Android逆向感兴趣或者正在准备相关的安全竞赛这篇笔记或许能给你提供一些清晰的思路和实用的避坑技巧。2. 解题环境与工具链准备工欲善其事必先利其器。处理这类混合了Java和Native代码的Android逆向题目一套顺手的工具链能极大提升效率。这里我分享一下我常用的组合并解释为什么这么选。2.1 核心工具选型与配置我的主力分析环境是一台安装了Ubuntu的虚拟机当然Windows配合WSL2或纯Windows环境也完全可以。工具的选择上我遵循“静态分析深入动态调试验证”的原则。静态分析套件Jadx-GUI这是分析Java层代码的首选几乎替代了老旧的JD-GUI。它的反编译质量高支持资源查看、搜索跳转而且开源免费。对于LoopCrypto我们首先需要用Jadx打开APK快速浏览MainActivity找到加载Native库System.loadLibrary和调用Native函数的地方。Ghidra/IDA Pro这是分析.so文件的核心。Ghidra是NSA开源的神器反编译能力极强完全免费是我的主力。IDA Pro则是行业标准交互和插件生态更成熟两者可以互补使用。我们需要用它们来逆向libloopcrypto.so中的JNI_OnLoad和关键的Java_com_xxx_MainActivity_encrypt这类函数。Apktool用于解包APK获取其中的AndroidManifest.xml、资源文件以及最重要的lib目录下的.so文件。命令很简单apktool d LoopCrypto.apk -o output_folder。动态调试与辅助工具Android Studio 模拟器/真机用于运行APK观察其行为。搭配logcat查看日志输出有时题目会在Log里留下关键提示。Frida“动态仪器化”框架堪称移动安全分析的“瑞士军刀”。对于LoopCrypto我们可以写一个简单的Frida脚本Hook住Native的加密函数直接打印出它的输入、输出和中间变量这比纯静态分析快得多。尤其是在算法逻辑复杂时动态获取数据流至关重要。radare2/Cutter轻量级的逆向框架有时用于快速查看文件格式或进行简单的二进制修补作为备用。注意工具版本很重要。特别是Android NDK的版本会影响.so文件的编译选项如栈保护、符号表。建议准备多个版本的IDA/Ghidra以应对不同编译器生成的代码。我通常会在虚拟机里固定一套稳定版本的环境避免临场折腾。2.2 题目文件初步探查拿到LoopCrypto.apk后不要急着扔进反编译工具。先做一些基础检查文件类型确认file LoopCrypto.apk确认是ZIP格式。解压查看结构unzip -l LoopCrypto.apk | grep -E ‘(\.so|\.dex|Manifest)’快速查看有无.so文件、几个dex、主配置文件。证书与签名keytool -printcert -jarfile LoopCrypto.apk(需要先unzip出META-INF中的.RSA或.DSA文件) 或使用apksigner工具。虽然CTF题通常不验签但好习惯有助于理解APK构成。对于LoopCrypto解包后我们在lib/armeabi-v7a/目录下找到了libloopcrypto.so也可能是x86等架构但移动设备以ARM为主。同时用Jadx打开后发现Java代码非常简单主要就是声明了一个Native方法然后调用它处理输入。这明确告诉我们战场在Native层。3. 静态分析深入拆解LoopCrypto核心逻辑静态分析的目标是在不运行程序的情况下通过阅读反汇编/反编译代码理解加密算法的每一个步骤。这是逆向工程的基本功。3.1 Java层入口定位用Jadx打开APK首先找到入口Activity。通常搜索“onCreate”或查看AndroidManifest.xml。在LoopCrypto中我们很快定位到MainActivity。其核心代码可能类似这样public class MainActivity extends AppCompatActivity { static { System.loadLibrary(loopcrypto); // 加载名为“loopcrypto”的Native库 } public native String encrypt(String input); // 声明Native方法 Override protected void onCreate(Bundle savedInstanceState) { // ... 界面初始化 String userInput editText.getText().toString(); String encrypted encrypt(userInput); // 调用Native加密 // ... 比较encrypted与某个硬编码的字符串输出成功失败 } }关键信息获取Native库名loopcrypto对应文件libloopcrypto.so。Native函数名根据JNI命名规范这个encrypt方法对应的C函数名很可能是Java_com_example_loopcrypto_MainActivity_encrypt。这里的com_example_loopcrypto是包名需要从AndroidManifest.xml或类定义中确认。算法黑盒加密逻辑完全在encrypt这个Native函数里。3.2 Native层函数逆向将libloopcrypto.so拖入Ghidra或IDA Pro。首先查看导出函数寻找符合JNI命名规则的函数。在Ghidra中的操作流程分析完成后在Symbol Tree的Functions视图里搜索“Java”或“encrypt”。找到目标函数例如Java_com_ctf_loopcrypto_MainActivity_encrypt。Ghidra会自动进行反编译显示伪C代码。关键步骤分析函数原型通常是JNIEnv* env, jobject thiz, jstring input。核心逻辑在于字符串转换使用env-GetStringUTFChars将Java的jstring转换为C风格的char*。加密循环会出现一个for或while循环遍历输入字符串的每一个字符。核心操作在循环体内大概率是异或^、加减、移位等操作。可能会有一个固定的密钥数组或者是一个动态生成的密钥流。“Loop”的含义题目名“LoopCrypto”已经提示加密过程包含循环。我们需要仔细分析这个循环的边界条件是字符串长度吗、迭代步长、以及每次迭代中施加在字符上的变换。数据追踪注意查找程序中的常量数组。在Ghidra的Defined Strings或Listing视图里搜索十六进制数组这很可能是密钥或S盒。右键数据选择“Parse as C Array”可以方便查看。假设我们分析出的核心加密逻辑如下伪代码char* encrypt(char* input) { int len strlen(input); char key[] {0x12, 0x34, 0x56, 0x78}; // 假设的密钥 int key_len 4; for (int i 0; i len; i) { input[i] input[i] ^ key[i % key_len]; // 循环异或加密 input[i] input[i] i; // 可能再加上索引值 } return input; }这个例子展示了典型的“循环异或”密钥key被循环使用与明文的每个字节异或然后再进行一个与索引i相关的加法操作。逆向解密就是逆过程先减i再异或相同的密钥。3.3 算法还原与可视化仅仅看懂代码还不够最好能将算法逻辑用流程图或更清晰的公式表达出来。对于LoopCrypto我们需要明确输入/输出格式是字符串到字符串还是字符串到十六进制文本循环结构是单层循环还是嵌套循环循环变量是否参与运算如上例的i运算单元核心运算是异或、加/减、乘/除、移位中的哪几种或哪几种组合密钥材料密钥是硬编码的常量还是从输入或环境中派生出来的将分析结果整理成如下表格有助于后续编写解密脚本组件分析结果备注算法类型流密码循环密钥异或 位置相关变换模式简单但需注意操作顺序密钥硬编码字节数组e.g.,[0xAB, 0xCD, 0xEF]在.data或.rodata节中循环for(i0; istrlen(input); i)遍历每个明文字符核心操作cipher[i] (plain[i] ^ key[i%key_len]) i先异或再加索引值逆操作plain[i] (cipher[i] - i) ^ key[i%key_len]注意顺序和数据类型实操心得静态分析时要特别注意数据类型的转换。Java的char是16位Unicode而C中的char通常是8位。JNI函数GetStringUTFChars得到的是修改过的UTF-8编码的字节流。在逆向算法时我们通常直接处理字节unsigned char避免字符编码带来的干扰。在Ghidra中可以手动修改变量类型如将char改为unsigned char来获得更准确的反编译代码。4. 动态调试验证让算法“跑起来”静态分析得出的结论需要验证。动态调试可以让我们看到程序实际运行时的内存数据和执行流程是验证静态分析结果、理解复杂逻辑的利器。4.1 基于Frida的快速Hook对于LoopCrypto这类题目如果APK没有反调试使用Frida是最快捷的方式。我们编写一个Frida脚本直接Hook Native的加密函数。假设我们通过静态分析确认了目标函数在libloopcrypto.so中的偏移地址是0x1234也可以通过导出函数名Hook。脚本示例如下Java.perform(function() { // 找到目标模块 var libcrypto Module.findBaseAddress(libloopcrypto.so); if (libcrypto) { // 计算函数绝对地址基址 偏移 var encryptAddr libcrypto.add(0x1234); // 拦截该函数 Interceptor.attach(encryptAddr, { onEnter: function(args) { // args[2] 是第三个参数即jstring input // 将jstring转换为C字符串指针再读出来 var inputPtr Java.vm.getEnv().getStringUtfChars(args[2], null); var inputStr Memory.readCString(inputPtr); console.log([] encrypt() called.); console.log( Input: inputStr); // 也可以在这里打印密钥数组的内存内容 var keyPtr libcrypto.add(0x5678); // 假设的密钥地址 var keyBytes Memory.readByteArray(keyPtr, 4); console.log( Key: Array.from(new Uint8Array(keyBytes)).map(b (0 b.toString(16)).slice(-2)).join( )); }, onLeave: function(retval) { // retval 是返回值也是一个jstring var outputPtr Java.vm.getEnv().getStringUtfChars(retval, null); var outputStr Memory.readCString(outputPtr); console.log( Output: outputStr); // 释放内存重要 Java.vm.getEnv().releaseStringUtfChars(retval, outputPtr); } }); console.log([*] Hook placed on encrypt function.); } else { console.log([-] libloopcrypto.so not found!); } });将脚本保存为hook.js在设备上运行APK然后执行frida -U -f com.ctf.loopcrypto -l hook.js --no-pause。在App输入框中输入测试数据如”flag{test}”控制台就会打印出输入、输出以及从内存中读出的密钥。对比静态分析推断的算法如果计算出的输出与Hook打印的输出一致那么算法就完全掌握了。4.2 使用IDA Pro进行Native层调试如果Frida hook失败如遇到反调试或者需要更细致的指令级跟踪就需要上IDA Pro调试。环境搭建需要配置Android调试服务器android_server并推送到设备进行端口转发。附加进程启动APK后用IDA附加到对应的进程上。下断点在Java_com_..._encrypt函数入口或核心循环处下断点。单步跟踪触发加密操作后程序会在断点处暂停。可以单步执行F7/F8观察寄存器和栈内存的变化验证每一行代码的行为是否与静态分析一致。动态调试的优点是直观但步骤繁琐且容易因环境问题卡住。对于LoopCrypto这类算法逻辑清晰的题目Frida通常足以胜任验证工作。踩坑记录有一次调试时Hook始终不成功。后来发现APK使用了System.load加载了绝对路径的.so文件而不是System.loadLibrary。这导致模块名不是常见的libxxx.so。解决方法是用Process.enumerateModules()打印出所有已加载模块找到目标so的真实名称。另一个常见问题是Native函数可能被混淆或动态注册在JNI_OnLoad中用RegisterNatives注册这时就需要HookRegisterNatives或直接搜索函数特征码来定位。5. 解密脚本编写与问题排查掌握了加密算法编写解密脚本就是水到渠成的事情。但这里也有不少细节需要注意否则可能功亏一篑。5.1 Python解密脚本实现根据我们分析出的逆算法plain[i] (cipher[i] - i) ^ key[i%key_len]用Python实现如下def loopcrypto_decrypt(encrypted_hex: str) - str: 解密LoopCrypto算法 :param encrypted_hex: 加密后的十六进制字符串如从APK中获得的密文 :return: 解密后的明文 # 1. 将十六进制字符串转换为字节数组 cipher_bytes bytes.fromhex(encrypted_hex) # 2. 硬编码的密钥从.so中提取 key bytes([0xAB, 0xCD, 0xEF]) # 示例密钥实际需替换 key_len len(key) # 3. 逆向算法循环 plain_bytes bytearray() for i, cipher_byte in enumerate(cipher_bytes): # 注意加密时是 (plain ^ key) i所以解密先减i再异或key # 由于涉及减法需确保结果为无符号字节0-255 intermediate (cipher_byte - i) 0xFF # 减索引值并取模256 plain_byte intermediate ^ key[i % key_len] plain_bytes.append(plain_byte) # 4. 将解密后的字节转换为字符串假设是UTF-8编码 try: return plain_bytes.decode(utf-8) except UnicodeDecodeError: # 如果不是合法UTF-8可能直接是ASCII或需要其他处理 return plain_bytes.decode(latin-1) # 或直接返回字节的hex表示 # 使用示例 if __name__ __main__: # 假设从APK的Java代码或资源文件中找到的密文十六进制形式 encrypted_flag_hex 2a3b4c5d6e7f # 替换为实际密文 flag loopcrypto_decrypt(encrypted_flag_hex) print(f解密后的Flag: {flag})5.2 常见问题与排查技巧即使算法分析正确写解密脚本时也可能遇到各种问题。下面是一个常见问题排查表问题现象可能原因排查与解决方法解密结果乱码1. 密钥错误。2. 运算顺序错误。3. 数据类型溢出未处理。1.核对密钥用Frida Hook或IDA动态调试确认从内存中读出的密钥字节与脚本中写的是否完全一致。2.验证逆运算用已知的明文-密文对测试。在加密函数入口Hook输入”A”、”AB”等简单字符串记录输出。用你的解密脚本尝试解密这个输出看是否能得到原始输入。3.处理溢出在Python中(cipher_byte - i)可能得到负数直接异或会出错。必须用 0xFF确保结果在0-255范围内。解密结果长度不对1. 密文编码理解错误。2. 循环边界条件错误。1.确认密文格式APK中存储的密文可能是Base64或直接是字符串需要先正确转换为字节。用file命令或文本编辑器查看密文来源确认它是Hex字符串还是其他编码。2.检查循环确认解密循环的次数是len(cipher_bytes)并且i从0开始。脚本运行报编码错误解密后的字节序列不是有效的UTF-8。1. Flag可能包含非ASCII字符如{,},_等但整体应是可打印字符。尝试用decode(‘latin-1’)或decode(‘ascii’, errors’ignore’)。2.输出字节直接打印plain_bytes的十六进制形式看是否符合Flag格式如66 6c 61 67 7b ... 7d对应flag{...}。动态Hook成功但脚本解密失败算法分析有遗漏存在其他变换。1.对比中间值在Frida脚本的onEnter和onLeave中不仅打印输入输出还可以在加密函数的循环内部插入日志打印每一轮迭代后的中间字节值。与你解密脚本中对应步骤的结果对比找到第一个出现差异的地方。2.检查初始化算法在循环前是否有初始化操作如设置初始值、种子循环后是否有最终处理如二次加密、哈希一个关键的实操技巧在解密脚本中最好加入一个“加密验证”函数。即用你分析出的加密算法对一个测试字符串进行加密看结果是否与Native函数Hook得到的结果一致。这是验证你对算法理解是否正确的“金标准”。def loopcrypto_encrypt(plaintext: str, key: bytes) - bytes: 用于验证的正向加密函数 plain_bytes plaintext.encode(utf-8) cipher_bytes bytearray() key_len len(key) for i, plain_byte in enumerate(plain_bytes): enc_byte (plain_byte ^ key[i % key_len]) i cipher_bytes.append(enc_byte 0xFF) return bytes(cipher_bytes) # 验证 test_str flag{test} key bytes([0xAB, 0xCD, 0xEF]) encrypted_test loopcrypto_encrypt(test_str, key) print(f自实现加密结果: {encrypted_test.hex()}) # 然后与Frida Hook得到的真实加密结果对比必须完全一致。6. 总结与进阶思考通过LoopCrypto这道题我们完整实践了从APK探查、Java层分析、Native层逆向、动静态验证到最终编写解密脚本的移动端逆向流程。这道题本身算法不复杂但它是一个绝佳的模板涵盖了这类问题的核心步骤。我个人在反复练习这类题目后有几点体会特别深刻工具链的熟练度至关重要。尤其是Ghidra/IDA的常用操作重命名变量、修改变量类型、查找交叉引用和Frida脚本的编写需要大量练习形成肌肉记忆。遇到问题优先考虑用动态Hook来验证猜想比埋头苦读反编译代码效率高得多。注重细节和上下文。逆向工程是“魔鬼在细节中”。一个 0xFF的遗漏一个运算顺序的颠倒都可能导致解密失败。必须紧密结合程序的上下文这是JNI函数所以第一个参数是JNIEnv*这个变量来自env-GetArrayElements所以它是个数组指针。建立自己的分析框架。对于这类“已知密文求明文”的逆向题可以总结一个通用 checklist找入口 - 定位关键函数 - 静态分析算法 - 动态验证数据流 - 编写逆向脚本 - 验证与提交。每完成一道题就把分析笔记和脚本整理归档未来遇到类似题目可以快速复用思路。LoopCrypto属于入门级的Native逆向。在此基础上可以挑战更复杂的题目比如涉及ARM/ARM64汇编指令优化、控制流混淆、字符串加密、或者需要破解自定义文件格式的题目。核心方法论是相通的耐心、细致、大胆假设并用动态调试小心求证。
返回列表