360免费壳分析复现

数字免费壳

用的是大佬 oacia 加固的apk,需要的可以去大佬的博客中下载~

将apk丢入jeb中,查看AndroidManifest.xml。

image-20250521191640996

从整体壳开始看起,一般整体壳会在App类中重写方法attachBaseContext和onCreate,在这两个方法中,实现自脱壳。

先看attachBaseContext。

分析

image-20250521193756011

通过反射,访问类**”android.content.pm.PackageParser$Package”**,设置构造方法为可访问。

image-20250521193925825

通过反射,获取当前线程的ActivityThread实例,然后设置mHiddenApiWarningShown字段为true,避免了日志中出现使用隐藏api时的警告信息。

image-20250521194051603

StubApp是一个静态类,存储加固相关的全局状态。

StubApp.a保存上下文,供加固逻辑使用;StubApp.c保存当前对象实例(App类实例),确保只初始化一次。

image-20250521194322034

在函数com.qihoo.util.a.a()中,通过下述3种方式判断当前的架构,若是x86架构,则返回true,然后加载X86Bridge;反之相反。

  1. 检查 Build.SUPPORTED_32_BIT_ABIS 系统属性。

  2. 读取 /system/build.prop 文件中的 ro.product.cpu.abi 属性。

  3. 检查 /system/bin/ls 的 ELF 文件头,判断其架构。

image-20250521195133236

com.qihoo.util.a.a(…)会检测…/assets/libjiagu.so和…/.jiagu/libjiagu.so是否完全一样,如果不一样,以assets的so为主,会把assets的so覆写.jiagu的so,假如之后我们patch了…/.jiagu的so文件进行重打包,将不会有效果;libjiagu_64.so同等”待遇”。

image-20250521201518518

随后来到方法interface5,跳转到native层执行。

image-20250521202133001

不必多说,这个interface5肯定是在libjiaguxxx.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
function find_RegisterNatives(params) {
let symbols = Module.enumerateSymbolsSync("libart.so");
let addrRegisterNatives = null;
for (let i = 0; i < symbols.length; i++) {
let symbol = symbols[i];

//_ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi
if (symbol.name.indexOf("art") >= 0 &&
symbol.name.indexOf("JNI") >= 0 &&
symbol.name.indexOf("RegisterNatives") >= 0 &&
symbol.name.indexOf("CheckJNI") < 0) {
addrRegisterNatives = symbol.address;
console.log("RegisterNatives is at ", symbol.address, symbol.name);
hook_RegisterNatives(addrRegisterNatives)
}
}

}

function hook_RegisterNatives(addrRegisterNatives) {

if (addrRegisterNatives != null) {
Interceptor.attach(addrRegisterNatives, {
onEnter: function (args) {
console.log("[RegisterNatives] method_count:", args[3]);
let java_class = args[1];
let class_name = Java.vm.tryGetEnv().getClassName(java_class);
//console.log(class_name);

let methods_ptr = ptr(args[2]);

let method_count = parseInt(args[3]);
for (let i = 0; i < method_count; i++) {
let name_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3));
let sig_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize));
let fnPtr_ptr = Memory.readPointer(methods_ptr.add(i * Process.pointerSize * 3 + Process.pointerSize * 2));

let name = Memory.readCString(name_ptr);
let sig = Memory.readCString(sig_ptr);
let symbol = DebugSymbol.fromAddress(fnPtr_ptr)
console.log("[RegisterNatives] java_class:", class_name, "name:", name, "sig:", sig, "fnPtr:", fnPtr_ptr, " fnOffset:", symbol, " callee:", DebugSymbol.fromAddress(this.returnAddress), "\n");
}
}
});
}
}

setImmediate(find_RegisterNatives);

结果如图,暂时忽视那个Bad access….目标函数在libjiagu_64.so offset为0x11be00的位置。

image-20250521203239178

先不着急分析interface5,先走完libjiagu_64.so的初始化,用IDA打开assets下的libjiagu_a64.so。

打开一看,天塌了,导入表、导出表全是空的。

使用System.loadLibray加载一个so文件的流程是这样的(大致操作应该没问题,如果细节有误,就是我学术不精)。

  1. 动态链接器会映射这个so文件的PT_LOAD段到内存中;

  2. 解析so文件,映射所有其他依赖的共享库;

  3. 完成符号解析、动态重定位等操作;

  4. 动态库初始化:init_proc -> .init_array(函数指针) -> JNI_OnLoad;

  5. 完成加载,等待调用。

一般来说,一个普通的so文件是没有start的(注意,_start和_dl_start不是一个函数),只有可执行文件有,而libjiagu_64.so的导出表有start,而且只有一个start,甚至被标记为了main_entry。但对于so文件来说,main_entry应该是用不着的。

image-20250521220343528

上述种种特征说明,它不是一个普通的so文件,而是一个类似于自加载器的so文件,壳so负责将加固后的so文件解密,解析并加载,然后链接重定位。

那么问题来了,何时进行加载、何时重定位呢?

得先找到壳的程序入口点。

按照我们的常规思路,先去看看有没有函数叫init_proc(旧版本的才有的函数,一般情况下已经见不到这个函数)、再看init_array上有没有函数指针列表、最后找JNI_OnLoad。

结果,在IDA中看不到init_proc、init_array节、JNI_OnLoad。

后来,我通过readelf -a读到了INIT_ARRAY的偏移。

image-20250522121310523

跳转到0x2d760,没有看到函数指针。

image-20250522145858137

360壳对so文件的结构字段做了很多修改,有一些字段是用来观察so文件结构的,他们属于对动态链接器加载流程不构成影响的字段,这些字段要么给删了,要么给加密了。

事实上,完全可以从内存中dump一个已经加载好的so文件,但我想看看一般这种情况要如何进行下一步分析。

重新理一下思路——360壳对ELF文件做了手脚,通过工具无法查看某些字段的信息,但对于动态链接器来说,它依旧可以正确调用init_array上的函数和JNI_OnLoad。

因此,下一个切入点是:动态链接器是如何获得到init_array和JNI_OnLoad地址的。

动态链接器找init_array节地址的流程是这样的:

  1. 查找.dynamic节的位置,.dynamic节是一个由Elf_Dyn结构体组成的数组,每个Elf_Dyn包含2个主要成员。

    • d_tag:一个标记(tag),表示这个条目的类型,比如,当前这个条目指向符号表或重定位表等。
    • d_un:一个联合体,根据d_tag的不同,它可以是一个值,或者指向一个虚拟地址。
  2. 查找特定的d_tag,对于.init_array来说,有2个相关的d_tag标记。

    • d_tag == DT_INIT_ARRAY,此时d_un包含了.init_array节的偏移地址。
    • d_tag == DT_INIT_ARRAYSZ,此时d_un包含了.init_array节的总大小。(以字节为单位)
  3. 动态链接器会找到.init_array的位置,并根据.init_array的大小,得到函数指针的数目,执行上面的函数指针。

实操如下。

通过010editor,找到节头表,然后根据节头表找到.dynamic节的偏移。

image-20250522163243458

下面这个网址,给出了d_tag的值与含义。

https://docs.oracle.com/cd/E23824_01/html/819-0690/chapter6-42444.html

image-20250522163712631

一个Elf_Dyn元素占16个字节,前8个字节是d_tag,后8个字节是d_un。

接下来,在010editor中,从0x1DAD0开始,搜索d_tag == 25(0x19)或27(0x1B)的地方。

翻译一下,.init_array的地址是0x2D760,和之前readelf看到的一样;而.init_array的大小是0x10字节,可以存放2个函数指针。

image-20250522164021033

我们之前跳转到了0x2D760,发现.init_array上没有任何内容,这2个函数指针是哪里来的?突然想起来,在一个so文件加载的流程中,会先进行动态重定位,那大概率是在重定位的过程,为.init_array赋了2个函数指针。

重定位表(RELA类型)相关的d_tag如下:

  • DT_RELA:此时d_un指向了重定位表的地址。
  • DT_RELASZ:此时d_un指定了重定位表的总大小。
  • DT_RELAENT:此时d_un指定了每个重定位条目的大小(一般24字节)。

而重定位表(RELA类型)中的每个元素的结构(Elf_Rela)是这样的:

  • r_offset:需要修改的、需要重定位的地址偏移量。(字节)
  • r_info:这个成员变量包含2个信息——高4字节表示符号表索引,指向动态符号表(.dynsym)中的符号条目,该符号的地址将用于重定位计算;低4字节表示修正规则,用于判断执行哪种类型的地址修正操作。
  • r_addend:一个显式的加数,一个有符号的常量,它会参与重定位的计算中,比如说:写入r_offset的位置的值可能是符号地址 + r_addend。

而如果是REL类型的重定位表,相关的d_tag则是少了一个A,比如说,DT_REL、DT_RELSZ、DT_RELENT。

而REL类型重定位表每个元素的的结构只有:r_offset和r_info。

接下来是实操。

通过 readelf -r libjiagu_a64.so –use-dynamic,可以获得重定位表表项的信息。

第一条表项,是不是很眼熟?正是我们.init_array的地址,(除此之外,没找到0x2d768),由于r_info的低32位是0403,代表修正操作是R_AARCH64_RELATIV,这个修正规则的符号索引通常是0(指向空符号),因为这种重定位规则是相对于模块自身的加载基址,不依赖于其他特定符号。

因此,.init_array的第一个函数指针是sub_98a0。

image-20250522182759789

别着急找JNI_OnLoad,还没完,突然注意到RELA表中,第二项的Type也是R_AARCH64_RELATIV,并且r_addend是0x2e20,这不正是我们之前看到的start函数的地址吗?

它被存放到了0x2d770的位置,这个地址离.init_array就差16个字节。

image-20250521220343528

还记得我们之前使用readelf -a读到了.init_array的地址,其实当时还读到了.fini_array的地址。

image-20250522183812989

也就是说,这个start是.fini_array上的函数指针,很有迷惑性,.fini_array上的函数指针,是当程序退出的时候执行的,却取名叫了start。

至此,我们找到了.init_array上的函数指针:sub_98a0

下一步,动态链接器是如何找到JNI_OnLoad的呢?

动态链接器在执行完.init_array上函数指针的函数后,会调用dlsym(handle, “JNI_OnLoad”),用于在库中查找一个名为”JNI_OnLoad”的导出符号,具体来说,dlsym()回到库的动态符号表(.dynsym)中搜索这个名称,如果找到了这个符号,就会返回这个符号的内存地址,由Dalvik/ART虚拟机传递JavaVM*指针和一个保留参数并执行这个指向JNI_OnLoad的函数。

实操如下。

通过.dynamic的节表找到.dynamic节在文件中的偏移。

通过010editor,找到节头表(动态链接器会根据PHT来找,节表在装载过程可有可无),然后根据节头表找到.dynamic节的偏移。

image-20250522163243458

然后从0x1DAD0的位置,寻找d_tag == DT_SYMTAB(0x6)的表项。

image-20250522192323619

可以从d_un知道,动态符号表的偏移是0x4E8。

image-20250522192408776

**动态符号表(.dynsym)**每个元素的结构(Elf64_Sym)是这样的。

  • 一共24个字节。
  • st_name:4个字节,表示相对于动态字符串表起始地址(.dynstr)中的偏移,如果该值为0,表示该符号没有名称。
  • st_info:1个字节,表示符号的类型绑定属性(根据这1个字节的不同位区分),类型:函数还是对象;绑定属性:全局还是局部。
  • st_other:1个字节,定义符号的可见性。
  • st_shndx:2个字节,符号相关的节头表索引,指明了该符号定义在哪个节区(特殊值有:未定义符号-SHN_UNDEF、绝对符号-SHN_ABS)。
  • st_value:8个字节,符号的值,通常是符号的虚拟地址。
  • st_size:8个字节,符号关联的数据的大小,以字节为单位,例如:函数体的大小或者数据对象的大小。

而动态字符串表(.dynstr)里面,全部放着字符串,动态符号表的st_name的索引,对应着动态字符串表首地址的偏移量。

在动态符号表中,每个数据对象占24(0x18)个字节,我们假设JNI_OnLoad这个字符串一定存在于.dynstr里,先去计算它相对于.dynstr的首地址的偏移量。

找动态字符串表有个快捷的方式,直接在IDA中shift + f12,起始地址是0xFB0。

image-20250522210913892

然后搜索”JNI_OnLoad”,先考虑第1个JNI_OnLoad的偏移吧,如果根据第1个的偏移找不到,再考虑第2个。(仅仅根据字符串,无法通过交叉引用追踪到JNI_OnLoad的函数地址)

image-20250522211014029

计算偏移,得出0x230。

image-20250522211223432

接下来,从动态符号表(从0x4E8开始找)中,每次查看24字节的前4个字节,寻找0x230,然后根据st_value,找到符号的虚拟地址。——由于是小端排序,可以直接在010editor搜索,30 02。

image-20250522213102676

根据st_value在结构体中的偏移量,st_value = 0x8AFC。

image-20250522213143598

接下来我们去看看0x8AFC符不符合JNI_OnLoad的特征。

image-20250522213451658

其中,sub_8C74里面有关于JNI_OnLoad字符串的出现。

sub_4c70像不像dlsym?dlsym根据动态链接库操作句柄(pHandle)与符号(symbol),返回符号对应的地址,也就是一个函数指针,为什么有2个JNI_OnLoad呢?我猜测是壳so加载了一个so(暂且叫做主so),而这个主so负责JNI动态注册,简单来说,壳so的JNI_OnLoad调用了主so的JNI_OnLoad。

image-20250522214248568

至此,做个小总结,.init_array节上有函数指针sub_98a0,而壳so的JNI_OnLoad地址是0x8AFC,为了验证猜想,我们可以dump一个内存中加载好的libjiagu_64.so进行验证。

通过frida进行dump。

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
function my_hook_dlopen(soName = '') {
Interceptor.attach(Module.getExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
if (path.indexOf(soName) >= 0) {
console.log("准备加载")
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
dump_so("libjiagu_64.so");
}
}
}
);
}

function dump_so(so_name) {
console.log(so_name)
var libso = Process.getModuleByName(so_name);
console.log("[name]:", libso.name);
console.log("[base]:", libso.base);
console.log("[size]:", ptr(libso.size));
console.log("[path]:", libso.path);
var file_path = "/data/data/com.oacia.apk_protect/" + libso.name + "_" + libso.base + "_" + ptr(libso.size) + ".so";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
Memory.protect(ptr(libso.base), libso.size, 'rwx');
var libso_buffer = ptr(libso.base).readByteArray(libso.size);
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump]:", file_path);
}
}

setImmediate(my_hook_dlopen("libjiagu_64.so"));

然后使用elf修复工具进行修复,再用ida打开,这回导入、导出表的内容恢复了。

image-20250522125906310

来查看.init_array和JNI_OnLoad的地址。

image-20250522215252155

image-20250522215337793

下图是壳so的JNI_OnLoad。

image-20250522215721040

下图是主so的JNI_OnLoad。

image-20250522215826312

