学习安卓逆向时偶然发现了OWASP的crackme练习,相关资料也挺多的,正好用来学习下xposed和frida。链接:https://github.com/OWASP/owasp-mstg/tree/master/Crackmes
我使用的环境和工具:
一个包含root检测的程序,需要绕过并得到其中的flag
安装好程序打开之后发现检测到root,点击OK后就结束了程序,无法进行后面的操作
首先静态分析一下文件,在MainActivity中有检测root和debuggable的代码块
通过检查后,程序设置了一个按钮监听器,调用a.a()并传递edit_text中的字符串作为参数来判断输入是否符合条件。
继续跟进,找到了用于判断输入的函数逻辑,可以看到加密方式为AES,并且给出了密钥和密文,而真正的解密函数在另一个包内(sg.vantagepoint.a)。sg.vantagepoint.a.a的a方法的返回值就是解密后的值(注意是byte []类型),我们只需要hook这个包内的a方法并得到返回值就行。
但是首先要绕过MainActivity的root检测,简单粗暴的绕过方式就是直接将这块代码删除,然后重新回编apk。
编写xposed模块:
package com.example.hookuncrack; import de.robv.android.xposed.IXposedHookLoadPackage; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; import de.robv.android.xposed.callbacks.XC_LoadPackage; public class HookMain implements IXposedHookLoadPackage { @Override public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable { if (loadPackageParam.packageName.equals("owasp.mstg.uncrackable1")) { try { XposedBridge.log("UncrackHOOKED!!!"); XposedBridge.log("Loaded app: "+loadPackageParam.packageName); //Class clazz = loadPackageParam.classLoader.loadClass("sg.vantagepoint.a.a"); XposedHelpers.findAndHookMethod("sg.vantagepoint.a.a", loadPackageParam.classLoader, "a", byte [].class, byte [].class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { } protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { String flag = new String((byte[]) param.getResult()); XposedBridge.log("FLAG IS:" + flag); } }); } catch (Throwable e) { XposedBridge.log("hook failed"); XposedBridge.log(e); } } } }
安装并重启后运行app,随便输入之后就可以在xposed日志中看到被hook的flag
新建js文件,exploit.js:
Java.perform(function () { send("Starting hook"); /* hook java.lang.System.exit, 使该函数只用来输出下面的字符串 避免了应用的检测机制导致应用退出, 使用该方法绕过Java层的root/debug检测 */ var sysexit = Java.use("java.lang.System"); sysexit.exit.overload("int").implementation = function(var_0) { send("java.lang.System.exit(I)V // We avoid exiting the application :)"); }; var a = Java.use("sg.vantagepoint.a.a"); a.a.overload('[B', '[B').implementation = function(arg1,arg2){ var ret = this.a.overload("[B","[B").call(this,arg1,arg2); var flag=""; for (var i=0;i<ret.length;i++){ flag+=String.fromCharCode(ret[i]); } send("flag: "+flag) return ret; } });
打开模拟器中对应的app
frida -U owasp.mstg.uncrackable1 -l exploit.js
使用夜神模拟器(android5,x86)
在1的基础上将flag放到了so文件中,使得xposed的方法无法hook到函数的返回值。所以先使用frida试试
输入检测函数位于CodeCheck类中的a方法,看样子调用的bar函数应该是lib中的函数
把libfoo.so放进ida,注意到Java_sg_vantagepoint_uncrackable2_CodeCheck_bar,应该就是上面找到的函数了
由于ida在分析android so文件时缺少对JNIEnv结构的定义,所以反编译后会看到函数调用会变成a1+736这种难以阅读的形式。为了有更高的可读性,需要手动导入JNIEnv的结构定义,方法如下:
修改之后的可读性大大增加了
很容易看出来是将输入的内容和s2的内容对比,把s2转化成ascii就得到了flag:Thanks for all the fish
和上一题一样,用frida hook掉exit函数,绕过检测,再输入flag就行了
虽然lib中的字符串很容易就被找出来了,但是如果生成的字符串的逻辑非常复杂就没办法一眼看出来了,所以要考虑更通用解法。这里尝试hook libfoo.so中的bar函数,直接得到strcmp的参数值,因为第二个参数就是flag。
不知道什么原因r2frida始终连不上夜神,所以换了个Android studio自带的模拟器(x86_64 android10 Pixel_2_API_29_2),重新下载frida-server的时候注意其版本号不能大于主机上frida的版本号。
先尝试一下用frida附加到进程
却被提示有两个同名进程,很奇怪。想起刚才用jadx查看java伪代码时native除了bar()还有一个init(),可能是调用了fork之类的函数?尝试杀掉子进程(pid较大的那一个)再试试
直接提示没有找到进程,所以两个进程都被杀了?那再试试直接用pid附加到父进程进行调试
依然失败,只能进so看看了。
查看函数导出表可知,确实存在init函数,进去看看init到底做了什么
调用了sub_8D0(),所以继续跟进
首先fork出一个子进程,然后调用ptrace将子进程附加到父进程。随后进入while循环,不断判断子进程是否还存在,如果子进程被杀死则调用exit结束掉主进程。这也就解释了为什么之前会看到两个同名的进程,并且杀掉子进程后父进程也会一起被杀掉。查资料后知道了由于程序使用了ptrace将子进程提前附加到父进程(相当于子进程调试父进程),所以我们再用frida附加到父进程调试的话就会报错,因为一个父进程只允许附加一个调试进程。这也是最简单的反调试机制。
frida提供了参数-f FILE,可以在程序运行之前就将脚本注入Zygote,从而绕过了程序自带的反调试检测
编写frida脚本:
setImmediate(function() { //hook exit函数,防止点击OK后进程被结束 Java.perform(function() { console.log("[*] Hooking calls to System.exit"); const exitClass = Java.use("java.lang.System"); exitClass.exit.implementation = function() { console.log("[*] System.exit called"); } //得到libfoo中所有关于strncmp的调用 var strncmp = undefined; var imports = Module.enumerateImportsSync("libfoo.so"); for( var i = 0; i < imports.length; i++) { if(imports[i].name == "strncmp") { strncmp = imports[i].address; break; } } //过滤出符合要求的strcmp Interceptor.attach(strncmp, { onEnter: function (args) { if(args[2].toInt32() == 23 && Memory.readUtf8String(args[0],23) == "01234567890123456789012") { console.log("[*] Secret string at " + args[1] + ": " + Memory.readUtf8String(args[1],23)); } }, }); console.log("[*] Intercepting strncmp"); }); });
使用命令frida -U -f owasp.mstg.uncrackable2 -l exploit.js --no-pause
注入代码,没有报错并且成功hook strncmp得到flag:Thanks for all the fish
使用-f有时会产生各种莫名的报错,所以尝试直接patch libfoo.so
用ida载入libfoo.so,用keypatch将init nop掉,然后放回原来的文件夹,apktool b
重新打包。
安装之前要重新签名,否则会安装失败。
还是使用刚才的frida脚本和命令(不用f参数了)
可以更稳定地得到结果
放在夜神上莫名闪退,使用x86_64 android10 Pixel_2_API_29_2
首先观察MainActivity,与上一题的流程有所不同。
onCreate()首先调用verifyLibs(),并且给init()传入了字符串参数pizzapizzapizzapizzapizz,然后是和之前差不多的debugger检测和root检测。
关注一下verifyLibs函数,通过jadx的反编译代码可以知道,该函数主要完成了对各个版本的so库的crc校验,还有对classes.dex的crc校验。校验方式是重新计算一遍当前文件系统的crc校验码并将其与从apk文件本身获取的crc校验码比较,不同则调用system.exit(0)。这种检验方式只有在直接改动apk内文件时才会检测到差异,如果我们更改了so或者dex并重新打包,apk本身的crc也会重新计算一次,所以不会触发system.exit(0)
进入libfoo.so的init函数,接收参数后和上一题一样调用sub_3910使用ptrace进行反调试,之后再用strncpy将接收的字符串复制到dest(0x7040),猜测应该是之后提供给验证函数bar()作为加密密钥使用。最后++dword_705C
所以应该也可以像上一道题一样将反调试部分nop掉,即将sub_3910() nop掉。先试一下patch之后能不能用frida附加调试
出乎意料的的报错了:Trace/BPT trap
通过backtrace的报错信息找到了导致程序异常退出的是goodbye(),看来是还有一层检测。
找到了goodbye函数后并没有看到其交叉引用,手动找了一圈之后发现了sub_38A0。他启动了一个新线程并执行start_routine()
start_routine()首先打开/proc/self/maps,搜索任何包含'frida'和'xposed'的信息。因为maps会包含这个程序所有的内存映射区域,包括使用frida和xposed等调试器注入框架,所以当start_routine检测到它们的时候就会调用goodbye,并设置signal为6中止进程。二话不说,nop掉。
回到sub_38A0,这里最后也有一个++dword_705c。
然后进入bar()看看
可以看到需要满足dword_705c==2才能进入后面验证flag的流程,所以前面的init和sub_38A0在最后添加++dword_705c是为了确保反调试代码正确运行。
然后是验证flag的代码,判断加密方式是异或,大概是这样:
if(用户输入 == [pizza...] ^ [another key]) return 1;
现在已经知道了pizza,如果能找到另外一个key就能算出最后的flag。可以看到另一个key即是v7,并且函数sub_12c0对v7的值完成了初始化。
sub_12c0的逻辑看似很复杂,但其实只有最后几行代码才完成了对v7的赋值,并且都是固定的数据,可以直接得到。但是以防真的遇到了非常复杂的加密函数,所以这个地方还是用hook得到v7的值比较稳。问题是sub_12c0没有出现在函数导出表中,无法通过符号完成对该函数的hook。
这里学到一个frida新姿势,通过lib基址+函数偏移的方式动态获取函数实际地址,从而完成hook
Java.perform(function () { send("Starting hook"); var arch = Process.arch; send("arch: "+arch); var sysexit = Java.use("java.lang.System"); sysexit.exit.overload("int").implementation = function(var_0) { send("java.lang.System.exit(I)V // We avoid exiting the application :)"); }; function do_native_hooks_libfoo(){ var libfoo_base = Module.findBaseAddress("libfoo.so"); if(!libfoo_base){ send("p_foo is null!Returning now"); return 0; } else{ send("libfoo_base: "+libfoo_base); } var complex_function = libfoo_base.add(0x12c0); Interceptor.attach( complex_function, { onEnter: function (args) { this.pointer = args[0]; }, onLeave: function (retval) { var buf = Memory.readByteArray(this.pointer,64); send("KEY: "); console.log(hexdump(buf, {offset: 0, length:64, header: true, ansi: true})); var xorkey_location = libfoo_base.add(0x7040); var xorkey = Memory.readByteArray(xorkey_location, 64); console.log(hexdump(xorkey, {offset: 0, length:64, header: true, ansi: true})); } }); } do_native_hooks_libfoo(); });
pizza的偏移也能找到,所以利用frida同时hook v7值的同时顺便也获取了pizza的值
执行脚本,得到v7的值
参考资料,三道题做下来也花了挺长时间,题目本身不难,多数时间花在了熟悉xposed和frida的配置和编写模块上面,所以主要还是不够熟悉。
参考: