Android未做处理的.so相当于开源!编写插件简单加密Native层代码

2,962 阅读7分钟

前言

之前分享了如何将项目中的Kotlin/Java字符串加密到Native层:

Android字符串安全(一):如何逆向破解纯Kotlin/Java的字符串加密/混淆

Android字符串安全(二):Kotlin/Java字符串加密到Native层,扩展StringFog插件

但实际对于抵御静态分析还不太够。

有一些专业逆向Native层代码的工具,例如IDA Pro。将上一篇博客最后编译出的.so文件直接拖入IDA Pro,直接就可以看到我们加密后的字符串,然后可用第一篇博客中介绍的方法还原Kotlin/Java层的字符串。

image.png

上图是未处理符号的情况(打码的地方只是因为线上项目代码对上一篇博客中的变量名做了修改)。如果不处理符号,逆向者甚至能看到原本代码中的函数名和变量名。

即使是对上一篇博客中的代码进行扩展,虽然字符串原文不会被直接看到,但一样可以通过IDA Pro的"F5"功能一键把汇编指令翻译为C代码,轻松看懂其中的逻辑。破解者无需运行Apk就能还原字符串。

image.png

image.png

可以看到和源码没什么区别,如果这是关键业务代码,逆向者把这段代码复制到项目里稍微改改就能直接用了。

ELF基础

.so就是ELF格式文件, 关于ELF文件格式, 由于相关博客已经很多了, 这里只介绍本文所需要的最关键的内容: ELF文件头结构体Elf64_Ehdrsection表, 在IDA中Ctrl+S可快速查看到通过sectionprogram(segment)两个表共同解析出的内容。

image.png

一个ELF文件中有多个不同的section, 一般可执行的代码都会被编译到.textsection中, 比如上一篇的Java_safe_string_NativeStringFog_decrypt。但我们可以通过为函数加上__attribute__((section("xxx"))), 把函数编译在自定义的xxxsection中。

下面给出一段C语言代码, 适用于64位ELF文件, 获取当前.sosection表:

Dl_info info;
dladdr((void*) xxx/*这里是当前函数函数名*/, &info);

int fd = open(info.dli_fname, O_RDONLY);
struct stat file_stat;