暂且就不去分析sub_98a0和壳so的JNI_OnLoad的代码了,应该是修复导入、导出表,执行JNI_OnLoad、反调等操作。接下来会使用frida对这一块内容进行分析。


注意到,在frida注入代码,dump了so文件之后,进程会立即退出。

image-20250522125830770

hook了android_dlopen_ext,发现进程在加载了libjiagu_64.so后就退出了,说明检测frida的代码大概率在这个so文件里。

image-20250522130529768

通过hook发现,libjiagu_64.so的某段代码一直在检测maps文件,应该是这里在检测调试器。

解决办法:可以hook函数open,每当要执行函数open时,判断目标文件是否为maps文件,如果是,重定位到一个不存在的文件。

image-20250522130853416

hook的脚本如下。

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
function my_hook_dlopen(soName = '') {
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();
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
hook_proc_self_maps();
}
}
}
);
}

function hook_proc_self_maps() {
const openPtr = Module.getExportByName(null, 'open');
const open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
var fakePath = "/data/data/com.oacia.apk_protect/maps";
Interceptor.replace(openPtr, new NativeCallback(function (pathnameptr, flag) {
var pathname = Memory.readUtf8String(pathnameptr);
console.log("open", pathname);
if (pathname.indexOf("maps") >= 0) {
console.log("find",pathname,",redirect to",fakePath);
var filename = Memory.allocUtf8String(fakePath);
return open(filename, flag);
}
var fd = open(pathnameptr, flag);
return fd;
}, 'int', ['pointer', 'int']));
}


setImmediate(my_hook_dlopen,"libjiagu");

虽然还是退出了,但这次有了新的收获,可以看到打开了3个dex文件。

image-20250522131044479

用010editor打开,发现第一个dex处于加密状态,而其它的dex是空的。

image-20250522131542842

为了判断是哪里的代码open了dex文件,可以打印一下堆栈,常规的打印堆栈的方式是加下面这句代码,但我们前面将maps文件重定位了,所以不能使用DebugSymbol.fromAddress(用到了maps文件)了,得自己实现一个函数。

1
console.log('RegisterNatives called from:\\n' + Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join('\\n') + '\\n');

修改后的完整代码如下。

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
function addr_in_so(addr){
var process_Obj_Module_Arr = Process.enumerateModules();
for(var i = 0; i < process_Obj_Module_Arr.length; i++) {
if(addr>process_Obj_Module_Arr[i].base && addr<process_Obj_Module_Arr[i].base.add(process_Obj_Module_Arr[i].size)){
console.log(addr.toString(16),"is in",process_Obj_Module_Arr[i].name,"offset: 0x"+(addr-process_Obj_Module_Arr[i].base).toString(16));
}
}
}
function hook_proc_self_maps() {
const openPtr = Module.getExportByName(null, 'open');
const open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
var fakePath = "/data/data/com.oacia.apk_protect/maps_nonexistent";
Interceptor.replace(openPtr, new NativeCallback(function (pathnameptr, flag) {
var pathname = Memory.readUtf8String(pathnameptr);
console.log("open",pathname);//,Process.getCurrentThreadId()
if (pathname.indexOf("maps") >= 0) {
console.log("find",pathname+", redirect to",fakePath);
var filename = Memory.allocUtf8String(fakePath);
return open(filename, flag);
}
if (pathname.indexOf("dex") >= 0) {
Thread.backtrace(this.context, Backtracer.FUZZY).map(addr_in_so);
}
var fd = open(pathnameptr, flag);
return fd;
}, 'int', ['pointer', 'int']));
}

function my_hook_dlopen(soName='') {
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(path);
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
hook_proc_self_maps();
}
}
}
);
}
setImmediate(my_hook_dlopen,'libjiagu');

可以看到3个dex文件被打开时的调用栈了,鉴于3个dex文件的调用栈基本一样,大概率是在一个循环中依次加载的。

image-20250522133014663 image-20250522133032024

来到0x19b780,发现都是0,应该是在加载完dex文件后,将这块空间清空了。

image-20250523131131946

为了分析这块代码的逻辑,需要在open dex文件的时候,将so文件dump下来。

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
var hook_once = 0;

function my_hook_dlopen(soName = '') {
Interceptor.attach(Module.getExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
if (path.indexOf(soName) >= 0) {
console.log("准备加载")
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
// 获得open的地址
const openPtr = Module.getExportByName(null, 'open');
// 准备替换的open
const open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
// hook
Interceptor.replace(openPtr, new NativeCallback(function (pathnameptr, flag) {
var pathname = Memory.readUtf8String(pathnameptr);
console.log("open", pathname);//,Process.getCurrentThreadId()

if (pathname.indexOf("maps") >= 0) {
var fakePath = "/data/data/com.oacia.apk_protect/maps_nonexistent";
console.log("find",pathname+", redirect to",fakePath);
var filename = Memory.allocUtf8String(fakePath);
return open(filename, flag);
}

if (pathname.indexOf("/data/data/com.oacia.apk_protect/.jiagu/classes") >= 0) {
if(hook_once == 0){
dump_so("libjiagu_64.so");
hook_once = 1;
}
}
var fd = open(pathnameptr, flag);
return fd;
}, 'int', ['pointer', 'int']));
}
}
}
);
}

function dump_so(so_name) {
console.log(so_name)
var libso = Process.getModuleByName(so_name);
console.log("[name]:", libso.name);
console.log("[base]:", libso.base);
console.log("[size]:", ptr(libso.size));
console.log("[path]:", libso.path);
var file_path = "/data/data/com.oacia.apk_protect/" + libso.name + "_" + libso.base + "_" + ptr(libso.size) + ".so";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
Memory.protect(ptr(libso.base), libso.size, 'rwx');
var libso_buffer = ptr(libso.base).readByteArray(libso.size);
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump]:", file_path);
}
}

setImmediate(my_hook_dlopen("libjiagu_64.so"));

脚本执行结果。

image-20250522134705450

修复之后,用IDA打开,定位到0x19b780。

image-20250523131209960

可以使用winmerge查看两个文件的区别。分析被抽空的数据,是从哪里开始抽空的

image-20250523131635457

往上滚动,直到0xe7000,两边的文件才基本一模一样。

image-20250523131949048

注意到,0xe7000存放的是.ELF,这是ELF文件的魔数。也就是说,壳so里面藏了一个so(主so)。

使用下面这个脚本,将壳so里的ELF文件读出来,放入010editor查看。

1
2
3
4
with open('libjiagu_64.so_0x6f71613000_0x274000.so','rb') as f:
s=f.read()
with open('libjiagu_0xe7000.so','wb') as f:
f.write(s[0xe7000::])

除了ELF头,段头表和节表都被加密了。

image-20250523132740721

因此,IDA无法对这个ELF文件进行正常的分析。

根据前人的分析,壳ELF会对加密的主ELF进行解密,并且自己实现了linker,对主ELF进行解析,再将解析结果赋值到soinfo结构体中,然后调用dlopen进行手动加载。

先简单介绍一下什么是dlopen、dlsym和soinfo。

dlopen (Dynamic Load Open)

  • 是什么dlopen 是一个标准 C 库函数(定义在 dlfcn.h 中,通常由 libdl.so 提供)。它允许程序在运行时(而不是在程序启动时)显式地加载指定的共享对象(SO文件)到其地址空间。
  • 作用:当程序调用 dlopen("path/to/your_library.so", flags) 时,它会请求动态链接器加载这个库。如果加载成功,dlopen 会返回一个“句柄 (handle)”,后续可以使用这个句柄通过 dlsym 查找库中的符号(函数或变量),或者通过 dlclose卸载该库。
  • 谁调用它:应用程序代码可以直接调用 dlopen 来实现插件系统、按需加载功能模块等。在Java/Kotlin层面,System.loadLibrary()System.load() 在底层通常也是通过调用 dlopen 来加载JNI库的。

soinfo (Shared Object Information)

  • 是什么soinfoAndroid动态链接器内部使用的一个非常重要的数据结构。对于加载到进程中的每一个SO文件,动态链接器都会在内部创建一个对应的 soinfo 实例来管理它。其他类Unix系统的动态链接器也会有类似的内部结构来跟踪已加载的库,但 soinfo 这个名称特指Android的实现(源于Bionic C库的链接器)。
  • 作用:soinfo结构体存储了关于一个已加载SO文件的所有关键运行时信息,例如:
    • 库的名称和完整路径。
    • 库在内存中的加载基地址 (base address) 和大小。
    • 指向其ELF动态段 (.dynamic section) 的指针。
    • 指向其动态符号表 (.dynsym)、字符串表 (.dynstr)、哈希表 (.hash.gnu.hash) 的指针。
    • 重定位表的信息。
    • 依赖的其他 soinfo 实例的列表。
    • 库的句柄 (handle)、引用计数。
    • 初始化状态(例如,构造函数/.init_array 是否已运行)。
    • 标志位(例如,是否是主可执行程序、是否是PIE等)。

应用程序通过dlopen函数请求加载一个so文件,dlopen会将这个请求传递给动态链接器linker,动态链接器linker负责实际执行加载so文件的所有底层工作(查找文件、映射内存、解析依赖、重定位符号、运行初始化代码等)。在加载so文件的过程中,动态链接器会为这个新加载的so文件创建一个soinfo结构体实例,这个soinfo包含了管理该so所需的所有元数据和状态信息。

而当后续调用dlsym查找该库某个符号时,链接器会查阅对应soinfo中记录的符号表等信息——dlopen获得的handle,实际上就是soinfo结构体实例的指针。

因此,可以理解壳ELF的行为:对主ELF文件进行解析、映射、重定位…创建soinfo结构实例,然后供dlsym使用,获得符号的地址。

主so是壳so加载起来的,但要是连依赖项也由壳处理,就过于麻烦了,所以前人分析会用到dlopen,于是进行交叉引用追踪。

在函数sub_3C94中,用到了dlopen,同时会发现,这里的代码和aosp源码的预链接十分相似,下图是sub_3C94的代码。

image-20250523143433747

下图是AOSP源码中的预链接(直接用oacia大佬的图)。

image-20250523143521988

添加前人准备好的关于soinfo的结构体,然后将参数a1类型改成soinfo *。

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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// ELF64 启用该宏
#define __LP64__ 1
// ELF32 启用该宏
//#define __work_around_b_24465209__ 1

/*
https://android.googlesource.com/platform/bionic/+/master/linker/Android.bp
架构为 32 位 定义__work_around_b_24465209__宏arch: {
arm: {cflags: [" D__work_around_b_24465209__"],},
x86: {cflags: [" D__work_around_b_24465209__"],},
}
*/

// 定义 ELF 文件的基本类型,根据架构决定使用 ELF32 或 ELF64 类型
#if defined(__LP64__)
#define ElfW(type) Elf64_ ## type
#else
#define ElfW(type) Elf32_ ## type
#endif

// 32 位和 64 位重定位和符号表数据结构

// Elf32 和 Elf64 基本类型
typedef signed char __s8;
typedef unsigned char __u8;
typedef signed short __s16;
typedef unsigned short __u16;
typedef signed int __s32;
typedef unsigned int __u32;
typedef signed long long __s64;
typedef unsigned long long __u64;

// 32 位 ELF 基本类型
typedef __u32 Elf32_Addr;
typedef __u16 Elf32_Half;
typedef __u32 Elf32_Off;
typedef __s32 Elf32_Sword;
typedef __u32 Elf32_Word;

// 64 位 ELF 基本类型
typedef __u64 Elf64_Addr;
typedef __u16 Elf64_Half;
typedef __s16 Elf64_SHalf;
typedef __u64 Elf64_Off;
typedef __s32 Elf64_Sword;
typedef __u32 Elf64_Word;
typedef __u64 Elf64_Xword;
typedef __s64 Elf64_Sxword;

// 动态段数据结构(Elf32 和 Elf64)
typedef struct dynamic {
Elf32_Sword d_tag;
union {
Elf32_Sword d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;

typedef struct {
Elf64_Sxword d_tag; /* entry tag value */
union {
Elf64_Xword d_val;
Elf64_Addr d_ptr;
} d_un;
} Elf64_Dyn;

// 重定位数据结构(Elf32 和 Elf64)
typedef struct elf32_rel {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;

typedef struct elf64_rel {
Elf64_Addr r_offset; /* Location at which to apply the action */
Elf64_Xword r_info; /* index and type of relocation */
} Elf64_Rel;

typedef struct elf32_rela {
Elf32_Addr r_offset;
Elf32_Word r_info;
Elf32_Sword r_addend;
} Elf32_Rela;

typedef struct elf64_rela {
Elf64_Addr r_offset; /* Location at which to apply the action */
Elf64_Xword r_info; /* index and type of relocation */
Elf64_Sxword r_addend; /* Constant addend used to compute value */
} Elf64_Rela;

// 符号表数据结构(Elf32 和 Elf64)
typedef struct elf32_sym {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;

typedef struct elf64_sym {
Elf64_Word st_name; /* Symbol name, index in string tbl */
unsigned char st_info; /* Type and binding attributes */
unsigned char st_other; /* No defined meaning, 0 */
Elf64_Half st_shndx; /* Associated section index */
Elf64_Addr st_value; /* Value of the symbol */
Elf64_Xword st_size; /* Associated symbol size */
} Elf64_Sym;

// ELF 文件头数据结构(Elf32 和 Elf64)
#define EI_NIDENT 16

typedef struct elf32_hdr {
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;

typedef struct elf64_hdr {
unsigned char e_ident[EI_NIDENT]; /* ELF "magic number" */
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;

// 程序头数据结构(Elf32 和 Elf64)
typedef struct elf32_phdr {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;

typedef struct elf64_phdr {
Elf64_Word p_type;
Elf64_Word p_flags;
Elf64_Off p_offset; /* Segment file offset */
Elf64_Addr p_vaddr; /* Segment virtual address */
Elf64_Addr p_paddr; /* Segment physical address */
Elf64_Xword p_filesz; /* Segment size in file */
Elf64_Xword p_memsz; /* Segment size in memory */
Elf64_Xword p_align; /* Segment alignment, file & memory */
} Elf64_Phdr;

// 节头数据结构(Elf32 和 Elf64)
typedef struct elf32_shdr {
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset;
Elf32_Word sh_size;
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;

typedef struct elf64_shdr {
Elf64_Word sh_name; /* Section name, index in string tbl */
Elf64_Word sh_type; /* Type of section */
Elf64_Xword sh_flags; /* Miscellaneous section attributes */
Elf64_Addr sh_addr; /* Section virtual addr at execution */
Elf64_Off sh_offset; /* Section file offset */
Elf64_Xword sh_size; /* Size of section in bytes */
Elf64_Word sh_link; /* Index of another section */
Elf64_Word sh_info; /* Additional section information */
Elf64_Xword sh_addralign; /* Section alignment */
Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;

// 动态链接信息结构(Android 特有)
typedef void (*linker_dtor_function_t)();
typedef void (*linker_ctor_function_t)(int, char**, char**);

#if defined(__work_around_b_24465209__)
#define SOINFO_NAME_LEN 128
#endif

// Android 中的 soinfo 结构体,用于表示动态链接库信息
struct soinfo {
#if defined(__work_around_b_24465209__)
char old_name_[SOINFO_NAME_LEN];
#endif
const ElfW(Phdr)* phdr;
size_t phnum;
ElfW(Addr) base;
size_t size;
ElfW(Dyn)* dynamic;
soinfo* next;
uint32_t flags_;
const char* strtab_;
ElfW(Sym)* symtab_;
size_t nbucket_;
size_t nchain_;
uint32_t* bucket_;
uint32_t* chain_;
#if !defined(__LP64__)
ElfW(Addr)** unused4; // DO NOT USE, maintained for compatibility
#endif
#if defined(USE_RELA)
ElfW(Rela)* plt_rela_;
size_t plt_rela_count_;
ElfW(Rela)* rela_;
size_t rela_count_;
#else
ElfW(Rel)* plt_rel_;
size_t plt_rel_count_;
ElfW(Rel)* rel_;
size_t rel_count_;
#endif
linker_ctor_function_t* preinit_array_;
size_t preinit_array_count_;
linker_ctor_function_t* init_array_;
size_t init_array_count_;
linker_dtor_function_t* fini_array_;
size_t fini_array_count_;
linker_ctor_function_t init_func_;
linker_dtor_function_t fini_func_;
size_t ref_count_;
link_map link_map_head;
bool constructors_called;
ElfW(Addr) load_bias;
bool has_text_relocations;
bool has_DT_SYMBOLIC;
};

如果是一个标准的soinfo结构,不会出现a1[1],只能说壳ELF的soinfo魔改了。

image-20250523145851806

简单分析一下sub_3C94做了什么:

  1. 构建soinfo实例,从一些硬编码的信息中,将soinfo实例进行初始化;
  2. 加载主so的依赖库;

这些硬编码信息的偏移量是从a1获得的。

image-20250523152403741

sub_49F0调用了sub_3C94。

image-20250523152508833

v5的值与v1有关,v1的值与a1有关,a1的值来自sub_49f0的调用者——sub_4B54。

image-20250523152541657

a1的值与a2有关,但在函数sub_6128中,a2的值是由a1得来的,因此,还得继续追踪sub_4B54的调用者。

image-20250523152738670

sub_4B54有2个调用者,其中,sub_8c74很眼熟。

image-20250523153052668

0x8AFC是JNI_OnLoad的函数地址,其实之前交叉引用sub_8C74的时候,发现没有函数引用sub_8C74,但JNI_OnLoad的伪代码里突然发现了sub_8c74。

image-20250523160437480

通过汇编代码,可以看到,BL指令的下一条指令就是sub_8C74,这种方式的调用可能让IDA无法正常分析了

image-20250523162349320

言归正传,也就是说,当壳so被加载起来后,会通过JNI_OnLoad,最终将主so进行解析并加载。

回到sub_3C94的调用者——sub_49F0。

只有当sub_3C94的返回值是奇数,才能进一步执行sub_4918,而sub_3C94的作用我们前面分析过了(将主so的元数据填到soinfo实例,然后加载主so的依赖),这里的sub_4918大概率是进一步完成主so的加载。

image-20250523163024028

看到关于dynamic的字眼,大概率是重定位阶段。

image-20250523164348276

点进sub_4000。

image-20250523165245625

看到0x403,有点眼熟,搜了一下之前的内容,果然,v8是r_info的低32位,代表重定位过程,地址的修正规则,进一步说明当前函数是在进行重定位了。

image-20250522182759789

一般情况下,动态链接器会先对处理通用重定位(.rela.dyn或.rel.dyn),再处理 PLT 重定位(.rela.plt或.rel.plt)。

image-20250523183558728

再进入sub_5e6c观察。

  1. 观察到0x38(56个字节),刚好是一个段表的大小。

  2. 调用了mprotect。

基本可以确定,大概率是在处理各个LOAD段。

将begin_addr_pht_1类型改成elf64_phdr,IDA的伪代码将会更加清晰,下图是我分析后的sub_5E6C。

image-20250523184641892

0x6474E552换成十进制是1685382482,可以通过010Editor知道其含义是PT_GNU_RELRO。

image-20250523190428752

sub_5E6C 函数在处理 PT_GNU_RELRO 段。它的作用就是在“主SO”的重定位完成后,找到所有标记为 PT_GNU_RELRO 的程序段,并将这些段在内存中的对应区域设置为只读。这是ELF动态链接中的一个标准安全步骤,称为RELRO (Relocation Read-Only)目的是保护那些在重定位后不应再被修改的数据段(如部分GOT表、.data.rel.ro段)免遭篡改。

既然,这里将RELRO的段设置成了只读,说明这个时候,所有的PHT都应该处于解密状态,尝试在这个时候读取PHT,将它们dump下来。

第1个参数代表程序头表(们)的起始地址,第2个参数代表数量,第3个参数还没分析。

image-20250523193144378

使用frida脚本进行dump,需要注意,sub_5E6C是在壳so的JNI_OnLoad调用的,如果hook android_dlopen_ext,在onLeave回调时,hook sub_5E6C,那时就太晚了,JNI_OnLoad都执行完了,不会触发Interceptor.attach的trap。

因此,得hook call_constructors,在onEnter时进一步hook sub_5E6C,这个hook的时机是:壳so的内容映射到了内存中,但尚未执行init_proc、.init_array上的函数、JNI_OnLoad,这个时机就很完美。

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
// 针对 call_constructors 进行 Hook
function hook_linker_call_constructors() {
let linker64_base_addr = Module.getBaseAddress('linker64');
let offset_call = 0x51BA8; // 你手机的 linker64 中 call_constructors 的偏移量
let call_constructors = linker64_base_addr.add(offset_call);

let listener = Interceptor.attach(call_constructors, {
onEnter: function(args) {
let secmodule = Process.findModuleByName("libjiagu_64.so"); // 目标 SO 文件
if (secmodule != null) {
// Hook 目标函数 sub_5E6C
console.log("[+] ", "Module libjiagu_64.so base_addr: ", secmodule.base.toString(16));
hook_target_func(secmodule.base);
listener.detach(); // Hook 完成后解除
}
}
});
}

// Hook 目标函数 sub_5E6C
function hook_target_func(baseaddr) {
// sub_5E6C 的偏移
let target_offset = 0x5E6C;
let target_func = baseaddr.add(target_offset);

Interceptor.attach(target_func, {
onEnter: function(args) {
console.log('[+] Hooking sub_5E6C at:', target_func);

// 获取参数
let begin_addr_pht = args[0]; // 第一个 PHT 条目的地址
let nums_pht = args[1]; // PHT 条目数量

console.log('[+] PHT Parameters:');
console.log(' - begin_addr_pht:', begin_addr_pht);
console.log(' - nums_pht:', nums_pht);
console.log(' - a3: 0x' + args[2].toString(16))

// 验证 PHT 条目数量
if (nums_pht < 1) {
console.log('[-] Invalid PHT count:', nums_pht);
return;
}

// 计算 PHT 结束地址
let pht_size_per_entry = 56; // Elf64_Phdr 的大小为 56 字节
let end_addr_pht = begin_addr_pht.add(nums_pht * pht_size_per_entry);

// 准备保存 PHT 数据
let pht_data = Memory.alloc(nums_pht * pht_size_per_entry);
Memory.copy(pht_data, begin_addr_pht, nums_pht * pht_size_per_entry);

// 保存到文件
let file_path = '/data/data/com.oacia.apk_protect/pht_decrypt.bin';
let file = new File(file_path, 'wb');
if (file && file !== null) {
file.write(pht_data.readByteArray(nums_pht * pht_size_per_entry));
file.flush();
file.close();
console.log('[+] PHT dumped to:', file_path);
} else {
console.log('[-] Failed to open file:', file_path);
}
},
onLeave: function(retval) {
console.log('[+] sub_5E6C returned:', retval);
}
});
}

// 启动 Hook
setImmediate(hook_linker_call_constructors);

结果如下(忽视掉进程终止^_^)。

可以发现:0x70089b4000 - 0x70088cd000 = 0xe7000,而之前我们的分析中,偏移量0xe7000处是主so的起始地址,所以这里的a3是主so在内存中的基址。

同时,也可以看出来解密后的PHT的存放地址,和主so的ELF头并不在内存上相连(在之后的操作后就明白了,这里PHT的地址是堆的地址,主so的PHT是垃圾数据),与ELF头相连的PHT是垃圾数据填充的,或者说是加密的。

image-20250523214928222

将文件pull出来观察,看样子确实解密了。

image-20250523200915894

修改到之前脱掉的主so里去,现在可以清晰看到段表的内容了。

image-20250523201154653

至此,拿到了解密后的PHT,也知道了sub_5E6C的参数意义。

问题又来了,既然参数的意义分别是:第1个参数代表程序头表(们)的起始地址,第2个参数代表数量,第3个参数代表主so的基址。

image-20250523220006379

而soinfo的结构体是这样的。

image-20250523220224280

按理来说,soinfo偏移量为0的地方就是PHT的地址,而这里的a1->link_map_head.l_next实则指向了((byte*) a1) + 232,这说明在成员变量const Elf64_Phdr *phdr的前面,还有232字节,这232字节应该是360壳自定义的。

image-20250523220603855

添加后,第1个参数的位置对了,但第2个参数和第3个参数还是错的,说明还需要插入一些字节。

image-20250523220720253

正常情况应该是这样。

1
sub_5E6C(a1->phdr, a1->phnum, a1->base)

为了知道壳ELF是怎么解密出来的,大佬oacia写了一个IDA插件:stalker_trace_so,这个脚本可以追踪native函数的执行顺序,但似乎不分线程,而且也没有调用关系,下图是截的大佬oacia博客的图。

image-20250524093446012

我对这个脚本进行了二开,让打印更美观一点,而且要展示调用关系。

修改后,打印的内容好看了很多,能清晰地看到调用链。

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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
[ONEPLUS A6003::com.oacia.apk_protect ]-> start Stalker on thread 11793
Stalker started!
[180 ms] [TID:11793] ENTER: JNI_OnLoad
[180 ms] [TID:11793] ENTER: .interpreter_wrap_int64_t
[180 ms] [TID:11793] ENTER: interpreter_wrap_int64_t
[181 ms] [TID:11793] ENTER: ._Znwm
[181 ms] [TID:11793] EXIT: ._Znwm
[181 ms] [TID:11793] EXIT: interpreter_wrap_int64_t
[182 ms] [TID:11793] ENTER: sub_13908
[182 ms] [TID:11793] ENTER: ._Znam
[182 ms] [TID:11793] EXIT: ._Znam
[184 ms] [TID:11793] ENTER: sub_11220
[184 ms] [TID:11793] ENTER: .memset
[185 ms] [TID:11793] EXIT: .memset
[186 ms] [TID:11793] ENTER: sub_9DD8
[186 ms] [TID:11793] EXIT: sub_9DD8
[187 ms] [TID:11793] EXIT: sub_11220
[190 ms] [TID:11793] ENTER: sub_E3E0
[190 ms] [TID:11793] EXIT: sub_E3E0
[190 ms] [TID:11793] ENTER: .calloc
[191 ms] [TID:11793] EXIT: .calloc
[191 ms] [TID:11793] EXIT: sub_13908
[191 ms] [TID:11793] EXIT: .interpreter_wrap_int64_t
[193 ms] [TID:11793] EXIT: JNI_OnLoad
[204 ms] [TID:11793] ENTER: .malloc
[204 ms] [TID:11793] ENTER: .free
[204 ms] [TID:11793] EXIT: .free
[207 ms] [TID:11793] EXIT: .malloc
[208 ms] [TID:11793] ENTER: sub_E648
[209 ms] [TID:11793] EXIT: sub_E648
[209 ms] [TID:11793] ENTER: ._ZdaPv
[213 ms] [TID:11793] EXIT: ._ZdaPv
[213 ms] [TID:11793] ENTER: sub_C918
[213 ms] [TID:11793] EXIT: sub_C918
[214 ms] [TID:11793] ENTER: sub_9988
[214 ms] [TID:11793] EXIT: sub_9988
[214 ms] [TID:11793] ENTER: sub_9964
[214 ms] [TID:11793] EXIT: sub_9964
[215 ms] [TID:11793] ENTER: sub_9AC4
[216 ms] [TID:11793] EXIT: sub_9AC4
[216 ms] [TID:11793] ENTER: .ffi_prep_cif
[216 ms] [TID:11793] ENTER: ffi_prep_cif
[217 ms] [TID:11793] ENTER: .ffi_prep_cif_machdep
[217 ms] [TID:11793] ENTER: ffi_prep_cif_machdep
[218 ms] [TID:11793] EXIT: ffi_prep_cif_machdep
[218 ms] [TID:11793] ENTER: .ffi_call
[218 ms] [TID:11793] ENTER: ffi_call
[218 ms] [TID:11793] ENTER: sub_1674C
[219 ms] [TID:11793] EXIT: sub_1674C
[219 ms] [TID:11793] ENTER: .ffi_call_SYSV
[219 ms] [TID:11793] ENTER: ffi_call_SYSV
[219 ms] [TID:11793] ENTER: sub_167BC
[220 ms] [TID:11793] ENTER: sub_1647C
[220 ms] [TID:11793] EXIT: sub_1647C
[220 ms] [TID:11793] ENTER: sub_163DC
[220 ms] [TID:11793] EXIT: sub_163DC
[221 ms] [TID:11793] EXIT: sub_167BC
[221 ms] [TID:11793] EXIT: ffi_call_SYSV
[221 ms] [TID:11793] ENTER: sub_9900
[222 ms] [TID:11793] EXIT: sub_9900
[222 ms] [TID:11793] EXIT: .ffi_call_SYSV
[222 ms] [TID:11793] EXIT: ffi_call
[222 ms] [TID:11793] EXIT: .ffi_call
[223 ms] [TID:11793] EXIT: .ffi_prep_cif_machdep
[224 ms] [TID:11793] ENTER: sub_94BC
[224 ms] [TID:11793] ENTER: .dladdr
[226 ms] [TID:11793] EXIT: .dladdr
[226 ms] [TID:11793] EXIT: sub_94BC
[226 ms] [TID:11793] EXIT: ffi_prep_cif
[226 ms] [TID:11793] EXIT: .ffi_prep_cif
[226 ms] [TID:11793] ENTER: .strstr
[227 ms] [TID:11793] EXIT: .strstr
[231 ms] [TID:11793] ENTER: .setenv
[232 ms] [TID:11793] EXIT: .setenv
[235 ms] [TID:11793] ENTER: _Z9__arm_a_1P7_JavaVMP7_JNIEnvPvRi
[239 ms] [TID:11793] EXIT: _Z9__arm_a_1P7_JavaVMP7_JNIEnvPvRi
[241 ms] [TID:11793] ENTER: sub_9E58
[242 ms] [TID:11793] ENTER: sub_999C
[242 ms] [TID:11793] EXIT: sub_999C
[242 ms] [TID:11793] EXIT: sub_9E58
[242 ms] [TID:11793] ENTER: sub_10964
[243 ms] [TID:11793] ENTER: j_._ZdlPv_1
[243 ms] [TID:11793] ENTER: ._ZdlPv
[245 ms] [TID:11793] ENTER: sub_96E0
[246 ms] [TID:11793] EXIT: sub_96E0
[246 ms] [TID:11793] EXIT: ._ZdlPv
[246 ms] [TID:11793] ENTER: sub_8000
[246 ms] [TID:11793] ENTER: .strncpy
[247 ms] [TID:11793] EXIT: .strncpy
[247 ms] [TID:11793] ENTER: sub_60E0
[247 ms] [TID:11793] EXIT: sub_60E0
[247 ms] [TID:11793] ENTER: sub_6544
[248 ms] [TID:11793] EXIT: sub_6544
[248 ms] [TID:11793] ENTER: sub_4B54
[248 ms] [TID:11793] ENTER: sub_6128
[248 ms] [TID:11793] EXIT: sub_6128
[248 ms] [TID:11793] ENTER: _ZN9__arm_c_19__arm_c_0Ev
[250 ms] [TID:11793] EXIT: _ZN9__arm_c_19__arm_c_0Ev
[250 ms] [TID:11793] ENTER: sub_A3EC
[250 ms] [TID:11793] ENTER: sub_99CC
[250 ms] [TID:11793] ENTER: sub_9944
[250 ms] [TID:11793] EXIT: sub_9944
[251 ms] [TID:11793] EXIT: sub_99CC
[251 ms] [TID:11793] EXIT: sub_A3EC
[252 ms] [TID:11793] EXIT: sub_4B54
[254 ms] [TID:11793] EXIT: sub_8000
[254 ms] [TID:11793] EXIT: j_._ZdlPv_1
[254 ms] [TID:11793] EXIT: sub_10964
[257 ms] [TID:11793] ENTER: sub_6484
[257 ms] [TID:11793] ENTER: sub_6590
[257 ms] [TID:11793] ENTER: .memcpy
[258 ms] [TID:11793] EXIT: .memcpy
[258 ms] [TID:11793] ENTER: sub_6698
[260 ms] [TID:11793] ENTER: sub_9FFC
[261 ms] [TID:11793] EXIT: sub_9FFC
[268 ms] [TID:11793] EXIT: sub_6698
[268 ms] [TID:11793] ENTER: j_._ZdlPv_3
[268 ms] [TID:11793] ENTER: j_._ZdlPv_2
[269 ms] [TID:11793] ENTER: j_._ZdlPv_0
[269 ms] [TID:11793] ENTER: sub_A3A0
[269 ms] [TID:11793] ENTER: sub_9A90
[269 ms] [TID:11793] EXIT: sub_9A90
[269 ms] [TID:11793] EXIT: sub_A3A0
[270 ms] [TID:11793] EXIT: j_._ZdlPv_0
[270 ms] [TID:11793] EXIT: j_._ZdlPv_2
[270 ms] [TID:11793] ENTER: sub_5F20
[271 ms] [TID:11793] EXIT: sub_5F20
[271 ms] [TID:11793] EXIT: j_._ZdlPv_3
[271 ms] [TID:11793] ENTER: sub_6044
[276 ms] [TID:11793] EXIT: sub_6044
[276 ms] [TID:11793] EXIT: sub_6590
[276 ms] [TID:11793] ENTER: sub_3574
[276 ms] [TID:11793] ENTER: .uncompress
[279 ms] [TID:11793] EXIT: .uncompress
[279 ms] [TID:11793] EXIT: sub_3574
[281 ms] [TID:11793] EXIT: sub_6484
[316 ms] [TID:11793] ENTER: sub_49F0
[316 ms] [TID:11793] ENTER: sub_5400
[316 ms] [TID:11793] EXIT: sub_5400
[316 ms] [TID:11793] ENTER: sub_5478
[316 ms] [TID:11793] ENTER: sub_5B08
[318 ms] [TID:11793] EXIT: sub_5B08
[320 ms] [TID:11793] EXIT: sub_5478
[320 ms] [TID:11793] ENTER: sub_5650
[321 ms] [TID:11793] EXIT: sub_5650
[321 ms] [TID:11793] ENTER: sub_580C
[322 ms] [TID:11793] ENTER: .mprotect
[322 ms] [TID:11793] EXIT: .mprotect
[324 ms] [TID:11793] EXIT: sub_580C
[324 ms] [TID:11793] EXIT: sub_49F0
[325 ms] [TID:11793] ENTER: .strlen
[325 ms] [TID:11793] EXIT: .strlen
[325 ms] [TID:11793] ENTER: sub_3C94
[327 ms] [TID:11793] ENTER: .dlopen
[328 ms] [TID:11793] EXIT: .dlopen
[330 ms] [TID:11793] EXIT: sub_3C94
[352 ms] [TID:11793] ENTER: sub_4918
[353 ms] [TID:11793] ENTER: sub_4000
[353 ms] [TID:11793] ENTER: sub_41B4
[354 ms] [TID:11793] ENTER: sub_35AC
[355 ms] [TID:11793] EXIT: sub_35AC
[355 ms] [TID:11793] EXIT: sub_41B4
[356 ms] [TID:11793] ENTER: .dlsym
[357 ms] [TID:11793] EXIT: .dlsym
[358 ms] [TID:11793] EXIT: sub_4000
[359 ms] [TID:11793] EXIT: sub_4918
[369 ms] [TID:11793] ENTER: sub_5E6C
[370 ms] [TID:11793] EXIT: sub_5E6C
[370 ms] [TID:11793] ENTER: sub_5444
[370 ms] [TID:11793] EXIT: sub_5444
[386 ms] [TID:11793] ENTER: sub_633C
[386 ms] [TID:11793] EXIT: sub_633C
[386 ms] [TID:11793] ENTER: sub_8130
[387 ms] [TID:11793] ENTER: sub_4C70
[387 ms] [TID:11793] EXIT: sub_4C70
[388 ms] [TID:11793] EXIT: sub_8130
[388 ms] [TID:11793] ENTER: sub_825C
[389 ms] [TID:11793] EXIT: sub_825C
[389 ms] [TID:11793] ENTER: sub_8B50
[390 ms] [TID:11793] EXIT: sub_8B50
[390 ms] [TID:11793] ENTER: sub_8ED4
[391 ms] [TID:11793] EXIT: sub_8ED4
[391 ms] [TID:11793] ENTER: sub_8430
[394 ms] [TID:11793] EXIT: sub_8430
[395 ms] [TID:11793] ENTER: interpreter_wrap_int64_t_bridge
[397 ms] [TID:11793] ENTER: sub_9D60
[398 ms] [TID:11793] EXIT: sub_9D60
[398 ms] [TID:11793] EXIT: interpreter_wrap_int64_t_bridge
[780 ms] [TID:11793] ENTER: sub_166C4
[781 ms] [TID:11793] EXIT: sub_166C4
[782 ms] [TID:11793] ENTER: .puts
[787 ms] [TID:11793] EXIT: .puts
[1642 ms] [TID:11793] ENTER: sub_115AA0
[1643 ms] [TID:11793] EXIT: sub_115AA0
[1781 ms] [TID:11793] ENTER: _Z9__arm_a_2PcmS_Rii
[1790 ms] [TID:11793] EXIT: _Z9__arm_a_2PcmS_Rii
[2069 ms] [TID:11793] ENTER: .ffi_prep_cif_var
[2070 ms] [TID:11793] ENTER: ffi_prep_cif_var
[2070 ms] [TID:11793] EXIT: ffi_prep_cif_var
[2070 ms] [TID:11793] EXIT: .ffi_prep_cif_var

脚本链接:Call_Trace,如果觉得好用,麻烦点个星星,┭┮﹏┭┮这对我找工作真的很重要。——脚本是有局限的,在github里写了,如果只想要观察调用顺序而不需要调用关系,可以把打印内容的EXIT全部去掉,再把缩进也去掉,这样就恢复成原来oacia大佬的脚本了。由于这是360壳,有些调用不是标准的RET退栈,所以调用关系可能会有问题

继续分析。

image-20250524152046692

在解压缩操作附近的函数进行查看,发现存在RC4加密。

下面这个是标准RC4的KSA步骤。

image-20250524153516529

修改一下变量名,简单明了,和标准RC4一模一样,如果想得到密钥的值,hook sub_5F20,然后得到第1个参数的值即可。

image-20250524154214898

hook rc4的密钥脚本如下。

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
// 针对 call_constructors 进行 Hook
function hook_linker_call_constructors() {
let linker64_base_addr = Module.getBaseAddress('linker64');
let offset_call = 0x51BA8; // 你手机的 linker64 中 call_constructors 的偏移量
let call_constructors = linker64_base_addr.add(offset_call);

let listener = Interceptor.attach(call_constructors, {
onEnter: function(args) {
let secmodule = Process.findModuleByName("libjiagu_64.so"); // 目标 SO 文件
if (secmodule != null) {
// Hook 目标函数 sub_5E6C
console.log("[+] ", "Module libjiagu_64.so base_addr: ", secmodule.base.toString(16));
hook_target_func(secmodule.base);
listener.detach(); // Hook 完成后解除
}
}
});
}

// Hook 目标函数 sub_5f20
function hook_target_func(baseaddr) {
// sub_5E6C 的偏移
let target_offset = 0x5f20;
let target_func = baseaddr.add(target_offset);

Interceptor.attach(target_func, {
onEnter: function(args) {
console.log('[+] Hooking sub_5f20 at:', target_func);
console.log("rc4 key:");
console.log(hexdump(args[0], {length: 64}));
},
onLeave: function(retval) {
console.log('[+] sub_5f20 returned:', retval);
}
});
}

// 启动 Hook
setImmediate(hook_linker_call_constructors);

结果如下图所示,提取密钥,如果之后要用就方便了。0x76,0x55,0x56,0x34,0x23,0x91,0x23,0x53,0x56,0x74。

image-20250524215504808

按理来说,在ksa之后,应该会调用rc4的PRGA算法,然后进行rc4解密,再进行压缩,所以这里继续追踪sub_6044和sub_3574进行查看。

image-20250524154405943

先看sub_3574,这里直接调用了uncompress,那大概率sub_6044就是rc4_PRGA算法。

image-20250524154735343

标准的RC4_PRGA算法是这样的。

image-20250524154857110

而sub_6044是这样的。

image-20250524154817260

修改变量名后,如下图所示。

image-20250524214255632

在进行rc4解密后,主so的内容仍然是不可用的,还需要进行解压缩。sub_6044的下一个函数调用是sub_3574,而sub_3574直接调用uncompress,然后发现uncompress位于导入表,那大概率就是zlib库的uncompress了。

下面是uncompress的函数签名。

int uncompress(Bytef *dest, uLongf *destLen, const Bytef *source, uLong sourceLen);

根据这个函数签名,我们可以hook它,得到解压缩后的内容。

但在hook uncompress之前,先hook rc4_prga,通过它,可以获得加密主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
// 针对 call_constructors 进行 Hook
function hook_linker_call_constructors() {
let linker64_base_addr = Module.getBaseAddress('linker64');
let offset_call = 0x51BA8; // 手机的 linker64 中 call_constructors 的偏移量
let call_constructors = linker64_base_addr.add(offset_call);

let listener = Interceptor.attach(call_constructors, {
onEnter: function(args) {
let secmodule = Process.findModuleByName("libjiagu_64.so"); // 目标 SO 文件
if (secmodule != null) {
// Hook 目标函数 sub_6044
console.log("[+] ", "Module libjiagu_64.so base_addr: ", secmodule.base.toString(16));
hook_target_func(secmodule.base);
listener.detach(); // Hook 完成后解除
}
}
});
}

// Hook 目标函数 sub_6044
function hook_target_func(baseaddr) {
// sub_6044 的偏移
let target_offset = 0x6044;
let target_func = baseaddr.add(target_offset);

Interceptor.attach(target_func, {
onEnter: function(args) {
console.log('[+] Hooking sub_6044 at:', target_func);
console.log("encrypt 主elf addr:", "0x" + (args[0] - baseaddr).toString(16));
console.log("encrypt 主elf size:", "0x" + args[1].toString(16));
console.log("解密前:");
this.arg0 = args[0];
console.log(hexdump(args[0], {length: 64}));
},
onLeave: function(retval) {
console.log("解密后:");
console.log(hexdump(this.arg0, {length: 64}))
}
});
}

// 启动 Hook
setImmediate(hook_linker_call_constructors);

打印结果如下。

image-20250525103239983

0x74783ff0肯定不是一个正常的偏移量,估计做过转移(比如malloc申请空间,存到别的地方去了),真正的主so加密数据应该是在壳so的某个偏移。

尝试在壳so文件搜索加密前的内容——用010editor在文件中在搜索 01 18 25 e7…

可以发现,加密数据存放于libjiagu_64_after_open_fixed.so的0x2e270的位置,这是我之前趁着open(“…/xxx.dex”)的时候dump下来的libjiagu_a64.so。

image-20250525110933159

而在assets目录下的libjiagu_a64.so中,这段加密内容位于0x1E270的位置。

image-20250525111242312

估计是加载过程中对齐的缘故,又或者是360壳把把主so的基址设在了64K位置(0x10000)。

如果之后我们想直接从壳so中获得主so,用的是0x1E270,但分析的时候,继续用0x2e270。

image-20250525112814418

对这个变量名交叉引用,只有函数sub_8000引用了它。

可以发现,0xB8010正是sub_6044第2个参数的值,代表着RC4加密后主so的大小。

image-20250525112854039

接着hook uncompress,由于uncompress是系统库的函数,不需要考虑360壳做手脚,直接hook。

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
// 针对 call_constructors 进行 Hook
function hook_linker_call_constructors() {
let linker64_base_addr = Module.getBaseAddress('linker64');
let offset_call = 0x51BA8; // 手机的 linker64 中 call_constructors 的偏移量
let call_constructors = linker64_base_addr.add(offset_call);

let listener = Interceptor.attach(call_constructors, {
onEnter: function(args) {
let secmodule = Process.findModuleByName("libjiagu_64.so"); // 目标 SO 文件
if (secmodule != null) {
// Hook 目标函数 sub_6044
console.log("[+] ", "Module libjiagu_64.so base_addr: ", secmodule.base.toString(16));
hook_target_func(secmodule.base);
listener.detach(); // Hook 完成后解除
}
}
});
}

// Hook 目标函数 sub_6044
function hook_target_func(baseaddr) {
// sub_6044 的偏移
let target_offset = 0x6044;
let target_func = baseaddr.add(target_offset);

Interceptor.attach(target_func, {
onEnter: function(args) {
console.log('[+] Hooking sub_6044 at:', target_func);
console.log("encrypt 主elf在内存中的实际位置:", "0x" + args[0].toString(16));
console.log("encrypt 主elf在rc4加密后的大小:", "0x" + args[1].toString(16));
console.log("解密前:");
this.arg0 = args[0];
console.log(hexdump(args[0], {length: 64}));
},
onLeave: function(retval) {
console.log("解密后:");
console.log(hexdump(this.arg0, {length: 64}))
}
});
}

function hook_uncompress_res(){
Interceptor.attach(Module.findExportByName(null, "uncompress"), {
onEnter: function (args) {
console.log("hook uncompress")
console.log("解压缩前:")
console.log(hexdump(args[2], {
offset: 0,// 相对偏移
length: 64,//dump 的大小
header: true,
ansi: true
}));
console.log("解压缩前的大小:",args[3])
},
onLeave: function (ret) {
}
});
}

// 启动 Hook
setImmediate(function(){
hook_linker_call_constructors();
hook_uncompress_res();
});

打印的结果如下。

很容易猜到,rc4解密后的数据的前4个字节用来表示解压后的主so的大小(0x1a0eb9)。

image-20250525113844217

现在我们已经知道了主so的位置(0x1e270)、加密的算法与密钥(rc4和”0x76,0x55,0x56,0x34,0x23,0x91,0x23,0x53,0x56,0x74”)、解压用的函数及解压后的大小(zlib的uncompress和0x1a0eb9),现在可以很轻易地从壳so中直接得到主so解密的内容。

写脚本脱下来看看——这边直接抄了oacia大佬的。

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
import zlib
import struct
def RC4(data, key):
S = list(range(256))
j = 0
out = []

# KSA Phase
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]

# PRGA Phase
i = j = 0
for ch in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
out.append(ch ^ S[(S[i] + S[j]) % 256])

return out

def RC4decrypt(ciphertext, key):
return RC4(ciphertext, key)


wrap_elf_start = 0x1e270
wrap_elf_size = 0xb8010
key = b"vUV4#\x91#SVt"
with open('com.oacia.apk_protect/assets/libjiagu_a64.so','rb') as f:
wrap_elf = f.read()


# 对密文进行解密
dec_compress_elf = RC4decrypt(wrap_elf[wrap_elf_start:wrap_elf_start+wrap_elf_size], key)
dec_elf = zlib.decompress(bytes(dec_compress_elf[4::]))
with open('wrap_elf','wb') as f:
f.write(dec_elf)

将得到的wrap_elf放到010editor查看,一大堆D3。

image-20250525115700120

往下面看,发现存在ELF文件的特征,而ELF文件之前导出都是D3。

image-20250525132029227

可以将这里理解为2段数据,part1为D3相关的加密,part2为ELF文件头(可能还有其它内容)。

这里对wrap_elf进行切割。

1
2
3
4
5
6
7
8
9
10
11
with open('wrap_elf', 'rb') as f:
wrap_elf = f.read()
ELF_magic = bytes([0x7F, 0x45, 0x4C, 0x46])
for i in range(len(wrap_elf) - len(ELF_magic) + 1):
if wrap_elf[i:i + len(ELF_magic)] == ELF_magic:
print(hex(i))
with open('wrap_elf_part1', 'wb') as f:
f.write(wrap_elf[0:i])
with open('wrap_elf_part2', 'wb') as f:
f.write(wrap_elf[i::])
break

打开wrap_elf_part2,发现PHT填充了一堆垃圾数据(可能是加密数据,但我更倾向于PHT放在了别的地方)。

image-20250525133553692

往下分析,要进行重定位的话,要么是part2的PHT解密了,要么是PHT放在了其它地方。

image-20250525133145413

对其中的函数进行分析。

image-20250525142521149

一般加载一个so的时候,申请mmap空间之前需要有PHT,先读取PHT上写着的LOAD加载地址、大小、对齐方式等,再调用mmap进行申请。

因此,sub_5B08大概率在获得正确的PHT,点进来一看,发现存在常量0x38,刚好是一个PHT的大小,更说明猜测是对的了。

image-20250525143259739

pht_buffer与xor_key进行异或,异或的xor_key来自于a2+16。

image-20250525150624560

hook一下,查看xor_key的内容是多少。

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
// 针对 call_constructors 进行 Hook
function hook_linker_call_constructors() {
let linker64_base_addr = Module.getBaseAddress('linker64');
let offset_call = 0x51BA8; // 手机的 linker64 中 call_constructors 的偏移量
let call_constructors = linker64_base_addr.add(offset_call);

let listener = Interceptor.attach(call_constructors, {
onEnter: function(args) {
let secmodule = Process.findModuleByName("libjiagu_64.so"); // 目标 SO 文件
if (secmodule != null) {
console.log("[+] ", "Module libjiagu_64.so base_addr: ", secmodule.base.toString(16));
hook_target_func(secmodule.base);
listener.detach(); // Hook 完成后解除
}
}
});
}


function hook_target_func(baseaddr) {

let target_offset = 0x5B08;
let target_func = baseaddr.add(target_offset);

Interceptor.attach(target_func, {
onEnter: function(args) {
console.log("[xor_key]:", "0x"+args[1].add(16).readPointer().readU8().toString(16));
}
});
}


// 启动 Hook
setImmediate(function(){
hook_linker_call_constructors();
});

脚本执行结果是0xd3,说明真正的PHT很可能就是part1解密后的内容。

image-20250525152209680

除了异或,解密的循环还有arm64的neon运算。

NEON 是 ARM 架构的 SIMD 扩展,提供一组专用的寄存器和指令,用于并行处理多个数据元素。

下面两个链接介绍了sub_5B08中出现的vdupq_n_s8和veorq_s8。

https://developer.arm.com/architectures/instruction-sets/intrinsics/#q=vdupq_n_s8

https://developer.arm.com/architectures/instruction-sets/intrinsics/#q=veorq_s8

简单来说,一个字节一个字节的异或效率太低,而neon可以一次性异或16个字节。——根本不影响阅读。

image-20250525162553594

分析了一下,突然意识到v2似乎指向part2。

image-20250525155237926

打印一下看看。(脚本就不提供了,跟查看xor_key的脚本差不多)

下面这内容不就是part1的内容?

image-20250525155415726

也就是说,part1的第1个字节是xor_key,接下来的4个字节是长度(0x150),再接下来的0x150个字节是待异或的内容,注意到,我们之前分析的主so有6个pht,每个pht大小是0x38,加起来不就是0x150个字节吗?所以说,part1的前0x155字节与pht相关。

image-20250525155524905

整理了一下,假如每次要处理的数据量都很小,那每次的处理数据的方式都是逐字节异或,然后goto到下一块要处理的数据,整个sub_5B08有4个这样子的结构,因此有4块数据。

数据的分布结构是这样的:xor_key(1个字节)、data1_len(4个字节)、data1、data2_len(4个字节)、data2、data3_len(4个字节)、data3、data4_len、data4。

之前提过,soinfo中存在一些360壳的字段,而在sub_5B08可以推测一些字段了。

关于data1的相关字段。

image-20250525163327040

关于data2的相关字段。

image-20250525163402407

关于data3的相关字段。

image-20250525163427141

关于data4的相关字段。

image-20250525163513117

最后还有elf的相关字段,这里的v16、v27、v38、pht_total_size有3个是数据的大小,还有有一个是基址;17是1个字节的xor_key和4个4字节的data_len。

想想wrap_elf,part1基本都是0xD3,而part2是ELF文件,不难想出,a1的第152个字节指向elf起始地址。

image-20250525164721162

原先的soinfo是这样的。前232字节由于不知道是什么,直接当成一个char数组,暂时没做处理。

image-20250525163608632

根据分析,可以这么设置。

image-20250525170510570

但是想了想,似乎不太合适,这样子先入为主地认为之前的那个数据结构是soinfo了,虽然的确很像,如果如上图这么设置,pht_buffer和phdr是同一个意思,应该不会这么设计。

依照其它人的博客的意思,老老实实创建当前这个结构体即可,不要往soinfo去想,因为一开始的soinfo也是猜测。

因此,这里的Four_Section不需要凑232个字节了,只需要满足偏移量在对应的地方即可,结构体应该如下。

image-20250525201455502

既然sub_5B08的第1个参数是Four_Section,查看它的引用函数,传参的那个变量的类型也应该是Four_Section。

image-20250525201744955

继续查看引用sub_5478函数,将v14变量的类型改成Four_Section*,然后发现不对,存在2个问题。

  1. v14的类型是Four_Section*,为什么还要取地址再转成(Four_Section*)?
  2. v7_1[29] = v9,v9 = v16,但v16的值呢?

image-20250525201939099

针对上述两个问题,其实说明v14的类型取错了。

image-20250525202237290

这里的v15-v23找不到赋值的地方,它们很有可能是和v14作为一个完整的结构体对象,一起作为参数赋值的,而我们这边将v14设置成指针类型,导致将v14与v15-v23进行了切割。只需要把v14的指针符号去掉即可。

image-20250525202441708

再根据下图的逻辑,定义新的结构体。v7_1申请了0x1E0个空间,因此,我们需要创建的结构体大小应该也是0x1E0。

image-20250525202608074

创建的结构体是这样的,注:168个字节中,可能有好几个成员变量类型。

image-20250525203507669

其实到这一步,也能看出之前想的soinfo是错误的,因此要回到用到了soinfo的地方,把类型改成FourSection_t,然后观察section2/3/4到底是什么。

sub_3c94是设置重定位表、符号表等内容的函数,可以看出来section4存放的是.dynamic节(一般DT_DYNAMIC只含一个.dynamic节)。

image-20250525204112819

image-20250525203811167

而在函数sub_4918中,我们判断第1次调用sub_4000是进行常规重定位(对数据和指令的重定位),第2次调用sub_4000是进行plt重定位(大部分情况是对符号的重定位)。

image-20250525204312795

快有点模糊d_tag和r_info的区别了…这里的0x402和0x403分别是R_AARCH64_JUMP_SLOT、R_AARCH64_RELATIVE,是一种计算修正地址的规则,而这种规则分别常用于.rela.plt和.rela.dyn,因此认为这里的section3和2分别指向.rela.dyn和.rela.plt

区分.rela.plt和.rela.dyn的方式是d_tag,d_tag == DT_RELA是后者,d_tag == DT_JMPREL是前者。

image-20250525210705157

至此,重命名一下。

image-20250525211112557

总结一下释放主so的流程:

  1. wrap_elf位于assets/libjiagu_a64.so的0x1e270的位置。

  2. wrap_elf经过rc4解密(密钥:b”vUV4#\x91#SVt”)后,是一个长度为0xb8010的压缩数据A,真正参与解压的数据是A[4:],也就是说,参与解压的数据的大小是0xb800C,前4个字节描述了解压后数据的大小。

  3. 解压后的数据视作wrap_elf,而wrap_elf里分为part1和part2两部分,part1被0xD3异或加密,part2指向一个ELF文件,但PHT、.dynamic节的等内容均被垃圾数据占满。

  4. part1里有4组数据,它的数据结构是这样:xor_key(1个字节)、data1_len(4个字节)、data1、data2_len(4个字节)、data2、data3_len(4个字节)、data3、data4_len、data4;

  5. data1-data4分别是被0xD3异或加密的PHT、.rela.plt、.rela.dyn、.dynamic。

需要注意,0x1e270是在壳so文件里wrap_elf的偏移,而在内存中,wrap_elf的偏移来到了0x2e270,原0x1e270被清空了;之后的主so加载的基址是0xe7000。

通过下面这个脚本,可以从壳so里直接获得4个解密后的section。

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
import copy
import zlib

def RC4(data, key):
S = list(range(256))
j = 0
out = []

# KSA Phase
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]

# PRGA Phase
i = j = 0
for ch in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
out.append(ch ^ S[(S[i] + S[j]) % 256])

return out


def RC4decrypt(ciphertext, key):
return RC4(ciphertext, key)


wrap_elf_start = 0x1e270
wrap_elf_size = 0xb8010
key = b"vUV4#\x91#SVt"
with open('com.oacia.apk_protect/assets/libjiagu_a64.so', 'rb') as f:
wrap_elf = f.read()

# 对密文进行解密
dec_compress_elf = RC4decrypt(wrap_elf[wrap_elf_start:wrap_elf_start + wrap_elf_size], key)
dec_elf = zlib.decompress(bytes(dec_compress_elf[4::]))
with open('wrap_elf', 'wb') as f:
f.write(dec_elf)


class part:
def __init__(self):
self.name = ""
self.value = b''
self.offset = 0
self.size = 0


index = 1
extra_part = [part() for _ in range(7)]

seg = ["phdr", ".rela.plt", ".rela.dyn", ".dynamic"]
v_xor = dec_elf[0]

for i in range(4):
size = int.from_bytes(dec_elf[index:index + 4], 'little')
index += 4
extra_part[i + 1].name = seg[i]
extra_part[i + 1].value = bytes(map(lambda x: x ^ v_xor, dec_elf[index:index + size]))
extra_part[i + 1].size = size
index += size

for p in extra_part:
if p.value!=b'':
filename = f"libjiagu.so_{hex(p.size)}_{p.name}"
print(f"[{p.name}] get {filename}, size: {hex(p.size)}")
with open(filename,'wb') as f:
f.write(p.value)

至于修复,很简单,把part2的主so放到010editor,用得到的4个section进行覆盖就行。

image-20250526092610332

覆盖pht,其它的覆盖方法,需要找.dynamic节,然后得到.rela.plt和.rela.dyn节的偏移是多少,这里不过多赘述。

image-20250526092831748

在修复之后,基址设为0xe7000。

image-20250526093509859

在获得主so文件之后,下一个主线任务就是找到主dex是如何加载并被解密的,然后获取主dex。

之前我们在hook函数open的时候,发现open会打开dex文件,通过打印调用函数栈,我们发现主so位于偏移量为0xE7000的位置,现在回顾一下在打开dex文件时的函数调用栈。

image-20250526100506048

这里看到的调用栈其实只是部分,因为主so的函数列表并没有添加到壳so生成的脚本里,我之前写的脚本并没有测试过,所以这里还是用大佬oacia的吧。

在IDA中打开主so,然后使用插件stalker_trace_so,然后将主so的函数列表插回壳so的脚本里,这是佬oacia的截图。

image-20250526103628785

这里我将名字改成keke了hhhhh。

image-20250526103707527

然后插入1个判断即可。

image-20250526103748834

前面在大量地执行壳so的函数,后面基本都在调用主so的函数。

image-20250526103913279

在主so中搜索0x19b780,发现这个指令位于函数sub_19B760中,然后在trace.log中,发现没找到sub_19B760,应该是因为没hook maps文件,进程检测到frida,提前退出了。

在hook了maps文件后,发现获得的日志信息反而变少了。

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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
[ONEPLUS A6003::com.oacia.apk_protect ]-> start Stalker!
Stalker end!
[keke] call1:JNI_OnLoad
[keke] call2:.interpreter_wrap_int64_t
[keke] call3:interpreter_wrap_int64_t
[keke] call4:._Znwm
[keke] call5:sub_13908
[keke] call6:._Znam
[keke] call7:sub_11220
[keke] call8:.memset
[keke] call9:sub_9DD8
[keke] call10:sub_E3E0
[keke] call11:.calloc
[keke] call12:.malloc
[keke] call13:.free
[keke] call14:sub_E648
[keke] call15:._ZdaPv
[keke] call16:sub_C918
[keke] call17:sub_9988
[keke] call18:sub_9964
[keke] call19:sub_9AC4
[keke] call20:.ffi_prep_cif
[keke] call21:ffi_prep_cif
[keke] call22:.ffi_prep_cif_machdep
[keke] call23:ffi_prep_cif_machdep
[keke] call24:.ffi_call
[keke] call25:ffi_call
[keke] call26:sub_1674C
[keke] call27:.ffi_call_SYSV
[keke] call28:ffi_call_SYSV
[keke] call29:sub_167BC
[keke] call30:sub_1647C
[keke] call31:sub_163DC
[keke] call32:sub_9900
[keke] call33:sub_94BC
[keke] call34:.dladdr
[keke] call35:.strstr
[keke] call36:.setenv
[keke] call37:_Z9__arm_a_1P7_JavaVMP7_JNIEnvPvRi
[keke] call38:sub_9E58
[keke] call39:sub_999C
[keke] call40:sub_10964
[keke] call41:j_._ZdlPv_1
[keke] call42:._ZdlPv
[keke] call43:sub_96E0
[keke] call44:sub_8000
[keke] call45:.strncpy
[keke] call46:sub_60E0
[keke] call47:sub_6544
[keke] call48:sub_4B54
[keke] call49:sub_6128
[keke] call50:_ZN9__arm_c_19__arm_c_0Ev
[keke] call51:sub_A3EC
[keke] call52:sub_99CC
[keke] call53:sub_9944
[keke] call54:sub_6484
[keke] call55:sub_6590
[keke] call56:.memcpy
[keke] call57:sub_6698
[keke] call58:sub_9FFC
[keke] call59:j_._ZdlPv_3
[keke] call60:j_._ZdlPv_2
[keke] call61:j_._ZdlPv_0
[keke] call62:sub_A3A0
[keke] call63:sub_9A90
[keke] call64:sub_5F20
[keke] call65:sub_6044
[keke] call66:sub_3574
[keke] call67:.uncompress
[keke] call68:sub_49F0
[keke] call69:sub_5400
[keke] call70:sub_5478
[keke] call71:sub_5B08
[keke] call72:sub_5650
[keke] call73:sub_580C
[keke] call74:.mprotect
[keke] call75:.strlen
[keke] call76:sub_3C94
[keke] call77:.dlopen
[keke] call78:sub_4918
[keke] call79:sub_4000
[keke] call80:sub_41B4
[keke] call81:sub_35AC
[keke] call82:.dlsym
[keke] call83:sub_5E6C
[keke] call84:sub_5444
[main] call85:sub_11603C
[main] call86:j__Znwm
[main] call87:_Znwm
[main] call88:malloc
[main] call89:__cxa_atexit
[main] call90:sub_1160B4
[main] call91:sub_1160C4
[main] call92:strlen
[main] call93:memcpy
[main] call94:sub_1161FC
[main] call95:sub_1164AC
[main] call96:sub_1164D8
[main] call97:sub_116528
[main] call98:sub_1165C8
[main] call99:sub_1A32C0
[main] call100:sub_1A3150
[main] call101:sub_1A3204
[main] call102:sub_1166FC
[main] call103:sub_116728
[main] call104:sub_116750
[main] call105:sub_116830
[main] call106:sub_116BA0
[keke] call107:sub_633C
[keke] call108:sub_8130
[keke] call109:sub_4C70
[keke] call110:sub_825C
[keke] call111:sub_8B50
[keke] call112:sub_8ED4
[keke] call113:sub_8430
[main] call114:JNI_OnLoad
[main] call115:j_interpreter_wrap_int64_t
[main] call116:interpreter_wrap_int64_t
[keke] call117:interpreter_wrap_int64_t_bridge
[keke] call118:sub_9D60
[main] call119:sub_1B3F0C
[main] call120:gettimeofday
[main] call121:sub_11BD9C
[main] call122:sub_1182D8
[main] call123:sub_123970
[main] call124:sub_1B6448
[main] call125:getenv
[main] call126:sub_11F130
[main] call127:sub_12047C
[main] call128:j__ZdlPv
[main] call129:_ZdlPv
[main] call130:free
[main] call131:sub_1427E8
[main] call132:dlopen
[main] call133:sub_11BDA8
[main] call134:sub_11BE58
[main] call135:sub_11F69C
[main] call136:sub_117BE0
[main] call137:sub_117CA0
[main] call138:fopen
open /proc/self/maps
find /proc/self/maps, redirect to /data/data/com.oacia.apk_protect/maps_nonexistent
[main] call139:sub_117E90
[main] call140:sub_14285C
[main] call141:sub_1429CC
[main] call142:sub_11C1AC
[main] call143:sub_11C1B4
[main] call144:sub_11C210
[keke] call145:sub_166C4
[keke] call146:.puts
[main] call147:sub_123324
[main] call148:sub_1205A0
[main] call149:sub_11F768
[main] call150:memcmp
[main] call151:opendir
[main] call152:closedir
[main] call153:sub_11859C
[main] call154:sub_11C268
[main] call155:sub_11C300
[main] call156:sub_117B68
[main] call157:sub_1186B8
[main] call158:sub_143964
[main] call159:sub_1B66A8
[main] call160:pthread_mutex_lock
[main] call161:sub_142EA0
[main] call162:sub_143A38
[main] call163:sub_11CF8C
[main] call164:sub_131D58
[main] call165:sub_1B66D0
[main] call166:pthread_mutex_unlock
[main] call167:sub_1178E8
[main] call168:sub_13D70C
[main] call169:sub_19F984
[main] call170:sub_11F1C8
[main] call171:atoi
[main] call172:sub_12D2F8
[main] call173:sub_17ABE8
[main] call174:sub_172660
[main] call175:sub_13BFF0
[main] call176:sub_172AA4
[main] call177:sub_13BD80
[main] call178:sub_13BE2C
[main] call179:sub_13BE4C
[main] call180:memmove
[main] call181:sub_13BE64
[main] call182:sub_172D78
[main] call183:sub_13E510
[main] call184:sub_1926F0
[main] call185:sub_13DB7C
[main] call186:sub_1B7A08
[main] call187:sub_1B7ABC
[main] call188:pthread_cond_broadcast
open /proc/self/maps
find /proc/self/maps, redirect to /data/data/com.oacia.apk_protect/maps_nonexistent
open /proc/self/maps
find /proc/self/maps, redirect to /data/data/com.oacia.apk_protect/maps_nonexistent
[main] call189:sub_12FA34
[main] call190:sub_120664
[main] call191:sub_1332B8
[main] call192:sub_13E0F8
open /proc/self/maps
find /proc/self/maps, redirect to /data/data/com.oacia.apk_protect/maps_nonexistent
[main] call193:sub_12743C
[main] call194:sub_124C68
[main] call195:sub_125DC4
[main] call196:sub_124510
[main] call197:sub_126888
[main] call198:strdup
[main] call199:sub_126920
[main] call200:sub_122180
[main] call201:sub_11BC1C
[main] call202:sub_13DF34
[main] call203:getpid
[main] call204:memset
[main] call205:snprintf
open /proc/25453/maps
find /proc/25453/maps, redirect to /data/data/com.oacia.apk_protect/maps_nonexistent
[main] call206:sub_124FA0
[main] call207:sub_1B6498
[main] call208:sub_1A0C88
[main] call209:sub_217444
[main] call210:sub_2175E0
[main] call211:read
[main] call212:strncmp
[main] call213:close
[main] call214:sub_1B578C
[main] call215:j___self_lseek
[main] call216:__self_lseek
[main] call217:sub_1B586C
[main] call218:j_j___read_self
[main] call219:j___read_self
[main] call220:__read_self
[main] call221:sub_1B6528
[main] call222:sub_1B6578
[main] call223:mmap
[main] call224:sub_1B5B50
[main] call225:calloc
[main] call226:memchr
[main] call227:sub_1B5D04
[main] call228:sub_1B5EC4
[main] call229:sub_1B6270
[main] call230:sub_1B6180
[main] call231:sub_1B6678
[main] call232:inflateInit2_
[main] call233:inflate
[main] call234:inflateEnd
[main] call235:sub_1B6540
[main] call236:munmap
[main] call237:sub_1B56F8
[main] call238:sub_19BC9C
[main] call239:sub_19CCD4
open /proc/self/maps
find /proc/self/maps, redirect to /data/data/com.oacia.apk_protect/maps_nonexistent
[main] call240:sub_12D470
[main] call241:sub_142FE0
[main] call242:sub_143008
[main] call243:sub_142ABC
[main] call244:sub_143848
[main] call245:sub_143B48
[main] call246:sub_143088
[main] call247:sub_1222D0
[main] call248:sub_14316C
[main] call249:sub_142954
[keke] call250:_Z9__arm_a_2PcmS_Rii
[main] call251:sub_142894
[main] call252:sub_1428BC
[main] call253:sub_127DCC
[main] call254:sub_14292C
[main] call255:sub_121B78
[main] call256:sub_121BE0
[main] call257:sub_123CE8
[main] call258:sub_123BC0
[main] call259:sub_11959C
[main] call260:sub_1AC170
[main] call261:pthread_create
[main] call262:sub_1AC210
[main] call263:sub_1B5DE4
[main] call264:sub_1B60E8
[main] call265:sub_19F7C4
[main] call266:sub_1B2DC8
[main] call267:sub_1B1CE8
open /proc/self/maps
find /proc/self/maps, redirect to /data/data/com.oacia.apk_protect/maps_nonexistent
[main] call268:sub_1B0974
[main] call269:sub_1AFE6C
[main] call270:sub_126ED8
[main] call271:sub_1AFE8C
[main] call272:sub_1AFE90
[main] call273:sub_1AB87C
[main] call274:sub_1B26D4
[main] call275:sub_1B26F4
[main] call276:sub_1B27C8
[keke] call277:.ffi_prep_cif_var
[keke] call278:ffi_prep_cif_var
[main] call279:sub_1AAF48
[main] call280:sub_1AAF54
[main] call281:sub_2162D4
[main] call282:sub_1B2898
[main] call283:sub_1B2918
[main] call284:sub_1ABE90
[main] call285:sub_13E0EC
Process terminated

根据对日志的分析,发现有3个函数是zlib库中用来解压缩的函数——inflateInit2_、inflate、inflateEnd,大概率是用来解压dex文件的。

对inflateInit2_进行交叉引用,发现2个引用。

image-20250526120322384

根据日志,先查看sub_1B6270。

image-20250526120702211

注意到,inflate的参数是s和4。

image-20250526121122466

根据函数签名,可以知道s是z_streamp类型,需要在IDA中添加相应的结构体。

image-20250526121150955

结构体如下,Bytef可以改成Byte*,指针都是8个字节,感觉不用太担心具体类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
#  define z_const const
typedef unsigned char Byte; /* 8 bits */
typedef unsigned int uInt; /* 16 bits or more */
typedef unsigned long uLong; /* 32 bits or more */
typedef struct z_stream_s {
z_const Bytef *next_in; /* next input byte */
uInt avail_in; /* number of bytes available at next_in */
uLong total_in; /* total number of input bytes read so far */

Bytef *next_out; /* next output byte will go here */
uInt avail_out; /* remaining free space at next_out */
uLong total_out; /* total number of bytes output so far */
} z_stream;

接下来hook inflate,看看解压缩后的数据是什么,这里有个问题,在主so还没解密时是hook不了的,大佬oacia给了一个思路:哦统计inflate调用的次数,壳ELF在调用uncompress的时候会执行1次inflate,而解压dex的时候就是第2次。

大佬的思路很好,学习到了,但是不知道为什么大佬的脚本hook的是汇编层指令的地址(可能会报错),而非函数序言地址,直接在onLeave回调时不一样可以dump和打印吗,还可以少定义一个函数。

下面是从佬博客标注的代码,不全。

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
function dump_memory(start,size,filename) {
var file_path = "/data/data/com.oacia.apk_protect/" + filename;
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
var libso_buffer = start.readByteArray(size.toUInt32());
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump]:", file_path);
}
}
function hook_zlib_result(){
var module = Process.findModuleByName("libjiagu_64.so");
Interceptor.attach(module.base.add(0x1B63F0), {
// fd, buff, len
onEnter: function (args) {
console.log("inflate result")
console.log(hexdump(next_in, {
offset: 0,// 相对偏移
length: 0x50,//dump 的大小
header: true,
ansi: true
}));
console.log(hexdump(next_out, {
offset: 0,// 相对偏移
length: 0x50,//dump 的大小
header: true,
ansi: true
}));
dump_memory(next_out,avail_out,"dex001")
},
onLeave: function (ret) {
}
});
}
var zlib_count=0;
var next_in,avail_in,next_out,avail_out;
function hook_zlib(){
Interceptor.attach(Module.findExportByName(null, "inflate"), {
// fd, buff, len
onEnter: function (args) {
zlib_count+=1
if(zlib_count>1){
hook_zlib_result();
}
next_in=ptr(args[0].add(0x0).readS64());
avail_in=ptr(args[0].add(0x8).readS64());
next_out=ptr(args[0].add(0x18).readS64());
avail_out=ptr(args[0].add(0x20).readS64());
console.log(hexdump(next_in, {
offset: 0,// 相对偏移
length: 0x50,//dump 的大小
header: true,
ansi: true
}));
console.log(args[1]);
},
onLeave: function (ret) {
}
});
}

下面是我对佬代码做的删减及必要的补充。

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
function dump_memory(start,size,filename) {
var file_path = "/data/data/com.oacia.apk_protect/" + filename;
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
var libso_buffer = start.readByteArray(size.toUInt32());
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump]:", file_path);
}
}

var zlib_count=0;
var next_in,avail_in,next_out,avail_out;

function hook_zlib(){
Interceptor.attach(Module.findExportByName(null, "inflate"), {
// fd, buff, len
onEnter: function (args) {
zlib_count+=1
console.log("[inflate calls]", zlib_count);
next_in=ptr(args[0].add(0x0).readS64());
avail_in=ptr(args[0].add(0x8).readS64());
next_out=ptr(args[0].add(0x18).readS64());
avail_out=ptr(args[0].add(0x20).readS64());
console.log(hexdump(next_in, {
offset: 0,// 相对偏移
length: 0x64,//dump 的大小
header: true,
ansi: true
}));
},
onLeave: function (ret) {
if(zlib_count>1){
console.log("inflate result")

console.log(hexdump(next_out, {
offset: 0,// 相对偏移
length: 0x50,//dump 的大小
header: true,
ansi: true
}));
dump_memory(next_out,avail_out,"dex001")
}
}
});
}

function my_hook_dlopen(soName='') {
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(path);
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
hook_proc_self_maps();
hook_zlib();
}
}
}
);
}

