首页
社区
课程
招聘
[分享]frida辅助分析ollvm
2022-11-22 18:18 101271

[分享]frida辅助分析ollvm

2022-11-22 18:18
101271

字符串加密

先从程序的入口点看看:
图片描述
通过点击onclick按钮,随机生成一个字符串,然后传入sign1方法,这个sign1方法是个native层的方法,我们在用objection来验证一下:
图片描述
我们可以发现确实是这么回事:
然后用objection看一下so层的内存信息
图片描述
发现了libhello-jni.so
图片描述
图片描述
那么我们就可以分析so层了,拖入ida看看:
图片描述
在export导入函数中是没有发现sign1函数的,那么我们从init_array字段看看吧:
图片描述
很明显,我们发现了ollvm字符串混淆的关键字.datadiv开头的字段
我们选择第二个跟进去分析:
图片描述
发现确实是一堆字符串的异或运算,我们首先在010editor中还原一下看看:
先看一下他的值byte_6EA6DBB118
图片描述
是F9 D2 然后和 D2异或之后的结果是这样的:
图片描述
然后继续看看接下来几个字符串异或的结果:
byte_6EA6DBB11C -> byte_6EA6DBB122
图片描述
图片描述
这个是异或之后的结果:
图片描述
这里我们能确定他是一个ollvm字符串混淆的加密了,但是分析不出来算法,然后我们在看一下jni_onload函数,这里展示一下我修复之后的代码:
图片描述
接下来我们看一下RegisterNatives第三个参数是什么,一般动态注册的函数都在这里面:v7 = &off_33D60,在上面有个赋值: v7 = &off_33D60
跟进去看看这个字符串:
图片描述
继续跟进去看看
图片描述
发现也是被加密的,所以我们用frida看看他到底是什么值
附上js代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function hook_native(){
 
        //ollvm默认的字符串混淆,静态的时候没法看见字符串
        //执行起来之后,先调用.init_array里面的函数来解密字符串
        //解密完之后,内存中的字符串就是明文状态了。
 
    //首先找到函数的基址
    var base_hello_jni = Module.findBaseAddress("libhello-jni.so");
//这里是基址指针
    if(base_hello_jni){
        var addr_37070 = base_hello_jni.add(0x37070);
//利用指针加偏移的方法找到函数地址
        console.log("addr_37070:",ptr(addr_37070).readCString());
 
 
        var addr_37080 = base_hello_jni.add(0x37080);
//利用指针加偏移的方法找到函数地址
        console.log("addr_37080:",ptr(addr_37080).readCString());
 
    }
 
}

执行一下看看:
图片描述
发现就是我们要分析的函数,但是之前是被ollvm加密的
接下来我们分析一下注册的这个函数:
图片描述
发现它里面也是有一堆字符串,刚刚分析的过程中知道了这个so使用了ollvm加密,所以我们继续看看这些字符串里面是什么值
图片描述
用frida来hook一下:
这里有一种更好的js脚本:(这个js脚本可以查看任意地址的字符串的值)

1
2
3
4
5
6
7
8
9
10
function print_string(addr){
 
    var base_hello_jni = Module.findBaseAddress("libhello-jni.so");
//这里是基址指针
    var addr_str = base_hello_jni.add(addr);
//利用指针加偏移的方法找到函数地址
 
    console.log("addr:",addr,"   ",ptr(addr_str).readCString());
 
}

