没有去专门绕过壳的反调试,完全使用frida和IDA静态分析,完成了第一个任务成功获取flag。
加密算法共4种,第二个任务注册机,缺一个算法的解密算法,其他三个算法均已写好C实现的解密算法。
frida默认会被检测,经过修改源码中的几个特征(端口号、临时目录、maps中相关字符串等),自行编译后获得一个可以hook除了libsec2023.so之外的所有so的frida。
随后hook系统函数dlopen,当加载libil2cpp.so之后从内存dump下来解密后的so文件,通过比较apk中的so来修复一些结构体信息,此时得到了一个完整的可被IDA正常解析的libil2cpp.so,因此可以用IL2CPPDumper恢复IDA中符号和结构体。
找到CollectCoin函数,使用frida去patch其中金币增加的逻辑,改为每次增加1000,这样我们就可以一次性达到获取flag的分数,从而轻易获取flag。
之后发现有两个被混淆了函数名称的C#类,经过足够的分析后,可以判断其中一个是VM,包含一些字段用来模拟栈、操作码等,总共有21个方法,除了构造方法外均为VM的Handler。随后在xxx函数通过frida分析找到XTEA加密,然后用frida在内存中找到并提取了密钥(第一处加密)。
(PS:这里的加密多少处是指我找的顺序,不是逻辑上的顺序)
Hook SmallKeyboard__iI1Ii_4610736函数后发现函数在开头跳转到了g_sec2023_p_array的函数中,随后在libsec2023.so分析该函数,发现被混淆。模式基本上都是将常规的分支、直接跳转改为用寄存器寻址跳转,寄存器中的跳转地址由一个常数+一个偏移来得到,常数和取偏移的基址在一个函数中是固定的,在跳转前,使用条件选择指令等来获取相应的偏移,从而间接实现分支跳转,使用Unicorn模拟执行+IDAPython修改汇编+遇到特殊情况手动改的方式,我们可以轻易地去除这个混淆。
去除混淆后可以找到两个加密算法(第三、四处加密),其中第三处可以直接分析,第四处加密通过frida hook得知是调用了Sec2023.Encrypt方法,该类由壳加载,JADX / JEB等工具无法直接分析,需要使用frida-dump工具在运行时dump下来,然后分析。随后可以发现一个加了BlackObfuscator的dex中就找到了该方法,由于没有去除混淆的方案,这里选择使用复制所有真实指令,结合第三处加密,通过frida观察加密结果来分析加密算法实现的是否正确了,得到了想要的结果之后就可以写这两处加密的解密函数。
最后回头去分析用VM实现的加密算法(第二处加密),我的解决方案是在frida中hook所有handler,通过输出所有被执行过的指令为C代码,将栈在输出的源码中处理成寄存器变量(register关键字),所有通过opcode来取出的值处理为常量,这样重新编译后就可以得到较为精简的加密算法,该过程需要结合frida多次观察和分析。
分析完这四处算法,逆向编写出这4处加密算法的解密算法,就可以成功实现注册机。
下载frida源码,将其中的/server/server.vala中的re.frida_server改为其他字符串,再把src/linux/linux-host-session.vala中的so文件特征字符串更改,然后用一个27042之外的端口启动修改好的frida,随后启动游戏,发现启动成功,并不会被检测。
虽然用修改后的frida去hook libsec2023.so仍然会被检测,但是hook其他库没有出现问题。而so在被dlopen加载之后有可能就解密好了,因此这里选择用frida去尝试hook dlopen去dump libil2cpp.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
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
59
60
61
62
63
function WriteMemToFile(addr, size, file_path) {
Java.perform(function() {
var prefix
=
'/data/data/com.com.sec2023.rocketmouse.mouse/files/'
var mkdir
=
Module.findExportByName(
'libc.so'
,
'mkdir'
);
var chmod
=
Module.findExportByName(
'libc.so'
,
'chmod'
);
var fopen
=
Module.findExportByName(
'libc.so'
,
'fopen'
);
var fwrite
=
Module.findExportByName(
'libc.so'
,
'fwrite'
);
var fclose
=
Module.findExportByName(
'libc.so'
,
'fclose'
);
var call_mkdir
=
new NativeFunction(mkdir,
'int'
, [
'pointer'
,
'int'
]);
var call_chmod
=
new NativeFunction(chmod,
'int'
, [
'pointer'
,
'int'
]);
var call_fopen
=
new NativeFunction(fopen,
'pointer'
, [
'pointer'
,
'pointer'
]);
var call_fwrite
=
new NativeFunction(fwrite,
'int'
, [
'pointer'
,
'int'
,
'int'
,
'pointer'
]);
var call_fclose
=
new NativeFunction(fclose,
'int'
, [
'pointer'
]);
call_mkdir(Memory.allocUtf8String(prefix),
0x1FF
);
call_chmod(Memory.allocUtf8String(prefix),
0x1FF
);
var fp
=
call_fopen(
Memory.allocUtf8String(prefix
+
file_path),
Memory.allocUtf8String(
'wb'
));
if
(call_fwrite(addr,
1
, size, fp)) {
console.log(
'[+] Write file success, file path: '
+
prefix
+
file_path);
}
else
{
console.log(
'[x] Write file failed'
);
}
call_fclose(fp);
});
}
function HookLibWithCallback(name, callback) {
var dlopen
=
Module.findExportByName(
'libdl.so'
,
'dlopen'
);
var detach_listener
=
Interceptor.attach(dlopen, {
onEnter: function(args) {
var cur
=
args[
0
].readCString();
console.log(
'[+] dlopen called, name: '
+
cur);
if
(cur.indexOf(name) !
=
-
1
) {
this.hook
=
true;
}
},
onLeave: function() {
if
(this.hook) {
console.log(
'[+] Hook Lib success, name:'
, name);
callback();
detach_listener.detach();
}
}
});
}
function DumpIL2CPP() {
var libil2cpp
=
TraverseModules(
'single'
, {name:
'libil2cpp.so'
});
WriteMemToFile(libil2cpp.base, libil2cpp.size,
'libil2cpp.so'
);
}
function main() {
HookLibWithCallback(
'libil2cpp.so'
, DumpIL2CPP);
}
main();
从手机里把pull出来so,发现函数已经解密,IDA能正常解析,但是还有部分函数会爆红。因此用010editor对apk中的so和dump出来的so进行比对,补上尾部的重定位表,重新IDA打开,此时已经可以正常解析。
此时的libil2cpp.so非常完整,尝试使用IL2CPPDumper恢复符号,发现可以成功Dump
在dump.cs中搜索金币相关方法以及字段,发现了CollectCoin这个函数
IDA不断跟进,可以找到一处自增逻辑,那么我们可以利用frida去修改这里的汇编来使我们的金币获取更快
题目要求实现外挂的注册机,在dnspy中找到SmallKeyboard类,可以分析到输入的小键盘一些相关字段和方法
在IDA中跟进get_input_il1li函数,可以发现是一个判断按键类型进入不同函数的逻辑
结合frida辅助分析可以知道,当KeyType<2时为数字输入,当KeyType=2时为确认,触发关键逻辑
其中SmallKeyboard__iI1Ii_4610736为关键函数,将我们输入的key转为UInt64类型传入,跟进可以发现有一处跳转会跳到g_sec2023_p_array中的一个函数,此处我已经patch(为了IDA能正常反编译该函数)
使用frida hook首部的跳转指令(此处已经是patch成NOP)后一条指令,可以发现我们输入的key发生了变化
上面的情况说明跳转到的那个地方有对input进行加密的函数,打开libsec2023.so跟进那个函数分析,其中v5函数指针调用的是一个打log的函数,不用理会。主要是sub_3B8CC
跟进可以发现函数被混淆了,因为分支跳转指令利用寄存器寻址
分析可以发现,是将常规的分支、直接跳转改为用寄存器寻址跳转,寄存器中的跳转地址由一个常数+一个偏移来得到,常数和取偏移的基址在一个函数中是固定的,在跳转前,使用条件选择指令等来获取相应的偏移,从而间接实现分支跳转。
现在的情况是,我没有去干掉壳的反调试,因此没办法调试libsec2023.so,而这个壳frida也hook不上。但我又不想费力去手动patch掉所有混淆的汇编指令,此时的最佳选择显然是利用模拟执行相关的工具去帮我们完成。
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
from
emu_utils
import
*
from
unicorn
import
*
from
unicorn.arm64_const
import
*
def
trace_back_insn_with_target(insn_queue, target_reg):
for
insn
in
insn_queue:
if
(target_reg
in
insn.op_str):
if
(insn.mnemonic
=
=
'add'
):
print
(insn.mnemonic
+
'\t'
+
insn.op_str)
if
(insn.mnemonic
=
=
'ldr'
):
print
(insn.mnemonic
+
'\t'
+
insn.op_str)
if
(insn.mnemonic
=
=
'csel'
):
print
(insn.mnemonic
+
'\t'
+
insn.op_str)
def
log_hook(emu, addr, size, user_data):
disasm
=
get_disasm(emu, addr, size)
print
(
hex
(addr)
+
'\t'
+
disasm.mnemonic
+
'\t'
+
disasm.op_str)
def
step_over_hook(emu, addr, size, none):
disasm
=
get_disasm(emu, addr, size)
if
(disasm.mnemonic
=
=
'bl'
or
disasm.mnemonic
=
=
'blr'
):
emu.reg_write(UC_ARM64_REG_PC, addr
+
size)
if
(disasm.mnemonic
=
=
'ret'
):
print
(
'function returned'
)
emu.emu_stop()
if
(addr
=
=
0x3ac68
):
emu.reg_write(UC_ARM64_REG_W10,
0xEECF7326
)
def
normal_hook(emu, addr, size, insn_queue):
global
const_value, offset_value, cond, cond_value, uncond_value
disasm
=
get_disasm(emu, addr, size)
reg_maps
=
get_reg_maps()
insn_queue.insert(
0
, disasm)
if
(
len
(insn_queue) >
8
):
insn_queue.pop()
if
(disasm.mnemonic
=
=
'csel'
):
cond_value
=
emu.reg_read(reg_maps[disasm.op_str.split(
', '
)[
1
]])
uncond_value
=
emu.reg_read(reg_maps[disasm.op_str.split(
', '
)[
2
]])
cond
=
disasm.op_str.split(
', '
)[
3
]
if
(disasm.mnemonic
=
=
'cset'
):
cond_value
=
1
uncond_value
=
0
cond
=
disasm.op_str.split(
', '
)[
1
]
if
(disasm.mnemonic
=
=
'ldr'
):
if
(
len
(disasm.op_str.split(
', '
))
=
=
3
):
offset_value
=
emu.reg_read(
reg_maps[disasm.op_str.split(
', '
)[
1
].split(
'['
)[
1
]])
elif
(
len
(disasm.op_str.split(
', '
))
=
=
4
):
offset_value
=
emu.reg_read(
reg_maps[disasm.op_str.split(
', '
)[
1
].split(
'['
)[
1
]])
cond_value
*
=
8
if
(disasm.mnemonic
=
=
'add'
and
'#'
not
in
disasm.op_str
and
'w'
not
in
disasm.op_str):
const_value
=
emu.reg_read(reg_maps[disasm.op_str.split(
', '
)[
2
]])
if
(disasm.mnemonic
=
=
'br'
):
print
(
'on br insn'
)
target_reg
=
disasm.op_str
trace_back_insn_with_target(insn_queue, target_reg)
print
(
hex
(const_value),
hex
(offset_value),
cond, cond_value, uncond_value)
cond_addr
=
emu.mem_read(offset_value
+
cond_value,
4
)
cond_addr
=
(
int
.from_bytes(
cond_addr, byteorder
=
'little'
)
+
const_value) &
0xffffffff
uncond_addr
=
emu.mem_read(offset_value
+
uncond_value,
4
)
uncond_addr
=
(
int
.from_bytes(
uncond_addr, byteorder
=
'little'
)
+
const_value) &
0xffffffff
patch_asm
=
b''
patch_asm
+
=
get_asm(
'b'
+
cond
+
' '
+
hex
(cond_addr), addr
-
4
)
patch_asm
+
=
get_asm(
'b '
+
hex
(uncond_addr), addr)
emu.reg_write(UC_ARM64_REG_PC, addr
+
size)
def
emulate_execution(filename, start_addr, hook_func, user_data):
emu
=
Uc(UC_ARCH_ARM64, UC_MODE_LITTLE_ENDIAN)
textSec
=
get_section(filename,
'.text'
)
dataSec
=
get_section(filename,
'.data'
)
textSec_entry
=
textSec.header[
'sh_addr'
]
textSec_size
=
textSec.header[
'sh_size'
]
textSec_raw
=
textSec.header[
'sh_offset'
]
TEXT_BASE
=
textSec_entry >>
12
<<
12
TEXT_SIZE
=
(textSec_size
+
0x1000
) >>
12
<<
12
TEXT_RBASE
=
textSec_raw >>
12
<<
12
dataSec_entry
=
dataSec.header[
'sh_addr'
]
dataSec_size
=
dataSec.header[
'sh_size'
]
dataSec_raw
=
dataSec.header[
'sh_offset'
]
DATA_BASE
=
dataSec_entry >>
12
<<
12
DATA_SIZE
=
(dataSec_size
+
0x1000
) >>
12
<<
12
DATA_RBASE
=
dataSec_raw >>
12
<<
12
VOID_1_BASE
=
0x00000000
VOID_1_SIZE
=
TEXT_BASE
VOID_2_BASE
=
TEXT_BASE
+
TEXT_SIZE
VOID_2_SIZE
=
DATA_BASE
-
VOID_2_BASE
STACK_BASE
=
DATA_BASE
+
DATA_SIZE
STACK_SIZE
=
0xFFFFFFFF
-
STACK_BASE >>
12
<<
12
emu.mem_map(VOID_1_BASE, VOID_1_SIZE)
emu.mem_map(TEXT_BASE, TEXT_SIZE)
emu.mem_map(DATA_BASE, DATA_SIZE)
emu.mem_map(VOID_2_BASE, VOID_2_SIZE)
emu.mem_map(STACK_BASE, STACK_SIZE)
emu.mem_write(TEXT_BASE, read(filename)[TEXT_RBASE:TEXT_RBASE
+
TEXT_SIZE])
emu.mem_write(DATA_BASE, read(filename)[DATA_RBASE:DATA_RBASE
+
DATA_SIZE])
emu.reg_write(UC_ARM64_REG_FP, STACK_BASE
+
0x1000
)
emu.reg_write(UC_ARM64_REG_SP, STACK_BASE
+
STACK_SIZE
/
/
2
)
emu.hook_add(UC_HOOK_CODE, log_hook)
emu.hook_add(UC_HOOK_CODE, step_over_hook, user_data)
emu.hook_add(UC_HOOK_CODE, hook_func, user_data)
emu.emu_start(start_addr,
0x0
)
if
__name__
=
=
'__main__'
:
filename
=
'./libsec2023.so'
start_addr
=
0x3ac68
insn_queue
=
[]
emulate_execution(filename, start_addr, normal_hook, insn_queue)
去混淆后即可分析函数,经过分析,可以知道是对input以4字节为一组分别进行加密。
其中enc_1逻辑较为明显,显然是可逆的
下面这个函数实现了上面的加密算法,经过frida验证输入输出是正确的。
而enc_2中调用了一个类中的方法,我们只知道这个方法叫什么,但是并不知道是哪个类下的方法,因此我们需要hook GetStaticMethodID这个JNI函数。
[圖片]
通过hook JNI函数找到调用解密函数的类
通过hook,我们可以知道调用的是Sec2023.Encrypt方法,但是JADX中并没有找到,不能直接分析。说明这个类是由壳动态加载的,因此我们需要找办法dump下来dex。
通过这个工具我们可以很轻松地在内存里找到相关的dex,dump下来就可以接着分析了。
打开分析,可以发现加了BlackObfuscator,而我并没有现成的解决方案可以参考和使用,那就只能推理和猜猜看了。
这里我用一种很简单粗暴的办法,就是把每个case中的真实指令直接复制出来,然后可以发现变量之间的运算有明显的逻辑关系,经过推理和结合frida分析验证(上面的frida脚本log了加密完的key),可以推理出正确的加密算法
分析完上面提到的算法之后,我们可以在此处找到一个函数的调用,它改变了input的值,所以应为加密算法
对这两个类进行分析,可以知道上面那个类中的都是opcode dict的key,下面那个类是VM的Handler和一些相关的字段。
VM直接分析不好分析,我们需要侧面去分析。两种常用的方法:一是把指令数组和handler自己实现在程序外部自行分析,二是直接hook来分析。这里选择直接hook通过打log来还原VM逻辑。
首先需要解析VM类的this指针
随便输入一个字符串,点击确认按钮,我们就可以得到一个输出的形式为C代码的VM逻辑。
输出如下,因为后面我放到直接把他作为加密函数来验证VM逻辑是否dump正确,所以有一些改动
由于我们上面其他加密算法都已经找到了,再加上这个,我们可以对输入输出做完整的验证。经过frida hook输入输出可以知道dump下来的VM逻辑没问题。至此,最后一个问题就是如何去得到这个算法的逆算法。
对于这种VM,我们可以直接重新开优化编译,并把其中的一些变量用register关键字定义为寄存器变量,这样IDA的反编译效果会有极大的提升。
重新编译后,IDA反编译出的算法逻辑如下,总共一百多行,不多也不少。难就难在有指令替换。打算直接动调猜每一条指令的作用(因为变量不会被重复赋值,指令也并没有像原本VM这种难以接受的分析,所以我觉得这是可行的)
题目要求对于任意TOKEN,我们的注册机都能生成一个KEY来注册外挂。那就意味着随机生成的TOKEN经过解密之后就是KEY。那注册机其实就是上述所有算法的解密算法实现。我们知道,首先执行的是壳内两个算法加密,然后是VM内的算法加密,最后是XTEA算法加密,那么注册机就是逆过来实现就完事了。