本文来自@老疯一世的投稿
开发者提交给Appstore发布的App,都经过FairPlay作为版权保护而加密,这样可以保证机器上跑的应用是苹果审核过的,也可以管理软件授权,起到DRM的作用。经过加密的Store App也无法通过Hopper等反编译静态分析,无法Class-Dump,在逆向分析过程中需要对加密的二进制文件进行解密才可以进行静态分析,这一过程就是大家熟知的砸壳(脱壳)。
Mach-O文件在被操作系统内核加载器加载运行时会解密,我们可以通过如下命令判断是否加密,我们在iOS逆向基础Mach-O文件(1)文章中介绍了load command,通过LC_ENCRYPTION_INFO即可判断,如下:
# otool -l BINARY | grep -A 4 LC_ENCRYPTION_INFO
如果有加密则会有如下输出:
# otool -l OTHER_BINARY | grep -A 4 LC_ENCRYPTION_INFO cmd LC_ENCRYPTION_INFO cmdsize 20 cryptoff 16384 cryptsize 10502144 cryptid 1
解密的工具很多有stefanesser的Dumpdecrypted,也有monkey的frida-ios-dump,还有AppCrackr、Clutch、Crackulous简单粗暴的纯UI砸壳方式,砸壳工具的使用,并不是本文的重点,需要的同学可以自行google,很多教程。
在研究逆向的过程中一直喜欢探究背后的原理,那么这些工具解密的原理是什么呢?iOS/macOS 系统中,可执行文件、动态库等,都使用 DYLD 加载执行。在 iOS 系统中使用 DYLD 载入 App 时,会先进行 DRM 检查,检查通过则从 App 的可执行文件中,选择适合当前设备架构的 Mach-O 镜像进行解密,然后载入内存执行,这个程序并没有解密的逻辑,当他被执行时,其实加载器已经完成了目标mach-o文件的装载工作,对应的解密工作也已经完成。解密工具本生并不做解密,这些工具所做的工作是,遍历loadcommand中所有LC_ENCRYPTION_INFO或LC_ENCRYPTION_INFO_64的信息,将对应解密后的数据从内存中dump出来,复写到mach-o文件中,生成新的镜像文件,从而达到解密的效果。
本文以dumpdecrypted.dylib (德国安全专家stefanesser开发的一款砸壳工具)为例,探索解密背后的原理,dumpdecrypted使用,工作流程如图,源码十分简单,带注释一共只有242行,1个 C 函数。下边就来分析一下它的实现原理。
源文件第一行代码是个宏定义,这个宏的功能就是 :把数字从小端序转成大端序(这里不对小端绪大端序算法展开讲解,需要复习的知识点的自行还知识债)。
#define swap32(value) (((value & 0xFF000000) >> 24) | ((value & 0x00FF0000) >> 8) | ((value & 0x0000FF00) << 8) | ((value & 0x000000FF) << 24) )
接下来是源文件中唯一的一个函数,也是最长的函数dumptofile,__attribute__ GCC的关键字,用于为函数设置特殊属性, __attribute__((constructor))修饰的 dumptofile 函数,会在+load 方法之后, main 函数执行之前被自动调用。dumpdecrypted.dylib的init函数dumptofile是由下图doModInitFunctions函数调用的,通过苹果的代码可知init函数的5个参数:
tips:dyld初始化的入口是 initializeMainExecutable,该函数会执行 ImageLoader::runInitializers 方法,然后会调用 ImageLoader::doInitialization,最后会执行到 doModInitFunctions 方法。
__attribute__((constructor)) void dumptofile(int argc, const char **argv, const char **envp, const char **apple, struct ProgramVars *pvars) { ...... //省略 ...... } //Apple 源码 //http://www.opensource.apple.com/source/dyld/dyld-195.6/src/ImageLoaderMachO.cpp void ImageLoaderMachO::doModInitFunctions(const LinkContext& context) { ...... //省略 ...... //省略 ...... }
这五个参数前2个并不陌生在程序入口main函数中可以看到,我们通过阅读源文件了解下这五个参数:
//Apple 源码 //http://www.opensource.apple.com/source/dyld/dyld-195.6/src/ImageLoader.h struct ProgramVars { const void* mh; int* NXArgcPtr; const char*** NXArgvPtr; const char*** environPtr; const char** __prognamePtr; }; struct LinkContext { ...... //省略 ...... int argc; const char** argv; const char** envp; const char** apple; ProgramVars programVars; }
接下来我们进入这个函数内部,首先可以看到定义了一堆变量:
struct load_command *lc; struct encryption_info_command *eic; struct fat_header *fh; struct fat_arch *arch; struct mach_header *mh; char buffer[1024]; char rpath[4096],npath[4096]; /* should be big enough for PATH_MAX */ unsigned int fileoffs = 0, off_cryptid = 0, restsize; int i,fd,outfd,r,n,toread; char *tmp;
完成初始准备工作后,接下来通过文件头判断二进制文件架构,这里通过检查 magic 字段来检查当前镜像架构,在Mach-O 文件中,LoadCommands位于 Header 之后,所以这里以 Header 的大小作为偏移算出来 LoadCommand 的起始地址并赋值给 lc。
tips:mh 是一个 struct mach_header 结构体的指针,lc 是一个指向 struct load_command 结构体的指针,其在iOS逆向基础Mach-O文件(1)中有介绍。
/* detect if this is a arm64 binary */ if (pvars->mh->magic == MH_MAGIC_64) { lc = (struct load_command *)((unsigned char *)pvars->mh + sizeof(struct mach_header_64)); printf("[+] detected 64bit ARM binary in memory. "); } else { /* we might want to check for other errors here, too */ lc = (struct load_command *)((unsigned char *)pvars->mh + sizeof(struct mach_header)); printf("[+] detected 32bit ARM binary in memory. "); }
之后是一个很长的for循环,别怕我们从最外层一步步往里看,可以看到最外层是循环遍历每一个 LoadComand,如果存在 LC_ENCRYPTION_INFO 这个 Command,说明当前镜像是可能是进行过加密的,执行解密操作。否则代表当前镜像未加密,无需解密,程序结束运行。
/* searching all load commands for an LC_ENCRYPTION_INFO load command */ for (i=0; imh->ncmds; i++) { /*printf("Load Command (%d): x ", i, lc->cmd);*/ if (lc->cmd == LC_ENCRYPTION_INFO || lc->cmd == LC_ENCRYPTION_INFO_64) { ..... } lc = (struct load_command *)((unsigned char *)lc+lc->cmdsize); } printf("[-] This mach-o file is not encrypted. Nothing was decrypted. "); _exit(1);
接下来我们再看,找到 LC_ENCRYPTION_INFO 之后,将 lc 强转为 struct encryption_info_command * 并赋值给 eic, 之后判断 cryptid 是否 0, 0 则表示未加密,跳出循环,继续下一个,为1则继续执行。
eic = (struct encryption_info_command *)lc; /* If this load command is present, but data is not crypted then exit */ if (eic->cryptid == 0) { break; }
首先计算 cryptid 距镜像开始的偏移,将这个值赋给初始定义的变off_cryptid:
off_cryptid=(off_t)((void*)&eic->cryptid - (void*)pvars->mh); printf("[+] offset to cryptid found: @%p(from %p) = %x ", &eic->cryptid, pvars->mh, off_cryptid); printf("[+] Found encrypted data at address x of length %u bytes - type %u. ", eic->cryptoff, eic->cryptsize, eic->cryptid);
然后从初始化传入的参数获取镜像路径,然后以只读模式打开镜像文件,读入镜像文件头信息:
接着判断读出来的 FAT Header 中的 magic 字段,如果是 FAT_CIGAM,则表明当前镜像是一个 FAT Binary。否则判断是否是一个纯 Mach-O 镜像。如果都不是,则文件格式未知,程序结束,其中 nfat_arch 字段,表示fat_header 之后,包含多少个 fat_arch 结构体,也就是包含多少个 Mach-O 镜像。如果镜像是 FAT Binary,循环遍历每一个fat_arch,如果找到一个 fat_arch 中 cputype 和 subcputype 与传入的 mach_header(mh) 一致,则表明找到了当前加载镜像在 FAT Binary 中的位置。此时设置 fileoffs = (arch->offset)。注意,此处的 cputype、subcputype 和 offset 需要使用之前定义的 swap32 宏转为大端序再进行判断,获取当前架构的文件偏移位置。
生成解密镜像文件:在dumpdecrypted.dyld所在目录下以可读可写O_RDWR方式创建解密文件,如果在生成失败,则换个文件名重新生成,如果还失败,报错退出。
计算加密数据的起始地址,拷贝加密数据之前的所有数据,并写入解密文件:
将解密后的后的数据写入解密文件中:
把剩下的部分没加密的其他文件写入到解密文件中:
最后,把已解密架构的 Mach-O header 中的 cryptid 字段置为 0, 表示未加密,关闭原始 Mach-O文件,关闭解密文件,程序退出:
至此我们分析完了240行dumpdecrypted解密源程序,通过实现原理,做自己的解密工具或者对已有应用加固比如采用__RESTRICTED方式阻止动态库注入,当然其他工具可以绕过,这里我想说没有绝对的安全,只有相对的安全,预告一下我将在iOS逆向基础(3)分享动态库注入实现过程。
附相关资料 :
https://github.com/stefanesser/dumpdecrypted
http://www.opensource.apple.com/source/dyld/dyld-195.6/src
http://blog.dornea.nu/2014/10/29/howto-ios-apps-static-analysis/
https://www.tylinux.com/2018/03/12/how-dumpdecrypted-dylib-works/
http://bbs.iosre.com/t/dumpdecrypted/465Intel
iOS App Reverse Engineering by 五子棋