执行结果:
图片描述
我们接下来先不分析sign算法,最后在分析,我们接下来看看如果修改一下ollvm源码会发生什么:init_array的ollvm的字符串混淆的关键字段.datadiv也是可以修改的:
图片描述
然后我们跟进去分析发现也是字符串的异或:
图片描述
前面的字符串的异或我们刚刚已经介绍完了,接下来的这个我们怎么处理呢?
图片描述
stru_37010[0] = veorq_s8(stru_37010[0], v0);
我们首先看一下stru_37010的值
图片描述
然后看一下v0的值
图片描述
发现v0 = 0xC6
这里我们还是用010editor看看发现是这样的字符串:
图片描述
然后我们继续看一下jni_onload函数
图片描述
根据之前的分析,我们还是要确定一下这个的值
v7 = (_OWORD )&off_33D60
跟进去看看:
图片描述
然后找到字符串的地址:
图片描述
用刚刚的通杀字符串的js脚本看一下:
图片描述
发现是我们要分析的sign1函数
然后我们在修改一下ollvm源码,继续混淆字符串
还是先看init_array字段:
图片描述
跟进去看看函数
图片描述
我们在这里并看不出来解密字符串
接下来看看jni_onload函数吧
我们还是先找RegisterNatives函数
图片描述
然后定位到他的第三个参数:
我们向上定位分析,发现并不像之前那样好分析了(直接用查看字符串的js脚本不行了)
其实也能分析,先说一下吧,就是向上回溯分析,先讲一下这个麻烦的办法吧,一会再说一种通用的方法,
我们还是先看他的第三个参数
图片描述
v10 = xmmword_3E1E8;
然后继续向上回溯:
图片描述
图片描述
这里就出现了一点小问题,我们是可以知道xmmword_3E1E8 = sign1,但是他在往后的8个字节的长度,也就是一个指针的长度
图片描述
因为他进行了一些异或运算,我们其实可以动态调试分析出来,我们今天讲frida,就不再使用这种方法了,我们继续回溯,
图片描述
然后我们hook一下这个3E1BA看看:
图片描述
也是可以分析出来的
然后RegisterNatives函数的第二个参数我们也是可以分析出来的
图片描述
我们hook它来看看(用刚刚的通杀字符串的脚本)
图片描述
这样也是可以查看的
图片描述
然后接下来我们看一种更好用的方法,直接hook这个函数RegisterNatives函数的参数,这样是不是我们就不用分析过程了,无论它怎么加密,我们都能得到最后的值,附上js脚本:

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
function hook_libart() {
 
    var module_libart = Process.findModuleByName("libart.so");
    //枚举所有的文件来找到
    //首先找到so
    var symbols = module_libart.enumerateSymbols();   
     //枚举模块的符号
 
    var addr_RegisterNatives = null;      
     // 怎么hook RegisterNatives
 
    for (var i = 0; i < symbols.length; i++) {
        var name = symbols[i].name;
        if (name.indexOf("art") >= 0) {
            if ((name.indexOf("CheckJNI") == -1) && (name.indexOf("JNI") >= 0)) {
                 if (name.indexOf("RegisterNatives") >= 0) {//找到函数的名字
                    console.log("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
                    console.log(name);
                    addr_RegisterNatives = symbols[i].address;
                    break;
                }
            }
        }
    }
 
    if (addr_RegisterNatives) {
        Interceptor.attach(addr_RegisterNatives, {
            onEnter: function (args) {
                console.log("$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$")
                console.log("addr_RegisterNatives name:", ptr(args[2]).readPointer().readCString())
                //因为RegisterNatives函数的第三个参数的类型是指针类型,所以要先ptr().readPointer()再读取其中的值
                console.log("addr_RegisterNatives sig:", ptr(args[2]).add(Process.pointerSize).readPointer().readCString());
                //增加一个指针的长度,也就是我们刚刚分析过程中加密的那一段字符串的值,
                //注意一下这里不是第四个参数,因为这里是一个指针,首先指向了第三个参数的地址,然后从第三个参数的那片内存区域中增加了8个长度,也就是刚刚那段加密的字符串
            }, onLeave: function (retval) {
            }
        });
    }
}

这个就是执行结果了:
图片描述
但是有的时候如果混淆的特别严重的话,我们就会找不到RegisterNatives这个函数,所以第一种方法还是要会的,接下来在说一种通过hook寄存器的值来得到结果的方法,这里我们定位byte_3E1BA,然后发现了eor,
图片描述
eor之后的值就是我们需要的值了:
也就是这个w13的值
图片描述
附上js脚本:

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
//即使按照挂起的方式hook这个函数,也不能找到加载时机,需要hook "dlopen"这个函数
//按照这种方式hook可以看到每一个寄存器当中的值,从arm汇编代码中
function inline_hook() {
    var base_hello_jni = Module.findBaseAddress("libhello-jni.so");
    console.log("base_hello_jni:", base_hello_jni);
    if (base_hello_jni) {
        console.log(base_hello_jni);
        //inline hook
        //text:0000000000007320 6D 69 29 38                 STRB            W13, [X11,X9]
        //w13寄存器是第四位,x13是总的寄存器的位数
        var addr_07320 = base_hello_jni.add(0x07320);
        Interceptor.attach(addr_07320, {
            onEnter: function (args) {
                console.log("addr_07320 x13:", this.context.x13);
            }, onLeave: function (retval) {
            }
        });
    }
}
//如果只hook "dlopen"这个函数,能加载出来所有的so,在Android6版本的时候,但是高版本的系统,需要hook
function hook_dlopen() {
    var dlopen = Module.findExportByName(null, "dlopen");
    Interceptor.attach(dlopen, {
        onEnter: function (args) {
            this.call_hook = false;
            var so_name = ptr(args[0]).readCString();
            if (so_name.indexOf("libhello-jni.so") >= 0) {
                console.log("dlopen:", ptr(args[0]).readCString());
                this.call_hook = true;
            }
 
        }, onLeave: function (retval) {
            if (this.call_hook) {
                inline_hook();
            }
        }
    });
    // 高版本Android系统使用android_dlopen_ext
    var android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext");
    Interceptor.attach(android_dlopen_ext, {
        onEnter: function (args) {
            this.call_hook = false;
            var so_name = ptr(args[0]).readCString();
            if (so_name.indexOf("libhello-jni.so") >= 0) {
                console.log("android_dlopen_ext:", ptr(args[0]).readCString());
                this.call_hook = true;
            }
 
        }, onLeave: function (retval) {
            if (this.call_hook) {
                //在这里调用一下查看寄存器值的函数
                inline_hook();
            }
        }
    });
}

执行结果:
图片描述
放入010editor看一下:
图片描述

 

这样的话ollvm字符串的混淆就讲完了,就是这三种方法吧
第一个是看特定地址的字符串的值,用的时候直接拿来改js脚本就好啦
第二种是看某函数的参数,然后获取其中的值
第三种就是hook某寄存器的值,还是一样,用的时候直接改脚本就行了
然后就是字符串的解密一般都是再init_array字段

控制流平坦化

我们首先看一下java层
图片描述
发现和上个程序不一样了,点击按钮之后是生成两个随机的字符串,然后传入sign2函数
图片描述
在ida中的export导出函数中找到了sign2函数
图片描述
发现他的混淆力度还是挺强的
图片描述
伪c代码里也都是一些while循环(控制流平坦化的标志)
图片描述
首先我们修复一下代码参数
图片描述
因为程序执行的过程在会生成随机的字符串不利于我们分析,所以我们要编写一个主动调用的方法,传入固定的字符串:
我们在进行协议分析的时候分析so层的时候也可以采用这种方法,固定输入的参数

1
2
3
4
5
6
7
8
9
10
11
12
//固定参数易于分析
function call_sign2(){
    Java.perform(function () {
        Java.choose("com.example.hellojni.HelloJni", {
            onMatch: function (ins) {
                var result = ins.sign2("0123456789", "abcdef");
                console.log(result);
            }, onComplete: function () {
            }
        });      
    });
}

由于so经过了ollvm混淆,所以存在很多垃圾代码,所以这里我们采用参数跟踪的分析方法,ctrl+x来看参数
我们先来跟踪第一个参数str_1:
图片描述
然后再跟踪_str_1;
接下来的步骤中我就展示我修复之后的代码了,这样也便于理解
然后发现了_str_1传入了sub_13558函数
图片描述
(这里我们遵循一个原则,在ollvm混淆的so中,如果一个变量即被其他变量引用,又被函数引用,这里我们首先看一下函数引用,因为ollvm混淆过程中会生成很多垃圾代码),所以接下来分析sub_13558函数:
跟进去分析发现也是被ollvm混淆的:
图片描述
我们在分析过程在先不管分支函数的逻辑,先来hook一下函数看看参数的值
(这里还有一个小点,我分析完几个混淆程序总结出来的,我们在分析ollvm混淆的so中,不要完全相信ida,要相信frida hook之后的结果,ida反编译过程在有时候同一个函数的参数的个数会发生变化,当你从一个函数中退出之后重新F5一遍,就有可能发生变化,还有就是有时候ida把函数的结果放在参数列表之中,有时候放在变量中),
先不说了,hook一下这个sub_13558函数看看:这里我采用的是基址加偏移的方法,然后hook的时候又有两个方法:
js1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//找到so文件的基址
    var base_hello_jni =  Module.findBaseAddress("libhello-jni.so")
    var HelloJni_sign2 = base_hello_jni.add(0x122A4);
    if(base_hello_jni){
//找到要hook的函数的地址
 
 
        var sub_13558_3 = HelloJni_sign2.add(0x46C);
        Interceptor.attach(sub_13558_3,{
            onEnter:function(args){
                this.result = args[0];//得到函数的返回值
                //console.log("sub_13558 : OnEnter:  \r\n",hexdump(args[0]));//得到输入的字符串的值
                console.log("sub_13558 : OnEnter:",ptr(args[0]).readCString());//得到输入的字符串的值
            },onLeave:function(retval){
                //刚刚函数的返回值是int*类型,直接在函数的参数中作为返回值,存储着输入的字符串的长度和值
                console.log("sub_13558 : OnLeave:",hexdump(this.result));   
                console.log("sub_13558 : OnLeave:  \r\n",hexdump(retval));  
                console.log("sub_13558 : OnLeave: ",ptr(retval).readCString());       
            }
        })

这个js脚本的偏移是这么回事,就是首先找到libhello-jni.so的基址,然后找到HelloJni_sign2作为hook 函数代码的基址,这里可以这么理解,采用这种hook方法的好处是一定能hook到想看的函数,不会看到其他处引用的函数,因为一个函数会从多个地方被调用(但是我们根据输入的参数也能分析出来)
第二种就是hook所有的sub_13558函数
js2

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
var sub_13558 = base_hello_jni.add(0x13558);
Interceptor.attach(sub_13558,{
    onEnter:function(args){
        //如果想要hook函数的返回值,需要用this指针保存下来
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
 
 
        this.result = args[0];//得到函数的返回值
 
 
        //hook参数的时候建议首先hook一下每一个参数,不看任何东西
        //因为参数的值可能是地址,可能是长度
        console.log("sub_13558 : OnEnter: args[0] : \r\n",hexdump(args[0]));
        console.log("sub_13558 : OnEnter: args[1] : \r\n",hexdump(args[1]));
        console.log("sub_13558 : OnEnter: args[2] : \r\n",args[2]);
 
        //console.log("sub_13558 : OnEnter:",ptr(args[1]).readCString());//得到输入的字符串的值
    },onLeave:function(retval){
        //刚刚函数的返回值是int*类型,直接在函数的参数中作为返回值,存储着输入的字符串的长度和值
        //console.log("sub_13558 : OnLeave:",hexdump(this.result));   
        // console.log("sub_13558 : OnLeave:  \r\n",hexdump(retval)); 
        console.log("sub_13558 : onLeave: args[0] : \r\n",hexdump(this.arg0));
        console.log("sub_13558 : onLeave: args[1] : \r\n",hexdump(this.arg1));
        console.log("sub_13558 : onLeave: args[2] : \r\n",this.arg2);
        console.log("sub_13558 : OnLeave: ",hexdump(retval));       
    }
})

我们看一下hook结果:
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述

1
sub_13558(&str_1_str, _str_1, len_str1);      // str_1_str是函数的返回值

第一个参数是函数的返回值,第二个参数是输入的值,第三个参数是长度
这样我们呢就可以得到每一个参数在内存中的值了,

在hook的过程在也有一个小技巧,就是我们可以分三步骤来完善js脚本:
首先第一步,我们什么都不要加,就是hook参数,这里也当一个模板使用吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sub_666666 = base_hello_jni.add(0x666666);
Interceptor.attach(sub_666666,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_666666 onEnter arg0:",(this.arg0));
        console.log("sub_666666 onEnter arg1:",(this.arg1));
        console.log("sub_666666 onEnter arg2:",(this.arg2));
 
    },onLeave:function(retval){
        console.log("sub_666666 onLeave arg0:",(this.arg0));
        console.log("sub_666666 onLeave arg1:",(this.arg1));
        console.log("sub_666666 onLeave arg2:",(this.arg2));
        console.log("sub_666666 onLeave retval:",(retval));
 
    }
})

然后看看frida执行的结果,如果结果要是类似于0x88888888这样的地址的结构,我们就在参数前面加上hexdump()看看他在内存中的值,如果要是0xa 这样的结果的话,一般就是字符串的长度,我们就不要加hexdump了,不然frida会报错,如果想看他的值的话就是ptr().readCstring()就行了
就是类似于下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sub_666666 = base_hello_jni.add(0x666666);
Interceptor.attach(sub_666666,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_666666 onEnter arg0: \r\n",hexdump(this.arg0));
        console.log("sub_666666 onEnter arg1: \r\n",hexdump(this.arg1));
        console.log("sub_666666 onEnter arg2: \r\n",(this.arg2));
 
    },onLeave:function(retval){
        console.log("sub_666666 onLeave arg0 \r\n:",hexdump(this.arg0));
        console.log("sub_666666 onLeave arg1 \r\n:",hexdump(this.arg1));
        console.log("sub_666666 onLeave arg2 \r\n:",(this.arg2));
        console.log("sub_666666 onLeave retval: \r\n",ptr(retval).readCString());
 
    }
})

我们回到程序的分析过程中:
在ida中看看第二个参数:
图片描述
一样hook一下
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
跟上的函数一样的结果
然后我们定位这两个变量str_1_str,str_2_str
图片描述
发现他们都传入了12D70函数,我们来hook它看看参数的情况,篇幅太长了要不,这里我只展示最终的js脚本了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var sub_12D70 = base_hello_jni.add(0x12D70);
Interceptor.attach(sub_12D70,{
    onEnter:function(args){
        this.str_1_str = args[0];
        this.str_2_str = args[1];
        this.arg2 = args[2];
        this.arg3 = args[3];
 
       console.log("sub_12D70  :  onEnter  : str_1_str \r\n",hexdump(this.str_1_str),"\r\n")
       console.log("sub_12D70  :  onEnter  : str_2_str \r\n",hexdump(this.str_2_str),"\r\n")
       console.log("sub_12D70  :  onEnter  : arg2 \r\n",hexdump(this.arg2),"\r\n")
       console.log("sub_12D70  :  onEnter  : arg3 \r\n",this.arg3,"\r\n")
 
    },onLeave:function(retval){
        console.log("sub_12D70  :  onLeave  : str_1_str \r\n",hexdump(this.str_1_str),"\r\n")
        console.log("sub_12D70  :  onLeave  : str_1_str \r\n",hexdump(this.str_1_str),"\r\n")
        console.log("sub_12D70  :  onLeave  : arg2 \r\n",hexdump(this.arg2),"\r\n")
        console.log("sub_12D70  :  onLeave  : arg3 \r\n",this.arg3,"\r\n")
        console.log("sub_12D70  :  onLeave  : retval \r\n",hexdump(retval),"\r\n")
 
    }
})

我们来看一下hook的结果:
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
这里我们就发现ida反编译错误的问题了,虽然我们从ida中没有发现函数的返回值,但是在frida的hook的结果之中我们发现了函数的返回值,
我们看一下arm指令,发现返回值没有被使用
图片描述
但是我们通过交叉引用,发现了输入的两个字符串的逻辑关系处理到这个sub_12D70在往后就没有了,我们这里就猜吧,没别的方法了,之前看一些大佬的文章也是靠猜的有很多,这里我们就猜测s在后面会用到,
图片描述
然后v45传入了这个函数里面,继续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
29
var sub_13558 = base_hello_jni.add(0x13558);
Interceptor.attach(sub_13558,{
    onEnter:function(args){
        //如果想要hook函数的返回值,需要用this指针保存下来
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
 
 
        this.result = args[0];//得到函数的返回值
 
 
        //hook参数的时候建议首先hook一下每一个参数,不看任何东西
        //因为参数的值可能是地址,可能是长度
        console.log("sub_13558 : OnEnter: args[0] : \r\n",hexdump(args[0]));
        console.log("sub_13558 : OnEnter: args[1] : \r\n",hexdump(args[1]));
        console.log("sub_13558 : OnEnter: args[2] : \r\n",args[2]);
 
        //console.log("sub_13558 : OnEnter:",ptr(args[1]).readCString());//得到输入的字符串的值
    },onLeave:function(retval){
        //刚刚函数的返回值是int*类型,直接在函数的参数中作为返回值,存储着输入的字符串的长度和值
        //console.log("sub_13558 : OnLeave:",hexdump(this.result));   
        // console.log("sub_13558 : OnLeave:  \r\n",hexdump(retval)); 
        console.log("sub_13558 : onLeave: args[0] : \r\n",hexdump(this.arg0));
        console.log("sub_13558 : onLeave: args[1] : \r\n",hexdump(this.arg1));
        console.log("sub_13558 : onLeave: args[2] : \r\n",this.arg2);
        console.log("sub_13558 : OnLeave: ",hexdump(retval));       
    }
})

图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
发现在内存中都是0,没有什么好说的
我们就只能向上回溯看看了,再往下分析不动了,(还是按照我之前说得那个规则,就是有函数的先看函数,然后再看赋值,这里往下没有函数了,然后就该看看变量赋值了)
图片描述
发现了str_1_str = s
所以接下来分析str_1_str
图片描述
发现他也没有函数,就只能看看赋值了,我们首先从最近的赋值查看:
图片描述
定位到了这里,然后继续交叉引用:
定位到了这个函数sub_162B8
图片描述
所以接下来hook这个函数的参数看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var sub_162B8 = base_hello_jni.add(0x162B8);
Interceptor.attach(sub_162B8, {
    onEnter: function (args) {
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_162B8 onEnter:  args[0]", hexdump(args[0]), "\r\n");
        console.log("sub_162B8 onEnter:  args[1]", args[1], "\r\n");
        console.log("sub_162B8 onEnter:  args[2]", hexdump(args[2]), "\r\n");
    }, onLeave: function (retval) {
       console.log("sub_162B8 onLeave:  this.arg1 ", this.arg1, "\r\n");
       console.log("sub_162B8 onLeave:  args[2]", hexdump(this.arg2), "\r\n");
        console.log("sub_162B8 onLeave:  retval", hexdump(retval), "\r\n");
    }
});