function hook_proc_self_maps() {
const openPtr = Module.getExportByName(null, 'open');
const open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
var fakePath = "/data/data/com.oacia.apk_protect/maps_nonexistent";
Interceptor.replace(openPtr, new NativeCallback(function (pathnameptr, flag) {
var pathname = Memory.readUtf8String(pathnameptr);
console.log("open",pathname);//,Process.getCurrentThreadId()

if (pathname.indexOf("maps") >= 0) {
console.log("find",pathname+", redirect to",fakePath);
var filename = Memory.allocUtf8String(fakePath);
return open(filename, flag);
}

var fd = open(pathnameptr, flag);
return fd;
}, 'int', ['pointer', 'int']));
}

setImmediate(function(){
my_hook_dlopen('libjiagu');
});

执行效果如下,解压后的数据似乎是一个dex文件?

image-20250526132131214

难蚌,为什么解压后的数据与原dex完全相同。

image-20250526132323546

博主根据原apk、加固后的apk,通过比较大小,发现壳dex的末尾附带了一大串加密数据,既然当前的sub_1B6270解压出了和原dex一样的数据A,说明接下来该对这个数据A的末尾进行解密了。

观察sub_1B6270,会发现,return是用来判断函数sub_1B6270是否执行成功的。

image-20250526134858141

