UnCrackable-Level3分析

UnCrackable-Level3.apk

照常,安装并打开,被检测到了root,通过magisk进行root权限的隐藏。

之后还是输入正确的密码。

image-20250226195340118

在输入错误的Secret后,会提示Nope…That’s not it. Try again.

在Jeb中搜索这个字符串,发现关键点在check_code上。

image-20250226195531902

check_code调用了native层函数bar。

image-20250226195605177

在ida中,检索函数bar,一个很明显的xor解密,密钥的长度为24。

image-20250226195656697

为了获得正确的secret,需要将v8和qword_15038进行xor运算,得到最终的结果。

不难看出,v8的值来自于sub_10E0,这里考虑对sub_10E0进行hook,然后查看v8的值。

结果hook的过程遇到了反frida的检测,可以根据报错的调用栈定位到goodbye函数。

image-20250226195957335

追溯到源头,发现在sub_30D0处,调用了goodbye(),检测的逻辑是,通过读取映射到内存的文件名,只要发现frida等字段就退出。

image-20250226200242118

为了能够正常使用frida进行hook,有以下几种hook的方式,绕过反frida。

1.hook函数sub_30D0。

2.hook函数strstr。

Ⅰ.先讲hook sub_30D0:观察过函数sub_30D0,发现它被写在了.init_array节里,这个节里面的函数,会在库加载的时候依次被调用,因此,如果想要hook这个函数,必须在System.loadLibray执行过程中——因为执行完后,frida就hook不了了;而若是还没执行,libfoo.so还没加载,当然hook不了。

查阅了相关博客,so层的.init_array节里的函数,都是被模块linker64中的call_array调用的,所以要hook函数call_array,当下的核心目标是获得call_array在模块linker64中的偏移量。(方法随意,可以直接在ida中进行查看)

在hook后,流程变成:遇到加载的so是libfoo.so,在完成了内存映射后,call_array还没有执行,这个时候去修改内存中sub_30D0的内容,之后正常执行call_array。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    // 获取linker64模块的基地址
var linker64_module = Module.getBaseAddress("linker64");
//使用拦截器附加linker64模块的偏移地址
// 7D68B58764 - 7D68B38000 = 0x20764
Interceptor.attach(linker64_module.add(0x20764),{
// 进入函数,代码检查参数args[3]指向的字符串是否匹配libfoo.so
onEnter:function(args) {
if(args[0].readCString().match("libfoo.so")) {
// 获取libfoo.so的基地址
var libfoo_module = Module.findBaseAddress('libfoo.so');
console.log("获取libfoo.so的基地址==>"+libfoo_module)
Interceptor.replace(libfoo_module.add(0x30D0),new NativeCallback(function(){
return;
},'void',[]));
}
},onLeave:function(result){}
})

Ⅱ.hook strstr就比较简单,判断strstr的第1个参数里有没有frida字段,如果有,之后的返回值直接强制返回。

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
// 反frida检测
Interceptor.attach(Module.findExportByName("libc.so", "strstr"), {

onEnter: function(args) {
this.haystack = args[0];
this.needle = args[1];
this.frida = false;

// 方法 1:使用 readCString(自动处理 NULL 结尾)
try {
const haystack = this.haystack.isNull() ? "" : this.haystack.readCString();

if (haystack && (haystack.includes("frida") || haystack.includes("xposed"))) {
this.frida = true;
}
} catch (e) {
console.log("读取字符串失败:", e);
}
},

onLeave: function(retval) {

if (this.frida) {
retval.replace(0);
}
return retval;
}
});

ok,在解决了反frida后,继续对之前提到的sub_10E0进行解密,由于这个函数没有返回值,可以判断:这个函数会在v8所指向的地址处,修改24个字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
   function hookXor(){
// hook xor的其中一个key
Interceptor.attach((Module.findBaseAddress("libfoo.so")).add("0x010E0"), {
onEnter: function(args){
console.log("onEnter: hook xor key");
this.key = args[0];
},

onLeave: function(retval){
var key_ = new NativePointer(this.key);
var arr = key_.readByteArray(24);
console.log(arr);
}
});
}
setTimeout(hookXor, 1000);

这里为了避免hookXor未执行,特意延迟了1000ms再进行注入(方法比较简单)。

还有其它方法,在保证加载了libfoo.so后立马进行hook,而不用等待1000ms。(同样是在加载器上做文章,不过这里hook的是System.loadLibrary)

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
Java.perform(function() {
const System = Java.use("java.lang.System");
const Runtime = Java.use("java.lang.Runtime");
const SystemLoad_2 = System.loadLibrary.overload("java.lang.String");
const VMStack = Java.use("dalvik.system.VMStack");

SystemLoad_2.implementation = function(library) {
console.log("Loading dynamic library => " + library);
try {
const loaded = Runtime.getRuntime().loadLibrary0( VMStack.getCallingClassLoader(), library);
if(library.includes("foo")) {
//function that gets the xored value
Interceptor.attach(Module.findBaseAddress("libfoo.so").add('0x00000fa0'),{
onEnter: function(args){
console.log("getting other_key value");
this.other_key_address = args[0];
},
onLeave: function(retval){
var other_key = new NativePointer(this.other_key_address);
var arr = other_key.readByteArray(24);
console.log(arr);
}
});
}
return loaded;
} catch(ex) {
console.log(ex);
console.log(ex.stack);
}
};
});

最后得到的结果如下,即:1d 08 11 13…

image-20250226204101246

同时,另一个密钥key是qword_15038,追踪后,发现是通过init函数进行初始化的,这个函数是一个jni函数,根据代码,可以发现它的值由java层传递。

image-20250226204325745

因此,另一个key的值是pizzapizzapizzapizzapizz。

image-20250226204444346

将两者做异或运算。

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
def xor_bytes(str1, bytes2):
"""
对两个24字节的数据进行逐字节的异或运算。

参数:
str1: 长度为24字节的字符串
bytes2: 长度为24字节的字节数组

返回:
result: 异或运算后的字节数组
"""
# 确保输入长度为24字节
if len(str1) != 24 or len(bytes2) != 24:
raise ValueError("输入必须是24字节长")

# 将字符串转换为字节数组
bytes1 = str1.encode('utf-8')

# 进行逐字节的异或运算
result = bytearray()
for b1, b2 in zip(bytes1, bytes2):
result.append(b1 ^ b2)

return result


# 示例用法
if __name__ == "__main__":
# 输入字符串和字节数组
str_input = "pizzapizzapizzapizzapizz" # 24字节字符串
bytes_input = bytes([0x1d, 0x08, 0x11, 0x13, 0x0f, 0x17, 0x49, 0x15, 0x0d, 0x00, 0x03, 0x19, 0x5a, 0x1d, 0x13, 0x15, 0x08, 0x0e, 0x5a, 0x00, 0x17, 0x08, 0x13, 0x14]) # 24字节的字节数组 [0, 1, 2, ..., 23]

# 执行异或运算
result = xor_bytes(str_input, bytes_input)

# 输出结果
print("异或运算结果:", result)
print("十六进制表示:", result.hex())
image-20250226205334111

阿这,这个解密出来的结果有点6。

image-20250226205420426