图片描述
图片描述
图片描述
图片描述
图片描述
根据函数的返回值的特征,我们可以推测这个函数是base64加密,然后这个函数的第一个参数是把我们输入的两个字符串拼接成一个字符串,这也就和我们之前的分析猜测过程对接上了,也就是这个函数:

1
sub_12D70((unsigned __int8 *)&str_1_str, (unsigned __int8 *)str_2_str, v15, s);

的返回值确实是存贮在s中,这里的赋值过程我总结一下就是:
s -> str_1_str -> input_buffer -> sub_162B8(input_buffer, input_len, &out_len)
然后我们跟进去这个函数sub_162B8分析一下就是这样的
图片描述
同时我们也发现了base64的码表
图片描述
然后函数的返回值存在了base64_input_buffer这里面:

1
base64_input_buffer = (char *)sub_162B8(input_buffer, input_len, &out_len);

继续交叉引用,还是先看函数,发现了他传入了这个函数里面 sub_130F0(&v45, base64_input_buffer, out_len, v35, v36);:
图片描述
hook一下看看参数:
图片描述
我们发现这个函数并没有被调用,继续看看其他处的调用:sub_15F1C
图片描述
hook看看参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var sub_15F1C = base_hello_jni.add(0x15F1C);
Interceptor.attach(sub_15F1C, {
    onEnter: function (args) {
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_15F1C  onEnter :  this.args0\r\n", hexdump(this.arg0));
        console.log("sub_15F1C  onEnter :  this.args1\r\n", this.arg1);
        console.log("sub_15F1C  onEnter :  this.args2\r\n", hexdump(this.arg2));
    }, onLeave: function (retval) {
        console.log("sub_15F1C  onLeave :  this.args0\r\n", hexdump(this.arg0));
        console.log("sub_15F1C  onLeave :  this.args1\r\n", this.arg1);
        console.log("sub_15F1C  onLeave :  this.args2\r\n", hexdump(this.arg2));
        console.log("sub_15F1C  onLeave :  retval\r\n", hexdump(retval));
    }
});

结果:
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
图片描述
虽然我们hook的retval为0,但是她的返回值存储在了arg2中
这个是我们从java层获得的sign字段的结果:
d91248ab5f405cbbb2500ce18f95f051
这个是arg2的值:
图片描述
我们发现他们是一样的,所以这个函数就是加密函数了,跟进去看看:
图片描述
进去看看:
图片描述
修改一下参数:
图片描述
这里我们已经知道第三个参数是输出结果了,所以我们直接定位这个变量也是可以的:
图片描述

1
2
3
4
5
6
7
8
var sub_158AC = base_hello_jni.add(0x158AC);
Interceptor.attach(sub_158AC, {
    onEnter: function (args) {
        this.arg1 = args[1];
    }, onLeave: function (retval) {
        console.log("sub_158AC onLeave:", hexdump(this.arg1));
    }
});

图片描述
图片描述
发现就是我们的结果,所以跟进分析这个函数:
图片描述
发现传入了这个函数里面:
继续跟进这个函数:
图片描述
看看out_buffer的交叉引用
图片描述
发现传入了sub_14844函数:

 

hook它看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    var sub_154D4 = base_hello_jni.add(0x154D4);
    Interceptor.attach(sub_154D4,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_154D4 onEnter arg0: \r\n",hexdump(this.arg0));
        console.log("sub_154D4 onEnter arg1: \r\n",hexdump(this.arg1));
        console.log("sub_154D4 onEnter arg2: \r\n",(this.arg2));
 
    },onLeave:function(retval){
        console.log("sub_154D4 onLeave arg0 \r\n:",hexdump(this.arg0));
        console.log("sub_154D4 onLeave arg1 \r\n:",hexdump(this.arg1));
        console.log("sub_154D4 onLeave arg2 \r\n:",(this.arg2));
        console.log("sub_154D4 onLeave retval: \r\n",hexdump(retval));
 
    }
})

图片描述
图片描述
图片描述
图片描述
图片描述
发现base64加密之后多了几个+++++++++salt1+这样的字符串(这个盐加的真明显啊)

 

图片描述
我们在把他转成cstring看看:
发现确实是这么回事
图片描述

1
2
3
4
5
6
7
8
var sub_154D4 = base_hello_jni.add(0x154D4);
Interceptor.attach(sub_154D4, {
    onEnter: function (args) {
        this.arg0 = args[0];
        console.log("sub_154D4 onEnter:", ptr(args[1]).readCString(), "\r\n");
    }, onLeave: function (retval) {
    }
});

我们目前只能确定一个参数
跟进去看看吧:
图片描述
既然我们刚刚已经hook到了结果,所以还是跟着结果分析:
交叉引用查看out_buffer发现只有这一处函数调用:
图片描述
跟进去看看是md5加密
图片描述
我们来验证一下,发现确实是这样的
图片描述

指令替换:

图片描述
他们的java层的逻辑都是一样的,点击按钮之后像so层传入两个随机的字符串:
图片描述
所以我们还是要固定输入的参数便于分析:

1
2
3
4
5
6
7
8
9
10
11
12
function call_sign2() {
    Java.perform(function () {
        Java.choose("com.example.hellojni.HelloJni", {
            onMatch: function (ins) {
                var result = ins.sign2("0123456789", "abcdefg");
                console.log("call_sign2:", result);
            }, onComplete: function () {
 
            }
        });
    });
}

然后尝试看一下so层的参数传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    //上节课只是从java层看了函数的参数,这次从native层看看函数的参数
    //首先用findExportByName找到sign2函数