void *file_data = mmap(NULL, file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

Elf64_Ehdr *elf_header = (Elf64_Ehdr *)file_data;

Elf64_Shdr *sectionHeader = (Elf64_Shdr *)(file_data + elf_header->e_shoff);
for(int i = 0 ; i < elf_header->e_shnum ; i++) {
    Elf64_Shdr *item = sectionHeader + i;
    LOGD("shdr i = %d, sh_addr = %llx, offset = %llx, flags = %llx, size = %llx",
         i, item->sh_addr, item->sh_offset, item->sh_flags, item->sh_size);
}

munmap(file_data, file_stat.st_size);
close(fd);

注意要在声明manifest里面声明android:extractNativeLibs="true", 否则获info.dli_fname取到的so路径是/data/app/...省略.../base.apk!/lib/arm64-v8a/libxxx.so(注意那感叹号), 并不能直接open

linker负责elf文件的加载。因为linker并不会把所有section内容装载到内存中,所以还是需要额外打开so文件。

初学JNI一般会认为JNI_OnLoad.so被加载后第一个调用的函数, 但实际上可以通过为函数声明__attribute__((constructor(101))), 声明了这个标记的函数的偏移会被加入到.init_arraysection中, 其中101控制在.init_array中的出现顺序,.init_array里面的偏移对应的函数会被linker在加载.so文件时依次调用。

因此, 最简单的.so加密方法就是编译后加密其中的.text, 在运行时解密, 解密函数不能定义在.text, 而且要确保解密函数要在任何.text中被加密的代码执行前就调用。

另外再提一下个人刚研究ELF时疑惑/担心过的地方:Linker加载elf的时候会不会为了某些优化而不严格按照section表的sh_addr加载呢?并不会。仅仅是严格按照segment表(phdr表)加载,因为.text中的汇编指令存在对不同section内的函数/变量的偏移, section在内存中的偏移如果会变,许多指令的参数也就需要变了,成本极高,对某个section加密和防篡改检测也就很难实现了。

Gradle任务实现.so代码段加密

先确定下我的AGP版本classpath 'com.android.tools.build:gradle:7.3.1'

观察构建项目时,有哪些和Native相关的任务,确定好处理.so的时机。

image.png

这里就挑stripDebugDebugSymbols这个任务了(自行注意打release包时任务名有变)。下载AGP版本对应的源码大概看一下,链接:

dl.google.com/android/mav…

此任务源码:gradle-7.3.1-sources\com\android\build\gradle\internal\tasks\StripDebugSymbolsTask.Kt, 可以通过源码确定这个任务会将处理后的so文件放到哪个路径。

/**
 * Task to remove debug symbols from native libraries.
 */
@DisableCachingByDefault
abstract class StripDebugSymbolsTask : NewIncrementalTask() {

    //... 忽略
    
    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty
    
    // ...

手工解析ELF是对项目的不负责, 这里直接用已经过较多验证的Java库net.fornwall:jelf:0.9.0, 在buildSrc新增此插件之后, 编写任务,找出so文件的.textsection, 并进行加密:

abstract class NativeCodeProtect : DefaultTask() {

    @get:Input
    abstract val nativeDir: DirectoryProperty

    @TaskAction
    fun action(){
        nativeDir.asFile.get().walk().forEach {
            // println("it.absolutePath = ${it.absolutePath}")

            if(!it.isFile || !it.name.endsWith(".so")) {
                return@forEach
            }
            
            // 这里自行判断文件名, 别把不需要加密的so加密了, 自己在这里掉坑了一个小时..
            
            val file = ElfFile.from(it)
            for(i in 0 until file.e_shnum){
                val section = file.getSection(i)
                /*
                println("name = " + section.header.name)
                println("header = " + section.header.toString())
                println("sh_offset = ${section.header.sh_offset}, sh_size = ${section.header.sh_size}")
                println("------")
                */

                if(section.header.name != ".text") {
                    continue
                }

                RandomAccessFile(it, "rw").use {raf->
                    val mappedByteBuffer = raf.channel.map(
                        FileChannel.MapMode.READ_WRITE,
                        section.header.sh_offset, section.header.sh_size
                    )

                    // 这里读者自行实现加密逻辑

                    mappedByteBuffer.force()
                    println("k3x1n_sec: ok. ${it.absolutePath}")
                }

                break
            }
        }
    }

}

然后在build.gradle控制NativeCodeProtect任务和stripDebugDebugSymbols的顺序:

tasks.register('nativeCodeProtect', NativeCodeProtect) {
    nativeDir = stripDebugDebugSymbols.outputDir
}

afterEvaluate{
    // println("stripDebugDebugSymbols.outputDir: " + stripDebugDebugSymbols.outputDir.asFile.get())
    stripDebugDebugSymbols.finalizedBy nativeCodeProtect
    
    //...
}

至此,加密工作已完成。

运行时解密

根据上文所述,只需要在解密阶段找到.text的位置,修改内存属性为可写(rwxp),然后解密.text数据,再把内存段恢复为r-xp。这里直接通过随便一个.text中的函数Java_safe_string_NativeStringFog_decrypt即可确定哪个section是.text,无需解析section名称了。

#define SH_UTIL_ALIGN_START(x, align) ((uintptr_t)(x) & ~((uintptr_t)(align)-1))
#define SH_UTIL_ALIGN_END(x, align)   (((uintptr_t)(x) + (uintptr_t)(align)-1) & ~((uintptr_t)(align)-1))

#define SH_UTIL_PAGE_START(x) SH_UTIL_ALIGN_START(x, 0x1000)
#define SH_UTIL_PAGE_END(x)   SH_UTIL_ALIGN_END(x, 0x1000)

__attribute__((constructor(101)))
__attribute__((section(".k3x1n_sec")))
void resumeCode(){
    Dl_info info;
    dladdr((void*) resumeCode, &info);

    size_t functionOffset = (size_t)Java_safe_string_NativeStringFog_decrypt - (size_t)info.dli_fbase;
    int fd = open(info.dli_fname, O_RDONLY);

    struct stat file_stat;
    fstat(fd, &file_stat);

    void *file_data = mmap(NULL, file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

    Elf64_Ehdr *elf_header = (Elf64_Ehdr *)file_data;

    Elf64_Shdr *sectionHeader = (Elf64_Shdr *)(file_data + elf_header->e_shoff);
    for(int i = 0 ; i < elf_header->e_shnum ; i++) {
        Elf64_Shdr *item = sectionHeader + i;
        if(item->sh_addr > functionOffset || item->sh_addr + item->sh_size <= functionOffset) {
            continue;
        }
        char* ptr = (char*)info.dli_fbase + item->sh_addr;
        
        uintptr_t start = SH_UTIL_PAGE_START((uintptr_t)ptr);
        uintptr_t end = SH_UTIL_PAGE_END((uintptr_t)ptr + item->sh_size - 1);
        mprotect((void *)start, end - start, PROT_READ | PROT_WRITE | PROT_EXEC);

        //... 这里自行实现对应的解密逻辑

        mprotect((void *)start, end - start, PROT_READ | PROT_EXEC);

        __builtin___clear_cache((void *)start, (void*)end);
        break;
    }

    munmap(file_data, file_stat.st_size);
    close(fd);
}

还需要注意的是修改了指令,要刷新cache,否则cpu可能缓存有加密前的指令数据(这里参考shadowhook源码直接用的同款__builtin___clear_cache函数,另外为了减少出错可能性, mprotect用到的宏也是直接从shadowhook摘的)。

最终效果

再把.so拖进IDA Pro, 看.text, 显然它已无法把机器码识别为正确的指令, 更不必说F5一键把汇编指令转为伪代码了。

image.png

现在可以比上篇文章防住更多的逆向入门选手,但还是不够安全,抛开.init_array里的函数容易被看穿不谈,刚好被F8LEFT/SoFixer (github.com)这个项目完美克制。

很多人说Section表相关某些数据不会被Linker使用,但每年追着最新版AOSP Linker代码去验证也不现实,个人也只有小米、一加机型各一台,可覆盖机型太少了,所以还是不抹掉对应数据,未来直接进入自定义linker的研究吧。欢迎&感谢读者从技术上予以指点。

参考

这里给出一篇看雪论坛找到的@ThomasKing十年前的帖子:简单粗暴的so加解密实现 ,很有价值,实现方式略有不同(要不然我都不太好意思发这个博客了),他是加密具体某个函数,也就是.text的一部分,学习成本略高一点点。帖子评论区也有价值,例如@cacorothuo补充的需要刷新cache,确实让我这个初学者避免踩到"随机崩溃"的坑了。