一种linux平台下算法库二进制文件加密方法探讨

        最近做项目遇到一个需求,需要把我们的图像算法库提供给客户使用,为防止算法库被对方滥用和逆向破解,需要对算法库二进制文件做加密处理以及加密狗绑定,同时防止库文件被反调试跟踪。算法库加密可以借助开源软件 openssl实现,加密狗的使用也很简单,从加密狗映射,整个过程中加密后的算法由外壳进程直接解密到内存中,并由驱动程序直接映射至调用进程的地址空间中,完全不会暴露给文件系统,配合一定的反调试技术,使得用户很难直接拿到算法库的二进制代码,从而大大提高对算法库文件的保护,防止恶意破解。

1. 借助内核编译initramfs的方式将加密后的算法库二进制文件嵌入到外壳中

        利用开源的openssl开发例程很容易实现对算法库文件的加密和解密,接下来的问题是如何将加密后的算法库文件套上外壳,多年的内核编译经验让我想到内核的一些奇技淫巧,早些年简单研究过内核使用cpio打包initramfs的一些原理,内核也是将打包后的文件系统嵌套进vmlinux中的,于是借鉴一下代码,套壳的第一步就完成了,代码如下:

.section .init.data,"a" .globl __idata_start __idata_start: .incbin "../deplib/encrypt.so" .globl __idata_end __idata_end:

        代码里的关键指令是incbin汇编命令,该命令可以用来包含可执行文件及其他任意数据,文件内容将按字节逐一添加到当前 ELF 节中,原样包含不进行任何汇编,同时代码中定义必要的全局变量定位该段位置。这样就可以将加密后的算法文件encrypt.so嵌入到外壳程序的ELF段中,并在外壳程序初始化时将其解密至外壳进程的内存中。

2. 常规的动态库载入方法和问题

        解密后的算法库文件位于内存中,如何将其作为动态库文件导入到进程地址空间中是接下来要处理的问题。常规的动态库加载方案主要有2种,第一种是最常用的方法,程序编译时由gcc指定依赖的动态库信息会记录到ELF文件中,并可由readelf -d获取,应用程序加载时ld.so会根据指定的路径加载相应的库。第二种方法是利用libdl库的dlopen/dlclose/dlsym等接口,在程序执行过程中动态载入库文件并解析其内部符号等。这2种方法有一个共同的问题是动态库的载入必须通过文件系统的方式导入进来,而一旦将动态库暴露给文件系统,技术人员就很容易通过文件系统拿到算法库的二进制文件进行反编译,且这2种方式都可以通过cat /proc/{pid}/maps直接定位到导入的动态库的具体位置,如下所示:

cat /proc/3639/maps  00400000-00402000 r-xp 00000000 b3:02 797096                             /root/test/shtest 00411000-00412000 rw-p 00001000 b3:02 797096                             /root/test/shtest 00412000-00433000 rw-p 00000000 00:00 0                                  [heap] ... 7f9b56d000-7f9b57c000 ---p 00017000 b3:02 393672                         /lib/aarch64-linux-gnu/libpthread-2.23.so 7f9b57c000-7f9b57d000 r--p 00016000 b3:02 393672                         /lib/aarch64-linux-gnu/libpthread-2.23.so 7f9b57d000-7f9b57e000 rw-p 00017000 b3:02 393672                         /lib/aarch64-linux-gnu/libpthread-2.23.so

3. 间接借助文件系统的帮助将动态库导入进程地址空间

        如何不通过文件系统将动态库映射到进程的地址空间就是接下来需要面对的问题,显而易见的想法是硬杠glibc,直接改写dlopen等接口的实现,将其动态库的载入脱离文件系统,后来发现自己迷失在glibc盘根错节的层层套用和复杂的符号解析引用中。之后还考虑借鉴内核的vdso及uselib机制,但对ELF文件的符号解析并不是一件短时间内容易做到并可以确保无误的事情。最好的方法就是仍然沿用dlopen那一套机制,由glibc来处理动态库的符号解析和引用问题。踌躇之际突然想到驱动程序的设备节点也是一种文件,也有自己的file_operations操作,也支持read/write/mmap等基本的文件操作接口,把它用作动态库的代理入口交由dlopen处理,由驱动程序配合完成dlopen对动态库的所有操作,用这种斗转星移的方法,将处于用户态内存中的算法库映射到用户进程地址空间中,间接脱离文件系统的支持。

