上一篇文章【Android NDK】(三)使用c++ 解析so文件结构 讲述了so库的解析,为本章打好了基础,文章主要讲述如何实现so库的加解密,增加动态库的安全性。
1. so加密方案介绍
-
动态加载so
System.load(String pathName)方式支持加载绝对路径下的so,也就是说我们完全可以把so整个加密之后放在APP assets或者服务器上,然后在加载之前先解密。这样的方式简单粗暴,而且如果动态下载的话,so不用随APP打包,达到APP瘦身的效果。 缺点就是流程复杂,加载时间长。 -
现有模式下,将so加密,APP运行时再解密
本地先将so库加密之后,还是放在lib目录下,打包安装运行,加载时使用System.loadLibrary(fileName),然后再进行解密,这样工程目录不需要变化,加载解析时间快。
第1种目前先不讨论,有兴趣的可以在网上查查,本章重点讲第2点的实现。
2. 方案的原理和可行性分析
首先需要提出两个问题需要考虑:
- 加密之后还能正常加载吗?
- 什么时机解密?
第1个问题,我们可以尝试将so文件整个解密,但是这样的话,APP调用的时候,System.loadLibrary()的时候就崩溃了。
为什么会崩溃呢?
这是因为so加载之后,系统会对so解析,装载。我们如果对文件整个加密的话,会导致结构破坏,系统无法解析就崩溃了。所以我们只能寻找一部分区域来加密。
加载流程可参考【腾讯Bugly干货分享】Android Linker 与 SO 加壳技术
android
需要加密哪些区域呢?
首先我们需要明确的是:我们需要加密什么东西?其实很简单,就是和java混淆一样,将我们自己写的java代码混淆掉,这里是将我们写的c++代码加密处理,提高破解难度。所以我们加密的区域就是我们存放源代码的位置区域。
如何确定源代码的位置区域?
上一章讲解的so库的解析,寻找过程如下:
- 读取so文件,解析出ELF Header。
- 解析Program Headers信息
- 找出类型为PT_DYNAMIC的Program Header。
- 根据PT_DYNAMIC 的程序头解析出符号表地址,符号散列表地址,字符串表地址和字符串字节总大小,共四个值。
- 遍历散列表中所有符号表的索引,在符号表找到对应的符号结构。
- 根据符号结构的名称判断,过滤系统函数,剩下的就是我们自己的源代码符号结构,然后根据符号结构里的偏移和大小确定方法体的区域。
如何加密?
上面找到了文件中我们源代码的偏移量和大小,我们读出来之后进行取反或者字符偏移+1的方式(可以使用更为可靠的对称加密方式,本章先不讨论),主要不能改变长度而且可还原。
如何验证已加密?
- 使用IDA Pro工具查看so,我so里面有个加密的方法encryptOrDecrypt。
加密前:
加密后:
可以看到右侧,明显看不到指令了,说明已经加密了。
如何验证加密成功了?
单单看到加密了还是不行的,我们还需要成功加载才算加密成功。此时我们将加密的so替换,运行APP,代码里面使用System.loadLibrary()加载,注意,只是加载一下,还不能调用我们的native方法。加载之后,程序不崩溃,说明之前的加密确实是成功了!此时调用native方法,程序会崩溃,是因为加密了,是正常现象。
至此,上面第1个问题,加密之后还能正常加载吗?答案是可以的!
第2个问题,什么时机解密呢?对此,我们需要先找到so库的位置。
APP加载so库之后,so库放在哪儿了?
在linux中,有一句名言,"一切皆文件",进程也是。System.loadLibrary()调用,so库加载之后,会在/proc/APP进程pid/maps文件里面记录基地址。
// 查看手机进程
adb shell ps
// --找到当前包名对应的pid,比如是1001
// 进入手机控制台
adb shell
cd /proc/1001 //上面找到的pid 1001
su // 直接查看cat maps可能提示无权限,所以需要提升权限
cat maps
//以下是cat maps的结果
....
8fb8c000-8fb93000 r-xp 00000000 fd:20 22465 /data/app/com.kongge.solibencryption-ns70YbB0b5JyL9sKnYs4Q==/lib/x86/libDataEncryptionLib.so
8fb93000-8fb95000 r-xp 00007000 fd:20 22465 /data/app/com.kongge.solibencryption-ns70YbB0b5JyL9sKnYs4Q==/lib/x86/libDataEncryptionLib.so
...
// 第一个8fb8c000就是基地址
如何解密?
基地址找到之后,我们就可以和之前解析加密一样,开始解析和解密。
如何验证解密成功?
最后一步就很简单了,调用native方法,能正确运行,就代表解密成功。
至此,上面第2个问题,什么时机解密?答案是System.loadLibrary()之后,调用native方法之前
经过一系列摸索,最开始的两个问题解决之后,可以确定该方案是可行的!
3. 方案实现
3.1 文件加密实现
// 包含此前缀的不需要加解密
const string excludePreStr[] = {
"_Z",
"__"
};
// 以下函数名不需要加解密
const string excludeNameStr[] = {
"JNI_OnLoad",
"etext",
"_etext",
"edata",
"_edata",
"end",
"_end"
};
bool isExcludeFunc(const string& funcName) {
for (int i = 0; i < sizeof(excludePreStr) / sizeof(excludePreStr[0]); ++i) {
if (funcName.find(excludePreStr[i]) == 0) {
return true;
}
}
for (int i = 0; i < sizeof(excludeNameStr) / sizeof(excludeNameStr[0]); ++i) {
if (funcName.compare(excludeNameStr[i]) == 0) {
return true;
}
}
return false;
}
typedef struct elf32_Hash {
Elf32_Word nbucket;
Elf32_Word nchain;
Elf32_Word* bucketArr;
Elf32_Word* chainArr;
} Elf32_Hash;
// 简单取反
void encryptOrDecyptContent(char* content, long long int size, int isDecrypt) {
for (int i = 0; i < size; ++i) {
content[i] = ~content[i];
}
}
// 文件加密实现
void ELF32Struct::encryptOrDecryptSo(fstream &ioFile, int isDecrypt) {
Elf32_Ehdr* elf32Ehdr = NULL;
// 读取elf头
elf32Ehdr = new Elf32_Ehdr[1];
ioFile.seekg(0, ios::beg);
ioFile.read((char*) elf32Ehdr, sizeof(Elf32_Ehdr));
// 解析Program Header,段信息
Elf32_Phdr* elf32Phdr = new Elf32_Phdr[elf32Ehdr->e_phnum];
ioFile.seekg(elf32Ehdr->e_phoff, ios::beg);
ioFile.read((char*) elf32Phdr, sizeof(Elf32_Phdr) * elf32Ehdr->e_phnum);
Elf32_Phdr* elf32PhdrPTDynamic = NULL;
for (int i = 0; i < elf32Ehdr->e_phnum; ++i) {
if (elf32Phdr[i].p_type == PT_DYNAMIC) {
elf32PhdrPTDynamic = &elf32Phdr[i];
break;
}
}
if (elf32PhdrPTDynamic == NULL) {
LOGD("cannot find PT_DYNAMIC in program headers\n");
return;
}
int dynNum = elf32PhdrPTDynamic->p_filesz / sizeof(Elf32_Dyn);
// 在动态段中找到对应的Section
Elf32_Dyn dyn;
Elf32_Word dyn_size, dyn_strsz;
Elf32_Addr dyn_symtab, dyn_strtab, dyn_hashtab;
int flag = 0;
ioFile.seekg(elf32PhdrPTDynamic->p_offset, ios::beg);
for (int i = 0; i < dynNum; ++i) {
ioFile.read((char*)&dyn, sizeof(Elf32_Dyn));
if (dyn.d_tag == DT_SYMTAB) {//符号表地址
dyn_symtab = dyn.d_un.d_ptr;
flag++;
} else if (dyn.d_tag == DT_HASH) {// 符号散列表地址
dyn_hashtab = dyn.d_un.d_ptr;
flag++;
} else if (dyn.d_tag == DT_STRTAB) {//字符串表地址
dyn_strtab = dyn.d_un.d_ptr;
flag++;
} else if (dyn.d_tag == DT_STRSZ) {// DT_STRTAB 字节总大小
dyn_strsz = dyn.d_un.d_val;
flag++;
}
}
if (flag != 4) {
return;
}
char *dynstr = new char[dyn_strsz];// 动态字符串
ioFile.seekg(dyn_strtab, ios::beg);
ioFile.read(dynstr, dyn_strsz);
Elf32_Hash elf32Hash;
ioFile.seekg(dyn_hashtab, ios::beg);
ioFile.read((char*)&elf32Hash.nbucket, sizeof(Elf32_Word));
ioFile.read((char*)&elf32Hash.nchain, sizeof(Elf32_Word));
elf32Hash.bucketArr = new Elf32_Word[elf32Hash.nbucket];
elf32Hash.chainArr = new Elf32_Word[elf32Hash.nchain];
ioFile.read((char*) elf32Hash.bucketArr, sizeof(Elf32_Word) * elf32Hash.nbucket);
ioFile.read((char*) elf32Hash.chainArr, sizeof(Elf32_Word) * elf32Hash.nchain);
//遍历散列表中所有符号表的索引,在符号表找到对应的符号结构 从而进行加密
for (int i = 0; i < elf32Hash.nbucket; ++i) {
for (int j = elf32Hash.bucketArr[i]; j != 0; j = elf32Hash.chainArr[j]) {
Elf32_Sym funSym;
ioFile.seekg(dyn_symtab + j * sizeof(Elf32_Sym), ios::beg);
ioFile.read((char*)&funSym, sizeof(Elf32_Sym));
if (funSym.st_size != 0 && ELF32_ST_TYPE(funSym.st_info) == 2) {
string targetFuncStr = dynstr + funSym.st_name;
bool isExclude = isExcludeFunc(targetFuncStr);
if (isExclude) {
continue;
}
Elf32_Off offset = funSym.st_value;
Elf32_Word size = funSym.st_size;
char* codeContent = new char[size];
// 区分thumb和arm指令
if (funSym.st_value & 0x0000001) {
offset = offset - 1;
}
ioFile.seekg(offset, ios::beg);
ioFile.read(codeContent, size);
encryptOrDecyptContent(codeContent, size, isDecrypt);
ioFile.seekg(offset, ios::beg);
ioFile.write(codeContent, size);
if (isDecrypt) {
LOGD("func name=%s decrypt succeed!\n", targetFuncStr.c_str());
} else {
LOGD("func name=%s encrypt succeed!\n", targetFuncStr.c_str());
}
}
}
}
}
3.2 so加载之后读取基地址
static unsigned long long getLibAddr(const char *name) {
unsigned long long ret = 0;
char buf[4096], *temp;
int pid;
FILE *fp;
pid = getpid(); // 获取进程pid
sprintf(buf, "/proc/%d/maps", pid); // 生成进程maps路径
LOGE("buf :%s", buf);
fp = fopen(buf, "r"); // 打开maps文件
if (fp == NULL) {
LOGE("open failed");
} else {
// 按行读取
while (fgets(buf, sizeof(buf), fp)) {
// 根据目标函数名找到对应库信息
if (strstr(buf, name)) {
LOGE("buf :%s", buf);
// 字符串切割,返回库函数基地址
temp = strtok(buf, "-");
// 将字符串转为无符号整数
ret = strtoull(temp, NULL, 16);
LOGE("ret :%lld", ret);
break;
}
}
}
fclose(fp);
return ret;
}
3.3 解密实现
void decrypt32FuncInfo(unsigned long long base, Elf32_Sym* elf32Sym) {
Elf32_Off offset = elf32Sym->st_value;
Elf32_Word size = elf32Sym->st_size;
// 区分thumb和arm指令
if (elf32Sym->st_value & 0x0000001) {
offset = offset - 1;
}
long long start = (base + offset) / PAGE_SIZE * PAGE_SIZE;
long long baseAddress = base + offset;
unsigned int curPage = size / PAGE_SIZE + ((size % PAGE_SIZE == 0) ? 0 : 1);
if ((baseAddress + size) > (start + curPage * PAGE_SIZE)) {
curPage++;
}
// 修改读写权限
if (mprotect((void *) start, curPage * PAGE_SIZE, PROT_READ | PROT_EXEC | PROT_WRITE) != 0) {
LOGE("mprotect failed\n");
return;
}
encryptOrDecyptContent((char*)baseAddress, size, 1);
// 还原读写权限
if (mprotect((void *) start, curPage * PAGE_SIZE, PROT_READ | PROT_EXEC) != 0) {
LOGE("mprotect restore failed\n");
return;
}
}
void ELF64Struct::decryptSo(unsigned long long base) {
Elf32_Ehdr *elf32Ehdr = (Elf32_Ehdr *) base;
int flag = 0;
Elf32_Phdr *elf32Phdr = (Elf32_Phdr *) (base + elf32Ehdr->e_phoff);
for (int i = 0; i < elf32Ehdr->e_phnum; ++i) {
if (elf32Phdr->p_type == PT_DYNAMIC) {
flag = 1;
break;
}
elf32Phdr++;
}
if (flag == 0) {
return;
}
Elf32_Off dynVaddr = elf32Phdr->p_vaddr + base;
Elf32_Word dynSize, dynStrsz;
Elf32_Addr dynSymtab, dynStrtab, dynHashtab;
dynSize = elf32Phdr->p_filesz;
flag = 0;
int dyn_num = dynSize / sizeof(Elf32_Dyn);
Elf32_Dyn *dyn;
for (int i = 0; i < dyn_num; ++i) {
dyn = (Elf32_Dyn *) (dynVaddr + i * sizeof(Elf32_Dyn));
if (dyn->d_tag == DT_HASH) {
dynHashtab = (dyn->d_un).d_ptr;
flag++;
}
if (dyn->d_tag == DT_SYMTAB) {
dynSymtab = (dyn->d_un).d_ptr;
flag++;
}
if (dyn->d_tag == DT_STRTAB) {
dynStrtab = (dyn->d_un).d_ptr;
flag++;
}
if (dyn->d_tag == DT_STRSZ) {
dynStrsz = (dyn->d_un).d_val;
flag++;
}
}
if (flag != 4) {
LOGD("can not find four need section!\n");
return;
}
dynSymtab += base;
dynHashtab += base;
dynStrtab += base;
Elf32_Sym *funSym = (Elf32_Sym *) dynSymtab;
char *dynstr = (char *) dynStrtab;
unsigned nBucket, nChain;
unsigned int *bucket, *chain;
nBucket = *((int *) dynHashtab);
nChain = *((int *) dynHashtab + sizeof(int*));
bucket = (unsigned int *) (dynHashtab + sizeof(int*) * 2);
chain = (unsigned int *) (dynHashtab + sizeof(int*) * (2 + nBucket));
for (int i = 0; i < nBucket; i++) {
for (int j = bucket[i]; j != 0; j = chain[j]) {
Elf32_Sym* elf32SymTarget = funSym + j;
string targetFuncStr = dynstr + elf32SymTarget->st_name;
if (elf32SymTarget->st_value == 0 || elf32SymTarget->st_size == 0) {
continue;
}
bool isExclude = isExcludeFunc(targetFuncStr);
if (isExclude) {
continue;
}
decrypt32FuncInfo(base, elf32SymTarget);
LOGD("func name=%s decrypt succeed!\n", targetFuncStr.c_str());
}
}
}
3.4 java解密接口
package com.kongge.soencryptionlib;
import android.text.TextUtils;
import android.util.Log;
public class SoLibLoadUtil {
private static final String TAG = "SoLibLoadUtil";
static {
System.loadLibrary("SoDecryptionLib");
}
public static void loadLibrary(String soLibName) {
if (TextUtils.isEmpty(soLibName)) return;
System.loadLibrary(soLibName);
decryptLibrary(soLibName);
}
public static void decryptLibrary(String soLibName) {
if (TextUtils.isEmpty(soLibName)) return;
boolean isDecryption = decryptSoLib("lib" + soLibName + ".so");
if (isDecryption) {
Log.i(TAG, "soLibName : " + soLibName + " decrypt succeed");
} else {
Log.i(TAG, "soLibName : " + soLibName + " decrypt failed");
}
}
private static native boolean decryptSoLib(String name);
}
3.5 jni解密
#define ANDROID_PROGRAM 1
#include <string.h>
#include <jni.h>
#include <cstdio>
#include <unistd.h>
#include <stdlib.h>
#include <stdexcept>
#include "ElfParser.h"
#include "include/android_log.h"
extern "C" {
static unsigned long long getLibAddr(const char *name) {
...见上面
}
JNIEXPORT jboolean JNICALL
Java_com_kongge_soencryptionlib_SoLibLoadUtil_decryptSoLib(JNIEnv *env, jclass clazz,
jstring name) {
LOGD("load solib in");
jboolean b = false;
const char* nameCharArr = env->GetStringUTFChars(name, &b);
if(nameCharArr == NULL) {
return false; //OutOfMemoryError already thrown
}
LOGD("load solib name = %s" , nameCharArr);
unsigned long long base = getLibAddr(nameCharArr);
LOGD("base addr = 0x%llx;", base);
if (base != 0) {
char fileName[strlen(nameCharArr)];
strcpy(fileName, nameCharArr);
LOGD("fileName = %s" , fileName);
try {
if (isElf64(base)) {
ELF64Struct elf64Struct;
elf64Struct.fileName = fileName;
elf64Struct.decryptSo(fileContent);
} else {
ELF32Struct elf32Struct;
elf32Struct.fileName = fileName;
elf32Struct.decryptSo(base);
}
} catch (runtime_error err) {
LOGE("err = %s", err.what());
} catch (exception err) {
LOGE("err = %s", err.what());
} catch (...) {
LOGE("err...");
}
}
return true;
}
}
4. 碰到的问题
4.1 加密之后,System.loadLibrary()立马崩溃了。
因为加密了系统函数,导致加载失败,JNI_OnLoad方法,_Z、 _ _ 开头的都不需要加密。因为System.loadLibrary()加载so之后,会立即调用JNI_OnLoad方法,如果这个方法加密了,就会崩溃。
4.2 解密时,读出来ELF头文件和解析文件时不一样,而且解析找的地址也不一样。
因为so加载分三步,装载、分配soinfo和链接,所以整个结构并不是连续的,和解析文件时是不一样的。
4.3 日志的输出问题,c++加密时,用的printf,jni解密是用的LOG,如果共用同一个加解密头文件的话,怎么兼容下?
我定义了c_log.h和android_log.h,分别用于c++ printf和Android的log。封装了LOGD、LOGE等方法,使用时统一使用LOGD,LOGE的方式,c++环境下就会执行printf输出,APP形式就会LOG输出。
c_log.h
#ifndef SOLIBENCRYPTION_C_LOG_H
#define SOLIBENCRYPTION_C_LOG_H
#include <stdarg.h>
void LOGV(const char* format, ...) {
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
void LOGD(const char* format, ...) {
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
void LOGI(const char* format, ...) {
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
void LOGW(const char* format, ...) {
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
void LOGE(const char* format, ...) {
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
void LOGF(const char* format, ...) {
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
}
#endif //SOLIBENCRYPTION_C_LOG_H
android_log.h
#ifndef NATIVE_AUDIO_ANDROID_DEBUG_H_H
#define NATIVE_AUDIO_ANDROID_DEBUG_H_H
#include <android/log.h>
#define MODULE_NAME "jniLog"
#define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, MODULE_NAME, __VA_ARGS__)
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, MODULE_NAME, __VA_ARGS__)
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, MODULE_NAME, __VA_ARGS__)
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN,MODULE_NAME, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR,MODULE_NAME, __VA_ARGS__)
#define LOGF(...) __android_log_print(ANDROID_LOG_FATAL,MODULE_NAME, __VA_ARGS__)
#endif //NATIVE_AUDIO_ANDROID_DEBUG_H_H
ElfParser.h --解析头文件
#ifndef ANDROID_PROGRAM // 如果未定义ANDROID_PROGRAM
#define ANDROID_PROGRAM // 定义ANDROID_PROGRAM
// c++加密cpp文件不定义ANDROID_PROGRAM,则会引入这些头文件
#define PAGE_SIZE 4096
#include "include/c_log.h"
#include "include/elf.h" // 本地放了一份,Linux有这个头文件,但是windows没有,所以兼容下
#else
// jni里面的解密cpp定义了ANDROID_PROGRAM,则会引入这些头文件
#include "include/android_log.h"
#include <elf.h> // jni环境有elf.h,所以使用系统的就可以
#endif
SoDecrypt.cpp --jni解密cpp
#define ANDROID_PROGRAM 1
#include "ElfParser.h"
4.3 64so怎么解析?
ELF Header 魔数里面第五位(EI_CLASS)判断01->32位,02->64位,解析和32位流程完全一样,可以复制32的解析和加解密,将Elf32_Ehdr 改为 Elf64_Ehdr,其他结构同样的操作,将数字32全部改为64即可。
4.4 解密时修改内存失败?
本地修改so文件,任何位置的都可以修改,但是APP加载so之后,就不行了,因为Linux系统对某些代码段只有读和执行的权限,并没有写权限,此时需要修改读写权限,修改完之后再还原。
5. 小结
前期要对so文件结构有一定的了解,NDK开发和c/c++语法也要熟悉。中间也遇到过不少问题,感谢网上大神的资料和朋友的帮助,后续想实现非入侵式so加固,想想都有点小激动呢~
6. 参考文献:
Android逆向之旅---SO(ELF)文件格式详解
对抗静态分析——so文件的加密
Android SO文件保护加固——加密篇(一)
Android so(ELF) 文件解析
Android ELF文件根据函数名查找函数位置
Android so文件函数加密
【腾讯Bugly干货分享】Android Linker 与 SO 加壳技术