目录

iOS逆向学习(二)砸壳原理之dumpdecrypted

此文为加密收费内容添加我微信支付后可看:

https://tool.lu/netcard/

iOS逆向学习(二)砸壳原理之dumpdecrypted

1、基本原理

从AppStore中下载的包,是被苹果使用FairPlay技术加密的,没办法直接重签名给其他人用,也不能做IDA分析之类的逆向操作。所以为了得到没有加密的包,就需要进行砸壳操作。

看完Mach-O文件跟dyld加载介绍后,使劲拍拍脑袋能想到两种砸壳思路:

  • 1、直接破解FairPlay加密技术:很可惜暂时做不到,还没有公开的破解方案出来
  • 2、从APP的加载运行入手,越过解密阶段,直接从内存中读取被dyld解密后的内容

所以目前常见的砸壳工作,都是使用第二种思路来做的,这里我们以dumpdecrypted为例进行分析。

2、dumpdecrypted

dumpdecrypted分为初代stefanesser做的版本,和后面conradev改进的版本。但其原理基本不变,改进版主要是为了能够dump出动态加载库。咱们这次的分析以改进版的为例,先看使用步骤:

  • 1、make生成.dylib文件
  • 2、使用ssh把dylib文件放入目标app的沙盒路径下
  • 3、执行砸壳并导出砸壳后的ipa文件

除了使用步骤以外,dumpdecrypted还要求手机中已经安装了cycript。所以我们不难看出,dumpdecrypted是通过dylid的方式,介入到dyld的加载过程中的。

然后再看源码,主要定义了三个函数:dumptofileimage_addeddumpexecutable,没有main函数。但可以看出这三个函数大致的调用关系,是dumpexecutable -> image_added -> dumptofile。继续分析,dumpexecutable是如何被调用的呢。答案在于其方法定义上:

1
2
3
__attribute__((constructor))
static void dumpexecutable() {
}

把换行去掉,就成了

1
2
__attribute__((constructor)) static void dumpexecutable() {
}

其中比较特殊的是__attribute__((constructor))这一段,可以大致理解成使用这段话,让我们新定义的dumpexecutable方法,在main函数之前被执行。而其得以做到的原因,就在于首先constructor函数,是在main函数之前执行的,而__attribute__修饰,又使得我们新定义的方法,可以和constructor一道,在main函数之前执行。这部分更详细的解释可以参照GUNC的文档

The constructor attribute causes the function to be called automatically before execution enters main ().

所以重新梳理一下大致的调用关系:constructor -> dumpexecutable -> image_added -> dumptofile,需要注意这里说的调用关系仅仅是大致的。

弄清了整个的启动时机后,再看dumpexecutable函数内部,实际的操作只做了一件事:_dyld_register_func_for_add_image(&image_added);。因为咱们已经意识到整个砸壳过程是跟dyld有关的,所以可以从dyld源码中找到对_dyld_register_func_for_add_image的解释

1
2
3
4
5
6
7
8
9
/*
 * _dyld_register_func_for_add_image registers the specified function to be
 * called when a new image is added (a bundle or a dynamic shared library) to
 * the program.  When this function is first registered it is called for once
 * for each image that is currently part of the program.
 */
void
_dyld_register_func_for_add_image(
void (*func)(const struct mach_header *mh, intptr_t vmaddr_slide))

大致的意思,就是说注册了一个监听方法,用于监听bundle或者动态加载库的加载事件,而且加载事件,对于每个bundle或者动态库仅会触发一次。到这里,我们就可以梳理出整个砸壳工具的逻辑顺序了:

  • 1、伴随constructor函数的调用,在main函数执行前,调用dumpexecutable函数
  • 2、在dumpexecutable函数中,注册bundle或动态库的加载事件监听,而且其响应方法是image_added函数
  • 3、当bundle或者动态库被加载时,触发事件监听,调用image_added方法,并向其传递header结构体指针,和另外一个不知道有什么用的参数slide
  • 4、然后在image_added方法内,调用dumptofile函数,进行真正的文件导出工作