a3将缓冲区的地址传给了s.next_out,解压后的数据在a3,缓冲区的长度为v19。

image-20250526135343573

追踪sub_1B6270,判断a3是在哪申请的缓冲区,根据日志,选择sub_1A0C88。

image-20250526135805308

v10拿到了装有原dex的缓冲区,然后赋给了a3。

image-20250526135905993

根据日志,这里选择追踪函数sub_124FA0。

image-20250526140025579

这里返回值用来判断是否执行成功,s1获得了原dex。

image-20250526140247694

s1会给v27传递一些信息,此后s1没出现过了。

image-20250526141948951

对sub_19BC9C进行分析。

  1. 内部状态初始化:对 a1 指向的内存区域进行清零和内部指针设置。

  2. DEX文件处理:解析传入的 s1(壳dex文件),识别其具体类型(DEX, ODEX, CDEX),并调整 a1 中的指针以指向有效的DEX数据区域。这使得后续代码可以通过 a1 方便地访问DEX内容。

  3. 构建JNI类型映射:在 a1 结构的某个区域(从偏移 0x138 开始)构建一个从JNI短类型签名到完整Java类名的映射表。这个映射表是执行JNI操作(如方法调用、字段访问)时进行类型匹配和转换的基础。

看来也没有壳dex末尾的加密数据进行解密,继续交叉引用sub_124FA0,结合日志,下一个追踪的函数是sub_1332B8。