//jstring __fastcall Java_com_example_hellojni_HelloJni_sign2(JNIEnv *env, jclass j_class, __int64 str_1, __int64 str_2)
//打印参数的值
    var sign2 = Module.findExportByName("libhello-jni.so", "Java_com_example_hellojni_HelloJni_sign2");
 
    console.log(sign2);
    Interceptor.attach(sign2, {
        onEnter: function (args) {
            //jstring
            //通过jstring方法打印so层和java层之间联系的参数,有一个frida_java_native_bridge
            console.log("sign2 str1:", ptr(Java.vm.tryGetEnv().getStringUtfChars(args[2])).readCString());
            console.log("sign2 str2:", ptr(Java.vm.tryGetEnv().getStringUtfChars(args[3])).readCString());
        }, onLeave: function (retval) {
            console.log("sign2 retval:", ptr(Java.vm.tryGetEnv().getStringUtfChars(retval)).readCString());
        }
    });
 
}

看一下hook结果:
图片描述
然后进入so层开始分析,我们还是先修复一下参数
图片描述
然后采用交叉引用的方法:
图片描述
发现了赋值过程:str_1 -> c_str_1 -> _c_str_1
图片描述
最终交叉引用两个变量到了这个函数这里
图片描述
然后我们hook一下这个函数看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var sub_1DFB4 = base_hello_jni.add(0x1DFB4);
Interceptor.attach(sub_1DFB4,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        console.log("sub_1DFB4 onEnter arg0:",hexdump(this.arg0));
        console.log("sub_1DFB4 onEnter arg1:",hexdump(this.arg1));
    },onLeave:function(retval){
 
        console.log("sub_1DFB4 onLeave arg0:",hexdump(this.arg0));
        console.log("sub_1DFB4 onLeave arg1:",hexdump(this.arg1));
        console.log("sub_1DFB4 onLeave retval:",hexdump(retval));
 
    }
})

看一下hook结果:
图片描述
图片描述
图片描述
我们发现这个函数就是加密函数(还是这个好定位,直接就找到了,混淆力度小)
两个参数是我们通过主动调用传入的参数,然后函数的返回值也和我们hook得到的结果是一样的,所以我们继续分析这个函数了:
(这里让我明白了以后不能完全相信ida,ida反编译出来的c代码是没有返回值的,但是我们hook内存得到的值是存在的)
进入这个函数看看,这里ida也被ollvm欺骗了,所以我们要手动修复一下参数表
图片描述
但是这里我们只看ida的交叉引用发现不了什么:
所以接下来我们分析arm代码:
图片描述
如果参数被使用了,那么一定会使用x0,x1寄存器,这里没有使用,所以看跳转函数,这里猜测跳入了下一个函数(这里还是猜测的),先hook看看吧append_1E298,反正没思路了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sub_1E298 = base_hello_jni.add(0x1E298);
  Interceptor.attach(sub_1E298,{
      onEnter:function(args){
          this.arg0 = args[0];
          this.arg1 = args[1];
          this.arg2 = args[2];
          console.log("sub_1E298 onEnter arg0:",hexdump(this.arg0));
          console.log("sub_1E298 onEnter arg1:",hexdump(this.arg1));
          console.log("sub_1E298 onEnter arg2:",hexdump(this.arg2));
 
      },onLeave:function(retval){
          console.log("sub_1E298 onLeave arg0:",hexdump(this.arg0));
          console.log("sub_1E298 onLeave arg1:",hexdump(this.arg1));
          console.log("sub_1E298 onLeave arg2:",hexdump(this.arg2));
          console.log("sub_1E298 onLeave retval:",hexdump(retval));
 
      }
  })

图片描述
图片描述
图片描述
图片描述
我们从hook的结果可以看出来这个函数的作用就是把两个字符串拼接成一个字符串
然后我们进去这个函数看看交叉引用第三个参数,因为第三个参数是函数的返回值
图片描述
最终定位到了这个函数:
图片描述
还是hook他看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sub_1AB50 = base_hello_jni.add(0x1AB50);
Interceptor.attach(sub_1AB50,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_1AB50 onEnter arg0:",hexdump(this.arg0));
        console.log("sub_1AB50 onEnter arg1:",hexdump(this.arg1));
        console.log("sub_1AB50 onEnter arg2:",this.arg2);
 
    },onLeave:function(retval){
        console.log("sub_1AB50 onLeave arg0:",hexdump(this.arg0));
        console.log("sub_1AB50 onLeave arg1:",hexdump(this.arg1));
        console.log("sub_1AB50 onLeave arg2:",this.arg2);
        console.log("sub_1AB50 onLeave retval:",hexdump(retval));
 
    }
})

hook结果:
图片描述
图片描述
图片描述
发现这个函数也是拼接字符串的函数,也就是我们之前见到的append函数,就是一个函数里面调用了append函数,然后被ollvm混淆成了这个样子:
图片描述
然后回到sign函数中继续分析:
图片描述
还是猜测的只能hook这个函数了,hook一下看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sub_1AB4C = base_hello_jni.add(0x1AB4C);
Interceptor.attach(sub_1AB4C,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_1AB4C onEnter arg0:",hexdump(this.arg0));
        console.log("sub_1AB4C onEnter arg1:",(this.arg1));
        console.log("sub_1AB4C onEnter arg2:",hexdump(this.arg2));
 
    },onLeave:function(retval){
        console.log("sub_1AB4C onLeave arg0:",hexdump(this.arg0));
        console.log("sub_1AB4C onLeave arg1:",(this.arg1));
        console.log("sub_1AB4C onLeave arg2:",hexdump(this.arg2));
        console.log("sub_1AB4C onLeave retval:",hexdump(retval));
 
    }
})

