【Android NDK】(四)so库的加解密实现

4,349 阅读9分钟

上一篇文章【Android NDK】(三)使用c++ 解析so文件结构 讲述了so库的解析,为本章打好了基础,文章主要讲述如何实现so库的加解密,增加动态库的安全性。

1. so加密方案介绍

  1. 动态加载so
    System.load(String pathName)方式支持加载绝对路径下的so,也就是说我们完全可以把so整个加密之后放在APP assets或者服务器上,然后在加载之前先解密。这样的方式简单粗暴,而且如果动态下载的话,so不用随APP打包,达到APP瘦身的效果。 缺点就是流程复杂,加载时间长。

  2. 现有模式下,将so加密,APP运行时再解密
    本地先将so库加密之后,还是放在lib目录下,打包安装运行,加载时使用System.loadLibrary(fileName),然后再进行解密,这样工程目录不需要变化,加载解析时间快。

第1种目前先不讨论,有兴趣的可以在网上查查,本章重点讲第2点的实现。

2. 方案的原理和可行性分析

首先需要提出两个问题需要考虑:

  1. 加密之后还能正常加载吗?
  2. 什么时机解密?

第1个问题,我们可以尝试将so文件整个解密,但是这样的话,APP调用的时候,System.loadLibrary()的时候就崩溃了。

为什么会崩溃呢?

这是因为so加载之后,系统会对so解析,装载。我们如果对文件整个加密的话,会导致结构破坏,系统无法解析就崩溃了。所以我们只能寻找一部分区域来加密。
加载流程可参考【腾讯Bugly干货分享】Android Linker 与 SO 加壳技术 android

需要加密哪些区域呢?

首先我们需要明确的是:我们需要加密什么东西?其实很简单,就是和java混淆一样,将我们自己写的java代码混淆掉,这里是将我们写的c++代码加密处理,提高破解难度。所以我们加密的区域就是我们存放源代码的位置区域。

如何确定源代码的位置区域?

上一章讲解的so库的解析,寻找过程如下:

  1. 读取so文件,解析出ELF Header。
  2. 解析Program Headers信息
  3. 找出类型为PT_DYNAMIC的Program Header。
  4. 根据PT_DYNAMIC 的程序头解析出符号表地址,符号散列表地址,字符串表地址和字符串字节总大小,共四个值。
  5. 遍历散列表中所有符号表的索引,在符号表找到对应的符号结构。
  6. 根据符号结构的名称判断,过滤系统函数,剩下的就是我们自己的源代码符号结构,然后根据符号结构里的偏移和大小确定方法体的区域。

如何加密?

上面找到了文件中我们源代码的偏移量和大小,我们读出来之后进行取反或者字符偏移+1的方式(可以使用更为可靠的对称加密方式,本章先不讨论),主要不能改变长度而且可还原。

如何验证已加密?

  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 加壳技术