背景
前段时间, 我们的项目组在帮客户解决一些操作系统安全领域的问题, 涉及到 Windows,Linux,macOS 三大操作系统平台. 无论什么操作系统, 本质上都是一个软件, 任何软件在一开始设计的时候, 都不能百分之百的满足人们的需求, 所以操作系统也是一样, 为了尽可能的满足人们需求, 不得不提供一些供人们定制操作系统的机制. 当然除了官方提供的一些机制, 也有一些黑魔法, 这些黑魔法不被推荐使用, 但是有时候面对具体的业务场景, 可以作为一个参考的思路.
Linux 中常见的拦截过滤
本文着重介绍 Linux 平台上常见的拦截:
用户态动态库拦截.
内核态系统调用拦截.
堆栈式文件系统拦截.
inline hook 拦截.
LSM(Linux Security Modules)
动态库劫持
Linux 上的动态库劫持主要是基于 LD_PRELOAD 环境变量, 这个环境变量的主要作用是改变动态库的加载顺序, 让用户有选择的载入不同动态库中的相同函数. 但是使用不当就会引起严重的安全问题, 我们可以通过它在主程序和动态连接库中加载别的动态函数, 这就给我们提供了一个机会, 向别人的程序注入恶意的代码.
假设有以下用户名密码验证的函数:
- #include
- #include
- #include
- int main(int argc, char **argv)
- {
- char passwd[] = "password";
- if (argc < 2) {
- printf("Invalid argc!\n");
- return;
- }
- if (!strcmp(passwd, argv[1])) {
- printf("Correct Password!\n");
- return;
- }
- printf("Invalid Password!\n");
- }
我们再写一段 hookStrcmp 的程序, 让这个比较永远正确.
- #include
- int strcmp(const char *s1, const char *s2)
- {
- /* 永远返回 0, 表示两个字符串相等 */
- return 0;
- }
依次执行以下命令, 就会使我们的 hook 程序先执行.
- gcc -Wall -fPIC -shared -o hookStrcmp.so hookStrcmp.c
- export LD_PRELOAD="./hookStrcmp.so"
结果会发现, 我们自己写的 strcmp 函数优先被调用了. 这是一个最简单的劫持 , 但是如果劫持了类似于 geteuid/getuid/getgid, 让其返回 0, 就相当于暴露了 root 权限. 所以为了安全起见, 一般将 LD_PRELOAD 环境变量禁用掉.
Linux 系统调用劫持
最近发现在 4.4.0 的内核中有 513 多个系统调用 (很多都没用过), 系统调用劫持的目的是改变系统中原有的系统调用, 用我们自己的程序替换原有的系统调用. Linux 内核中所有的系统调用都是放在一个叫做 sys_call_table 的内核数组中, 数组的值就表示这个系统调用服务程序的入口地址. 整个系统调用的流程如下:
当用户态发起一个系统调用时, 会通过 80 软中断进入到 syscall hander, 进而进入全局的系统调用表 sys_call_table 去查找具体的系统调用, 那么如果我们将这个数组中的地址改成我们自己的程序地址, 就可以实现系统调用劫持. 但是内核为了安全, 对这种操作做了一些限制:
sys_call_table 的符号没有导出, 不能直接获取.
sys_call_table 所在的内存页是只读属性的, 无法直接进行修改.
对于以上两个问题, 解决方案如下 (方法不止一种):
获取 sys_call_table 的地址 :
grep sys_call_table /boot/System.map-uname -r
控制页表只读属性是由 CR0 寄存器的 WP 位控制的, 只要将这个位清零就可以对只读页表进行修改.
- /* make the page writable */
- int make_rw(unsigned long address)
- {
- unsigned int level;
- pte_t *pte = lookup_address(address, &level);// 查找虚拟地址所在的页表地址
- pte->pte |= _PAGE_RW;// 设置页表读写属性
- return 0;
- }
- /* make the page write protected */
- int make_ro(unsigned long address)
- {
- unsigned int level;
- pte_t *pte = lookup_address(address, &level);
- pte->pte &= ~_PAGE_RW;// 设置只读属性
- return 0;
- }
1. 开始替换系统调用
本文实现的是对 ls 这个命令对应的系统调用, 系统调用号是__NR_getdents.
- static int syscall_init_module(void)
- {
- orig_getdents = sys_call_table[__NR_getdents];
- make_rw((unsigned long)sys_call_table); // 修改页属性
- sys_call_table[__NR_getdents] = (unsigned long *)hacked_getdents; // 设置新的系统调用地址
- make_ro((unsigned long)sys_call_table);
- return 0;
- }
2. 恢复原状
- static void syscall_cleanup_module(void)
- {
- printk(KERN_ALERT "Module syscall unloaded.\n");
- make_rw((unsigned long)sys_call_table);
- sys_call_table[__NR_getdents] = (unsigned long *)orig_getdents;
- make_ro((unsigned long)sys_call_table);
- }
使用 Makefile 编译, insmod 插入内核模块后, 再执行 ls 时, 就会进入到我们的系统调用, 我们可以在 hook 代码中删掉某些文件, ls 就不会显示这些文件, 但是这些文件还是存在的.
堆栈式文件系统
Linux 通过 vfs 虚拟文件系统来统一抽象具体的磁盘文件系统, 从上到下的 IO 栈形成了一个堆栈式. 通过对内核源码的分析, 以一次读操作为例, 从上到下所执行的流程如下:
内核中采用了很多 c 语言形式的面向对象, 也就是函数指针的形式, 例如 read 是 vfs 提供用户的接口, 具体底下调用的是 ext2 的 read 操作. 我们只要实现 VFS 提供的各种接口, 就可以实现一个堆栈式文件系统. Linux 内核中已经集成了一些堆栈式文件系统, 例如 Ubuntu 在安装时会提醒你是否需要加密 home 目录, 其实就是一个堆栈式的加密文件系统 (eCryptfs), 原理如下:
实现了一个堆栈式文件系统, 相当于所有的读写操作都会进入到我们的文件系统, 可以拿到所有的数据, 就可以进行做一些拦截过滤.
以下是我实现的一个最简单的堆栈式文件系统, 实现了最简单的打开, 读写文件, 麻雀虽小但五脏俱全.
- https://github.com/wangzhangjun/wzjfs
- inline hook
我们知道内核中的函数不可能把所有功能都在这个函数中全部实现, 它必定要调用它的下层函数. 如果这个下层函数可以得到我们想要的过滤信息内容, 就可以把下层函数在上层函数中的 offset 替换成新的函数的 offset, 这样上层函数调用下层函数时, 就会跳到新的函数中, 在新的函数中做过滤和劫持内容的工作. 所以从原理上来说, inline hook 可以想 hook 哪里就 hook 哪里.
inline hook 有两个重要的问题:
如何定位 hook 点.
如何注入 hook 函数入口.
1. 对于第一个问题:
需要有一点的内核源码经验, 比如说对于 read 操作, 源码如下:
在这里当发起 read 系统调用后, 就会进入到 sys_read, 在 sys_read 中会调用 vfs_read 函数, 在 vfs_read 的参数中正好有我们需要过滤的信息, 那么就可以把 vfs_read 当做一个 hook 点.
2. 对于第二个问题:
如何 Hook? 这里介绍两种方式:
第一种方式: 直接进行二进制替换, 将 call 指令的操作数替换为 hook 函数的地址.
第二种方式: Linux 内核提供的 kprobes 机制.
其原理是在 hook 点注入 int 3(x86) 的机器码, 让 CPU 运行到这里的时候会触发 sig_trap 信号, 然后将用户自定义的 hook 函数注入到 sig_trap 的回调函数中, 达到触发 hook 函数的目的. 这个其实也是调试器的原理.
LSM
LSM 是 Linux Secrity Module 的简称, 即 Linux 安全模块. 是一种通用的 Linux 安全框架, 具有效率高, 简单易用等特点. 原理如下:
LSM 在内核中做了以下工作:
在特定的内核数据结构中加入安全域.
在内核源代码中不同的关键点插入对安全钩子函数的调用.
加入一个通用的安全系统调用.
提供了函数允许内核模块注册为安全模块或者注销.
将 capabilities 逻辑的大部分移植为一个可选的安全模块, 具有可扩展性.
适用场景
对于以上几种 Hook 方式, 有其不同的应用场景.
动态库劫持不太完全, 劫持的信息有可能满足不了我们的需求, 还有可能别人在你之前劫持了, 一旦禁用 LD_PRELOAD 就失效了.
系统调用劫持, 劫持的信息有可能满足不了我们的需求, 例如不能获取 struct file 结构体, 不能获取文件的绝对路径等.
堆栈式文件系统, 依赖于 Mount, 可能需要重启系统.
inline hook, 灵活性高, 随意 Hook, 即时生效无需重启, 但是在不同内核版本之间通用性差, 一旦某些函数发生了变化, Hook 失效.
LSM, 在早期的内核中, 只能允许一个 LSM 内核模块加载, 例如加载了 SELinux, 就不能加载其他的 LSM 模块, 在最新的内核版本中不存在这个问题.
总结
篇幅有限, 本文只是介绍了 Linux 上的拦截技术, 后续有机会可以一起探讨 Windows 和 macOS 上的拦截技术. 事实上类似的审计 HOOK 放到任何一个系统中都是刚需, 不只是 kernel, 我们可以看到越来越多的 vm 和 runtime 甚至包括很多 web 组件, 前端应用都提供了更灵活的 hook 方式, 这是透明化和实时性两个安全大趋势下最常见的解决方案.
来源: http://netsecurity.51cto.com/art/201812/588267.htm