hook结果:
图片描述
图片描述
图片描述
我们发现这个函数就是加密函数了,第一个参数是拼接的字符串,第三个是加密后的结果,所以我们跟进去分析这个函数了:
图片描述
发现还是一大堆的指令替换,所以只能交叉引用查看了:
图片描述
定位定位到了这里:
图片描述
直接hook一下看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sub_171C4 = base_hello_jni.add(0x171C4);
Interceptor.attach(sub_171C4,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        //this.arg2 = args[2];
        console.log("sub_171C4 onEnter arg0:",hexdump(this.arg0));
        console.log("sub_171C4 onEnter arg1:",hexdump(this.arg1));
        //console.log("sub_171C4 onEnter arg2:",this.arg2);
 
    },onLeave:function(retval){
        console.log("sub_171C4 onLeave arg0:",hexdump(this.arg0));
        console.log("sub_171C4 onLeave arg1:",hexdump(this.arg1));
        //console.log("sub_171C4 onLeave arg2:",this.arg2);
        console.log("sub_171C4 onLeave retval:",hexdump(retval));
 
    }
})

hook结果:
图片描述
图片描述
图片描述
发现结果就是我们期望的结果,然后输入的参数的前面拼接了一些字符串 +++++++++salt1+0,我们向上面回溯看看:
图片描述
发现是这里随机生成一个字符串拼接上去的,然后进去这个函数看看发现确实是md5函数:
图片描述
验证一下:
图片描述
发现是一样的结果
先发这些了,一会再补充,文章太长有点卡了
图片描述

虚假控制流

java层还是和原来一样:
图片描述
处理方法还是和之前一样,写一个主动调用的函数,传入两个固定的字符串便于分析
看看so层:
图片描述
找到sign2函数,修改一下参数:
图片描述
我们这里还是采用交叉引用的方法:
图片描述
我们首先跟踪第一个参数,定位到了c_str_1,然后传入了两个函数:
图片描述
我们首先来hook一下这个函数:
copy_12B44((__int64)str_str_1, c_str_1);
js代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var sub_12B44 = base_hello_jni.add(0x12B44);
Interceptor.attach(sub_12B44,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        console.log("sub_12B44 onEnter arg0: \r\n", hexdump(this.arg0));
        console.log("sub_12B44 onEnter arg1: \r\n", hexdump(this.arg1));
 
    },onLeave:function(retval){
        console.log("sub_12B44 onLeave arg0: \r\n", hexdump(this.arg0));
        console.log("sub_12B44 onLeave arg1: \r\n", hexdump(this.arg1));
        console.log("sub_12B44 onLeave retval: \r\n", hexdump(retval));
 
    }
})

看一下hook结果:
图片描述
图片描述
图片描述
图片描述
图片描述
从hook的结果来看这个函数的作用就是将第二个参数的值赋值给第一个参数,
然后又传入了这个函数中:
图片描述
copy_1391C,hook他的参数看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var sub_1391C = base_hello_jni.add(0x1391C);
Interceptor.attach(sub_1391C,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        console.log("sub_1391C onEnter arg0: \r\n", hexdump(this.arg0));
        console.log("sub_1391C onEnter arg1: \r\n", hexdump(this.arg1));
 
    },onLeave:function(retval){
        console.log("sub_1391C onLeave arg0: \r\n", hexdump(this.arg0));
        console.log("sub_1391C onLeave arg1: \r\n", hexdump(this.arg1));
        console.log("sub_1391C onLeave retval: \r\n", hexdump(retval));
 
    }
})

看看hook结果:
图片描述
图片描述
图片描述
图片描述
发现他也是给字符串赋值的函数,就是一个append函数,混淆成了我们不认识的名字
最后发现两个copy后的字符串都传递到了这个函数中
图片描述
所以接下来我们来hook这个函数:sub_18AB0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sub_18AB0 = base_hello_jni.add(0x18AB0);
Interceptor.attach(sub_18AB0, {
    onEnter: function (args) {
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg8 = this.context.x8;
        //console.log("sub_18AB0 onEnter:", hexdump(args[0]), "\r\n", hexdump(args[1]));
    }, onLeave: function (retval) {
        //console.log("sub_18AB0 onLeave:", hexdump(retval));
        console.log("sub_18AB0 onLeave:", hexdump(this.arg8));
        //根据上边的结果可以推测出要按照std:string的方法打印一下这个参数
        //769fae20d0  31 00 00 00 00 00 00 00 20 00 00 00 00 00 00 00  1....... .......
        //769fae20e0  10 a1 83 e0 76 00 00 00 0e 61 62 63 64 65 66 67  ....v....abcdefg
        //769fae20f0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
        //console.log("sub_18AB0 onLeave:", hexdump(ptr(this.arg8).add(Process.pointerSize * 2).readPointer()));
        // console.log("sub_18AB0 onLeave:", hexdump(this.arg0), "\r\n", hexdump(this.arg1));
    }
});

hook结果:
图片描述


 

图片描述
图片描述
图片描述
发现这个函数是加密函数,所以我们跟进去分析一下:
图片描述
我们先hook这个函数看看
图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var sub_19248 = base_hello_jni.add(0x19248);
Interceptor.attach(sub_19248,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_19248 onEnter arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_19248 onEnter arg1:  \r\n",hexdump(this.arg1));
        console.log("sub_19248 onEnter arg2:  \r\n",(this.arg2));
    },onLeave:function(retval){
        console.log("sub_19248 onLeave arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_19248 onLeave arg1:  \r\n",hexdump(this.arg1));
        console.log("sub_19248 onLeave arg2:  \r\n",(this.arg2));
        console.log("sub_19248 onLeave retval:  \r\n",(retval));
    }
})

