Android下的dex加固技术从最初爱加密实现的dex整体内存加载,到目前各大厂商都在研究的代码虚拟化技术,整体技术已经很成熟了。但是因为Android系统开源的特性,这种加固方法的安全性并不高,只要修改源码中dex加载的部分,就可以dump出隐藏的dex,根据这一原理目前也已经有多个Android通用脱壳机。既然dex保不住,那么就可以将安全要求较高的代码写到native层,现在加固厂商已基本都提供了对so的加固。针对so的加固有很多种方案,通常也都是混合使用。比较高级的做法是针对Android下的so加载器linker做的加固,从编译器层做混淆,甚至VMP等。本文主要一些简单的加固方法,比如针对段和函数的加密等。使用的方法来源于http://bbs.pediy.com/showthread.php?t=191649。
在前面分析ELF时我们已经知道ELF文件有两种视图,在ELF被加载进内存中后,系统会通过program header table解析ELF。Android下的so加载器linker在加载so时使用的是so的执行视图,所以与section相关的成员在加载的时候并没有用处,而对于一些反编译器如IDA在解析ELF时却需要这些信息,这意味着我们可以通过修改section中的成员来对IDA等反编译器做Anti,事实上这种做法已经很常见了,各大厂商的so加固后直接拖到IDA中基本都会报一堆错的。像下面这样:
这个提示表示节区头部表的大小错误。其实实现这样的anti很简单,我们用《使用AS中进行ndk开发》中的so,然后再次用到文件格式分析神器010editor,打开libdemo.so,并运行ELF模板,定位到e_shunm字段。可以看到现在是22个,我们将它修改成25。然后保存,然后用IDA打开,就可以看到上图中的错误了。这样修改后对so的运行是没有影响的。
下图表示了ELF在内存中的映射。
我们知道在ELF中我们可以在函数或变量声明前加上
1
|
__attribute__((section("xxxx"))) \\这里的xxxx替换成自定义section的名字
|
来将一个函数或者变量存放到指定的section中。然后我们可以再将自定义的section加密。具体流程如下:
看起来好像很复杂,事实上前5步在《ELF文件格式分析》最后的python脚本中已经完成了,我们只需要关注怎么加密以及怎么解密。为了简单,这里我们的加密算法使用异或算法,将加密section中的字节异或11。解密的话当然也可以按照原流程做,不过我们知道关于Section的字段是可以修改的,所以我们可以简化流程,将要加密的section的addr和length写入到这些字段中。这里我们将待加密的section的地址写入到ehdr->e_entry字段,将section的长度写入到e->sh_offset中。这样做还可使静态修复后的ELF无法运行。下面是加密的python代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
def encryptContent(content): encontent = [] for i in content: encontent.append(chr(ord(i) ^ 11)) return encontent def encryptSh(fd,e_hdr,sh_hdrs,sname): sh_hdr = {} for s in sh_hdrs: if s['sh_name'] == sname: sh_hdr = s break offset = sh_hdr['sh_offset'] size = sh_hdr['sh_size'] fd.seek(24) fd.write(struct.pack("I",size)) fd.seek(32) fd.write(struct.pack("I",offset)) fd.seek(offset) content = fd.read(size) encontent = encryptContent(content) fd.seek(offset) fd.write("".join(encontent)) print "encrypt complete!" |
这里的加密算法很简单,仅仅是使用异或,当然在实际加固中需要使用强度更高的算法。加密之后,我们还需要在原so中增加解密代码,在运行时解密才能正常运行。首先声明一个解密函数,使用”attribute((constructor))”来将这个函数放到”.init_array”段。
1
|
void init_getString() __attribute__((constructor));
|
下面是完整代码:
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 |
void init_getString(){ char name[15]; unsigned int nblock; unsigned int psize; unsigned long base; unsigned long text_addr; unsigned int i; Elf32_Ehdr *ehdr; Elf32_Shdr *shdr; base = getLibAddr(); //得到"libdemo.so"在进程中的地址 ehdr = (Elf32_Ehdr *)base; text_addr = ehdr->e_shoff + base; //得到待解密节的内存地址 __android_log_print(ANDROID_LOG_INFO, "JNITag", "base = 0x%lx", text_addr); nblock = ehdr->e_entry; psize = ehdr->e_shoff / 4096 + (ehdr->e_shoff % 4096 == 0 ? 0 : 1); //得到待解密节占用的页的大小 __android_log_print(ANDROID_LOG_INFO, "JNITag", "psize = 0x%x", psize); if(mprotect((void *)(text_addr / PAGE_SIZE * PAGE_SIZE), 4096 * psize, PROT_READ | PROT_EXEC | PROT_WRITE) != 0){ //mprotect修改权限是以页为单位的,所以这里必须将起始地址设置为PAGE_SIZE的整数倍 __android_log_print(ANDROID_LOG_INFO, "JNITag", "mem privilege change failed"); } for(i=0;i< nblock; i++){ char *addr = (char*)(text_addr + i); *addr = (*addr) ^ 11; } if(mprotect((void *)(text_addr / PAGE_SIZE * PAGE_SIZE), 4096 * psize, PROT_READ | PROT_EXEC) != 0){ __android_log_print(ANDROID_LOG_INFO, "JNITag", "mem privilege change failed"); } __android_log_print(ANDROID_LOG_INFO, "JNITag", "Decrypt success"); } unsigned long getLibAddr(){ unsigned long ret = 0; char name[] = "libdemo.so"; char buf[4096], *temp; int pid; FILE *fp; pid = getpid(); sprintf(buf, "/proc/%d/maps", pid); fp = fopen(buf, "r"); if(fp == NULL) { puts("open failed"); goto _error; } while(fgets(buf, sizeof(buf), fp)){ if(strstr(buf, name)){ temp = strtok(buf, "-"); ret = strtoul(temp, NULL, 16); break; } } _error: fclose(fp); return ret; } |
编译后用python脚本加密,然后生成apk,可以看到运行效果。
说明可以运行,下面我们来看下用IDA打开的效果。首先看未加密的so。
加密后用IDA打开,可以看到IDA不能正确的解析节信息,并且代码已经被加密了。
注意并不是所有节都可以被加密的。比如直接对.text加密,会把与crt有关的代码也加密。所以只能选择性的加密。
上一节中我们实现了对ELF中自定义节的加密。但是自定义节很容易被发现,更好的做法是对函数进行加密。既然是对函数加密,我们就需要找到函数的地址和大小。在上一节我们可以通过”.shstrtab”来找到每一个section的”sh_name”,而ELF文件头中就有”.shstrtab”在节表中的索引,所以我们很容易就可以找到它。但是我们不能这样去查找指定的函数名。
在ELF中,每个函数的结构描述放在.dynsym中,函数的名称放在.dynstr中,我们怎么去查找它们的对应关系呢?这需要另一个段叫”.hash”的节。这个节中有一个hash表,它的结构如下图:
bucket数组包含nbucket个项目,chain数组包含nchain个项目,下标都是从0开始。bucket和chain中都保存了符号表的索引。chain表项和符号表存在对应。符号表项的数目应该和nchain相等,所以符号表的索引也可以用来选取chain表项。哈希函数能够接受符号名并返回一个可以用来计算bucket的索引。如果哈希函数针对某个名字返回了数值x,则bucket[x%nbucket]给出了一个索引y,该索引可用于符号表,也可用于chain表。如果该符号表项不是所需要的,那么chain[y]则给出了具有相同哈希值的下一个符号表项。我们可以沿着chain链一直搜索,直到所选中的符号表项包含了所需要的符号,或者chain项中包含值STN_UNDEF。
从名字可以看出.dynsym和.dynstr是和动态链接相关的符号表和字符串表。这个我们在《ELF文件格式分析》中的符号表和字符串表有什么不一样呢?.dynsym和.dynstr是用于动态链接的,它们的结构和..symtab和.strtab一样,但是内容是.symtab和.strtab中的一部分,就是和动态链接有关的一部分。而.symtab和.strtab在strip后会被去掉,ndk生成的so是经过strip的,未strip的so可以在/local/obj中找到。一般release版的elf文件都是经过strip的,因为不strip的elf中保存了很多debug信息是文件很容易被分析。
我们来看符号表的结构:
1 2 3 4 5 6 7 8 9 10 11 |
/* Symbol table entry. */ typedef struct { Elf32_Word st_name; /* Symbol name (string tbl index) */ Elf32_Addr st_value; /* Symbol value */ Elf32_Word st_size; /* Symbol size */ unsigned char st_info; /* Symbol type and binding */ unsigned char st_other; /* Symbol visibility */ Elf32_Section st_shndx; /* Section index */ } Elf32_Sym; |
通过st_name就能找到我们需要的函数。
这里使用执行视图来实现加密和解密。需要注意的是如果用static声明加密函数,那么函数不会出现在”.dynsym”中,也无法在执行视图中通过函数名找到进行解密。当然也可以把函数的地址和大小写入到elf头部。这里我们要加密的是Java_com_example_zke1e_enfunc_MainActivity_getString这个函数,可以看到它是一个导出函数,所以肯定能在符号表中找到它。
1 2 3 4 |
JNIEXPORT jstring JNICALL Java_com_example_zke1e_enfunc_MainActivity_getString (JNIEnv *env, jobject){ return (*env).NewStringUTF("Hello JNI!"); } |
我们已经知道执行视图中的segment包含多个section,和section header table一样,segment也有一个头部表叫program header。它的定义如下(32位):
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/* Program segment header. */ typedef struct { Elf32_Word p_type; /* Segment type */ Elf32_Off p_offset; /* Segment file offset */ Elf32_Addr p_vaddr; /* Segment virtual address */ Elf32_Addr p_paddr; /* Segment physical address */ Elf32_Word p_filesz; /* Segment size in file */ Elf32_Word p_memsz; /* Segment size in memory */ Elf32_Word p_flags; /* Segment flags */ Elf32_Word p_align; /* Segment alignment */ } Elf32_Phdr; |
program header仅在可执行文件和共享目标文件中存在。在程序头部中e_phentsize和e_phnum成员描述了程序头部的大小和项数。program header的各个成员说明如下:
其中p_type的可选项如下表所示:
当p_type为PT_LOAD时,就表示这是一个可以加载的段。不过这里我们要关注的是PT_DYNAMIC这个类型对应的段,从名字我们就可以看到这个段是和动态链接有关的,它表示了动态链接器(即INTERP段中指定的解释器)所需要的基本信息,包括依赖的共享对象,符号的位置和重定位信息等。这个段包含了.dynamic这个section,这个节的数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 |
/* Dynamic section entry. */ typedef struct { Elf32_Sword d_tag; /* Dynamic entry type */ union { Elf32_Word d_val; /* Integer value */ Elf32_Addr d_ptr; /* Address value */ } d_un; } Elf32_Dyn; |
对于每个这种类型的结构,d_tag控制了d_un的解释含义:
下面的表格总结了可执行文件和共享目标文件对标志的要求。如果标志被标记为“必需”,那么符合ABI规范的文件的动态链接数组必须包含一个该类型表项。“可选”意味着该标志可以出现,但不是必需的。
这个表格很长,通过上面的内容我们知道这里需要关注DT_SYMTAB,DT_HASH,DT_STRTAB,DT_STRSZ这几种类型的节。我们查看表,可以看到对于DT_SYMTAB,DT_HASH,DT_STRTAB这三种类型,d_ptr给出了它们对应的表的地址,对于DT_STRSZ,d_ptr给出了字符串表的大小。找到这几个表后,就可以找到我们想找到的函数了。再来梳理下具体的流程:
根据函数名称,计算hash 值。可以从bonic/linker.c中找到hash值得计算方法。
1 2 3 4 5 6 7 8 |
unsigned long elf_hash (const unsigned char *name) { unsigned long h = 0, g; while (*name) { h=(h<<4)+*name++; if (g = h & 0xf0000000) h^=g>>24; h&=-g; } return h; } |
根据hash 值,找到下标hash % nbuckets 的bucket;根据bucket 中的值,读取.dynsym 中的对应索引的Elf32_Sym 符号;从符号的st_name 所以找到在.dynstr 中对应的字符串与函数名进行比较。若不等,则根据chain[hash % nbuckets]找下一个Elf32_Sym 符号,直到找到或者chain 终止为止。
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 |
dynamic_hdr = {} dyn_hash = {} dyn_strtab = {} dyn_symtab = {} dyn_strsz = {} for ph_hdr in ph_hdrs: if ph_hdr['p_type'] == 2: dynamic_hdr = ph_hdr break dynamic_offset = dynamic_hdr['p_offset'] dynamic_size = dynamic_hdr['p_filesz'] dynamic_entsize = 8 fd.seek(dynamic_offset) dynamic_num = dynamic_size / dynamic_entsize fields_dyn = ['d_tag','value'] for i in range(dynamic_num): data = fd.read(dynamic_entsize) if len(data) != dynamic_entsize: print "Read .dynamic information failed" exit(0) dyn_hdr = dict(zip(fields_dyn,struct.unpack('II',data))) if dyn_hdr['d_tag'] == 4: dyn_hash = dyn_hdr elif dyn_hdr['d_tag'] == 5: dyn_strtab = dyn_hdr elif dyn_hdr['d_tag'] == 6: dyn_symtab = dyn_hdr elif dyn_hdr['d_tag'] == 10: dyn_strsz = dyn_hdr fd.seek(dyn_strtab['value']) strtab = fd.read(dyn_strsz['value']) fhash = elfhash(fname) fd.seek(dyn_hash['value']) nbucket = struct.unpack('I',fd.read(4))[0] nchain = struct.unpack('I',fd.read(4))[0] fhash = fhash % nbucket fd.seek(fhash * 4,1) findex = struct.unpack('I',fd.read(4))[0] symtab_entsize = 16 fd.seek(dyn_symtab['value'] + findex * symtab_entsize) fields_sym = ['st_name','st_value','st_size','st_info','st_other','st_shndx'] data = fd.read(symtab_entsize) fsym = dict(zip(fields_sym,struct.unpack('IIIcch',data))) if strtab[fsym['st_name']:].split('\0')[0] != fname: while True: fd.seek(dyn_hash['value'] + 4 * (2 + nbucket + findex)) findex = struct.unpack('I',fd.read(4))[0] if findex == 0: print "Cannot find function" exit(0) fd.seek(dyn_symtab['value'] + findex * symtab_entsize) data = fd.read(symtab_entsize) fsym = dict(zip(fields_sym,struct.unpack('IIIcch',data))) if strtab[fsym['st_name']:].split('\0')[0] == fname: break print fsym['st_value'] print fsym['st_size'] fd.seek(fsym['st_value'] - 1) content = fd.read(fsym['st_size'] - 1) encontent = encryptContent(content) fd.seek(fsym['st_value'] - 1) fd.write("".join(encontent)) print "complete!" |
我们先来看看未加密的so,用IDA打开查看Java_com_example_zke1e_enfunc_MainActivity_getString函数:
然后使用py脚本加密,然后再打开,可以看到加密成功。
当然我们还需要在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 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 |
static char getTargetFuncInfo(unsigned long base, const char *funcName, funcInfo *info){ char flag = -1, *dynstr; int i; Elf32_Ehdr *ehdr; Elf32_Phdr *phdr; Elf32_Off dyn_vaddr; Elf32_Word dyn_size, dyn_strsz; Elf32_Dyn *dyn; Elf32_Addr dyn_symtab, dyn_strtab, dyn_hash; Elf32_Sym *funSym; unsigned funHash, nbucket; unsigned *bucket, *chain; ehdr = (Elf32_Ehdr *)base; phdr = (Elf32_Phdr *)(base + ehdr->e_phoff); // __android_log_print(ANDROID_LOG_INFO, "JNITag", "phdr = 0x%p, size = 0x%x\n", phdr, ehdr->e_phnum); for (i = 0; i < ehdr->e_phnum; ++i) { // __android_log_print(ANDROID_LOG_INFO, "JNITag", "phdr = 0x%p\n", phdr); if(phdr->p_type == PT_DYNAMIC){ flag = 0; print_debug("Find .dynamic segment"); break; } phdr ++; } if(flag) goto _error; dyn_vaddr = phdr->p_vaddr + base; dyn_size = phdr->p_filesz; __android_log_print(ANDROID_LOG_INFO, "JNITag", "dyn_vadd = 0x%x, dyn_size = 0x%x", dyn_vaddr, dyn_size); flag = 0; for (i = 0; i < dyn_size / sizeof(Elf32_Dyn); ++i) { dyn = (Elf32_Dyn *)(dyn_vaddr + i * sizeof(Elf32_Dyn)); if(dyn->d_tag == DT_SYMTAB){ dyn_symtab = (dyn->d_un).d_ptr; flag += 1; __android_log_print(ANDROID_LOG_INFO, "JNITag", "Find .dynsym section, addr = 0x%x\n", dyn_symtab); } if(dyn->d_tag == DT_HASH){ dyn_hash = (dyn->d_un).d_ptr; flag += 2; __android_log_print(ANDROID_LOG_INFO, "JNITag", "Find .hash section, addr = 0x%x\n", dyn_hash); } if(dyn->d_tag == DT_STRTAB){ dyn_strtab = (dyn->d_un).d_ptr; flag += 4; __android_log_print(ANDROID_LOG_INFO, "JNITag", "Find .dynstr section, addr = 0x%x\n", dyn_strtab); } if(dyn->d_tag == DT_STRSZ){ dyn_strsz = (dyn->d_un).d_val; flag += 8; __android_log_print(ANDROID_LOG_INFO, "JNITag", "Find strsz size = 0x%x\n", dyn_strsz); } } if((flag & 0x0f) != 0x0f){ print_debug("Find needed .section failed\n"); goto _error; } dyn_symtab += base; dyn_hash += base; dyn_strtab += base; dyn_strsz += base; funHash = elfhash(funcName); funSym = (Elf32_Sym *) dyn_symtab; dynstr = (char*) dyn_strtab; nbucket = *((int *) dyn_hash); bucket = (unsigned int *)(dyn_hash + 8); chain = (unsigned int *)(dyn_hash + 4 * (2 + nbucket)); flag = -1; __android_log_print(ANDROID_LOG_INFO, "JNITag", "hash = 0x%x, nbucket = 0x%x\n", funHash, nbucket); for(i = bucket[funHash % nbucket]; i != 0; i = chain[i]){ __android_log_print(ANDROID_LOG_INFO, "JNITag", "Find index = %d\n", i); if(strcmp(dynstr + (funSym + i)->st_name, funcName) == 0){ flag = 0; __android_log_print(ANDROID_LOG_INFO, "JNITag", "Find %s\n", funcName); break; } } if(flag) goto _error; info->st_value = (funSym + i)->st_value; info->st_size = (funSym + i)->st_size; __android_log_print(ANDROID_LOG_INFO, "JNITag", "st_value = %d, st_size = %d", info->st_value, info->st_size); return 0; _error: return -1; } |
运行效果如下:
最后讲讲使用UPX给so加壳,爱加密就是通过UPX给so加壳,然后将so中的”UPX”字符串修改成AJM。首先需要下载编译UPX的源码。
upx3.92的源代码地址:https://www.pysol.org:4443/hg/upx.hg/tags
upx3.92版修复了upx加固android下的so运行的问题。但是upx官方的版本是3.91,加固so会有问题。所以需要自己下载源码编译。详情可以看下面这篇帖子:
http://sourceforge.net/p/upx/bugs/223/?page=0
下载好源码后还需要下载一些依赖库,具体如下:
LZMA4.43:http://nchc.dl.sourceforge.net/project/sevenzip/LZMA%20SDK/4.43/lzma443.tar.bz2
UCL1.03:http://www.oberhumer.com/opensource/ucl/download/ucl-1.03.tar.gz)
ZLIB1.2.3:http://pkgs.fedoraproject.org/repo/pkgs/zlib/zlib-1.2.3.tar.gz/debc62758716a169df9f62e6ab2bc634/zlib-1.2.3.tar.gz
将这几个包都放到在一起后解压。然后编译zlib,将生成的lib放到/usr/lib64目录下。
1 2 3 |
cd zlib-1.2.3 make cp libz.a /usr/lib |
进入upx目录编译。编译过程中可能会出现下面这样的错误。
可能还会遇到和ucl相关的错误,我的做法是进ucl目录编译一次 然后将生成的libucl.a也放到/usr/lib64目录下
进入upx-hg-d9e7cb4b5485/src/目录,编辑Makefile,注释掉一这句话就可以了。
然后下面的命令编译。
1
|
CXX=g++ UPX_UCLDIR=~/UPX/ucl-1.03 UPX_LZMADIR=~/UPX/lzma443 UPX_LZMA_VERSION=0x443 make all
|
这里”~/UPX/“替换成自己的目录
如果没有出错,就会在src目录生成一个upx.out文件。将这个文件拷贝出来,然后对我们的so加固。但是需要注意的是,upx对压缩的文件的大小有限制,好像小于10kb就不能压缩,会提示”NotCompressibleException”,另外还需要加壳的文件中有INIT段,否则会提示”UnknownExecutableFormatException”。添加init段的代码如下:
1 2 |
void _init(void){} \\c++ extern "C" {void _init(void){}} \\c |
准备就绪后就可以使用upx.out压缩了,命令如下:
1
|
./upx.out -o libdemo_upx.so libdemo.so
|
我们使用IDA打开加壳后的so,可以发现已经隐藏了原来的代码了。
针对段和函数的加密,虽说能有效的防止静态分析,但是如果动态分析就基本没有用了,上面说了这几种方法,一条dd命令就可以还原出解密后的so了。当然更有效的做法是对调用关键函数前解密,调用后再次加密。但是也可以将断点设置在调用函数的过程中dump内存来获取解密的so,所以又要涉及到anti-debug的手段了。当然了,没有绝对安全的系统,再大强度的加固也只是为了提高破解的成本,而加固和破解技术也在不断的攻于防中进化。
原文地址: http://zke1ev3n.me/2015/12/27/Android-So%E7%AE%80%E5%8D%95%E5%8A%A0%E5%9B%BA/