n1ctf ezapk wp
0x01 写在前面
又是一个周五晚上,就在刚才搜集到这道题的关键“证据”,到这里这道题算是完全”破解“成功(开心)。所以就写一篇文章,总结下我的心路历程吧,包括但不限于:
ezapk的解体思路
环境问题搭建tips
完整的脚本(避免缺头少尾,让新手看了懵逼)
总之,完全是新手向的文章,遇到的坑,一步步怎么做,我都会说清楚,即使你是新手也没关系。
然后,这也是我第一次真真切切深入安卓逆向,之前只是静态反编译解决,这次学习了frida,CE(cheat engine),安卓模拟器等工具使用,写篇文章,也算是自己的一个阶段性总结。
看完这篇文章你讲学到:
安卓逆向基本思路
dex反编译
frida hook
CE 找出是谁在暗中修改函数
深入JNI机制
frida 环境搭建以及基本使用
CE 使用 以及 CE server 构建
0x02 题目背景
题目非常简单,输入n1ctf{flag}, 点击check检查,很正规的安卓题
0x03 开门见山-dex反编译
放入jadx-gui查看一下主逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public
class
MainActivity
extends
AppCompatActivity {
private
ActivityMainBinding binding;
public
native
String enc(String str);
public
native
String stringFromJNI();
void
m157lambda$onCreate$
0
$comn1ctf2024ezapkMainActivity(View view) {
String obj =
this
.binding.flagText.getText().toString();
if
(obj.startsWith(
"n1ctf{"
) && obj.endsWith(
"}"
)) {
if
(enc(obj.substring(
6
, obj.length() -
1
)).equals(
"iRrL63tve+H72wjr/HHiwlVu5RZU9XDcI7A="
)) {
Toast.makeText(
this
,
"Congratulations!"
,
1
).show();
return
;
}
else
{
Toast.makeText(
this
,
"Try again."
,
0
).show();
return
;
}
}
Toast.makeText(
this
,
"Try again."
,
0
).show();
}
static
{
System.loadLibrary(
"native2"
);
System.loadLibrary(
"native1"
);
}
}
可以看到主要逻辑在enc中,enc 属于native 函数,通过JNI调用,enc位于通过System.loadLibrary()的两个so中
0x03 迷雾重重-JNI逆向
ida 打开libnative.so反编译会发现有大量的类似指针数组的调用,其实这是JNI调用
关于JNI调用可以看:[https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/jniTOC.html](https://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/jniTOC.html)
native code 想要访问java VM的特性就需要调用JNI函数,调用JNI函数需要JNI interface pointer
并且JNI interface pointer是native函数的第一个参数,如下:
1
2
3
4
5
6
7
8
9
package
pkg;
class
Cls {
native
double
f(
int
i, String s);
...
}
这里 double 会经过名称混淆变为Java_pkg_Cls_f_ILjava_lang_String_2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jdouble Java_pkg_Cls_f__ILjava_lang_String_2 (
JNIEnv *env,
jobject obj,
jint i,
jstring s)
{
const
char
*str = (*env)->GetStringUTFChars(env, s, 0);
...
(*env)->ReleaseStringUTFChars(env, s, str);
return
...
}
JNI interface pointer是一个pointer to pointer,具体来说就是一个指针数组,这个数组保存着JNI函数的地址,包括:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const
struct
JNINativeInterface ... = {
NULL,
NULL,
NULL,
NULL,
GetVersion,
DefineClass,
GetJavaVM,
GetStringRegion,
GetStringUTFRegion,
GetObjectRefType
};
但是ida中并没有JNIEnv等等结构体,一个个倒入自动识别太麻烦,手动计算又太蠢
该怎么办呢?
0x04 拨云见日-frida hook
定位enc
其实真正常用的JNI 函数就那几个,可以看到enc中传入了字符串,所以native函数想要获取这个字符串,会调用关于String的JNI调用 一般为GetStringUTFChars
关于环境:我的pc是mac m1,手头也没有安卓设备,最后选择mu mu pro模拟器(啥都好,就是要花钱)
在mu mu pro模拟器中安装好 frida server后,运行frida server后就可以hook了
Java.perform(() => {
const MainActivity = Java.use("com.n1ctf2024.ezapk.MainActivity");
MainActivity.enc.implementation = function(input) {
console.log("enc called with input:", input);
const result = this.enc(input);
startHook();
// startHooklib();
console.log("enc returned:", result);
return result;
};
});
function startHook(){
const lib_art = Process.findModuleByName('libart.so');
const symbols = lib_art.enumerateSymbols();
for (let symbol of symbols) {
var name = symbol.name;
if (name.indexOf("art") >= 0) {
if ((name.indexOf("CheckJNI") == -1) && (name.indexOf("JNI") >= 0)) {
if (name.indexOf("GetStringUTFChars") >= 0) {
console.log('start hook', symbol.name);
Interceptor.attach(symbol.address, {
onEnter: function (arg) {
console.log('GetStringUTFChars called from:\n' + Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n') + '\n');
},
onLeave: function (retval) {
console.log('onLeave GetStringUTFChars:', ptr(retval).readCString())
}
})
}
}
}
}
}
运行结果
// frida -U -f com.n1ctf2024.ezapk -l hook.js
GetStringUTFChars called from:
0x6d55c7117c libnative1.so!0x1b17c
//没有 多点几次 hook和输出在一起 所有你需要hook了 再点几次
这里就知道sub_1b148是enc了
stacktrace
接下来,定位enc调用了哪些函数,还是hook
ava.perform(() => {
const MainActivity = Java.use("com.n1ctf2024.ezapk.MainActivity");
MainActivity.enc.implementation = function(input) {
console.log("enc called with input:", input);
const result = this.enc(input);
//startHook();
startHooklib();
console.log("enc returned:", result);
return result;
};
});
function startHooklib(){
var functions_lib1 = Module.enumerateExports("libnative1.so");
functions_lib1 = []
var functions_lib2 = Module.enumerateExports("libnative2.so");
functions_lib1 = functions_lib1.map(item => {
return { ...item, module: "libnative1.so" };
})
functions_lib2 = functions_lib2.map(item => {
return { ...item, module: "libnative2.so" };
})
var functions = [...functions_lib1,...functions_lib2];
// {
// "address": "0x6d56602ca8",
// "name": "aE7KMLpKuUbB",
// "type": "function"
// }
functions.forEach(function(func) {
var moduleBase_lib1 = Module.findBaseAddress(func.module);
var moduleBase_lib2 = Module.findBaseAddress(func.module);
if ( moduleBase_lib1 && moduleBase_lib2) {
var address = func.address
// console.log("Attaching to function at " + func.module + "!" + func.addr);
Interceptor.attach(address, {
onEnter: function(args) {
console.log(func.module + " function called at " + func.address + " " + func.name);
},
onLeave: function(retval) {
console.log(func.module + " function returned at "+ func.address + " " + func.name);
}
});
} else {
console.log("Module " + func.module + " not found!");
}
});
}
运行结果
libnative2.so function called at 0x6d55c0306c iusp9aVAyoMI
libnative2.so function returned at 0x6d55c0306c iusp9aVAyoMI
libnative2.so function called at 0x6d55c032c0 SZ3pMtlDTA7Q
libnative2.so function returned at 0x6d55c032c0 SZ3pMtlDTA7Q
libnative2.so function called at 0x6d55c03ab0 UqhYy0F049n5
libnative2.so function returned at 0x6d55c03ab0 UqhYy0F049n5
这就获取了调用顺序,在ida里看一下,一眼丁真,分别是EOR,rc4,base64
0x05 九九八十难-谁改了我的rand()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
_BYTE *__fastcall iusp9aVAyoMI(
__int64
a1,
size_t
a2)
{
size_t
i;
_BYTE *v4;
v4 =
malloc
(a2);
__memcpy_chk(v4, a1, a2, -1LL);
for
( i = 0LL; i < a2; ++i )
v4[i] ^=
rand
();
return
v4;
}
_BYTE *__fastcall SZ3pMtlDTA7Q(
__int64
a1,
int
a2)
{
v20[2] = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
v16 =
malloc
(a2);
__memcpy_chk(v16, a1, a2, -1LL);
v20[1] = 0LL;
v20[0] = 0LL;
for
( i = 0; i < 16; ++i )
*((_BYTE *)v20 + i) =
rand
();
}
可以看到EOR和rc4的密钥都是rand()获取的,libnative2.so中的.init.array中有个init函数,初始化了随机种子
1
2
3
4
void
init()
{
srand
(0x134DAD5u);
}
真正解密会发现解密失败,实际上这里rand被修改了,如法炮制,在libnative1.so的.init.array中有三个函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
__int64
sub_1B540()
{
FILE
*v0;
char
*v1;
unsigned
__int64
v2;
__int64
v3;
__int64
v4;
__int64
v5;
__int64
v6;
__int64
v7;
__int64
(**v8)();
__int64
result;
char
filename[4096];
__int64
v11;
v11 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
sub_1B6C4(filename);
v0 =
fopen
(filename,
"r"
);
if
( v0 )
{
while
(
fgets
(filename, 4096, v0) )
{
if
(
strstr
(filename,
"libnative2.so"
) )
{
v1 =
strtok
(filename,
"-"
);
v2 = strtoull(v1, 0LL, 16);
goto
LABEL_6;
}
}
}
v2 = 0LL;
LABEL_6:
fclose
(v0);
sub_1B000(v2, &qword_40F70);
if
( (
int
)(qword_40FC8 / 0x18uLL) < 1 )
{
LABEL_10:
v7 = 0LL;
}
else
{
v3 = (unsigned
int
)(qword_40FC8 / 0x18uLL);
v4 = qword_40F78;
v5 = unk_40F80;
v6 = qword_40FC0 + 8;
while
(
strcmp
((
const
char
*)(v5 + *(unsigned
int
*)(v4 + 24LL * *(unsigned
int
*)(v6 + 4))),
"rand"
) )
{
v6 += 24LL;
if
( !--v3 )
goto
LABEL_10;
}
v7 = *(_QWORD *)(v6 - 8);
}
v8 = (
__int64
(**)())(v7 + v2);
result = mprotect(v8, 8uLL, 3);
*v8 = sub_1B140;
return
result;
}
这里可以很明显的是一个rand的替换操作,rand替换为了sub_1B140,这个函数恒定返回233,就是真正的密钥了。
0x06 on more thing-我的思考
如果这个修改rand got表的操作不在.init.got表中,如何找到他呢?
方案一 CE + CE server + frida hook(小坑+小坑+大坑)
tips:
官网只给了mac版本的7.5.2 CE 但是给的CE server是7.5,所以连接的时候会报错,最后只能自己编译,编译的时候ndk版本别太高,不然一堆报错(血的教训)
如果主机和模拟器不能ping通 那连接ce server时 使用127.0.0.1的话 记得 adb forwar tcp:52736 tcp:52736
要看so 在哪被修改了,CE 扫描的时机很重要,要在native2加载的时候扫描一次,然后native1加载后或者再往后的一个时机扫描改变的字节
所以要hook System.loadLibrary()
这里真是大坑了,查看github issues 才知道System.loadLibrary()是不可以hook的函数之一,因为你在Java.perfrom()里使用,但它会修改classloadrer,导致报错
所以最根本的方法就是hook dlopen 或者 android_dlopen_ext
这里我选择 hook android_dlopen_ext,在 native1加载的时候暂停一会,方便CE 扫描
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Interceptor.attach(Module.findExportByName(
'libc.so'
,
'android_dlopen_ext'
), {
onEnter:
function
(args) {
var
libraryPath = Memory.readUtf8String(args[0]);
console.log(
'android_dlopen_ext called to load library: '
+ libraryPath);
if
(libraryPath.indexOf(
'native1.so'
) !== -1) {
console.log(
'Pausing for 10 seconds before loading native1.so...'
);
var
sleep_string = Module.findExportByName(
'libc.so'
,
'sleep'
);
var
sleep_address = parseInt(sleep_string, 16);
new
NativeFunction(ptr(sleep_address),
'void'
, [
'int'
])(20);
}
},
onLeave:
function
(retval) {
console.log(
'android_dlopen_ext returned: '
+ retval);
}
});
方案二 frida hook (推荐)
hook mprotect的调用,关注地址在so地址范围的地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
hook_mprotect()
var module = Process.findModuleByName(
'libnative2.so'
);
console.
log
(
'libnative2.so loaded at: '
+ module.base);
function hook_mprotect(){
Interceptor.attach(Module.findExportByName(
"libc.so"
,
'mprotect'
), {
onEnter: function(args) {
this
.addr = args[0];
this
.len = args[1];
this
.prot = args[2];
console.
log
(
'mprotect called'
);
console.
log
(
'Address: '
+
this
.addr,
'Length: '
+
this
.len +
'Protection: '
+
this
.prot);
},
onLeave: function(retval) {
}
});
}
运行结果
1
2
mprotect called
Address: 0x6d55c3c3f8 Length: 0x8Protection: 0x3
计算偏移 正好是0x43f8 也就是 rand_ptr的位置
CE 查看修改后的内容
正好是 native1 中 sub_1B140
0x07 总结
整体 难度不大 但是很有趣 这个过程中探索了各种工具的使用 各种环境的搭建 还是学到了很多,感谢你看到了这里 祝你玩的开心。