image-20250526143837983

image-20250526143857946

调用链应该是这样的,除此之外,暂时没什么成果。

1
sub_1332B8->sub_124FA0->sub_1A0C88->sub_1B6270->inflate

回到函数open打开classes.dex时的函数调用栈,如下图。

image-20250526100506048 image-20250522133032024

查看函数0x19b780的引用,发现有2次引用来自sub_1332b8,根据上面2个图,结合0x134680 == 0x1332b8 + 0x13c4 + 0x4和0x134598 == 0x1332b8 + 0x12dC + 0x4。

image-20250526150825957

所以classes.dex对应的sub_19b760,在sub_1332B8+12DC处被调用。

而classes2.dex和classes3.dex对应的sub_19b760,在subsub_1332B8+13C4处被调用。

image-20250526151921387

注意到,壳dex的中注入的加密数据位于0x3198h处,而大小是0x41FD18。

image-20250526152253621

尝试hook一下sub_19b760,观察buffer和buffer_size是否有符合壳dex末尾加密数据的特征。

大佬没给脚本,自己写一个脚本吧。

考虑到sub_19b760是主so中的函数,如果hook得太早,主so还没有加载,这样hook不上,甚至可能会有报错;如果hook太晚,sub_19b70可能执行完了。

所以需要找一个时机,主so已经加载完,并且sub_19b70还没清空的时机——其实拿inflate第2次执行的时机就行。

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
function my_hook_dlopen(soName='') {
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(path);
if (path.indexOf(soName) >= 0) {
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
hook_proc_self_maps();

hook_zlib();

}
}
}
);
}