3.1 将算法库二进制文件导入到内核空间

        首先通过ioctl将用户态下解密后的算法库文件导入到驱动程序申请的一段内存空间中,如果内存足够,使用dma_alloc_coherent直接从CMA区域拿到一段连续内存即可。更加普适的方法是使用alloc_page申请足够的物理页帧(成功概率高于连续内存段),将用户态算法库数据分页拷贝至内核空间中,然后使用vmap机制将page页面数组对应的物理内存映射到vmalloc地址空间中,核心代码摘录如下:

wxcoder_dev->pages = kmalloc(sizeof(struct page *) * npages, GFP_KERNEL); 	if (!wxcoder_dev->pages) 		goto oom; 	for (i = 0; i < npages; i++) { 		struct page *p; 		p = alloc_page(GFP_KERNEL); 		if (!p){ 			goto oom; 		} 		wxcoder_dev->pages[i] = p; 		if (copy_from_user(page_address(p), (const void __user *)usr_data->algo_start+i*PAGE_SIZE,  								(usr_data->algo_len - i*PAGE_SIZE) > PAGE_SIZE ? PAGE_SIZE : (usr_data->algo_len - i*PAGE_SIZE))){ 			debug("err copy %d pages, left: %d pagesn", i, npages - i); 			ret = -EFAULT; 			goto oom; 		} 	}  	wxcoder_dev->vbase = vmap(wxcoder_dev->pages, npages, 0, PAGE_KERNEL); 	if (!wxcoder_dev->vbase){ 		ret = -EFAULT; 		goto oom; 	}

        算法库二进制文件拷贝到内核空间后,接下来驱动程序需要支撑dlopen的实现,首先需要了解dlopen的具体执行过程,涉及到哪些系统调用,最简单的方法是使用strace调试工具追踪dlopen的调用过程,其中dlopen使用RTLD_NOW参数调用,结果如下:

