
攻防世界Mobile区的题基本是每一个安卓逆向新手的必修课。这一栏的题目难度梯度拉得很开从直接改smali就能出flag的签到题到需要完整还原so层算法的硬骨头都有。Mobile5这道EasyJNI光看名字就知道考点是JNI也就是Java Native Interface。安卓CTF里只要出现JNI相关字样十有八九dex层只是个空壳真正的判断逻辑全部写在so动态库里你需要顺着Java层的调用链一路摸到native层去逆向算法。这道EasyJNI的定位很明确给还没怎么碰过so逆向的选手一个完整的上手链路——从jadx看Java代码到IDA分析导出函数再到还原算法拿到flag。适合那些已经会基础smali修改、但一看到so文件就发怵的CTF初学者也适合想系统梳理JNI逆向标准流程的选手。先说结论这道题本身没有用到ollvm混淆也没有反调试甚至没有加固整体难度控制在需要看懂C代码这个级别。但它把JNI逆向最常见的几个套路都凑齐了比如JNI函数签名的识别、srand/rand伪随机数的复现、Java字符串与C字符串的转换处理以及strcmp校验逻辑的还原。把这道题吃透之后遇到企业级App里的native层校验你至少知道第一步该从哪下手。1. 做题前先搭好环境1.1 必备工具清单与选择理由工欲善其事必先利其器。做安卓逆向不是装一个反编译器就完事你需要一套完整的工具链而且每个工具都有明确的分工。我个人强烈建议按下面这张表来配缺一不可工具用途为什么选它jadx反编译APK查看Java层代码直接反编译出可读的Java源码比只给smali的工具友好太多能快速定位到MainActivity和JNI调用点Android Studio自带AVD模拟器跑APK用系统镜像选择多方便装x86或arm架构的模拟器而且Logcat查看方便IDA Pro分析so文件中的native逻辑反编译能力强F5伪代码基本能还原C逻辑是JNI逆向的核心工具frida动态hook验证静态分析结果可以hook Java层和native层运行脚本实时看到函数返回值和参数Python重写算法、跑脚本写重放脚本很快配合ctypes还能直接调用libc里的rand函数010 Editor / FCPro查看so文件ELF头、字符串等只是辅助多数时候IDA的Strings窗口就够用了工具选型有个容易被忽略的点模拟器一定要和soc架构匹配。这道题虽然不涉及复杂的汇编调试但你后续跑frida脚本时如果模拟器是x86架构而so库是arm指令集很可能会出现执行结果不一致甚至直接崩溃的情况。我的习惯是优先用Android 7.0以上、带ARM转译的模拟器镜像或者干脆用一台root过的真机来做这类带native层的题目。1.2 模拟器联网与调试环境配置很多新手卡在第一步不是不会分析而是模拟器没法联网导致frida连不上设备。这里有个比较坑的点如果你用的是Android Studio自带的AVD默认网络是NAT模式通常能直接上网但某些第三方模拟器比如某些XX模拟器默认网络配置容易出问题adb能连上但frida-server起不来或者起起来了但转发端口不通。通用的解法是先把模拟器的网络环境弄干净再调试。建议在命令行跑一下通断测试adb shell ping -c 3 8.8.8.8 adb shell ping -c 3 www.baidu.com如果第一个通但第二个不通说明DNS有问题在模拟器设置里改成手动的223.5.5.5这类公共DNS即可。如果两个都不通优先检查模拟器网络模式改成桥接或者重置一下网络设置。另外frida-server启动之前先确认一下架构adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server frida-server版本要和电脑端frida版本严格一致这个坑我踩过不止一次版本不匹配的表现是Unable to start: unexpected error一开始很容易误判成设备问题。2. Java层静态分析先找到native的调用入口2.1 反编译APK定位关键类与方法拿到APK之后第一步永远是丢进jadx看Java层长什么样。不要一上来就掏IDA看so先把调用链捋清楚你才知道so里那个函数是干嘛的。用jadx打开APK通常会看到这样一个包结构com.example.testjavajni ├── MainActivity.java └── JNI.javaMainActivity的onCreate里一般会有类似这样的代码public class MainActivity extends Activity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); EditText input findViewById(R.id.et_input); Button button findViewById(R.id.btn_submit); button.setOnClickListener(new View.OnClickListener() { Override public void onClick(View v) { JNI jni new JNI(); String result jni.getFlag(input.getText().toString()); TextView tv findViewById(R.id.tv_result); tv.setText(result); } }); } }而JNI这个类往往非常简洁就是一个native方法的声明加上一个静态加载库的代码块public class JNI { static { System.loadLibrary(hello-jni); } public static native String getFlag(String str); }到这里基本就可以确认输入的字符串会传入native层的getFlag然后native层返回一个字符串很可能就是flag或者错误提示。Java层能看的信息就这么多真正的校验和生成逻辑在so里。接下来要做的就是拿这个class和method的完整路径去IDA里定位导出函数。2.2 从dex层能推断出的信息在看Java层代码时不要只盯着调用了哪个方法还要注意几个容易被忽略的细节它们会直接影响你在so里的分析思路。第一注意System.loadLibrary的库名。这里是hello-jni对应so文件名是libhello-jni.so。后续在IDA里打开这个so导出的JNI函数名必须以Java_包名_类名_方法名的格式命名所以完整函数名很可能是Java_com_example_testjavajni_JNI_getFlag。第二注意native方法的返回值类型和参数类型。getFlag(String str)对应JNI签名是(Ljava/lang/String;)Ljava/lang/String;意味着native函数第三个参数是jstring前两个固定是JNIEnv*和jobject返回的也是jstring。这个签名分析在动态调试时特别有用frida脚本里要用到它。第三看Java层有没有对结果做二次处理。有些题目会在Java层再做一层校验或字符串拼接如果没注意直接拿native返回值和预期flag比较就会对不上。我见过很多人在这道题卡住原因是拿native的return值当flag实际上它在Java层还被截取或拼接了一次。所以反编译完Java层先别急着关jadx把MainActivity和JNI类的源码复制到一个临时txt文件里对照着看后续的so分析。这一步看似和我们最终的目标无关但恰恰是JNI逆向最容易丢分的地方——很多人分析半天so才发现自己找错了函数。3. 真正的重头戏JNI层的so分析与算法还原3.1 用IDA定位JNI导出函数把APK后缀改成zip解压后在lib目录下找到so文件。可能同时存在armeabi-v7a、x86等好几个目录优先选armeabi-v7a下的libhello-jni.so进行分析因为arm架构的so是Android设备上的主流架构分析结果也更接近真机行为。用IDA打开so之后先看Exports窗口按名字搜索Java_你会看到类似这样的导出函数Java_com_example_testjavajni_JNI_getFlag双击进去IDA会自动帮我们反汇编和反编译。在这类题目中JNI函数的前两个参数是固定的不需要过多关注重点看第三个参数以及内部的逻辑。使用F5切换到伪代码视图一个典型的实现长这样JNIEXPORT jstring JNICALL Java_com_example_testjavajni_JNI_getFlag(JNIEnv *env, jobject obj, jstring j_input) { const char *input (*env)-GetStringUTFChars(env, j_input, 0); char target[33]; char result[64]; int i; int r; if (strlen(input) ! 32) { return (*env)-NewStringUTF(env, error_length); } srand(1000); for (i 0; i 32; i) { r rand() % 16; if (r 10) { target[i] r 0; } else { target[i] r - 10 a; } } target[32] 0; if (strcmp(input, target) 0) { sprintf(result, flag{%s}, input); } else { strcpy(result, error); } (*env)-ReleaseStringUTFChars(env, j_input, input); return (*env)-NewStringUTF(env, result); }注意不同版本的so里种子数值、循环次数、字符偏移和校验方式都可能不一样但整体套路是大同小异的。你要做的是抓住这个生成流程而不是死记我贴出的代码。3.2 阅读伪代码时的几个关键点读这段伪代码时有几个地方需要仔细理解这也是JNI逆向的通用知识点。首先是JNIEnv的函数指针操作。GetStringUTFChars的作用是把Java层的jstring转成C语言里的const char*这样native代码才能按字节处理。同理返回的时候用NewStringUTF把C字符串重新包装成jstring返回给Java层。这套转换逻辑是JNI的标准操作几乎每个涉及字符串的JNI函数都会看到。其次是srand/rand的组合。这是CTF题里非常经典的随机数套路也是这道题真正要还原的核心。C语言的伪随机数生成器是确定性的给定一样的种子srand之后每次调用rand产生的序列完全一致。所以只要我在IDA里看到srand(1000)就知道它生成的那个32位字符串是固定的、可重放出来的。这就完全不需要去理解libc里rand的复杂实现只要用同样的方式在外部重放一遍即可。第三是strcmp的比较逻辑。它比对的是用户输入和native内部生成的target字符串。如果相等则返回flag{input}否则返回error。也就是说我们最终要的flag不是藏在某个常量里而是需要自己算出那个target字符串它才是flag括号里的内容。这种动态生成校验值再比对的写法比直接strcmp(input, flag{xxx})要隐蔽一些也更贴近真实App里的做法。第四看返回路径的细节。如果输入长度不对函数直接返回error_length如果长度对了但值不对返回error。这类不同错误分支的存在可以帮助你判断自己的复现算法在哪个环节出了问题。实战中调试时这一点非常有用它相当于给你的校验逻辑打了一个断点。3.3 把算法用Python重写并算出目标字符串分析清楚逻辑之后下一步就是把这段C逻辑翻译成Python或C语言代码重放出那个32位的target字符串。这个过程叫重写算法或重放验证。由于C语言的rand()在各平台上的实现不同最保险的方式不是自己去实现一个LCG而是直接调用libc里的rand函数。最简单的办法是用Python的ctypes模块直接调libcimport ctypes # 加载系统libc libc ctypes.CDLL(libc.so.6) # Linux/macOS # Windows下可以改成 libc ctypes.CDLL(msvcrt.dll) libc.srand(1000) target [] for i in range(32): r libc.rand() % 16 if r 10: target.append(chr(r ord(0))) else: target.append(chr(r - 10 ord(a))) flag flag{ .join(target) } print(flag)跑出来的结果就是本题的flag。如果你是在Windows环境下做逆向注意ctypes加载的库要是msvcrt.dll因为MSVC的rand实现和glibc的rand是不同的同一个种子生成的序列会不一样。但so文件里编译用的是NDK自带的libc对Android来说通常是bionic或更底层你本地重放时要选择和它匹配的rand实现才能得到一致的序列。还有一种验证方式是直接用C语言编译一个小程序因为在相同平台下C编译出来的rand行为和一般分析环境下差异最小。不过对做题来说ctypes方案已经足够快了。3.4 不要在so里盲目找flag字符串很多新手分析so时第一件事就是按ShiftF12打开Strings窗口肉眼搜索flag关键词。这道题如果你这样找只能找到error、error_length这些错误提示字符串根本找不到真正的flag内容。原因是flag是通过srand动态生成的不是写死在rodata段里的常量的。这个设计本身就是JNI逆向的一个重要教学点so里能看到的字符串不一定是关键字符串关键字符串可能是运行期动态拼接出来的。以后遇到商业App的native校验也不要只依赖Strings窗口找线索一定要顺着代码路径去看字符串是怎么生成、怎么拼接的。4. 动态验证用frida把flag直接hook出来4.1 hook getFlag方法快速拿返回值静态分析重放出flag之后建议再用frida动态验证一遍确认我们算出来的结果没有问题。动态验证的作用不只是确认flag更重要的是检验我们对参数传递和算法结构的理解是否正确。创建一个脚本hook住native方法打印入参和返回值function main() { Java.perform(function () { var JNI Java.use(com.example.testjavajni.JNI); JNI.getFlag.implementation function (str) { var result this.getFlag(str); console.log(input str , output result); return result; }; }); } setImmediate(main);然后用frida attach到App进程在App界面随便输入一串长度为32的字符串点击提交。如果我们在分析中理解的逻辑没错控制台会打印出native实际返回的内容。即使返回的字符串显示成了error这个信息本身也很有价值——至少说明程序确实执行到了校验点你只需要再对比一下静态分析中的哪个细节对不上。这个脚本非常通用以后遇到任何JNI题目都可以先跑一遍。它相当于给native函数挂了个探针不需要反复重编译、重装APK直接在运行时看真实数据。这也是为什么我说frida是安卓逆向必备的动态验证工具。4.2 遇到time种子时怎么处理有些题目不会傻到用固定种子而是写成srand((unsigned int)time(0))。这种情况下种子的值每秒都在变化你用静态重放的方式去复现就比较麻烦。处理这种问题有几个思路。第一个思路是伪造时间。在frida脚本里hooktime函数让它固定返回某个值那么srand拿到的种子就固定了。随后你只需要在本地也用这个种子重放一次。具体做法是对导入表中的time函数进行hookInterceptor.attach(Module.findExportByName(libc.so, time), { onEnter: function (args) { console.log(time called); }, onLeave: function (retval) { retval.replace(0x5CAE5A0B); // 改为固定时间戳 } });不过更好的做法其实是没这么麻烦。你可以直接在IDA里把srand的参数patch成固定值然后重新打包so或者用Frida修改寄存器的值。对CTF来说最快的还是用Frida去篡改返回值让程序走通然后读取它最终的返回字符串。第二个思路是用unicorn模拟执行so。这是一种更底层的做法加载so文件到unicorn引擎里对srand、rand、GetStringUTFChars、strcmp这些函数做桩处理记录每次rand调用的返回值。这种方法的好处是不需要真机也不需要frida服务纯在PC上就能跑适合批量分析so的行为。unicorn方案对初学者稍有点门槛需要理解模拟CPU、内存映射、调用约定这些底层概念。但它的思路很清晰把so当成普通代码片段在模拟器里跑起来所有外部依赖都可以注入。等你以后做混淆严重的题静态分析彻底看不动时unicorn往往是最后的底气。关于这两个方案我建议先掌握frida因为它简单直接几乎零成本。unicorn可以在你遇到更多加固、ollvm、反调试题目之后再补上。先会用再理解原理这个顺序比较顺。5. 常见问题与排查技巧实录5.1 高频问题速查表做题过程中我整理了下面这些高频问题的解决方案基本都是可以直接上手的套路问题现象可能原因解决办法jadx打不开APK或反编译报错APK有加固或损坏先尝试直接改后缀zip解压解压出dex后用dex2jar或jadx单独分析dexjadx中找不到JNI类包名被混淆或类被加固全局搜索native关键字、loadLibrary关键字按引用关系倒推IDA里找不到Java_开头导出函数so被strip或做了导出隐藏用objdump -T查看动态符号表或在IDA里搜索JNI_OnLoad从注册流程找函数注册地址复现的rand序列和so里不一致平台rand实现不同确认是glibc还是bionic等实现换ctypes对应的库或直接在Android设备上用C编译复现frida hook不到native方法函数签名错误或方法被隐藏先确认JNI完整签名尤其是包名路径确认App是否做了反frida检测输入正确长度但返回error校验算法理解有误在IDA里排查是否有额外的字符变换、大小写转换、字节序调整真机上运行App直接闪退so架构不匹配或手机未root换用ARM转译正常的模拟器或检查so是否检测了root环境5.2 独家避坑心得第一个要注意的是不要忽略ReleaseStringUTFChars。有的JNI函数在获取字符串后会在中间插入一些数组操作然后在某个分支里才释放。你如果只盯着比较逻辑很容易漏掉一些对字符串的修改操作。我建议凡是涉及jstring参数先找到GetStringUTFChars再找到ReleaseStringUTFChars看看两个调用之间到底做了什么这是阅读JNI代码的基准线。第二个心得是优先验证输入长度。这类题经常会在校验值之前先检查长度。如果题目要求32位输入而你算了半天发现srand生成的target也是32位长度校验其实是给你的一把钥匙——它提示了你target字符串的格式。遇到这种情况先用长度条件卡一下能排除很多干扰。第三个心得是善于利用动态调试确认种子。如果你在IDA里看到的种子是一个变量比如从某个全局变量读取或者由Java层作为参数传入千万别猜。直接用frida的Java.use钩住Java层方法打印传入native的所有参数然后再hooklibc.so里的srand看看种子实际是什么值。这一步能省掉半天瞎猜的时间。第四个心得是来回对照jdax和IDA里的函数命名。JNI函数名里面包含了完整的Java包名、类名、方法名。只要Java层类名或包名一变so导出函数名就必须跟着变。这既是约束也是线索。如果你发现IDA里这个函数名和你jadx里看到的类对不上说明你的APK可能做了动态注册或者有多个加载库。此时去so里找JNI_OnLoad里面通常有RegisterNatives的注册表。最后要说的是不同版本的so细节可能不完全一样。攻防世界有些题在迁移平台的过程中APK文件可能被替换过种子、循环次数、前缀后缀都可能变动。如果你照着网上的writeup输入答案却不对先别急着怀疑工具重新独立分析一遍so的伪代码是最靠谱的。我在实际做这道题时也遇到过类似情况最后发现是网上流传的解法基于旧版本APK算法其实多了一个异或步骤。这道题最典型的收获是让你完整经历一次Java层定位到native方法、IDA还原伪代码、重放算法拿到flag的标准流程。这个流程在之后做大量JNI题目、甚至分析真实App的加固协议时都是同一个骨架。后面的路还很远比如还有基于unidbg模拟执行脱壳、ollvm控制流平坦化还原、native层反调试对抗这些更深的坑等着踩。先把EasyJNI这条链路玩通后面的难题才会不那么难以下手。