function hook_proc_self_maps() {
const openPtr = Module.getExportByName(null, 'open');
const open = new NativeFunction(openPtr, 'int', ['pointer', 'int']);
var fakePath = "/data/data/com.oacia.apk_protect/maps_nonexistent";
Interceptor.replace(openPtr, new NativeCallback(function (pathnameptr, flag) {
var pathname = Memory.readUtf8String(pathnameptr);
console.log("open",pathname);//,Process.getCurrentThreadId()

if (pathname.indexOf("maps") >= 0) {
console.log("find",pathname+", redirect to",fakePath);
var filename = Memory.allocUtf8String(fakePath);
return open(filename, flag);
}

var fd = open(pathnameptr, flag);
return fd;
}, 'int', ['pointer', 'int']));
}


var zlib_count = 0;
function hook_zlib(){
Interceptor.attach(Module.findExportByName(null, "inflate"), {
// fd, buff, len
onEnter: function (args) {
zlib_count = zlib_count + 1;
},
onLeave: function (ret) {
// 此时的sub_19b760已经解密完成,而且没有被清空
if(zlib_count == 2){
hook_sub_19b760();
}
}
});
}


function hook_sub_19b760(){
const module = Process.findModuleByName("libjiagu_64.so");
console.log("[so base]", "0x" + module.base.toString(16))
Interceptor.attach(module.base.add(0x19b760), {
onEnter: function(args){
console.log("[path]", args[0].readCString());
console.log("[buffer] size =", "0x"+args[2].toString(16));
console.log(hexdump(args[1], {length: 64}));
}
})
}

setImmediate(function(){
my_hook_dlopen('libjiagu');
});

执行结果如下,可以看到,打开的classes.dex正是壳dex末尾的注入数据,连大小都是0x41fd18。

image-20250526155617908

在sub_1332B8中,打开了classes.dex,已经确定了sub_19B760的3个参数的内容是什么,追踪dex_buffer。

image-20250526163850323

dex_buffer会作为参数传递给sub_128D44。

image-20250526164059339

image-20250526164137748

转到汇编层,可以看到j_interpreter_wrap_int64_t的参数不应该是0。

image-20250526180148434

根据寄存器的大小,修改成下面这样。

image-20250526180048792

接着,通过stalker_trace_so的日志可以发现,函数sub_128D44没执行。博主在这个问题上的解决办法是:在调用sub_128D44的位置,再调用一次trace_so()。

image-20250526180701496

博主给出了脚本的片段,如下图所示。

image-20250526181208584

我试验了一下,进程还是退出了,可能是我hook的时机不对,干脆先把反调过了,然后再执行stalker_trace_so,使用下面这个脚本可以找到哪里调用了pthread_create。

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
function check_pthread_create() {
var pthread_create_addr = Module.findExportByName(null, 'pthread_create');

var pthread_create = new NativeFunction(pthread_create_addr, "int", ["pointer", "pointer", "pointer", "pointer"]);
Interceptor.replace(pthread_create_addr, new NativeCallback(function (parg0, parg1, parg2, parg3) {
var so_name = Process.findModuleByAddress(parg2).name;
var so_path = Process.findModuleByAddress(parg2).path;
var so_base = Module.getBaseAddress(so_name);
var offset = parg2 - so_base;
var PC = 0;
if ((so_name.indexOf("jiagu") > -1)) {
console.log("======")
console.log("find thread func offset", so_name, offset.toString(16));
Thread.backtrace(this.context, Backtracer.ACCURATE).map(addr_in_so);

var check_list = []//1769036,1771844
if (check_list.indexOf(offset)!==-1) {
console.log("check bypass")
} else {
PC = pthread_create(parg0, parg1, parg2, parg3);
}
} else {
PC = pthread_create(parg0, parg1, parg2, parg3);
}
return PC;
}, "int", ["pointer", "pointer", "pointer", "pointer"]))
}

function addr_in_so(addr){
var process_Obj_Module_Arr = Process.enumerateModules();
for(var i = 0; i < process_Obj_Module_Arr.length; i++) {
if(addr>process_Obj_Module_Arr[i].base && addr<process_Obj_Module_Arr[i].base.add(process_Obj_Module_Arr[i].size)){
console.log(addr.toString(16),"is in",process_Obj_Module_Arr[i].name,"offset: 0x"+(addr-process_Obj_Module_Arr[i].base).toString(16));
}
}
}



setImmediate(function(){
check_pthread_create();
});
image-20250526184917507

由于无法判断是哪个线程检测了frida,这里并不方便把目标线程函数替换为空。

先来0x17710看看,并没有看到pthread_create,这里的X24里面应该存放了pthread_create的地址。

image-20250526185047625

大佬oacia在这里写得有些模糊。这里X24不一定是pthread_create,还有可能是其它的函数,通过这种动态跳转的方式,避免在静态调试工具中暴露易被检测的函数。

大佬在这里一个一个改x0-x6,emmm,先判断X24跳转的函数是什么,然后查看对应的寄存器会更好吧。

这里借着大佬已经分析出的解决方案——修改x6指向的字符串,去思考为什么是修改x6。

之前我们hook maps文件,是因为存在读maps文件的操作,但要如何逐行检测frida呢?是靠strstr吗?这里的x6说明了参数的数目之多,strstr可没那么多参数。

我写了个脚本,hook x6寄存器,并根据x24跳转的地址,根据它的偏移量,计算跳转的目标函数是什么。

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
function anti_frida_check() {
var module = Process.findModuleByName("libjiagu_64.so");
if (!module) {
// console.log("[anti_frida_check] libjiagu_64.so not found.");
return;
}
Interceptor.attach(module.base.add(0x1770C), {
onEnter: function (args) {
try {
// 确保 X6 是一个有效的指针再尝试读取
if (this.context.x6 && !this.context.x6.isNull()) {
var s = null;
try {
s = this.context.x6.readCString();
} catch (readError) {
// console.log("[anti_frida_check] Error reading string from X6: " + readError.message);
return; // 如果无法读取字符串,则不继续
}

if (s && (s.indexOf('frida') !== -1 ||
s.indexOf('gum-js-loop') !== -1 ||
s.indexOf('gmain') !== -1 ||
s.indexOf('linjector') !== -1 ||
s.indexOf('/proc/') !== -1)) {

console.log("\n==================================================");
console.log("[+] Frida-related string detected in X6!");
console.log(" Original string (s) from X6: \"" + s + "\"");

console.log("--- Register Dump (X0-X6) ---");
console.log(" X0: " + this.context.x0);
console.log(" X1: " + this.context.x1);
console.log(" X2: " + this.context.x2);
console.log(" X3: " + this.context.x3);
console.log(" X4: " + this.context.x4);
console.log(" X5: " + this.context.x5);
console.log(" X6: " + this.context.x6 + " (Pointer value)");

console.log("--- X24 Analysis ---");
var x24_val = this.context.x24;
console.log(" X24 points to address: " + x24_val);

if (x24_val && !x24_val.isNull()) {
var symbolInfo = DebugSymbol.fromAddress(x24_val);
if (symbolInfo && symbolInfo.name) {
var moduleName = symbolInfo.moduleName || "unknown module";
var moduleBase = Module.findBaseAddress(moduleName) || ptr(0);
var symbolOffset = symbolInfo.address.sub(moduleBase); // Offset of symbol from module base
var pcOffsetFromSymbolStart = x24_val.sub(symbolInfo.address); // Offset of PC from symbol start

console.log(" -> Resolved Symbol: " + symbolInfo.name);
console.log(" Module: " + moduleName + " (Base: " + moduleBase + ")");
console.log(" Symbol Address: " + symbolInfo.address + " (Offset in module: 0x" + symbolOffset.toString(16) + ")");
console.log(" X24 is +0x" + pcOffsetFromSymbolStart.toString(16) + " bytes from symbol start.");

} else {
// 如果 DebugSymbol 未直接找到名称,尝试通过 ModuleMap 查找模块
var moduleDetails = Process.findModuleByAddress(x24_val);
if (moduleDetails) {
console.log(" -> Address is within module: " + moduleDetails.name);
console.log(" Module Base: " + moduleDetails.base);
console.log(" Offset from module base: 0x" + x24_val.sub(moduleDetails.base).toString(16));
} else {
console.log(" -> Could not resolve X24 to a specific function or module.");
}
}
} else {
console.log(" -> X24 is null or its value is invalid.");
}
console.log("==================================================\n");
}
}
} catch (e) {
// 捕获外部 try-catch 的错误,例如 this.context 访问问题(虽然不太可能)
// console.log("[anti_frida_check] General error in onEnter: " + e.message);
}
},
onLeave: function (ret) {
}
});
}

function my_hook_dlopen(soName = '') {
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null && !pathptr.isNull()) { // 添加 !pathptr.isNull() 检查
var path = "";
try {
path = ptr(pathptr).readCString();
} catch (e) {
// console.log("Error reading SO path: " + e.message);
return;
}

if (path && soName && path.indexOf(soName) >= 0) { // 确保 soName 也有效
this.is_can_hook = true;
}
}
},
onLeave: function (retval) {
if (this.is_can_hook) {
// console.log("[my_hook_dlopen] Detected " + (soName || "target SO") + ". Attaching anti_frida_check.");
anti_frida_check();
this.is_can_hook = false; // 重置标志,避免重复附加
}
}
}
);
}

setImmediate(function() {
// 你可以在这里指定要监控的 soName,例如 'libjiagu_64.so'
// 如果不指定,或者指定空字符串,my_hook_dlopen 默认的 soName=''
// path.indexOf('') 总是返回0(为真),这会导致对每个dlopen都尝试设置anti_frida_check
// 建议明确指定 soName 来提高目标性,例如:
// my_hook_dlopen('libjiagu_64.so');
// 如果你希望保持原样,对所有SO加载(dlopen)后都调用 anti_frida_check(如果 libjiagu_64.so 已加载)
// 那么下面的调用是正确的,但请注意 anti_frida_check 内部会找 libjiagu_64.so
my_hook_dlopen('libjiagu_64.so'); // 修改此处,明确指定目标SO,使anti_frida_check只在该SO加载后执行一次
});

执行结果如下,那应该很好猜了,sscanf是用来逐行解析maps文件的,然后比较对应的字段,判断是否有frida的痕迹。

image-20250526193502474

接下来,只要将x6替换掉即可,博主给出了代码,添加一下执行时机即可使用,在这里就不写了。

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
function anti_frida_check(){
var module = Process.findModuleByName("libjiagu_64.so");
Interceptor.attach(module.base.add(0x1770C), {
onEnter: function (args) {
try{
var s = this.context.x6.readCString();
if (s.indexOf('frida')!==-1 ||
s.indexOf('gum-js-loop')!==-1 ||
s.indexOf('gmain')!==-1 ||
s.indexOf('linjector')!==-1 ||
s.indexOf('/proc/')!==-1){
//console.log(s)
Memory.protect(this.context.x0, Process.pointerSize, 'rwx');
var replace_str=""
for(var i=0;i<s.length;i++){
replace_str+="0"
}
this.context.x0.writeUtf8String(replace_str);
}
}
catch (e){

}
},
onLeave: function (ret) {
}
});
}

