学习安卓逆向时偶然发现了OWASP的crackme练习,相关资料也挺多的,正好用来学习下xposed和frida。链接:https://github.com/OWASP/owasp-mstg/tree/master/Crackmes
我使用的环境和工具:
- x86_64 android10 Pixel_2_API_29_2
- frida-server 12.7.12
- frida 12.7.20
- apktool 2.4.0
- 夜神模拟器(android5,x86)
Uncrackable1-3
Uncrackable1
一个包含root检测的程序,需要绕过并得到其中的flag
xposed
安装好程序打开之后发现检测到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
frida
-
新建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
- 进入adb shell开启frida-server
- 在外面新开shell,使用
frida -U owasp.mstg.uncrackable1 -l exploit.js
- 在app内随便输入并点击按钮触发hook,得到flag
Uncrackable2
使用夜神模拟器(android5,x86)
frida & 静态分析
在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的结构定义,方法如下:
- File->Load file->Parse c header file
- 选择jni.h,如果安装了Android studio则一般位于Android-studio\jre\include\下,但是需要修改后才能导入,所以直接贴一个已经改好的下载链接
- 选中要修改的函数指针,按y键,提示选择类型,直接手动输入JNIEnv*就行(看到各种教程说选择JNIEnv*但是一直没找到,后面发现可以直接手动输入。。。)
修改之后的可读性大大增加了
很容易看出来是将输入的内容和s2的内容对比,把s2转化成ascii就得到了flag:Thanks for all the fish
和上一题一样,用frida hook掉exit函数,绕过检测,再输入flag就行了
hook
虽然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
patch
使用-f有时会产生各种莫名的报错,所以尝试直接patch libfoo.so
用ida载入libfoo.so,用keypatch将init nop掉,然后放回原来的文件夹,apktool b
重新打包。
安装之前要重新签名,否则会安装失败。
还是使用刚才的frida脚本和命令(不用f参数了)
可以更稳定地得到结果
Uncrackable3
放在夜神上莫名闪退,使用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)
so静态分析
进入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的配置和编写模块上面,所以主要还是不够熟悉。
参考: