分析思路和上一篇帖子一样,只是「动态调试分析秘钥」变成了「frida hook分析秘钥」
定位Check()方法--->JNI动态注册识别--->frida hook分析秘钥--->计算最终flag值
两种方法本质都是动态调试,第一篇帖子是IDA本身的动态调试,而这里用的frida去动态调试。
本篇帖子只对第三步「frida hook分析秘钥」进行分析,其余内容可参考帖子2025春节红包之Android中级题Writeup - 吾爱破解 - 52pojie.cn。
回顾之前分析
Check()方法检查输入秘钥是否正确,对应到so层的native函数为sub_E8C54()。
对关键变量的数据流分析后,sub_E8C54()的关键代码简化后如下:
v10 = getenv(byte_7767FEA58A);
v11 = jgbjkb();
...
if ( v10 )
v13 = v12 >= 3;
else
v13 = 0;
v14 = !v13;
...
v22[1] = *(_OWORD *)off_7767FE1638;
v22[0] = *(_OWORD *)off_7767FE1628;
v15 = (void (__fastcall *)(__int64 *, __int64, __int64, _QWORD *))nullsub_2(*(_QWORD *)(
(unsigned __int64)v22 & 0xFFFFFFFFFFFFFFF7LL |
(8LL * (((unsigned __int8)(v11 | v14)
^ (((unsigned int)ao ^ (unsigned int)a) >> 24))
& 1))));
数组v22的内存布局如下:
所以要让v15执行函数a的逻辑,需要第三步的结果为0,也就是(v11|v14)的值为0,最终第四步的结果为v22,这样v15就指向了v22[0],然后(_QWORD*)进行8字节转换,v15也就指向了a函数。
上一篇帖子分析错了,写的让(v11|v14)值为1,正确的应该是让(v11|v14)值为0。因为v22的地址是16字节对齐的,所以v22内存地址低4位一定是0。对于v22 & 0xFFFFFFFFFFFFFFF7LL|8*(v11|14) ^ 0
,如果(v11|v14)的值为0,由于v22内存地址低4位为0,所以与运算结果不发生变化,v22还是指向v22[0],转换为8字节地址就是a()函数的地址;但如果(v11|v14)的值为1,运算结果的低4位变为了8,v22就指向v22[0]的第二个元素,即ao()函数,那么就会走错误的代码逻辑了。
所以要走a()函数的逻辑,(v11|v14)的值应该为0,数据流回溯一下就是需要控制下面两行代码:
v10 = getenv(byte_7767FEA58A);
v11 = jgbjkb()
编写hook代码
知道了要控制的变量值,接着就是hook对应的函数getenv()和jgbjkb(),借用了@ngiokweng大佬的代码。
帖子链接:2025吾愛解題領紅包活動(Android題解) - 吾爱破解 - 52pojie.cn
function hook_dlopen(soName) {
var dlopen_func = Module.findExportByName(null,"dlopen");
if(dlopen_func != null){
Interceptor.attach(dlopen_func, {
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log("dlopen path: ", path);
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
console.log("hook start...");
hook_func(soName)
}
}
}
);
}else{
console.log("Not found dlopen address")
}
var android_dlopen_ext_func = Module.findExportByName(null, "android_dlopen_ext");
if (android_dlopen_ext_func != null) {
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log("android_dlopen_ext_func path: ", path);
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
console.log("hook start...");
hook_func(soName)
}
}
}
);
}else{
console.log("Not found android_dlopen_ext address")
}
}
function hook_func(soName) {
function hook_xorkey(base) {
Interceptor.attach(base.add(0xE9954), {
onLeave: function(retval) {
console.log("[xor_key] ", hexdump(retval))
}
})
}
function hook_test2(base) {
Interceptor.attach(base.add(0xE98A0), {
onEnter: function(args) {
console.log("[call a()] ")
}
})
Interceptor.attach(base.add(0xE74E8), {
onEnter: function(args) {
console.log("[call jgbjkb] ")
},
onLeave: function(retval) {
console.log("[jgbjkb] retval: ", retval)
retval.replace(0);
console.log("[jgbjkb] retval: ", retval)
}
})
Interceptor.attach(Module.findExportByName(null, "getenv"), {
onEnter: function(args) {
let a0 = args[0].readCString();
if (a0.indexOf("name") != -1) {
this.flag = true
console.log("[getenv] a0: ", args[0].readCString())
}
},
onLeave: function(retval) {
if (this.flag) {
console.log("[getenv] retval: ", retval.readCString())
retval.replace(1);
}
}
})
}
var base = Module.findBaseAddress(soName);
hook_xorkey(base);
hook_test2(base);
}
function main() {
Java.perform(function(){
hook_dlopen("libwuaipojie2025_game.so")
});
}
setImmediate(main)
要理解这段代码需要清楚so的加载流程:
来自正己大佬:《安卓逆向这档事》十二、大佬帮我分析一下 - 吾爱破解 - 52pojie.cn
System.loadLibrary()/System.load()
位于Java层,用于加载so文件;android_dlopen_ext
和dlopen()
位于so层,主要用于在运行时动态加载共享库。与标准的dlopen() 函数相比,android_dlopen_ext
提供了更多的参数选项和扩展功能,例如支持命名空间、符号版本等特性。
调用Module.findExportByName()是为了找到dlopen()
或android_dlopen_ext()
的函数地址,然后确定是否加载的so库为libwuaipojie2025_game.so,最后调用hook_func()方法完成hook操作。其中hook_xorkey()用于打印加密密钥,hook_test2()对于a()、jgbjkb()、getenv()均进行了hook。hook a()的目的是为了确认是否执行了a()方法,而getenv()是libc的库函数,所以要调用Module.findExportByName()重新寻找函数地址。
运行frida的一个关键点是需要在app启动时进行hook,因此使用Spawn注入模式。
frida -U -f com.zj.wuaipojie2025_game -l 2025android_middle_hook.js
frida控制台输入%resume,随便输入一个秘钥123,最终输出结果如下:
可以看到确实调用了a()函数,表明成功绕过了反调试。
把两次加密秘钥进行拼接:2e 4b ee c8 e0 95 88 47 b0 72 1b 68 40 d0 0a 84 ca 9e 82
加密后的结果:48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7
(分析加密结果的过程也参考上一篇帖子)。
两者进行异或计算:
嗯???(⊙_⊙)?为啥最后三个字节还是乱码,这不和走ao()函数逻辑得到的结果一样吗?一定是哪里出了问题。
第二次hook
首先一定是执行a()函数,不然不会输出[call a()]
,那为什么秘钥还是错的?
再审计一下a()函数看出问题了。在i=16时,会第二次调用sub_7767F70954()重新计算v10,得到一个新的加密密钥。基于第二次的加密密钥,会决定最后三字节(a[16] = v10[0]^a2[16]、a[17] = v10[0]^a2[17]、a[18] = v10[0]^a2[18])的加密结果。而sub_7767F70954()的入参v10受到*(_BYTE *)((unsigned __int64)&v10 | v9) = v8;
的影响,所以需要保证用户输入的秘钥前16字节是正确的,因为在i=0~15时,v10的前16字节受到用户输入的影响,也就是输入秘钥以flag{md5(uid+2025
开头。
__int128 *__fastcall a(__int128 *result, __int64 a2, __int64 a3, __int64 a4)
{
__int64 i;
char v8;
__int64 v9;
__int128 v10;
__int64 v11;
v11 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
v10 = *result;
if ( a3 )
{
for ( i = 0LL; i != a3; ++i )
{
v9 = i & 0xF;
if ( (i & 0xF) == 0 )
result = (__int128 *)sub_7767F70954(&v10);
v8 = *(_BYTE *)((unsigned __int64)&v10 | v9) ^ *(_BYTE *)(a2 + i);
*(_BYTE *)(a4 + i) = v8;
*(_BYTE *)((unsigned __int64)&v10 | v9) = v8;
}
}
return result;
}
所以要输入形如flag{md5(uid+2025
开头的秘钥,进行第二次hook得到加密加密的后三字节,最终结果如下:
最终的输入秘钥
所以正确的加密密钥为:2e 4b ee c8 e0 95 88 47 b0 72 1b 68 40 d0 0a 84 77 70 8a
加密结果不变:48 27 8F AF 9B F8 EC 72 98 07 72 0C 6B E2 3A B6 42 59 F7
两者异或得到最终正确的输入秘钥:flag{md5(uid+2025)}