简介
Android 系统安全愈发重要,像传统pc安全的可执行文件加固一样,应用加固是Android系统安全中非常重要的一环。目前Android 应用加固可以分为dex加固和Native加固,Native 加固的保护对象为 Native 层的 SO 文件,使用加壳、反调试、混淆、VM 等手段增加SO文件的反编译难度。目前最主流的 SO 文件保护方案还是加壳技术,本篇主要用来记录下native层加密
目标
通过修改之后,使用readelf和ida等工具打开,会报各种错误
ELF文件的理解
elf文件是一种目标文件格式,用于定义不同类型目标文件以什么样的格式,都放了些什么东西。主要 用于linux平台。windows下是PE/COFF格式,可执行文件、可重定位文件(.o)、共享目标文件(.so)、核心转储文件都是以elf文件格式存储的,ELF文件组成部分:文件头、段表(section)、程序头,具体的可以参考下面有关ELF文件的详解
elf文件视图
链接视图是以节(section)为单位,执行视图是以段(segment)为单位。链接视图就是在链接时用到的视图,而执行视图则是在执行时用到的视图。上图左侧的视角是从链接来看的,右侧的视角是执行来看的。总个文件可以分为四个部分:
ELF header: 描述整个文件的组织。
Program Header Table: 描述文件中的各种segments,用来告诉系统如何创建进程映像的。
sections 或者 segments:segments是从运行的角度来描述elf文件,sections是从链接的角度来描述elf文件,也就是说,在链接阶段,我们可以忽略program header table来处理此文件,在运行阶段可以忽略section header table来处理此程序(所以很多加固手段删除了section header table)。从图中我们也可以看出,segments与sections是包含的关系,一个segment包含若干个section。
Section Header Table: 包含了文件各个segction的属性信息,我们都将结合例子来解释。
因为 so文件是以segment为单位映射到内存的,所以加密的原理我们就可以利用装载视图,对于Session的内容作特定的修改,比如对于so来说,e_entry 入口地址是无意义的,所以我们可以进行修改,当然修改字段也是有讲究的,并不是每个字段都是可以的,还有对于session表中的.init和.fini的特点,这里要注意下
.init:可执行指令,构成进程的初始化代码,发生在main函数调用之前。
.fini:进程终止指令,发生在main函数调用之后。
以上这么分析感觉有点像c++的构造函数和析构函数,的确构造和析构是由此实现的。
并且结合GGC的可扩展机制:attribute((section(“.mytext”)));可以把相应的函数和要保护的代码放在自己所定义的节里面,这就引入了我们今天的主题,可以把我们关键的so文件中的核心函数放在自己所定义的节里面,然后进行加密保护,在合适的时机构造解密函数,当然解密函数可以用这个_attribute__((constructor))进行定义;类似于C++构造函数发生在main函数之前。
Android So的加载过程
具体的加载过程,可以参考下面的链接
08-SO加载解析过程
07-ELF文件格式分析
腾讯Bugly干货分享 Android Linker 与 SO 加壳技术
Android ELF系列:ELF文件格式简析到linker的链接so文件原理分析
大致就是加载解析so,就是解析 ELF 文件,将解析的信息保存到 soinfo中,对于dlopen返回的其实就是这个类型的对象,下面重点看下当解析出soinfo的时候,还做了什么处理
CallConstructors
在编译 SO 时,可以通过链接选项-init或是给函数添加属性attribute((constructor))来指定 SO 的初始化函数,这些初始化函数在 SO 装载链接后便会被调用,再之后才会将 SO 的 soinfo 指针返回给 dl_open 的调用者。SO 层面的保护手段,有两个介入点, 一个是 jni_onload, 另一个就是初始化函数,比如反调试、脱壳等,逆向分析时经常需要动态调试分析这些初始化函数。完成 SO 的装载链接后,返回到 do_dlopen 函数, do_open 获得 find_library 返回的刚刚加载的 SO 的 soinfo,在将 soinfo 返回给其他模块使用之前,最后还需要调用 soinfo 的成员函数 CallConstructors。
soinfo* do_dlopen(const char* name, int flags, const Android_dlextinfo* extinfo) {
...
soinfo* si = find_library(name, flags, extinfo);
if (si != NULL) {
si->CallConstructors();
}
return si;
}
CallConstructors 函数会调用 SO 的首先调用所有依赖的 SO 的 soinfo 的 CallConstructors 函数,接着调用自己的 soinfo 成员变量 init 和 看 init_array 指定的函数,这两个变量在在解析 dynamic section 时赋值。
void soinfo::CallConstructors() {
//如果已经调用过,则直接返回
if (constructors_called) {
return;
}
// 调用依赖 SO 的 Constructors 函数
get_children().for_each([] (soinfo* si) {
si->CallConstructors();
});
// 调用 init_func
CallFunction("DT_INIT", init_func);
// 调用 init_array 中的函数
CallArray("DT_INIT_ARRAY", init_array, init_array_count, false);
}
有了以上分析基础后,在需要动态跟踪初始化函数时,我们就知道可以将断点设在 do_dlopen 或是 CallConstructors。
So简单加密demo实现
首先看加密的实现
#include <jni.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <elf.h>
#include <sys/mman.h>
#include <stdio.h>
#include <fcntl.h>
#include <elf.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "PrintLog.h"
JNIEXPORT jint JNICALL
Java_com_example_androidelf_MainActivity_encoder(JNIEnv *env, jobject thiz) {
char target_section[] = ".textdemo";
//存储 Session的 String 索引表
char *shstr = NULL;
char *content = NULL;
Elf64_Ehdr ehdr;
Elf64_Shdr shdr;
int i;
unsigned int base, length;
unsigned short nblock;
unsigned short nsize;
unsigned char block_size = 16;
int fd;
fd = open("/sdcard/libnative-lib.so", O_RDWR);
if (fd < 0) {
LOGD("open libnative-lib.so failed\n");
goto _error;
}
//读取头部 ELF Header的结构体:
if (read(fd, &ehdr, sizeof(Elf64_Ehdr)) != sizeof(Elf64_Ehdr)) {
LOGD("Read ELF header error");
goto _error;
}
//定位到shstrndx 对应的 Elf32_Shdr,代表session的段
lseek(fd, ehdr.e_shoff + sizeof(Elf64_Shdr) * ehdr.e_shstrndx, SEEK_SET);
//读取shstrndx 中的字符串,存放在str空间中
if (read(fd, &shdr, sizeof(Elf64_Shdr)) != sizeof(Elf64_Shdr)) {
LOGD("Read ELF section string table error");
goto _error;
}
//分配内存空间,从shoff位置开始读取section header,存放在shdr,这个做为后续用来存储当前 Session的字符串的索引
if ((shstr = (char *) malloc(shdr.sh_size)) == NULL) {
LOGD("Malloc space for section string table failed");
goto _error;
}
//定位到 sh_offset代表此session在文件中的偏移
lseek(fd, shdr.sh_offset, SEEK_SET);
//读取字符串的内容
if (read(fd, shstr, shdr.sh_size) != shdr.sh_size) {
LOGD("Read string table failed");
goto _error;
}
//定位到 Section Header Table 偏移量
lseek(fd, ehdr.e_shoff, SEEK_SET);
// ehdr.e_shnum 代表当前 Section Header Tabele中 含有多少个 Session,下面遍历这些Session
for (i = 0; i < ehdr.e_shnum; i++) {
//依次执行读取操作,读取到 shdr 变量中
if (read(fd, &shdr, sizeof(Elf64_Shdr)) != sizeof(Elf64_Shdr)) {
LOGD("Find section .text procedure failed");
goto _error;
}
LOGD("session name %s", shstr + shdr.sh_name);
//依次读取 Section的名字,注意 sh_name 代表Session的名字,他实际上是.shstrtab中的索引,该string table中存储着所有 session 的名字
//通过shdr -> sh_name 在str字符串中索引,与.mytext进行字符串比较,如果不匹配,继续读取
if (strcmp(shstr + shdr.sh_name, target_section) == 0) {
//找到对应的我们要加密的session, 通过shdr -> sh_offset 和 shdr -> sh_size字段,将.mytext内容读取并保存在content中。
base = shdr.sh_offset;
length = shdr.sh_size;
LOGD("Find section %s\n", target_section);
break;
}
}
//定位到查找到session的位置
lseek(fd, base, SEEK_SET);
//分配内存空间,存储要加密的内容
content = (char *) malloc(length);
if (content == NULL) {
LOGD("Malloc space for content failed");
goto _error;
}
//读取内容
if (read(fd, content, length) != length) {
LOGD("Read section .text failed");
goto _error;
}
nblock = length / block_size;
nsize = base / 4096 + (base % 4096 == 0 ? 0 : 1);
LOGD("base = %d, length = %d\n", base, length);
LOGD("nblock = %d, nsize = %d\n", nblock, nsize);
//为了验证第二节中关于section 字段可以任意修改的结论,这里,将shdr -> addr 写入ELF头e_shoff,将shdr -> sh_size 和 addr 所在内存块写入e_entry中,
//即ehdr.e_entry = (length << 16) + nsize。当然,这样同时也简化了解密流程,还有一个好处是:如果将so文件头修正放回去,程序是不能运行的。
ehdr.e_entry = (length << 16) + nsize;
//ndroid7.0后JNI库必须保留Section Headers。由于加密时修改了shoff值,导致加载so库值解析Section Headers 解析不了,
//包.dynamic section header was not found。修改测量:shoff和entry目的是为了存储加密的偏移大小和加密的大小。我们可以使用entry高低16位来分别存储着两个值。即可解决该问题。
ehdr.e_flags = base;
//ehdr.e_entry = (length >> 16) + base;
// 为了便于理解,不使用复杂的加密算法。这里,只将content的所有内容取反,即 *content = ~(*content);
for (i = 0; i < length; i++) {
content[i] = ~content[i];
}
//最后将修改的内容写回去
lseek(fd, 0, SEEK_SET);
if (write(fd, &ehdr, sizeof(Elf64_Ehdr)) != sizeof(Elf64_Ehdr)) {
LOGD("Write ELFhead to .so failed");
goto _error;
}
lseek(fd, base, SEEK_SET);
if (write(fd, content, length) != length) {
LOGD("Write modified content to .so failed");
goto _error;
}
LOGD("Completed");
_error:
free(content);
free(shstr);
close(fd);
return 0;
}
加密流程:
1) 从so文件头读取section偏移shoff、shnum和shstrtab
2) 读取shstrtab中的字符串,存放在str空间中
3) 从shoff位置开始读取section header, 存放在shdr
4) 通过shdr -> sh_name 在str字符串中索引,与.mytext进行字符串比较,如果不匹配,继续读取
5) 通过shdr -> sh_offset 和 shdr -> sh_size字段,将.mytext内容读取并保存在content中。
6) 为了便于理解,不使用复杂的加密算法。这里,只将content的所有内容取反,即 content = ~(content);
7) 将content内容写回so文件中
8) 为了验证第二节中关于section 字段可以任意修改的结论,这里,将shdr -> addr 写入ELF头e_shoff,将shdr -> sh_size 和 addr 所在内存块写入e_entry中,即 ehdr.e_entry = (length << 16) + nsize。当然,这样同时也简化了解密流程,还有一个好处是:如果将so文件头修正放回去,程序是不能运行的。
这里要注意,对于第八点来说,在Android 7.0以上是不允许修改 e_shoff 的,具体可以参考下面的连接,这里为了方便将addr放在了 flags字段中,还有要注意是64位还是32位的,这个可以通过头部的class字段知道
ELF学习日记-Android SO库文件头分析
解密的实现
#include <jni.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <elf.h>
#include <sys/mman.h>
#include <stdio.h>
#include <fcntl.h>
#include <elf.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "PrintLog.h"
jstring getString(JNIEnv*) __attribute__((section (".textdemo")));
jstring getString(JNIEnv* env){
return (*env)->NewStringUTF(env, "Native method return 123!");
};
//解密时,需要保证解密函数在so加载时被调用,所以函数的声明为 void init_getString() __attribute__((constructor));
void init_getString() __attribute__((constructor));
unsigned long getLibAddr();
void init_getString(){
char name[15];
unsigned int nblock;
unsigned int nsize;
unsigned long base;
unsigned long text_addr;
unsigned int i;
Elf64_Ehdr *ehdr;
Elf64_Shdr *shdr;
//首先调用 getLibAddr方法,得到so 文件在内存中的开地址
base = getLibAddr();
//读取前 52个字节,即 ELF 头,通过 e_shoff 获得 .mytext内存加载地址,endr_entry获取 .mytext大小和所在内存块
//这是因为你存的时候,就是这样存储的
ehdr = (Elf64_Ehdr *)base;
text_addr = ehdr->e_flags + base;
// ehdr.e_entry = (length << 16) + nsize;
nblock = ehdr->e_entry >> 16;
nsize = ehdr->e_entry & 0xffff;
LOGD("nblock = %d nsize == %d \n", nblock,nsize);
//修改.mytext所在内存块的读写权限
if(mprotect((void *) base, 4096 * nsize, PROT_READ | PROT_EXEC | PROT_WRITE) != 0){
LOGD("mem privilege change failed");
}
//将 [e_shoff,e_shoff+size]内存区域数据解密,即取反操作
for(i=0;i< nblock; i++){
char *addr = (char*)(text_addr + i);
*addr = ~(*addr);
}
//修改会内存区域的读写权限
if(mprotect((void *) base, 4096 * nsize, PROT_READ | PROT_EXEC) != 0){
LOGD("mem privilege change failed");
}
LOGD("Decrypt success");
}
unsigned long getLibAddr(){
unsigned long ret = 0;
char name[] = "libnative-lib.so";
char buf[4096], *temp;
int pid;
FILE *fp;
//当前当前进程下面的所有的文件描述符
pid = getpid();
sprintf(buf, "/proc/%d/maps", pid);
fp = fopen(buf, "r");
if(fp == NULL)
{
LOGD("open failed");
goto _error;
}
while(fgets(buf, sizeof(buf), fp)){
//找到 libnative-lib.so 的内存映射地址
if(strstr(buf, name)){
temp = strtok(buf, "-");
ret = strtoul(temp, NULL, 16);
break;
}
}
_error:
fclose(fp);
return ret;
}
JNIEXPORT jstring JNICALL
Java_com_example_androidelf_MainActivity_stringFromJNI( JNIEnv* env,jobject thiz )
{
return getString(env);
}
在这里重点解释这个解密函数:
首先看到的是getLibAddr()这个函数:在介绍这个函数之前首先了解一个内存映射问题:
和Linux一样,Android提供了基于/proc的“伪文件”系统来作为查看用户进程内存映像的接口(cat /proc/pid/maps)。可以说,这是Android系统内核层开放给用户层关于进程内存信息的一扇窗户。通过它,我们可以查看到当前进程空间的内存映射情况,模块加载情况以及虚拟地址和内存读写执行(rwxp)属性等。
因为根据前面加载so的流程可以知道,其实也是调用open函数打开一个文件,所以肯定可以这里面找到
接下来包括内存权限的修改以及函数的解密算法,最后包括内存权限的修改回去,应该都比较好理解。ok,以上编写完以后就编译生成.so文件
解密时,需要保证解密函数在so加载时被调用,那函数声明为:init_getString attribute((constructor))。(也可以使用c++构造器实现, 其本质也是用attribute实现)
解密流程:
1) 动态链接器通过call_array调用init_getString
2) Init_getString首先调用getLibAddr方法,得到so文件在内存中的起始地址
3) 读取前52字节,即ELF头。通过 e_flags 获得.textdemo内存加载地址,ehdr.e_entry获取.mytext大小和所在内存块
4) 修改.textdemo 所在内存块的读写权限
5) 将[e_flags, e_flags + size]内存区域数据解密,即取反操作:content = ~(content);
6) 修改回内存区域的读写权限
(这里是对代码段的数据进行解密,需要写权限。如果对数据段的数据解密,是不需要更改权限直接操作的)
这里要注意的是,我们的这个解密算法是肯定写在我们的so里面的,第一次运行的时候,我们只能执行安装操作,而不能直接使用,因为我们使用了解密函数,而一开始我们的so是没有加密的,那肯定会崩溃所以一开始我们我们可以将这个原始的so打包出来,然后放到sd卡下面,接着 程序运行的时候,加载 System.loadLibrary(“encoder-lib”); 完成加密的操作
加密的结果,我们可以使用 readelf -S libnative-lib.so ,查看session信息,可以看到,我们加的session textdemo 是在里面的
拿到加密后的so使用 IDA 打开,可以看到出现了错误
接着拿到加密后的so重新加载,此时就可以解析出来
LLVM
LLVM可以做到达到混淆native层代码的效果,增大破解的难度,可以参考下面的文章
手把手编译OLLVM(obfuscator-llvm)
利用OLLVM混淆Android Native代码篇一
也可以参考我之前写的关于LLVM的搭建过程,其实有些目前市面上的sdk,有些就是直接使用了llvm来做的
Window下OOLLVM Android编译环境搭建
参考链接
- ELF学习日记-Android SO库文件头分析
- Android SO库文件头分析
- android so加固之section加密
- ELF中可以被修改又不影响执行的区域
- ELF文件解析和加载(附代码)
- Android SO文件保护加固——加密篇
- ELF文件格式分析
- SO加载解析过程
- ELF 解析.dynamic 节
- ELF文件格式修复
- 无文件的ELF执行
- 【腾讯Bugly干货分享】Android Linker 与 SO 加壳技术
- Android ELF系列:ELF文件格式简析到linker的链接so文件原理分析
- Windows下的ELF文件解析代码C++
- elfloader
- ReflectiveELFLoader