image-20250526195657484

现在再试试能不能使用stalker_trace_so追踪所有函数了。

成功了,这回能够一直trace下去了,哈哈哈哈哈哈哈: /

image-20250526200220622

顺着stalker_trace_so,沿着0x128D44继续往下分析,直到分析sub_18FEA8,这个函数内部存在大量的字符。

先hook这个函数的参数看看。

image-20250526202751628

打印了3次,有理由去怀疑它与3个dex文件相关,感觉a1是数字、a2是地址、a3是大小,a4是地址,a5又是大小,a6暂时不知道。

image-20250526202809100

修改一下,a2和a3分别代表着解密后的dex文件和文件大小。

image-20250526203312634

根据oacia大佬的说明,函数sub_18FEA8里的字符串解密后,与dex文件的加载有关,这里就不深入了,主要还是关心dex文件是如何解密的。

我还是觉得sub_128d44很可疑,只输入一个加密的dex,加密的dex又不能提取什么信息,除了解密,还能干嘛?如果要解密,必须读取加密dex,所以可以在加密dex设置读写断点。

让gemini帮我写一个,脚本过于丑陋,就不外露了。

下面是打印结果。

image-20250526205010061

注意到,打印出来的内容基都是read的操作,而没有写的操作,说明解密后的dex放到了内存的另一个地方。——20250526-20:51,明天再看,看了一天了有点累了。

——早上好~现在是20250527-09:04,继续分析。

通过上图的hook,可以知道libjiagu_64.so偏移0xd364的位置访问了加密dex,而且这个偏移一眼是壳代码,偏移为0xd364的指令位于函数sub_C918内。

然而查看stalker_trace_so,发现sub_128D44后面没有跟着sub_C918,为什么?我的猜测如下:脚本hook的时机不对,frida_stalker_so hook的时机是当android_dlopen_ext检测到库libjiagu_64.so时,在onLeave回调时调用trace_so,对所有在func_addr列表上的地址进行追踪,然而,主so在没加载的时候,主so某些函数尚未解密,此时frida-stalker插的桩似乎并不有效,总之,可能会引起问题。

尝试验证一下:在第二次inflate的时候开始执行trace_so,第二次inflate的时候,主so应该是解密完成的。

下图是打印结果,看来猜对了。

image-20250527095842692

hook一下sub_c918,根据观察,a1总为0,a2和a3应该是地址,a4不知道是什么;而且我明明写了onLeave回调,但这里没触发,说明函数sub_C918内访问了加密dex并调用了解密函数。

image-20250527121537670

hook一下,查看执行到0xd364时,X19和X24的值。

也就是说x19 + x24 == 0x7009ee4ce4,而[0x7009ee4ce4]的值为0x707aa982e0,0x707aa982e0刚好是0x707aa872d8 + 8的位置,因此访问了加密dex的缓冲区,触发了断点。

image-20250527133205197

image-20250527133617161

把脚本改了一下,验证一下,结果如下。

image-20250527134953849

和在010editor中加密dex的第2个8字节一样,说明思路是对的——但是注意,这里的汇编指令是STR W0,也就是传了4个字节,而非8个字节。

image-20250527135015190

光看sub_c918,没发现解密内容。

image-20250527143339957

而且似乎只访问了上图的内容,然后做了一个内存移动的操作,从将dex加密的第3个4字节从[x19, x24]移到[x19, x21]。

image-20250527133617161

根据stalker_frida_so,继续往下分析其它的函数,寻找加密点。

在sub_143008发现了加解密的代码。

vaddq_s8,批量进行加法;veorq_s8,批量进行异或。

image-20250527153041340

hook sub_143008的形参。

image-20250527154322756

结果如下。

image-20250527155431502

arg1不知道是什么。

arg2指向着压缩的dex的第12个字节开始的地址。

image-20250527160301779

arg2 == 0x41e,这不正是偏移0xD364处的指令,所读取那4个字节吗?

image-20250527155938623

解密一下,发现是配置信息。

image-20250527164253814

解密算法是:(加密字节 + 0x70)^0x36,但这仅仅针对0x41E个字节,并不足以解密整个dex文件。

接下来,大佬的思路是:在调用链中,发现用到了pthread_mutex_lock函数,说明有多个线程,当前的frida-stalker追踪的是某一线程,有可能是其它的线程完成了对dex的解密。

image-20250527161717098

查看sub_143848、sub_1B66A8,看看是哪个函数调用了锁。

image-20250527162127366

既然函数sub_143848涉及到锁,说明会有多个线程调用sub_143848,因此对函数sub_143848进行hook。

照着博客的代码,改了一下脚本,追踪非主线程的线程,只trace一次。

image-20250527163146812

打印结果如下,和大佬博客的一模一样。

image-20250527163120637

然后大佬发现sub_1A1D84是一个解密函数。

但是!其实我在主线程的trace.log中也看到了这个函数,可能是大佬没注意吧?

sub_1A1D84是一个典型的RC4算法函数,一眼a2是密钥,a3是长度。

image-20250527164604090

hook一下,获得密钥的内容。长16字节,内容是:0x68,0x76,0x99,0x72,0x96,0x60,0x9f,0x63,0x96,0x2c,0x98,0x30,0xc2,0x36,0x51,0x42

image-20250527165421138

与sub_1A1D84的下一个函数调用是sub_1A1E74,明显的rc4解密。

hook一下,查看函数sub_1A1E74在对什么进行解密。

第一个加密内容如下所示。

image-20250527181237282

将f7 4f e8 0e 62 19作为搜索关键词,在壳dex中进行搜索。

蓝色部分是不知道什么作用的8个字节,蓝色与红色直接是加密内容的长度,也就是0x10949f,红色部分是加密内容,因此,可以判断sub_1A1E74在对壳dex尾部的加密数据进行逐段解密。

image-20250527181335376

同时注意到,先前先加0x70,再异或0x36的加密内容,同样源于壳dex的末尾,同样前8个字节不知道是什么,然后4个字节代表加密内容大小,0x31A4 + 0x41E == 0x35C2,正好是上面那段加密内容的起始部分。

image-20250527181652626

因此,可以猜测壳dex有多个段被加密,数据结构是这样的:不知道什么作用的8个字节 + 加密内容长度(4个字节) + 加密内容 + 不知道什么作用的8个字节…….

计算一下下一段加密内容的起始地址:0x10949F + 0x35CE == 0x10CA6D,下一个加密内容的长度是0x31a3028,这个长度明显不肯,说明关于壳dex的数据结构猜测有问题,说明存在其它的加密方式。

image-20250527182226096

观察我们的打印日志,只有f7 4f e8 0e 62开头encrypt_data在壳dex中,其它的加密内容,并没有出现在壳dex中。统计一下,有3个f7 4f e8 0e 62开头encrypt_data,还有3个长度为0x6400的encrypt_data,会不会代表着3个dex文件及其加密组件呢?

image-20250527183939347

把这3个内容解密看看,然而并没有出现dex的魔术。

image-20250527184825546

总结一下,这3个rc加密的内容,分别在偏移0x35CE , 0x3A93AD , 0x417064的位置。

看了一眼博主的分析,好像确实是这样!

所以,加密dex的数据结构应该是这个样子:未知8个字节、配置信息长度(4字节)、配置信息、加密内容个数(4个字节)、加密内容1的总长度(4个字节)、加密内容1需要rc4解密的长度(4个字节)、加密内容1、加密内容2的总长度(4个字节)、加密内容2需要rc4解密的长度(4个字节)、加密内容2、加密内容3的总长度(4个字节)、加密内容3需要rc4解密的长度(4个字节)、加密内容3。

image-20240308004303427

如此,便可写一个脚本,尝试把加密内容脱下来并解密,如下图所示,下图是dex1的内容,仍然是加密内容。

image-20250527193149268

跟着博主(函数调用链)继续分析,函数sub_18F6AC似乎在做解密。

其中,a3是加密内容被rc4解密后,从0xC开始的数据地址。

image-20250527193749337

具体如下图所示。

image-20250527194144192

还有11个字节,在函数sub_18DCC0进行处理。以5、4、4字节的大小进行读取。

image-20250527194317120

以dex1.dex为例,按照这种方式进行读取的话,内容在破折号后面,记作a1、a2、a3——0x 01 00 00 00 5D、0x 00 40 00 00、0x 00 10 94 92。

a2:

之前将加密内容分成了:1.rc4解密数据,2.未加密数据;而在更早之前,我们在hook maps文件的时候,曾获得了一个解密的dex文件,经过搜索未加密数据,发现未加密数据在dex的偏移量刚好是a2。

以dex1.dex为例,dex1的a2是0x400000,那未加密数据的内容,在dex1.dex中需要移动到偏移量为0x400000的地方。

下图是dex1.dex。

image-20250527200812044

下图是未加密数据。

image-20250527200828145

而a3,代表着第2次解密的长度(尚未知道是什么算法)。

image-20250527201027621

随后,发现了一个很关键的信息——sub_128D44的返回值是一个3级指针,指向着解密之后的dex。

image-20250527201458653

而且,这个dex数据的首地址是0x6ffeae3000,一般mmap申请空间才会这么规整,按照0x1000对齐来给空间。

发现,sub_19b73c调用了mmap。

那咱可以尝试在mmap调用完后,获得申请的空间地址,然后对这部分空间下读写断点,同时打印调用栈。

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
function hook_mmap(){
var module = Process.findModuleByName("libjiagu_64.so");
Interceptor.attach(module.base.add(0x19B81C), {
// fd, buff, len
onEnter: function (args) {
console.log("mmap!")
console.log(this.context.x0);
MemoryAccessMonitor.enable(
{
base:this.context.x0,
size:30
},{
onAccess: function (details) {
console.log(details.operation)
console.log(get_addr_in_so(details.from));
}
}
)
},
onLeave: function (ret) {
}
});
}

function get_addr_in_so(addr){
var process_Obj_Module_Arr = Process.enumerateModules();
for(var i = 0; i < process_Obj_Module_Arr.length; i++) {
if(addr>process_Obj_Module_Arr[i].base && addr<process_Obj_Module_Arr[i].base.add(process_Obj_Module_Arr[i].size)){
return addr.toString(16)+" is in "+process_Obj_Module_Arr[i].name+" offset: 0x"+(addr-process_Obj_Module_Arr[i].base).toString(16);
}
}
return addr.toString(16);
}

脚本执行结果如下,可以发现调用了3次mmap,对应3个dex的映射,同时通过写断点,找到了是哪里在给这块内容进行写操作——0x18ebd4。

image-20250527202407163

定位过来,在函数sub_18E8D0内部,这个函数十分大,第2次解密算法是自定义的算法,一般自定义算法,我会通过trace,通过汇编指令逐步还原解密法。——实习还没着落,不太想花时间在这,暂时先搁置吧。

可以猜到v15是基址,v4是游标,v28是解密后的内容。

image-20250527202610910

最后写一个脚本,判断一下这里的v28是不是解密后的dex。

image-20250527205949901

还不是dex的魔数?但发现:a1 a0 bd cf f5 f6 fd c5与c5进行异或后。

image-20250527210536218

原来还有一步异或。

感想

很感谢有这样的大佬愿意分享技术博客,光是复现就学到很多了,谢谢!

从我自己的角度来说,这一次分析360免费壳真是学到了很多,不仅对动态链接的流程更加深刻了,同时开阔了眼界——还能这么玩?

在复现的时候,对于主so的还原,我还有些思路,就算没思路了还可以看看大佬的博客;但在主dex的还原时,明显感觉到,我的分析缺乏目的性,虽然看了大佬的博客会有思路,但很多时候还是踩在巨人的肩膀上,我很难想象要是从头自己分析,要分析得有多崩溃(可能我太菜了吧)。

总结

  1. 数字壳的加固方案:壳DEX->壳ELF->主ELF->主DEX——具体的加固方式在上面的内容中。
  2. 反调使用了sscanf解读maps文件,读取相应字段对frida进行检测,这算是常规操作了。

不足之处

1.学习如何写JEB脚本,oacia师傅写了一个JEB解密脚本,可以简单学习一下。

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
# coding=utf-8
from com.pnfsoftware.jeb.client.api import IScript, IconType, ButtonGroupType
from com.pnfsoftware.jeb.core import RuntimeProjectUtil
from com.pnfsoftware.jeb.core.units.code.java import IJavaSourceUnit
from com.pnfsoftware.jeb.core.units.code import ICodeUnit, ICodeItem
from com.pnfsoftware.jeb.core.output.text import ITextDocument
from com.pnfsoftware.jeb.core.units.code.java import IJavaSourceUnit, IJavaStaticField, IJavaNewArray, IJavaConstant, IJavaCall, IJavaField, IJavaMethod, IJavaClass
from com.pnfsoftware.jeb.core.events import JebEvent, J
from com.pnfsoftware.jeb.core.util import DecompilerHelper

# 解密字符串函数的类名以及方法名
methodName = ['Lcom/qihoo/util/a;', 'a']


class dec_str_360jiagu(IScript):
def run(self, ctx):
print('start deal with strings')
self.ctx = ctx
engctx = ctx.getEnginesContext()
if not engctx:
print('Back-end engines not initialized')
return

projects = engctx.getProjects()
if not projects:
print('There is no opened project')
return

units = RuntimeProjectUtil.findUnitsByType(projects[0], IJavaSourceUnit, False)
for unit in units:
javaClass = unit.getClassElement()
print('[+] decrypt:' + javaClass.getName())
self.cstbuilder = unit.getFactories().getConstantFactory()
self.processClass(javaClass)
unit.notifyListeners(JebEvent(J.UnitChange))
print('Done.')

def processClass(self, javaClass):
if javaClass.getName() == methodName[0]:
return
for method in javaClass.getMethods():
block = method.getBody()
i = 0
while i < block.size():
stm = block.get(i)
self.checkElement(block, stm)
i += 1

def checkElement(self, parent, e):
try:
if isinstance(e, IJavaCall):
mmethod = e.getMethod()
mname = mmethod.getName()
msig = mmethod.getSignature()
if mname == methodName[1] and methodName[0] in msig:
v = []
for arg in e.getArguments():
if isinstance(arg, IJavaConstant):
v.append(arg.getString())
if len(v) == 1:
decstr = self.decryptstring(v[0])
parent.replaceSubElement(e, self.cstbuilder.createString(decstr))

for subelt in e.getSubElements():
if isinstance(subelt, IJavaClass) or isinstance(subelt, IJavaField) or isinstance(subelt, IJavaMethod):
continue
self.checkElement(e, subelt)
except:
print('error')

def decryptstring(self, string):
src = []
for index, char in enumerate(string):
src.append(chr(ord(char) ^ 16))

return ''.join(src).decode('unicode_escape')

2.根据大佬的博客,在attachBaseContexr中会加载DtcLoader类,jeb中没显示出来,而在jadx中显示了,这是如何做到的?这个DtcLoader做了什么?

3.onCreate的vmp没分析。

参考链接

https://oacia.dev/360-jiagu/