... openat(AT_FDCWD, "/dev/wxcoder", O_RDONLY|O_CLOEXEC) = 3 read(3, "177ELF21132671340351"..., 832) = 832 fstat(3, {st_mode=S_IFCHR|0600, st_rdev=makedev(10, 41), ...}) = 0 mmap(NULL, 515904, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7ca5e000 mprotect(0x7f7cac8000, 65536, PROT_NONE) = 0 mmap(0x7f7cad8000, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x6a000) = 0x7f7cad8000 mmap(0x7f7cadb000, 3904, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f7cadb000 close(3)                                = 0 ...

        可以看出dlopen先后调用了openat、read、mmap、mprotect和close等系统调用,看起来dlopen读取了动态库的程序头表等信息,分析其内部段信息后将其映射到进程地址空间中。显而易见,我们只需在驱动里实现read/mmap接口即可支撑dlopen的功能。read的实现比较简单,只要利用copy_to_user函数配合vmap返回的内核虚拟地址即可将dlopen需要的数据返回给用户态空间,要注意的是dlopen完成后需关闭驱动的read和mmap通道,防止数据通过设备节点外泄给恶意用户,另外还可以在外壳程序和驱动程序间使用ioctl时加上简易的口令,以进一步保护数据。

3.2 自定义mmap实现算法库文件到进程地址空间的映射

        mmap的实现有多种方式,如果先前用的是dma_alloc_coherent申请的一段连续物理内存,mmap实现最简单,只要使用remap_pfn_range函数将相应的物理页帧映射到用户进程地址空间的vma段即可,该函数的底层实现即建立物理页帧至用户空间vma段的页表实例,代码如下:

static int wzalgo_mmap(struct file *filp, struct vm_area_struct *vma) { 	unsigned long size = PAGE_ALIGN(vma->vm_end - vma->vm_start);  	debug("wzalgo_mmap - start: 0x%lx, end: 0x%lx, size: %ldntpgoff: %lu, flags: %lun",  					vma->vm_start, vma->vm_end, size, vma->vm_pgoff, vma->vm_flags); 	if (false == readable || false == loadok){ 		return -EINVAL; 	} 	... 	vma->vm_pgoff += (wxcoder_dev->rxdma_addr >> PAGE_SHIFT);  	return remap_pfn_range(vma, vma->vm_start, vma->vm_pgoff, size, vma->vm_page_prot); }

        如果先前采用的是alloc_page申请的一组物理不连续页帧保存的算法库二进制文件,除了采用分页单独remap_pfn_range映射的方法,还可以借助缺页中断来实现,代码如下:

static vm_fault_t wzcoder_mapping_fault(struct vm_fault *vmf) { 	struct vm_area_struct *vma = vmf->vma; 	pgoff_t pgoff; 	struct page **pages;  	debug("wzcoder_mapping_fault vmf->pgoff: %lun", vmf->pgoff); 	pages = vma->vm_private_data; 	for (pgoff = vmf->pgoff; pgoff && *pages; ++pages) 		pgoff--;  	if (*pages) { 		struct page *page = *pages; 		get_page(page); 		vmf->page = page; 		return 0; 	}  	return VM_FAULT_SIGBUS; }  static const struct vm_operations_struct wzcoder_mapping_vmops = { 	.close = wzcoder_mapping_close, 	.fault = wzcoder_mapping_fault, };  static int wzalgo_mmap(struct file *filp, struct vm_area_struct *vma) { 	unsigned long size = PAGE_ALIGN(vma->vm_end - vma->vm_start);  	if (false == readable || false == loadok || !wxcoder_dev->vbase){ 		return -EINVAL; 	}  	debug("wzalgo_mmap - start: 0x%lx, end: 0x%lx, size: %ldntpgoff: %lu, flags: %lun", vma->vm_start, vma->vm_end, size,  						vma->vm_pgoff, vma->vm_flags); 	vma->vm_ops = &wzcoder_mapping_vmops; 	vma->vm_private_data = (void *)wxcoder_dev->pages;  	return 0; }

        在用户态进程mmap该段vma区域时,注册vm_operations_struct结构,缺页中断处理函数wzcoder_mapping_fault把vmf->pgoff对应的物理页帧返回给vmf->page,其中vmf->pgoff指示当前页在vma区域中逻辑页的偏移量,由于该段vma的逻辑页和实际的物理页帧是一一对应的,所以很容易找到对应的page实例。

        更深入一点的方法可以参考remap_pfn_range的底层实现,自己建立所需的页表结构,强化对内核建立页表过程的理解,代码摘录如下:

#define pte_alloc_wz(mm, pmd, address)          			(unlikely(pmd_none(*(pmd))) && __pte_alloc_wz(mm, pmd, address))  #define pte_alloc_map_lock_wz(mm, pmd, address, ptlp)   			(pte_alloc_wz(mm, pmd, address) ? NULL : pte_offset_map_lock(mm, pmd, address, ptlp))   int __pte_alloc_wz(struct mm_struct *mm, pmd_t *pmd, unsigned long address) { 	spinlock_t *ptl; 	pgtable_t new = pte_alloc_one(mm, address); 	if (!new) 		return -ENOMEM;  	smp_wmb(); /* Could be smp_wmb__xxx(before|after)_spin_lock */  	ptl = pmd_lock(mm, pmd); 	if (likely(pmd_none(*pmd))) {	/* Has another populated it ? */ 		mm_inc_nr_ptes(mm); 		pmd_populate(mm, pmd, new); 		new = NULL; 	} 	spin_unlock(ptl); 	if (new) 		pte_free(mm, new); 	return 0; }  int __pmd_alloc_wz(struct mm_struct *mm, pud_t *pud, unsigned long address) { 	spinlock_t *ptl; 	pmd_t *new = pmd_alloc_one(mm, address); 	if (!new) 		return -ENOMEM;  	smp_wmb(); /* See comment in __pte_alloc */  	ptl = pud_lock(mm, pud); #ifndef __ARCH_HAS_4LEVEL_HACK 	if (!pud_present(*pud)) { 		mm_inc_nr_pmds(mm); 		pud_populate(mm, pud, new); 	} else	/* Another has populated it */ 		pmd_free(mm, new); #else 	if (!pgd_present(*pud)) { 		mm_inc_nr_pmds(mm); 		pgd_populate(mm, pud, new); 	} else /* Another has populated it */ 		pmd_free(mm, new); #endif /* __ARCH_HAS_4LEVEL_HACK */ 	spin_unlock(ptl); 	return 0; }  static inline pmd_t *pmd_alloc_wz(struct mm_struct *mm, pud_t *pud, unsigned long address) { 	return (unlikely(pud_none(*pud)) && __pmd_alloc_wz(mm, pud, address))?  			NULL: pmd_offset(pud, address); }  static int remap_pte_range(struct mm_struct *mm, pmd_t *pmd, 			unsigned long addr, unsigned long end, 			unsigned long pfn, pgprot_t prot) { 	pte_t *pte; 	spinlock_t *ptl; 	int err = 0;  	pte = pte_alloc_map_lock_wz(mm, pmd, addr, &ptl); 	if (!pte) 		return -ENOMEM; 	arch_enter_lazy_mmu_mode(); 	do { 		BUG_ON(!pte_none(*pte)); 		if (!pfn_modify_allowed(pfn, prot)) { 			err = -EACCES; 			break; 		} 		set_pte_at(mm, addr, pte, pte_mkspecial(pfn_pte(pfn, prot))); 		pfn++; 	} while (pte++, addr += PAGE_SIZE, addr != end); 	arch_leave_lazy_mmu_mode(); 	pte_unmap_unlock(pte - 1, ptl); 	return err; }  static inline int remap_pmd_range(struct mm_struct *mm, pud_t *pud, 			unsigned long addr, unsigned long end, 			unsigned long pfn, pgprot_t prot) { 	pmd_t *pmd; 	unsigned long next; 	int err;  	pfn -= addr >> PAGE_SHIFT; 	pmd = pmd_alloc_wz(mm, pud, addr); 	if (!pmd) 		return -ENOMEM; 	VM_BUG_ON(pmd_trans_huge(*pmd)); 	do { 		next = pmd_addr_end(addr, end); 		err = remap_pte_range(mm, pmd, addr, next, 				pfn + (addr >> PAGE_SHIFT), prot); 		if (err) 			return err; 	} while (pmd++, addr = next, addr != end); 	return 0; }  static inline int remap_pud_range(struct mm_struct *mm, p4d_t *p4d, 			unsigned long addr, unsigned long end, 			unsigned long pfn, pgprot_t prot) { 	pud_t *pud; 	unsigned long next; 	int err;  	pfn -= addr >> PAGE_SHIFT; 	pud = pud_alloc(mm, p4d, addr); 	if (!pud) 		return -ENOMEM; 	do { 		next = pud_addr_end(addr, end); 		err = remap_pmd_range(mm, pud, addr, next, 				pfn + (addr >> PAGE_SHIFT), prot); 		if (err) 			return err; 	} while (pud++, addr = next, addr != end); 	return 0; }  static inline int remap_p4d_range(struct mm_struct *mm, pgd_t *pgd, 			unsigned long addr, unsigned long end, 			unsigned long pfn, pgprot_t prot) { 	p4d_t *p4d; 	unsigned long next; 	int err;  	pfn -= addr >> PAGE_SHIFT; 	p4d = p4d_alloc(mm, pgd, addr); 	if (!p4d) 		return -ENOMEM; 	do { 		next = p4d_addr_end(addr, end); 		err = remap_pud_range(mm, p4d, addr, next, 				pfn + (addr >> PAGE_SHIFT), prot); 		if (err) 			return err; 	} while (p4d++, addr = next, addr != end); 	return 0; }  int wx_remap_pfn_range(struct vm_area_struct *vma, unsigned long addr, 		    unsigned long pgoff, unsigned long size, pgprot_t prot) { 	pgd_t *pgd; 	unsigned long end = addr + PAGE_ALIGN(size); 	struct mm_struct *mm = vma->vm_mm; 	int err, i = 0;  	if (is_cow_mapping(vma->vm_flags)) { 		if (addr != vma->vm_start || end != vma->vm_end){ 			return -EINVAL; 		} 		vma->vm_pgoff = page_to_pfn(wxcoder_dev->pages[pgoff]); 	}  	vma->vm_flags |= VM_IO | VM_PFNMAP | VM_DONTEXPAND | VM_DONTDUMP; 	BUG_ON(addr >= end); 	pgd = pgd_offset(mm, addr); 	flush_cache_range(vma, addr, end); 	for (i = 0; i < (PAGE_ALIGN(size) >> PAGE_SHIFT); i++){ 		err = remap_p4d_range(mm, pgd, addr, addr + PAGE_SIZE, page_to_pfn(wxcoder_dev->pages[i+pgoff]), prot); 		if (err){ 			printk("remap_p4d_range: %dn", err); 			break; 		} 		addr += PAGE_SIZE; 	}  	return err; }  static int wzalgo_mmap(struct file *filp, struct vm_area_struct *vma) { 	unsigned long size = PAGE_ALIGN(vma->vm_end - vma->vm_start);  	if (false == readable || false == loadok){ 		return -EINVAL; 	}  	debug("wzalgo_mmap - start: 0x%lx, end: 0x%lx, size: %ldntpgoff: %lu, flags: %lun", vma->vm_start, vma->vm_end, size,  						vma->vm_pgoff, vma->vm_flags); 	if (!wxcoder_dev->vbase){ 		return -EINVAL; 	}  	vma->vm_ops = &wzcoder_mapping_vmops; 	vma->vm_private_data = (void *)wxcoder_dev->pages;  	return wx_remap_pfn_range(vma, vma->vm_start, vma->vm_pgoff, size, vma->vm_page_prot); } 

        函数wx_remap_pfn_range完成进程虚拟地址区域vma到物理页帧间的映射,即一一建立页表项,pgd_offset根据传入的addr拿到当前进程的全局页目录,考虑到我们需要映射的物理页帧是非连续的,因此分页调用remap_p4d_range建立第5级页表,remap_p4d_range函数根据待映射的地址范围分段调用remap_pud_range建立第四级页表,依次下去直至set_pte_at为每个虚拟内存页建立页表项。需要注意的是较新的linux内核采用了5级页表的模式,实际使用的页表级数依赖于cpu平台的定义,不同cpu平台下各级页表的页表处理宏的实现是不一样的,内核使用这种方式将页表的建立过程统一到5级模式之下。

4. 借助内核ptrace机制设计初步的反调试手段

        以上工作完成后,设计的内核驱动程序就足以支撑用户态进程使用dlopen/dlsym等libdl库中的函数导入动态库并解析其符号等。加密后的算法库二进制文件嵌入到外壳程序中,并配合内核驱动程序完成其内存映射的方法大大提高了对算法库文件的保护,为进一步提高安全性,防止用户使用strace等调试工具追踪其执行过程,我们可以借助内核的ptrace机制建立初步的反调试技术,ptrace是linux内核支持的一种进程调试手段,值得庆幸的是即使是常用的gdb的实现也完全依赖于ptrace机制,为此可以采用在检测到用户使用ptrace追踪外壳进程时返回错误码等反制手段。

        检测外壳程序是否被ptrace跟踪调试有2种简易方法,一种是在驱动程序中添加获取当前进程task_struct->ptrace值的功能,当用户态进程被采用PTRACE_ATTACH调试时,内核会修改该值为一个非0值,指示当前进程的调试状态,外壳程序可以据此判定自己是否被跟踪调试。另一种方法可以在用户态监测/proc/self/status,检查其TracerPid项是否非0,如果非0值则表示当前进程被监控跟踪了,示例如下:

lyfan@MV:/home/lyfan/shtest$ cat /proc/3629/status     Name:   shtest State:  t (tracing stop) Tgid:   3629 Pid:    3629 PPid:   3627 TracerPid:      3627 ...

        TracerPid为3627,可以看到进程3629被其父进程3627跟踪调试了。

5. 不足和待研究的地方

        采用这种外壳加固的方法虽然可以将原SO文件完全抹去,但外壳程序在动态加载算法库文件后,仍然会将解密后的部分动态库内容暴露到进程地址空间,需要进一步配合反dump技术的使用,加强外壳在内存安全强度方面的不足,prctl(PR_SET_DUMPABLE, 0)可以关闭进程的coredump功能,但仍需结合其他方面的内存安全技术来提升外壳程序的防御能力。另外针对ELF段分别加解密也是一种思路,前提是需要深入了解ELF文件的详细组织方式,及其内部符号的解析方法等。

        至此,对算法库二进制文件的加密和导入的研究算是全部完成了,文中讨论的相关技术的示例工程已上传到gitee上,考虑到安全性,示例工程中的具体实现以及使用的密钥口令等均做了较大调整。测试采用的内核版本是Linux 4.19.0,cpu是arm64平台,gcc版本8.2.0。

附上项目地址:https://gitee.com/liangyuf/linux_so_encrypt

版权声明:玥玥 发表于 2021-08-22 22:51:44。
转载请注明:一种linux平台下算法库二进制文件加密方法探讨 | 女黑客导航