hook结果:
图片描述
图片描述
图片描述
从frida的hook的结果发现,这个函数的第二个参数是结果
然后回溯,看看从哪里过来的:
图片描述
然后看看outbuffer从哪里来的:
图片描述
hook他来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var sub_16900 = base_hello_jni.add(0x16900);
Interceptor.attach(sub_16900,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_16900 onEnter arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_16900 onEnter arg1:  \r\n",(this.arg1));
        console.log("sub_16900 onEnter arg2:  \r\n",hexdump(this.arg2));
    },onLeave:function(retval){
        console.log("sub_16900 onLeave arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_16900 onLeave arg1:  \r\n",(this.arg1));
        console.log("sub_16900 onLeave arg2:  \r\n",hexdump(this.arg2));
        console.log("sub_16900 onLeave retval:  \r\n",hexdump(retval));
    }
})

图片描述
图片描述
图片描述
图片描述
发现第一个参数是我们主动调用输入的字符串,第三个参数是返回结果,所以这个就是加密函数了:跟进去看看
图片描述
还是从第三个参数看看,第三个参数是返回值,传入了这个函数之中:
sub_16530,进去看看:
hook看看参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var sub_16530 = base_hello_jni.add(0x16530);
Interceptor.attach(sub_16530,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        this.arg2 = args[2];
        console.log("sub_16530 onEnter arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_16530 onEnter arg1:  \r\n",(this.arg1));
        console.log("sub_16530 onEnter arg2:  \r\n",hexdump(this.arg2));
    },onLeave:function(retval){
        console.log("sub_16530 onLeave arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_16530 onLeave arg1:  \r\n",(this.arg1));
        console.log("sub_16530 onLeave arg2:  \r\n",hexdump(this.arg2));
        console.log("sub_16530 onLeave retval:  \r\n",hexdump(retval));
    }
})

图片描述
图片描述
图片描述
图片描述
发现第一个参数是我们主动调用输入的字符串,第三个参数是返回结果进去看看:
图片描述
从out_buffer定位的
图片描述
进去看看:
hook看看

1
2
3
4
5
6
7
8
9
10
11
12
13
var sub_16214 = base_hello_jni.add(0x16214);
Interceptor.attach(sub_16214,{
    onEnter:function(args){
        this.arg0 = args[0];
        this.arg1 = args[1];
        console.log("sub_16214 onEnter arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_16214 onEnter arg1:  \r\n",hexdump(this.arg1));
    },onLeave:function(retval){
        console.log("sub_16214 onLeave arg0:  \r\n",hexdump(this.arg0));
        console.log("sub_16214 onLeave arg1:  \r\n",hexdump(this.arg1));
        console.log("sub_16214 onLeave retval:  \r\n",hexdump(retval));
    }
})

图片描述
图片描述
发现是输入的字符串加盐之后传入的,返回值是结果:
跟进去看看:
图片描述
从out_buffer定位到了a1然后定位到了这个函数
sub_15FAC(a1, &off_35370, v9 - v8);
图片描述
然后跟进去继续交叉引用,发现了熟悉的md5函数
图片描述

总结:

到此位置我们的ollvm基本的处理方法已经讲完了,过程中没有遇到很恶心的混淆,基本的步骤就是交叉引用定位字符串,然后hook函数看看参数结果,然后继续跟进去分析就好啦


[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 2022-11-23 17:29 被以和爲貴编辑 ,原因:
收藏
点赞18
打赏
分享
最新回复 (15)
雪    币: 256
活跃值: (71)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
真烧鱼 2022-11-23 09:40
2
0
你好,分析样本能否发一个
雪    币: 270
活跃值: (424)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
mb_fcagklmv 2022-11-23 16:53
3
0
我想知道修复so是怎么个修复法?小白求问
雪    币: 5643
活跃值: (7277)
能力值: ( LV7,RANK:110 )
在线值:
发帖
回帖
粉丝
以和爲貴 1 2022-11-23 17:25
4
0
真烧鱼 你好,分析样本能否发一个

抱歉啊师傅太大了传不上去

最后于 2022-12-15 17:40 被以和爲貴编辑 ,原因:
雪    币: 5643
活跃值: (7277)
能力值: ( LV7,RANK:110 )
在线值:
发帖
回帖
粉丝
以和爲貴 1 2022-11-23 17:25
5
0
mb_fcagklmv 我想知道修复so是怎么个修复法?小白求问[em_33]
您好师傅,在ida中按y修改参数类型,按n修改参数名称
雪    币: 6571
活跃值: (3823)
能力值: (RANK:200 )
在线值:
发帖
回帖
粉丝
LowRebSwrd 4 2022-12-3 16:31
6
0
赞!
雪    币: 5643
活跃值: (7277)
能力值: ( LV7,RANK:110 )
在线值:
发帖
回帖
粉丝
以和爲貴 1 2022-12-3 17:53
7
0
LowRebSwrd 赞!
谢谢版主
雪    币: 156
活跃值: (953)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
bluegatar 2022-12-7 04:39
8
0
大佬的文章也的波涛汹涌的,看下来心情难以贫富
雪    币: 156
活跃值: (953)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
bluegatar 2022-12-7 04:49
9
0
以和爲貴 抱歉啊师傅[em_5]太大了传不上去
发个样品到网盘吧
雪    币: 745
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
_thouger 2023-3-2 15:00
10
0
求个样本咯,这么好的文章加个样本复现学习更好了
雪    币: 197
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
q2471910785 2023-3-2 21:46
11
0
有点长
雪    币: 1104
活跃值: (1624)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
mb_lpcoesnt 2023-3-28 19:08
12
0
大佬好强
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
mb_eaxjilip 2023-9-14 22:33
13
0
您好,可以求求对应的apk文件吗
雪    币: 573
活跃值: (949)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
ChengQing 2023-9-15 10:55
14
0
1024
雪    币: 2
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
zx_958536 2023-11-2 20:25
15
0
3w的案例?
雪    币: 35
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
cobe 2024-3-1 11:16
16
0
有样本就完美了
游客
登录 | 注册 方可回帖
返回