android逆向奇技淫巧三十:so加壳&加固原理简述
1、古人云:没有规矩,不成方圆!任何组织要想正常运作,肯定需要有一系列的规章制度来约束参与其中的每个个体,否则每个个体都是各干各的,从全盘来看就是一群做着布朗运动的散沙!IT技术也一样,最典型的就是计算机网络了:为了确保整个网络能正常收发数据包,早在几十年前就制定了一整套计算机网络的通信协议,这就是大家耳熟能详的TCP/IP协议!无论是源端发送数据包的、中途转发数据包的,还是目的端接受数据包的,大家都按照统一的协议格式生成、转发和解析数据包(本质就是一串字符串),确保了目的端能正确收到和解析源端的数据,由此带来了计算机网络的飞速发展!
基于计算机网络协议扩展一下:其实windwos下的PE格式、linux/android下的ELF格式本质上讲也是一种介于操作系统和编译器之间的协议!开发人员写好了代码,为了能在操作系统上顺利运行,编译器必须把高级语言的代码转换成操作系统能识别的文件格式+cpu能识别的机器码,只有这样,操作系统才能正确加载可执行文件到内存,然后运行!加壳最关键的原理就在这了:为了防止被静态分析,很多时候要想尽各种办法隐藏真实的代码,等执行的时候才还原!而且为了防止IDA等逆向软件的分析,文件格式可能还被故意破坏,不是标准的PE或ELF格式,导致操作系统无法正确地识别和加载,只能加壳的程序自己加载和执行,相当于“自立门户”了!整个过程大致如下:
加壳工具、loader、被保护SO。
- SO: 即被保护的目标 SO。
- loader: 自身也是一个 SO,系统加载时首先加载 loader,loader 首先还原出经过加密、压缩、变换的 SO,再将 SO 加载到内存,并完成链接过程,使 SO 可以正常被其他模块使用(本质是把自定义格式的so修复成操作系统能执行的格式)。
- 加壳工具: 将被保护的 SO 加密、压缩、变换,并将结果作为数据与 loader 整合为 packed SO。
从上述的描述看,loader本身就是个脱壳工具(只不过是加壳厂家官方的工具),抓住了loader,不就等于找到了脱壳的方法了?为了更好的学习loader,这里有必要研究一下加载到内存、修复链接等核心工作的原理和关键点!
2、(1)加载到内存!不知道大家平时在写代码调用其他so中函数的时候有没有仔细想过:为啥要用dlopen、dlsym函数,而不直接简单粗暴地用open、fopen等函数打开so了?dlopen和普通的open函数有啥本质区别了?open函数都很熟悉,本质是通过系统调用找到文件在磁盘的位置,然后生成fd,后续通过fd操控文件!相比之下,dlopen要复杂多了:不但要加载到内存,还要解析文件格式,然后修复链接。先来看看第一步加载和解析文件格式都是怎么做的(这里以8.0.1版本为例,源码链接在文章末尾参考处有)!
- 在/bionic/linker/linker.cpp中有do_dlopen函数(这里多说几句,linux内核有很多do开头的函数,都是内核内部的执行函数),核心代码如下:
ProtectedDataGuard guard; 2013 soinfo* si = find_library(ns, translated_name, flags, extinfo, caller);//查找so是否已经被加载 2014 loading_trace.End(); 2015 2016 if (si != nullptr) { 2017 void* handle = si->to_handle(); 2018 LD_LOG(kLogDlopen, 2019 "... dlopen calling constructors: realpath=\"%s\", soname=\"%s\", handle=%p", 2020 si->get_realpath(), si->get_soname(), handle); 2021 si->call_constructors();//初始化so 2022 failure_guard.Disable(); 2023 LD_LOG(kLogDlopen, 2024 "... dlopen successful: realpath=\"%s\", soname=\"%s\", handle=%p", 2025 si->get_realpath(), si->get_soname(), handle); 2026 return handle;
会先在soInfo链表中挨个遍历,看看目标so是否已经加载。如果没有,就继续调用CallConstructor继续初始化so!其中 find_library_internal 函数核心代码如下:
soinfo* candidate; 1438 1439 if (find_loaded_library_by_soname(ns, task->get_name(), search_linked_namespaces, &candidate)) { //根据so的名称查找是否已经加载 1440 task->set_soinfo(candidate); 1441 return true; 1442 } 1443 1444 // Library might still be loaded, the accurate detection 1445 // of this fact is done by load_library. 1446 TRACE("[ \"%s\" find_loaded_library_by_soname failed (*candidate=%s@%p). Trying harder...]", 1447 task->get_name(), candidate == nullptr ? "n/a" : candidate->get_realpath(), candidate); 1448 1449 if (load_library(ns, task, zip_archive_cache, load_tasks, rtld_flags, search_linked_namespaces)) { //正式加载so 1450 return true; 1451 }
先根据so的名称查找在soInfo的链表中查找是否已经存在。如果不存在,说明还没加载,继续调用load_library 加载so。代码非常多(在这里:http://aospxref.com/android-8.1.0_r81/xref/bionic/linker/linker.cpp#1178),而且分散在不同的函数,我这里选重点说明整个流程:
// Open the file. 先打开文件 1341 int fd = open_library(ns, zip_archive_cache, name, needed_by, &file_offset, &realpath); 1342 if (fd == -1) { 1343 DL_ERR("library \"%s\" not found", name); 1344 return false; 1345 } //生成soInfo结构体,后续用于记录so的属性 soinfo* si = soinfo_alloc(ns, realpath.c_str(), &file_stat, file_offset, rtld_flags); 1277 if (si == nullptr) { 1278 return false; 1279 } 1280 1281 task->set_soinfo(si); 1282 1283 // Read the ELF header and some of the segments.读取so的elf头,用于解析so 1284 if (!task->read(realpath.c_str(), file_stat.st_size)) { 1285 soinfo_free(si); 1286 task->set_soinfo(nullptr); 1287 return false; 1288 } 1289 1290 // find and set DT_RUNPATH and dt_soname 1291 // Note that these field values are temporary and are 1292 // going to be overwritten on soinfo::prelink_image 1293 // with values from PT_LOAD segments. 1294 const ElfReader& elf_reader = task->get_elf_reader(); 1295 for (const ElfW(Dyn)* d = elf_reader.dynamic(); d->d_tag != DT_NULL; ++d) { 1296 if (d->d_tag == DT_RUNPATH) { 1297 si->set_dt_runpath(elf_reader.get_string(d->d_un.d_val)); 1298 } 1299 if (d->d_tag == DT_SONAME) { 1300 si->set_soname(elf_reader.get_string(d->d_un.d_val)); 1301 } 1302 } 1303 1304 for_each_dt_needed(task->get_elf_reader(), [&](const char* name) { 1305 load_tasks->push_back(LoadTask::create(name, si, ns, task->get_readers_map())); 1306 }); 1307 //装载so,并给soInfo结构体赋值 641 ElfReader& elf_reader = get_elf_reader(); 642 if (!elf_reader.Load(extinfo_)) { 643 return false; 644 } 645 646 si_->base = elf_reader.load_start(); 647 si_->size = elf_reader.load_size(); 648 si_->set_mapped_by_caller(elf_reader.is_mapped_by_caller()); 649 si_->load_bias = elf_reader.load_bias(); 650 si_->phnum = elf_reader.phdr_count(); 651 si_->phdr = elf_reader.loaded_phdr(); 652 653 return true; 654 }
整个流程简单清晰:先打开文件,然后解析elf文件头,最后新生成so结构体记录加载的so文件属性!打开和加载的过程中,/bionic/linker/linker_phdr.cpp 中的这两个函数里面包括了整个加载过程的精髓:
bool ElfReader::Read(const char* name, int fd, off64_t file_offset, off64_t file_size) { 150 CHECK(!did_read_); 151 CHECK(!did_load_); 152 name_ = name; 153 fd_ = fd; 154 file_offset_ = file_offset; 155 file_size_ = file_size; 156 157 if (ReadElfHeader() && //读elf头 158 VerifyElfHeader() && //校验elf头 159 ReadProgramHeaders() && //读程序头 160 ReadSectionHeaders() && //读接头 161 ReadDynamicSection()) { //读动态节头 162 did_read_ = true; 163 } 164 165 return did_read_; 166 } 167 168 bool ElfReader::Load(const android_dlextinfo* extinfo) { 169 CHECK(did_read_); 170 CHECK(!did_load_); 171 if (ReserveAddressSpace(extinfo) && //为段开辟内存空间 172 LoadSegments() && //加载段,这里是很好的脱壳点 173 FindPhdr()) { //设置程序的加载地址 174 did_load_ = true; 175 } 176 177 return did_load_; 178 }
自己写壳的加载程序时,完全可以仿照这8个步骤一步一步做(事实上,很多加壳和脱壳loader程序也都是这么干的!)!至此,这个so的代码是不是就能执行了?很明显还不行,原因很简单:链接还没修复了!所谓的链接,本质就是调用其他函数时需要正确的地址!
(2)修复链接
整个过程最核心的函数就是prelink_image和link_image了,作用分别是:
- 解析linker文件中dynamic段的各项,例如重定位表,符号表
- 对全局变量,外部函数等地址进行重定位
核心代码如下:prelink_image 大都是这种switch case结构,根据不同的tag做不同的解析;
for (ElfW(Dyn)* d = dynamic; d->d_tag != DT_NULL; ++d) { 2857 DEBUG("d = %p, d[0](tag) = %p d[1](val) = %p", 2858 d, reinterpret_cast<void*>(d->d_tag), reinterpret_cast<void*>(d->d_un.d_val)); 2859 switch (d->d_tag) { 2860 case DT_SONAME: 2861 // this is parsed after we have strtab initialized (see below). 2862 break; 2863 2864 case DT_HASH: //导出函数定位 2865 nbucket_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[0]; 2866 nchain_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[1]; 2867 bucket_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr + 8); 2868 chain_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr + 8 + nbucket_ * 4); 2869 break; 2870 2871 case DT_GNU_HASH: //导出函数定位 2872 gnu_nbucket_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[0]; 2873 // skip symndx 2874 gnu_maskwords_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[2]; 2875 gnu_shift2_ = reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[3]; 2876 2877 gnu_bloom_filter_ = reinterpret_cast<ElfW(Addr)*>(load_bias + d->d_un.d_ptr + 16); 2878 gnu_bucket_ = reinterpret_cast<uint32_t*>(gnu_bloom_filter_ + gnu_maskwords_); 2879 // amend chain for symndx = header[1] 2880 gnu_chain_ = gnu_bucket_ + gnu_nbucket_ - 2881 reinterpret_cast<uint32_t*>(load_bias + d->d_un.d_ptr)[1]; 2882 2883 if (!powerof2(gnu_maskwords_)) { 2884 DL_ERR("invalid maskwords for gnu_hash = 0x%x, in \"%s\" expecting power to two", 2885 gnu_maskwords_, get_realpath()); 2886 return false; 2887 } 2888 --gnu_maskwords_; 2889 2890 flags_ |= FLAG_GNU_HASH; 2891 break; 2892 2893 case DT_STRTAB: 2894 strtab_ = reinterpret_cast<const char*>(load_bias + d->d_un.d_ptr); 2895 break; 2896 2897 case DT_STRSZ: 2898 strtab_size_ = d->d_un.d_val; 2899 break; 2900 2901 case DT_SYMTAB: 2902 symtab_ = reinterpret_cast<ElfW(Sym)*>(load_bias + d->d_un.d_ptr); 2903 break; 2904 2905 case DT_SYMENT: 2906 if (d->d_un.d_val != sizeof(ElfW(Sym))) { 2907 DL_ERR("invalid DT_SYMENT: %zd in \"%s\"", 2908 static_cast<size_t>(d->d_un.d_val), get_realpath()); 2909 return false; 2910 } 2911 break;
解析完后就是重定位了,根据不同的条件给relocate函数传入不同的参数!
if (android_relocs_ != nullptr) { 3300 // check signature 3301 if (android_relocs_size_ > 3 && 3302 android_relocs_[0] == 'A' && 3303 android_relocs_[1] == 'P' && 3304 android_relocs_[2] == 'S' && 3305 android_relocs_[3] == '2') { 3306 DEBUG("[ android relocating %s ]", get_realpath()); 3307 3308 bool relocated = false; 3309 const uint8_t* packed_relocs = android_relocs_ + 4; 3310 const size_t packed_relocs_size = android_relocs_size_ - 4; 3311 3312 relocated = relocate( 3313 version_tracker, 3314 packed_reloc_iterator<sleb128_decoder>( 3315 sleb128_decoder(packed_relocs, packed_relocs_size)), 3316 global_group, local_group); 3317 3318 if (!relocated) { 3319 return false; 3320 } 3321 } else { 3322 DL_ERR("bad android relocation header."); 3323 return false; 3324 } 3325 } 3326 3327 #if defined(USE_RELA) 3328 if (rela_ != nullptr) { 3329 DEBUG("[ relocating %s ]", get_realpath()); 3330 if (!relocate(version_tracker, 3331 plain_reloc_iterator(rela_, rela_count_), global_group, local_group)) { 3332 return false; 3333 } 3334 } 3335 if (plt_rela_ != nullptr) { 3336 DEBUG("[ relocating %s plt ]", get_realpath()); 3337 if (!relocate(version_tracker, 3338 plain_reloc_iterator(plt_rela_, plt_rela_count_), global_group, local_group)) { 3339 return false; 3340 } 3341 } 3342 #else 3343 if (rel_ != nullptr) { 3344 DEBUG("[ relocating %s ]", get_realpath()); 3345 if (!relocate(version_tracker, 3346 plain_reloc_iterator(rel_, rel_count_), global_group, local_group)) { 3347 return false; 3348 } 3349 } 3350 if (plt_rel_ != nullptr) { 3351 DEBUG("[ relocating %s plt ]", get_realpath()); 3352 if (!relocate(version_tracker, 3353 plain_reloc_iterator(plt_rel_, plt_rel_count_), global_group, local_group)) { 3354 return false; 3355 } 3356 } 3357 #endif
relocate函数内部同样也是switch case结构,根据不同的type修改reloc表、符号表等,还有修改系统函数、全局/静态变量的绝对地址!
switch (type) { 2579 case R_GENERIC_JUMP_SLOT: 2580 count_relocation(kRelocAbsolute); 2581 MARK(rel->r_offset); 2582 TRACE_TYPE(RELO, "RELO JMP_SLOT %16p <- %16p %s\n", 2583 reinterpret_cast<void*>(reloc), 2584 reinterpret_cast<void*>(sym_addr + addend), sym_name); 2585 2586 *reinterpret_cast<ElfW(Addr)*>(reloc) = (sym_addr + addend); 2587 break; 2588 case R_GENERIC_GLOB_DAT: 2589 count_relocation(kRelocAbsolute); 2590 MARK(rel->r_offset); 2591 TRACE_TYPE(RELO, "RELO GLOB_DAT %16p <- %16p %s\n", 2592 reinterpret_cast<void*>(reloc), 2593 reinterpret_cast<void*>(sym_addr + addend), sym_name); 2594 *reinterpret_cast<ElfW(Addr)*>(reloc) = (sym_addr + addend); 2595 break; 2596 case R_GENERIC_RELATIVE: 2597 count_relocation(kRelocRelative); 2598 MARK(rel->r_offset); 2599 TRACE_TYPE(RELO, "RELO RELATIVE %16p <- %16p\n", 2600 reinterpret_cast<void*>(reloc), 2601 reinterpret_cast<void*>(load_bias + addend)); 2602 *reinterpret_cast<ElfW(Addr)*>(reloc) = (load_bias + addend); 2603 break; 2604 case R_GENERIC_IRELATIVE: 2605 count_relocation(kRelocRelative); 2606 MARK(rel->r_offset); 2607 TRACE_TYPE(RELO, "RELO IRELATIVE %16p <- %16p\n", 2608 reinterpret_cast<void*>(reloc), 2609 reinterpret_cast<void*>(load_bias + addend)); 2610 { 2611 #if !defined(__LP64__) 2612 // When relocating dso with text_relocation .text segment is 2613 // not executable. We need to restore elf flags for this 2614 // particular call. 2615 if (has_text_relocations) { 2616 if (phdr_table_protect_segments(phdr, phnum, load_bias) < 0) { 2617 DL_ERR("can't protect segments for \"%s\": %s", 2618 get_realpath(), strerror(errno)); 2619 return false; 2620 } 2621 } 2622 #endif 2623 ElfW(Addr) ifunc_addr = call_ifunc_resolver(load_bias + addend);
总结:所谓的重定位,本质就是把函数或变量在内存中的地址改正,方便其他代码找到和使用!想想x86架构下jmp指令为啥是相对偏移,而不是绝对地址了?就是因为代码加载到内存的地址不固定,但是代码之间的偏移是固定的,所以jmp指令只能用偏移而不是绝对地址!如果要用绝对地址,加载的时候就要把函数或变量的在内存的绝对地址标注清楚!
(3)加入soInfo结构体
此时只有loader这个so是被操作系统linker加载的,所以也只有loader的so能和art交互(猜猜为什么?)!为了让自己关键代码的so也能和art交互,需要替换掉loader的so的属性,把其改成关键代码so的属性; PS:soInfo记录了内存中so结构体的核心属性,完全可以用来检索so,核心属性列举如下:
装载链接期间主要使用的成员: 装载信息 const ElfW(Phdr)* phdr; size_t phnum; ElfW(Addr) base; size_t size; 符号信息 const char* strtab; ElfW(Sym)* symtab;
下一个soInfo节点
重定位信息 ElfW(Rel)* plt_rel; size_t plt_rel_count; ElfW(Rel)* rel; size_t rel_count; init 函数和 finit 函数 Linker_function_t* init_array; size_t init_array_count; Linker_function_t* fini_array; size_t fini_array_count; Linker_function_t init_func; Linker_function_t fini_func; 运行期间主要使用的成员: 导出符号查找(dlsym): const char* strtab; ElfW(Sym)* symtab; size_t nbucket; size_t nchain; unsigned* bucket; unsigned* chain; ElfW(Addr) load_bias; 异常处理: unsigned* ARM_exidx; size_t ARM_exidx_count; load_library 在为 SO 分配 soinfo 后,会将装载结果更新到 soinfo 中,后面的链接过程就可以直接使用soinfo的相关字段去访问 SO 中的信息。
操作系统为了便于管理,还会把soInfo组织成链表形式,所以在soinfo_alloc函数中就会把新生成的soInfo结构体加入链表,开发人员只要遍历链表就能查找到所有已经加载的so了!这里扩展一下:windows下做安全防护时,经常会把进程或线程的链表断开,让逆向破解人员找不到自己的进程或线程! 修改soInfo结构体的时候建议通过如下基址+偏移的形式修改!每个偏移代表什么含义可通过AOSP源码查询得到!
3、最后总结一下一般加壳的原理流程:
- 关键文件可以不是elf格式,比如自定义的文件格式;还可以对关键代码加密
- 执行时先装载loader,由于loader本身是标准的so,所以直接用操作系统的linker加载即可(就是调用操作系统提供现成的dlopen等API)!
- loader被加载后,会调用callConstractor函数,该函数内部先后依次执行init、initArray、jni_onload等函数,所以加载自己关键文件、解密代码、还原成elf结构、修复链接/重定位、加入soInfo链表(“解壳”三部曲:加载、链接、替换soInfo结构体)等工作都可以在这三个方法中做。具体代码完全可以参考android源码,结合自己文件实际格式即可!
这里借(抄)鉴(袭)某位大佬整理的ELF格式的解析图凑数:
附上看雪r0ysue大佬的加壳demo 链接:https://pan.baidu.com/s/1MZSjotH8cs7wrOIAiZM5NQ 提取码:kjvm 思路就是自己定义的loader完成了操作系统linker的功能!
脱壳点:
1、mprotect函数:以前关注这个函数是在反调试阶段,比如把内存设置为不可执行,等自己代码快要执行到目标内存区域时再调用mprotect把内存设置为可执行。如果检测到被ida调试,就不改内存属性为可执行,让ida调试时跳转到这里时就会报signal异常!除了反调试,在加壳领域mprotect也能用上。比如在脱壳时,肯定需要一段内存存放代码(以任何形式申请的内存默认都没有执行权限),并且同样需要调用mprotect函数把内存设置为可执行!所以逆向时可以通过hook mprotect函数,看看第三个参数是不是设置内存为可执行。同时如果第二个参数len也不小,那么这个mprotect很有可能就是在脱壳用了!参考的hook代码如下:
2、soInfo结构体:因为每个加载的so都会生成soInfo结构体,然后加入链表中,所以soInfo链表保存了所有已加载so的soInfo,从这个链表就能找到自己想要so的信息,然后借此脱壳;poc代码如下: 从maps文件查找linker64的地址,然后继续找solist的地址,最后遍历soInfo!
void initlinker(){ char line[1024]; int *end; long* base; int n = 1; FILE *fp = fopen("/proc/self/maps", "r"); while (fgets(line, sizeof(line), fp)) { if (strstr(line, "linker64")) { if (n == 1) { start = reinterpret_cast<int *>(strtoul(strtok(line, "-"), NULL, 16)); end = reinterpret_cast<int *>(strtoul(strtok(NULL, " "), NULL, 16)); } else { strtok(line, "-"); end = reinterpret_cast<int *>(strtoul(strtok(NULL, " "), NULL, 16)); } n++; } } int soheaderoff = findsym("/system/bin/linker64", "__dl__ZL6solist"); soheader = reinterpret_cast<long *>((char *) start + soheaderoff); } void *findsobase(const char *soname) { long* base; long* soinfo; int n=0; for (_QWORD * result = reinterpret_cast<uint64 *>(*(_QWORD *) soheader); result; result = (_QWORD *)result[5] ){ if(*(_QWORD *) ((__int64) result + 408)!= 0&&strcmp(reinterpret_cast<const char *>(*(_QWORD *) ((__int64) result + 408)), soname)==0) { base= reinterpret_cast<long *>(*(_QWORD *) ((char *) result + 16)); long* size= reinterpret_cast<long *>(*(_QWORD *) ((char *) result + 24)); long* load_bias= reinterpret_cast<long *>(*(_QWORD *) ((char *) result + 256)); const char* name= reinterpret_cast<const char *>(*(_QWORD *) ((__int64) result + 408)); soinfo= reinterpret_cast<long *>(result); break; } } return soinfo; }
3、mmap:从磁盘读取大文件后为了节约内存,用mmap是最合适不过的了!大文件很有可能是加壳的so!
4、so加载加壳总结:
为了增加内存中so被识别和dump的难度,可以把elf header或section header删除,或者用异或的方式加密混淆,比如下面这样的:
标红部分是改过的,再用ida打开就不行了!总的来说,建议用异或而不是直接删除,能让逆向人员dump还原so的时候怀疑人生: 我是不是dump的位置选错了?代码上就类似这种:
bool LoadSegments() { for (size_t i = 0; i < phdr_num_; ++i) { const ElfW(Phdr) *phdr = &phdr_table_[i]; if (phdr->p_type != PT_LOAD) { continue; } // Segment addresses in memory. ElfW(Addr) seg_start = phdr->p_vaddr^0x5a + load_bias_; ElfW(Addr) seg_end = seg_start + phdr->p_memsz^0x5a; ElfW(Addr) seg_page_start = PAGE_START(seg_start); ElfW(Addr) seg_page_end = PAGE_END(seg_end); ElfW(Addr) seg_file_end = seg_start + phdr->p_filesz^0x5a; // File offsets. ElfW(Addr) file_start = phdr->p_offset^0a5x; ElfW(Addr) file_end = file_start^0x5a + phdr->p_filesz; ElfW(Addr) file_page_start = PAGE_START(file_start); ElfW(Addr) file_length = file_end^0x5a - file_page_start; long* pp= reinterpret_cast<long *>(seg_page_start);
选一些自认为比较重要的字段在完全加载后再异或0x5a(当然其他数字也行),达到迷惑逆向人员的目的!
参考:
1、https://cloud.tencent.com/developer/article/1071358 android linker与so加壳技术
2、https://www.cnblogs.com/r0ysue/p/15398936.html 基于linker实现so加壳 上篇
https://zhuanlan.zhihu.com/p/413630468 下篇
3、http://aospxref.com/android-8.1.0_r81/ AOSP源码查询
4、https://wwm0609.github.io/2020/06/12/android-linker/ android dynamic linking简介