# 绕过kysec exectl执行二进制程序 ## 前言 在去年信创挑战赛时,和其他战队交流发现有队伍实现了绕过kysec exectl执行二进制程序。因此打算自己实现一下,经过一段时间的调研,最终确定使用python 脚本 hook dlopen 最终加载libc程序执行恶意代码。 ## 关于kysec 本小节主要回顾一下kysec如何检查链接库和可执行文件的权限实现方法。kysec主要是使用强制访问控制中的LSM(Linux Security Module)钩子函数检查文件权限。对于加载链接库文件,当用dlopen加载时,会调用mmap为链接库文件申请内存空间,内核在对mmap函数进行处理时,会在`security_mmap_file`处调用kysec实现的`kysec_file_mmap`对链接库文件的权限进行检查,不在白名单内的文件不允许为其分配内存空间。 ## remote-library-injection 思路源自于参考[1] ,这是一篇04年的文章,放在今天很多情景下仍然适用,本文主要详细阐述Linux下hook dlopen在内存中加载链接库的方法。上一小节简要概述了kysec对链接库文件的检查方法,众所周知,mmap共享内存大致分为两种方式:使用普通文件提供内存映射和使用特殊文件提供匿名内存映射。可见kysec对第一种方式进行了检查,但没有对第二种方式进行检查并且也没有检测hook的相关机制,这使得hook dlopen这一方法得以实现。 主要流程如下: - 从socket读取链接库大小 读取链接库的四字节长度并将其存储以备后用。 可以通过将 `MSG WAITALL` 标志传递给socket `recv` 系统调用并为 `len` 参数传递 4 个字节来优化此步骤。 这一步可以准确地知道应该从套接字读取多少字节。 - 使用匿名映射链接库大小的内存 `mmap`函数原型如下: ```c void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset); ``` 为了映射匿名内存,参数应该按照如下方式设置: | Argument Name | Argument Value | | :------------ | ------------------------------------ | | start | NULL | | length | 从 socket 读入的链接库长度 | | prot | `PROT_READ | PROT_WRITE | PROT_EXEC` | | flags | `MAP_PRIVATE | MAP_ANON` | | fd | -1 | | offset | 0 | 返回值将是映射内存的 VMA,或者失败时为 -1。此地址应保存以备后用。 - 将目标链接库拷贝进匿名映射 分配了用于链接库的内存后,下一步需要将实际内容读入其中。 这是通过调用 `recv` 并将 `buf` 参数设置为从 `mmap` 返回的 VMA 来完成的。 `len` 参数应设置为库的长度。 最后,`flags` 参数应该设置为 `MSG_WAITALL` 以便完整读取整个库。 成功时,`recv` 将返回读取的字节数,该字节数应等于库的长度 。 - 对dlopen中文件操作函数进行hook 这一步是最复杂的,需要对动态加载器的运行方式有所了解。 在正常运行期间,动态加载器(ld-linux.so)利用一系列文件操作函数来打开、读取库并将其映射到进程的地址空间。为了加载一个动态库,但在磁盘、程序上不存在,这种情况下必须在动态加载程序和所述文件操作函数之间添加额外代码。 这是通过hook完成的。 动态加载程序使用的实际文件操作函数,如下所示:`open`、`read`、`lseek`(高版本glibc中替换为`pread`)、`mmap`、`fxstat64`。 使用上述函数可以模拟它们所描述的目的,从而使动态加载器认为这些操作是在磁盘上的文件上执行的,而实际上它们只是在模拟针对匿名内存范围的操作。 下面是针对每个函数hook的具体实现方法: **open** open函数的hook涉及检查传递的路径名是否与hook期望看到的“假”库名匹配。 如果匹配,则应返回一个与任何现有文件描述符不冲突的虚拟文件描述符。 虚拟文件描述符应该存储诸如当前虚拟文件偏移量、内存中库的大小以及加载库的基地址等信息(通读dlopen源码后发现其实涉及的文件操作并不多,实际实现仅需返回伪造的虚拟文件描述符即可)。如果传入的路径名与假库名不匹配,则调用应该直接传递给真正的open函数。 **read** read函数hook应该检查作为 fd 参数传入的文件描述符是虚拟文件描述符还是真实文件描述符。 如果它是虚拟文件描述符,则应针对内存范围模拟逻辑读取操作。 成功读取操作后,应通过将实际复制的字节数添加到原始偏移量来更新当前文件偏移量。 如果传递给 read 函数的文件描述符不是虚拟文件描述符,则直接调用真正read函数即可。 **mmap** 当传入一个虚拟文件描述符时,mmap函数hook应该调用 mmap 函数并根据传入的参数映射一个匿名内存范围。成功映射范围后,应将指定为长度字节的偏移参数的偏移处的库内容复制到新映射的内存范围中。 如果传入的文件描述符不是虚拟文件描述符,则调用应该简单地传递给真正的 mmap 函数。 **fxstat64** fxstat64函数hook负责向调用者提供有关传入的文件描述符的信息。 在虚拟文件描述符上模拟这种操作的情况下,需要尽可能为调用者提供尽可能多的准确信息。由于`struct stat64`结构体信息较多,但dlopen加载链接库的过程中调用`fxstat64`的情况并不多,并且也没有用到`stat64`结构体内的全部信息。 ```c //glibc-2.31/source/sysdeps/posix/dl-fileid.h#L33 /* Sample FD to fill in *ID. Returns true on success. On error, returns false, with errno set. */ static inline bool _dl_get_file_id (int fd, struct r_file_id *id) { struct stat64 st; if (__glibc_unlikely (__fxstat64 (_STAT_VER, fd, &st) < 0)) return false; id->dev = st.st_dev; id->ino = st.st_ino; return true; } ``` 根据源码只需要模拟链接库大小,`st_ino` 即可。如果传入的文件描述符不是虚拟文件描述符,直接调用真正的 fxstat64 函数。 **lseek/pread64** 这两个函数之所以放在一起是因为当elf头过大时,第一次`read`没有完整读取时才会调用。较早libc使用的是lseek,[3]之后改用pread64。如果调用文件的elf头小于`sizeof (ElfW(Ehdr))`,可不用实现[4] [5]。 - 使用假链接库名调用 `_dl_open` c中使用`dlopen`,python下使用`cdll.LoadLibrary`打开约定的假链接库名即可。 ## 总结 这种方法很早之前就有人用c进行了实现,但想绕过kysec必须使用python脚本,并且hook代码得用arm64汇编重写。思路并不难主要是arm64汇编之前没有接触过,利用这个机会学习了一下。在参考之前用c实现的项目过程中还发现该项目的一个bug [2] ,mmap函数hook时memcpy拷贝没有加offset偏移。总的来说实现方法并不难,但想到用这个方法绕过kysec用了很久,走了很多弯路。这种方法的好处在于可以完美绕过kysec保护并且使exp完全在内存中存储,实现了无文件落地攻击。缺点在于由于需要libc和ld库的偏移,因此不同版本libc需要重新计算。 ## 参考资料 [1] http://www.hick.org/code/skape/papers/remote-library-injection.pdf [2] https://github.com/yifengchen-cc/memdlopen/blob/master/main.c#L269 [3] https://sourceware.org/pipermail/glibc-cvs/2019q4/067792.html [4] https://elixir.bootlin.com/glibc/glibc-2.31/source/elf/dl-load.c#L1555 [5] https://elixir.bootlin.com/glibc/glibc-2.31/source/elf/dl-load.c#L1662