所以到这里,我们转向dumptofile函数,看一下其内部实现。越过前面的变量定义,首先看这一段

1
2
3
4
5
6
7
8
9
/* extract basename */
tmp = strrchr(rpath, '/');
printf("\n\n");
if (tmp == NULL) {
	printf("[-] Unexpected error with filename.\n");
	_exit(1);
} else {
	printf("[+] Dumping %s\n", tmp+1);
}

这块的作用,是把路径进行裁剪,得到目标APP的文件名。然后下面这一段:

1
2
3
4
5
6
7
8
/* detect if this is a arm64 binary */
if (mh->magic == MH_MAGIC_64) {
	lc = (struct load_command *)((unsigned char *)mh + sizeof(struct mach_header_64));
	printf("[+] detected 64bit ARM binary in memory.\n");
} else { /* we might want to check for other errors here, too */
	lc = (struct load_command *)((unsigned char *)mh + sizeof(struct mach_header));
	printf("[+] detected 32bit ARM binary in memory.\n");
}

大致的作用,是通过对Mach-O文件中,header的magic字段进行判断,判断当前可执行文件是32还是64位的。然后,因为Mach-O文件的内容是连续的,所以可以通过header指针加上header区间大小的方式,拿到load commands的指针。

拿到了指针之后,再下一段代码

1
2
for (i=0; i<mh->ncmds; i++) {
	if (lc->cmd == LC_ENCRYPTION_INFO || lc->cmd == LC_ENCRYPTION_INFO_64) {

通过mh->ncmds得到load commands数量,对所有load command进行遍历。而在for循环内部,则对load command进行类型判断,只有当是LC_ENCRYPTION_INFO或者LC_ENCRYPTION_INFO_64类型时,才进入下一步。在Mach-O文档里没有找到LC_ENCRYPTION_INFO,但是可以google一下,大致的意思是加密相关的。所以我们可以把这个if语句,简单的理解成过滤加密相关的load command。然后继续深入if语句内部,首先是这么一段:

1
2
3
4
5
eic = (struct encryption_info_command *)lc;
/* If this load command is present, but data is not crypted then exit */
if (eic->cryptid == 0) {
	break;
}

为了理解这段,得先看encryption_info_command的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/*
 * The encryption_info_command contains the file offset and size of an
 * of an encrypted segment.
 */
struct encryption_info_command {
   uint32_t	cmd;		/* LC_ENCRYPTION_INFO */
   uint32_t	cmdsize;	/* sizeof(struct encryption_info_command) */
   uint32_t	cryptoff;	/* file offset of encrypted range */
   uint32_t	cryptsize;	/* file size of encrypted range */
   uint32_t	cryptid;	/* which enryption system,
				   0 means not-encrypted yet */
};

然后结合这段代码的注释理解下,大致意思可能是说从LC_ENCRYPTION_INFO类型的load command,取出数据的加密状态,如果不是被加密的状态就中断执行。继续往下看这一段:

1
2
3
off_cryptid=(off_t)((void*)&eic->cryptid - (void*)mh);
printf("[+] offset to cryptid found: @%p(from %p) = %x\n", &eic->cryptid, mh, off_cryptid);
printf("[+] Found encrypted data at address %08x of length %u bytes - type %u.\n", eic->cryptoff, eic->cryptsize, eic->cryptid);

第一句的意思,是通过(void*)eic->cryptid减去(void*)mh的方式,得到cryptid偏移量。然后最后一句,输出的日志里表示从eic->cryptoff开始,eic->cryptsize大小的区域,都是已经被dyld解密并加载后的内容。

继续看源码,下面跟着的一大段,主要用处是读取Mach-O文件的header内容,所以就不贴源码了。但其中比较特殊的一段if语句:

1
2
/* Is this a FAT file - we assume the right endianess */
if (fh->magic == FAT_CIGAM) {

这段的作用,是区分FAT类型Mach-O文件,然后重定位到真正的header地址。再往下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
strlcpy(npath, tmp+1, sizeof(npath));
strlcat(npath, ".decrypted", sizeof(npath));
strlcpy(buffer, npath, sizeof(buffer));

printf("[+] Opening %s for writing.\n", npath);
outfd = open(npath, O_RDWR|O_CREAT|O_TRUNC, 0644);
if (outfd == -1) {
	if (strncmp("/private/var/mobile/Applications/", rpath, 33) == 0) {
		printf("[-] Failed opening. Most probably a sandbox issue. Trying something different.\n");
		
		/* create new name */
		strlcpy(npath, "/private/var/mobile/Applications/", sizeof(npath));
		tmp = strchr(rpath+33, '/');
		if (tmp == NULL) {
			printf("[-] Unexpected error with filename.\n");
			_exit(1);
		}
		tmp++;
		*tmp++ = 0;
		strlcat(npath, rpath+33, sizeof(npath));
		strlcat(npath, "tmp/", sizeof(npath));
		strlcat(npath, buffer, sizeof(npath));
		printf("[+] Opening %s for writing.\n", npath);
		outfd = open(npath, O_RDWR|O_CREAT|O_TRUNC, 0644);
	}
	if (outfd == -1) {
		perror("[-] Failed opening");
		printf("\n");
		_exit(1);
	}
}

这块是创建一个文件路径并open得到句柄,里面一大堆复杂的if判断,主要是用来处理文件操作失败的情况。而这个新创建的文件,就是以后我们会得到的砸壳输出结果文件。继续往下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* calculate address of beginning of crypted data */
n = fileoffs + eic->cryptoff;
	
restsize = lseek(fd, 0, SEEK_END) - n - eic->cryptsize;			
lseek(fd, 0, SEEK_SET);
	
printf("[+] Copying the not encrypted start of the file\n");
/* first copy all the data before the encrypted data */
while (n > 0) {
	toread = (n > sizeof(buffer)) ? sizeof(buffer) : n;
	r = read(fd, buffer, toread);
	if (r != toread) {
		printf("[-] Error reading file\n");
		_exit(1);
	}
	n -= r;
	
	r = write(outfd, buffer, toread);
	if (r != toread) {
		printf("[-] Error writing file\n");
		_exit(1);
	}
}
	
/* now write the previously encrypted data */
printf("[+] Dumping the decrypted data into the file\n");
r = write(outfd, (unsigned char *)mh + eic->cryptoff, eic->cryptsize);
if (r != eic->cryptsize) {
	printf("[-] Error writing file\n");
	_exit(1);
}

这块就是向输出文件写入数据了,主要分成三步:

  • 第一步:计算并定位到解密数据的起始位置
  • 第二步:把解密数据位置前的数据,写入到输出文件内,这部分的数据估计是包含了header的标识位信息
  • 第三步:把解密数据写入到文件内。

然后再往下,直到整个dumptofile函数结束,剩下的代码段的作用,在于把剩下的数据一并写入输出文件里,并关闭文件句柄。

至此整个dumpdecrypted砸壳工具的代码已经分析完了,但是也留下了这几个问题:

  • *在判断加密状态那一段,当数据处于非加密的时候,为什么是break,而不是continue*
  • *在数据读取及写入阶段,加密数据段之前、之后的内容,具体是哪些数据*

这些问题估计可以从Mach-O文件格式里面找到答案,所以还得再回头研究Mach-O文件。

3、参考资料

  • Mach-O文档:https://web.archive.org/web/20090901205800/http://developer.apple.com/mac/library/documentation/DeveloperTools/Conceptual/MachORuntime/Reference/reference.html#//apple_ref/doc/uid/TP40000895-CH248-95908
  • dyld源码:https://github.com/opensource-apple/dyld