经过上次写完在ELF文件中根据函数名找函数,就准备开始编写so文件函数加密,这里这是对代码进行加密,还没有对函数名做混淆,会放到下次写。还有本次的测试机是nexus4,操作系统为android 4.4。
一般在android中,各种核心的东西都会放在so文件中,因为native层的代码分析难度大,执行效率高。本文选择对so文件的核心函数进行加密,用来对抗静态分析。在加密之前,需要知道加密的原理和流程,在我们对so文件的核心函数加密后,我们需要对其解密,如果不解密的话,你的程序肯定会崩溃。解密的时机肯定是在函数运行之前,本文选择了在 JNIOnLoad函数里执行解密操作,当然JNIOnLoad不是最早执行的函数,但是在库加载时就运行了,是满足我们的要求的。
首先介绍一下对so文件里的函数的加密,要对函数加密当然要先找到函数的地址,本人以前写过一个根据函数名找函数地址的文章子,不了解的同学可以先看看这篇文章,文章地址:https://blog.csdn.net/qq_16812035/article/details/87866217。找到函数地址后和函数大小后,事情就变得简单了。本人做的加密就是对函数的每个字节码进行异或操作,然后保存为新的so文件。
void entryCode(size_t code_base, size_t code_size) {
for (size_t i = 0; i < code_size; ++i) {
((char*)code_base)[i] = ((char*)code_base)[i] ^ 0xA;
}
}
生成新的so文件后,需要替换掉原so文件,替换掉后,重新打包,签名,安装到手机上,运行,直接崩溃,是因为程序执行到你的函数时,因为是加密的,执行了乱七八糟的指令,所以崩溃了,所以我们要编写解密代码。
我们要在so文件里定义一个JNIOnLoad来覆盖默认的JNIOnload函数,然后开始写我们的解密程序,第一步当然是找到我们的so函数的基地址,在linux中,有一句名言,"一切皆文件",进程也是。我们随便进入一个进程的目录,执行cat maps,可以看到liblog.so的地址范围在0x400e6000-0x400e9000之间,所以它的基地址就是0x400e6000。
接下来就是代码实现部分,首先找到本进程的maps文件,打开并且遍历文件的每一行,找到要查找的库,最后提取它的基地址。代码如下。
//获取目标库文件基地址
unsigned long get_lib_addr(char* libname){
char buf[4096];
char *temp;
unsigned long ret;
//获取pid
int pd = getpid();
//生成进程maps路径
sprintf(buf,"/proc/%d/maps",pd);
//打开maps文件
FILE* fp = fopen(buf,"r");
if(fp==NULL){
puts("open fail");
fclose(fp);
return -1;
}
//按行读取
while (fgets(buf, sizeof(buf),fp)){
//根据目标函数名找到对应库信息
if(strstr(buf,libname)){
//字符串切割,返回库函数基地址
temp = strtok(buf, "-");
//将字符串转为无符号整数
ret = strtoul(temp, NULL, 16);
__android_log_print(ANDROID_LOG_INFO, "JNITag", "base = 0x%x", ret);
break;
}
}
fclose(fp);
return ret;
}
找到基地址后,我们要对so文件进行解析,这时候so文件已经加载到内存了,我们不能像以前一样查找我们的函数,我们需要了解一下ELF文件格式的其它信息,在ELF文件头中,有两个字段,指向程序头的偏移e_phoff和程序头数组的数量e_phnum。
程序头中包含一个结构数组,用来描述与程序执行直接相关的目标文件结构信息。
我们要找的.hash、symstr、dynsym节在程序执行的时候会合并到一个类型为.dynamic的段中。代码很简单,遍历程序头数组,检查e_type类型,找到对应动态节区数组。
Elf32_Ehdr* ehdr = (Elf32_Ehdr*)addr;
Elf32_Phdr* phdr = (Elf32_Phdr*)(ehdr->e_phoff + addr);
Elf32_Dyn* dyn;
for(size_t i = 0;ie_phnum;++i){
if(PT_DYNAMIC == phdr->p_type){
dyn = (Elf32_Dyn*)(phdr->p_vaddr + addr);
break;
}
phdr++;
}
下面是动态节区结构体,d_tag对应节区头的e_type,d_ptr为虚拟地址的偏移。
根据动态节区节区结构体就可以找到我们要的.hash,dynstr,dynsym的内容,代码如下。
size_t dyncount = phdr->p_memsz/ sizeof(Elf32_Dyn);
size_t hashoff = 0;
size_t symtaboff = 0;
size_t strtaboff = 0;
for(size_t i = 0;id_tag){
hashoff = dyn->d_un.d_ptr;
}else if(DT_SYMTAB == dyn->d_tag){
symtaboff = dyn->d_un.d_ptr;
}else if(DT_STRTAB == dyn->d_tag){
strtaboff = dyn->d_un.d_ptr;
}
dyn++;
}
找到这三个节后,事情就好办了,根据.hash节遍历找到目标符号表,然后找到函数偏移地址和大小,可以参考前面链接中的文章。
unsigned long eflag = ELFHash(name);
Elf32_Word* bucketchain = (Elf32_Word*)(hashoff + addr);
Elf32_Word bucket_count = bucketchain[0];
Elf32_Word chain_count = bucketchain[1];
Elf32_Word* bucket = &bucketchain[2];
Elf32_Word* chain = &bucketchain[2 + bucket_count];
Elf32_Sym* sym = (Elf32_Sym*)(symtaboff+ addr);
char* str = (char*)(strtaboff + addr);
funInfo fun_info;
size_t mod = eflag%bucket_count;
for(size_t i = bucket[mod];i!=0;i = chain[i]){
char* findstr = (char*)(str + sym[i].st_name);
if(!strcmp(findstr,name)){
size_t code_revision = sym[i].st_value;
if(code_revision&0x00000001){
code_revision--;
}
fun_info.code_offset = code_revision;
fun_info.size = sym[i].st_size;
break;
}
}
得到函数地址后,我们可以进行解密了,因为之前加密的方法是异或0xA,所以这时候我们同样多加密的字节码异或上0xA,这叫做异或的自反性,A^B = C,可以得到C^B = A。但是我们不能像加密一样,直接改,因为有内存保护属性,代码段的权限通常只有读和执行,并没有写,强行写的话,程序会崩溃。所以我们要先修改内存保护属性,然后解密,解密完,将内存保护属性改为原来的权限。linux里提供了一个修改内存权限的函数mprotect。
int mprotect(const void *start, size_t len, int prot)
可以看到函数一共有三个参数:
成功时返回0,失败返回-1。代码如下。
size_t pagesize = (fun_info.code_offset/PAGE_SIZE + 1)*PAGE_SIZE;
mprotect(libBase, pagesize, PROT_EXEC|PROT_READ|PROT_WRITE);
for(size_t i = 0;i < fun_info.size;++i){
((char*)(fun_info.code_offset + libBase))[i]^=0xA;
}
mprotect(libBase,pagesize,PROT_EXEC|PROT_READ);
下面看下效果,加密前。
加密之后。
在nexus4手机 android4.4中正常运行,其它机型和操作系统没试过,可能会有很多问题,以后有时间测试一下,加密后加大了逆向分析的难度,但这种程度的加密,还是很容易破解的,继续学习点猥琐的招数吧。
代码下载:https://github.com/newhasaki/soEncryption