内核级sandbox设计原理与实现

简介:

作者:王智通

 

Index
1 – 背景
1.1 – 现有的技术方法
1.1.1 – selinux/apparmor
1.1.2 – Hook sys_call_table
1.2 – LKM or Patch
2 – 原理
2.1 – 截获中断处理程序
2.2 – Protect kernel from kernel
3 – 源码
——[ 1 - 背景
飞天系统会运行来自第三方的不可信二进制程序, 黑客可以随意提交后门, 蠕虫, rootkit,扫描, 溢出攻击等等恶意程序,因此需要一些防护措施来减小这些代码对系统安全造成的损失。Sandbox 的目的在于控制程序对系统的控制能力。但 SandboxP1 仅仅实现了降低用户uid 来控制用户行为的能力, 这远远不够。
黑客可以利用操作系统漏洞来达到权限提升的目的,从而突破 SandboxP1 的防护。本文提供了一种利用 LKM 内核模块来实现 Sandbox 的技术方法, 通过使用 System call hook 的技术来达到监视用户 api 和防御内核攻击的能力。
---[ 1.1 - 现有的技术方法
-[ 1.1.1 - selinux/apparmor
它们通过使用 LSM(linux security module)提供的安全钩子函数来实现对用户 api 的监控, 包括进程/进程间通讯,文件系统, 网络系统都提供了足够的钩子函数:
struct security_operations {
int (*ptrace) (struct task_struct * parent, struct task_struct * child);
...
int (*bprm_alloc_security) (struct linux_binprm * bprm);
...
int (*sb_alloc_security) (struct super_block * sb);
...
int (*sb_mount) (char *dev_name, struct nameidata * nd,
char *type, unsigned long flags, void *data);
...
int (*inode_rmdir) (struct inode *dir, struct dentry *dentry);
...
int (*task_setuid) (uid_t id0, uid_t id1, uid_t id2, int flags);
...
int (*shm_shmctl) (struct shmid_kernel * shp, int cmd);
...
int (*netlink_send) (struct sock * sk, struct sk_buff * skb);
...
int (*socket_listen) (struct socket * sock, int backlog);
}
selinux/apparmor 通过像 struct security_operations 结构注册相应钩子函数, 完成监控。
-[ 1.1.2 - Hook sys_call_table
通过替换 sys_call_table[]数组中的函数地址,来截获系统调用, 这在 windows 下的安全软件中常常使用。 如果要监控所有的 api, 那么需要重新编写所有 api 的替代函数。linuxkernel2.6.18 中大概有 300 多个系统调用函数, 编写新函数是非常浪费时间的, 而且会使
sandbox 变得非常庞大,不好管理。
—[ 1.2 - LKM or Patch
本文提出一种新的 system call hook 的技术方法, 通过 int $0x80 中断处理程序来达到监视所有 api 的目的。如果采用内核补丁的方式, 将会很容易实现, 因为可以随意的给 kernel 打补丁。
但是为此付出的代价就是要重新编译 kernel,对于飞天系统来说不是很容易维护。 采用 LKM 的方式可以动态加载内核模块,不用时又可以动态卸载,非常方便, 但编写难度很大, LKM 模块在加载到内核后,需要一系列的 hack 技巧来完成中断处理程序的截获, 下文会有详细的介绍。
------ [ 2 - 原理
--- [ 2.1 - 截获中断处理程序
我们的目的是劫持中断处理程序, 通过应用层成功传递的系统调用号来达到监视某个 API的作用,具体的调用号在 incude/asm-i386/unistd.h 中有定义:
#define __NR_restart_syscall
0
#define __NR_exit
#define __NR_fork
1
2
#define __NR_read
3
...
#define __NR_move_pages
317
#ifdef __KERNEL__
#define NR_syscalls
318
先看下内核是如何实现 int $0x80 处理程序的:
arch/i386/kernel/entry.S:
ENTRY(system_call)
RING0_INT_FRAME
pushl %eax
CFI_ADJUST_CFA_OFFSET 4
SAVE_ALL
GET_THREAD_INFO(%ebp)
testl $TF_MASK,EFLAGS(%esp)
jz no_singlestep
orl $_TIF_SINGLESTEP,TI_flags(%ebp)
no_singlestep:
testw
$(_TIF_SYSCALL_EMU|_TIF_SYSCALL_TRACE|_TIF_SECCOMP|_TIF_SYSCALL_AUDIT),TI_flags(%eb
p)
jnz syscall_trace_entry
cmpl $(nr_syscalls), %eax
jae syscall_badsys
syscall_call:
call *sys_call_table(,%eax,4)
pushl %eax 保存的就是系统调用号, SAVE_ALL 保存所有寄存器的值, 应用程序从 ring3 进入 ring0 的时候要进行堆栈切换,将使用内核堆栈进行操作。在经过信号检查后, 判断此次系统调用是否需要跟踪,使用 ptrace 进行 api 监控的 sandbox 就是这个原理。然后判断系统调用号是否超出了系统提供的最大值。
然后通过 call 指令根据寄存器号执行对应的系统调用函数。sys_call_table 是个指针数组,里面的每一项都保存着系统调用函数的地址,通过%eax*4 就可以得到数组中的偏移,取值就是这个系统调用函数的地址。通过替换数组中的地址,可以完成系统调用的截获:
orig_sys_read = sys_call_table[__NR_read];
sys_call_table[__NR_read] = new_sys_read;
这种方法的缺点就是要编写很多函数的替代品, 2.6.18 有 317 个函数, 比较麻烦。
我们现在需要一种方法来进行统一的判断, 就是想法让所有 api 的截获都到一个函数里去判断,然后又可以调用不同的原始函数, 这样管理起来非常方便。
通过前面的代码,我们知道 ptrace这个系统调用就可以完成类似的事情, 但是它要跟踪这个系统调用两次, 系统调用执行前跟踪一次,调用执行后在跟踪一次, 而且在跟踪的时候, 这个系统调用本身都已经运行完了。 看样子 ptrace 的代码可以完成我们之前提到的统一入口函数功能上, 去看下它怎么实现的:
)
syscall_trace_entry:
movl $-ENOSYS,EAX(%esp)
movl %esp, %eax
xorl %edx,%edx
call do_syscall_trace
cmpl $0, %eax
jne resume_userspace
# ret != 0 -> running under PTRACE_SYSEMU,
# so must skip actual syscall
movl ORIG_EAX(%esp), %eax
cmpl $(nr_syscalls), %eax
jnae syscall_call
jmp syscall_exit
首先设置一个错误的值给堆栈中的 eax 值, eax 是所有函数的返回值。 在用 eax 保存堆栈指 针 。 等会 看到 do_syscall_trace 的 参数 是 通过 堆 栈方 式 来传 递 的。 然 后 开始 调 用do_syscall_trace , 开 始 跟 踪 过 程 , 当 其 执 行 完 后 , 如 果 不 成 功 , 就 直 接 掉 转 到resume_userspace 出退出本次系统调用。如果成功, 还得继续让其把这个系统调用执行完,ORIG_EAX 保存的是系统调用号,再次判断系统调用号是否大于 nr_syscalls, 如果大于就跳到 syscall_exit,退出本次系统调用, 否则跳到 syscall_call 出完成本次系统调用的执行:
syscall_call:
call *sys_call_table(,%eax,4)
movl %eax,EAX(%esp)
syscall_exit 跟 resume_userspace 的不同之处在于, 在系统调用退出的时候还要在进行一次跟踪。现在看下 do_syscall_trace 具体是怎么实现的:
arch/i386/kernel/ptrace.c:
__attribute__((regparm(3)))
int do_syscall_trace(struct pt_regs *regs, int entryexit)
{

}
到目前为止,我们知道 ORIG_EAX 保存的是系统调用号, 又有 do_syscall_trace 这样的函数可以完成统一监控的入口函数。 那么我们只需要编写一个类似 do_syscall_trace 的函数, 根据 eax 值完成基于 sandbox 规则的判断,对其放行或拒绝。 编写一个 sandbox_syscall_trace的 函 数 比 较 简 单 , 难 的 是 我 们 还 需 要 修 改 system_call 函 数 , 让 其 能 跳 转 到sandbox_syscall_trace 中。 由于不能给内核打补丁,所以需要一系列 hack 手段来完成system_call 函数的修改, 将赤裸裸的 rootkit 技术用于其中。
1、 模块加载后找到 system_call 函数的地址。
2、 从 system_call 开始, 搜索 jae syscall_badsys 的机器码。
3、 替换掉 cmpl $(nr_syscalls), %eax 和 jae syscall_badsy 的机器码, 让其跳转到我们的新处理函数 asbox_idt_handler 中。
4、 asbox_idt_handler 完成系统调用的监控。
1、 怎样得到 system_call 函数的地址
idtr 寄存器保存的就是内核使用的 idt 表的地址,可以使用 sidt 执行来取得 idt 的地址。
__asm__ volatile (“sidt %0″: “=m” (idt48));
system_call 处理程序保存在 idt 表的第 0×80 选项上, 通过解析它即可得到 system_call 的地址。
pIdt80 = (struct descriptor_idt *)(idt48.base + 8 * 0×80);
addr = (pIdt80->offset_high << 16 | pIdt80->offset_low);
if (!addr) {
DbgPrint(“oh, shit! can’t find system_call address.\n”);
return 0;
}
2、怎样得到 sys_call_table 的地址
将 vmlinux 反汇编看看:
c1003cc4 <system_call>:
c1003cc4: 50
push
%eax
c1003cc5: fc cld c1003cc6: 06 push %es
c1003cc7: 1e push %ds
c1003cc8: 50 push %eax
c1003cc9: 55 push %ebp
c1003cca: 57 push %edi
c1003ccb: 56 push %esi
c1003ccc: 52 push %edx
c1003ccd: 51 push %ecx
c1003cce: 53 push %ebx
c1003ccf: ba 7b 00 00 00 mov $0x7b,%edx
c1003cd4: 8e da movl %edx,%ds
c1003cd6: 8e c2 movl %edx,%es
c1003cd8: bd 00 f0 ff ff mov $0xfffff000,%ebp
c1003cdd: 21 e5 and
c1003cdf: f7 44 24 30 00 01 00 %esp,%ebp
testl $0×100,0×30(%esp)
c1003ce6: 00 je
c1003ce7: 74 04 orl
c1003ce9: 83 4d 08 10
c1003ced <no_singlestep>
$0×10,0×8(%ebp)
c1003ced <no_singlestep>:
c1003ced: 66 f7 45 08 c1 01 testw $0x1c1,0×8(%ebp)
c1003cf3: 0f 85 bf 00 00 00 jne
c1003cf9: 3d 3e 01 00 00 cmp
c1003db8 <syscall_trace_entry>
$0x13e,%eax
c1003cfe:
0f 83 27 01 00 00
jae call
mov
c1003d04 <syscall_call>:
c1003d04:
ff 14 85 e0 e4 1e c1
c1003d0b:
89 44 24 18
call
c1003e2b <syscall_badsys>
*0xc11ee4e0(,%eax,4)
%eax,0×18(%esp)
*0xc11ee4e0(,%eax,4) 这 条 指 令 调 用 具 体 的 系 统 调 用 函 数 , 0xc11ee4e0 就 是sys_call_table 的地址。 ff 14 85 e0 e4 1e c1 是其机器码, 于是我们可以从 system_call 地址,开始向下搜索 0xff0x140x85, 后面便是 sys_call_table 的地址了。
void *get_sct_addr(void)
{
unsigned char *p;
unsigned int sct;
p = (unsigned char *)system_call_addr;
while (!((*p == 0xff) && (*(p + 1) == 0×14) && (*(p + 2) == 0×85)))
p++;
syscall_start = (unsigned long)p;
p += 3;
sct = *((unsigned int *)p);
p += 4;
syscall_end = (unsigned long)p;

}
注意这个函数不仅把 sys_call_table 的地址记录下来了, 还把 syscall_call, syscall_exit 标号出的地址也记录下来了。 后面 sandbox 处理函数还会用到这些地址。
3、 搜索并替换 jae syscall_badsys 的机器码
c1003cf9:
c1003cfe:
3d 3e 01 00 00
0f 83 27 01 00 00
cmp
$0x13e,%eax
jae
c1003e2b <syscall_badsys>
从 system_call 地址开始向后搜索 0x0f0x83,
找到后向前定位 5 个字节,便是 cmp 指令地址。
用 push xxxx;ret 机器码替换掉它们。这样当执行到这个地址的时候, push 指令会把 sandbox的处理函数 asbox_idt_handler 地址压入堆栈,在通过 ret 指令将其弹出,完成函数的跳转。
void set_idt_handler(void)
{
unsigned char buf[4] = “\x00\x00\x00\x00″;
unsigned int offset = 0;
unsigned char *p, *p1;
unsigned long *p2;
p = (unsigned char *)system_call_addr;
while (!((*p == 0x0f) && (*(p + 1) == 0×83)))
p++;
//printk(“found opcode.\n”);
// found syscall_badsys addr.
p1 = p + 2;
buf[0] = p1[0];
buf[1] = p1[1];
buf[2] = p1[2];
buf[3] = p1[3];
offset = *(unsigned int *)buf;
printk(“offset: 0x%08x\n”, offset);
syscall_badsys = offset + (unsigned int)p1 + 4;
printk(“jmp_badsys_addr: 0x%08x\n”, syscall_badsys);
// found resume_userspace addr.
p1 = (unsigned char *)syscall_badsys;
while (!(*p1 == 0xe9))
p1++;
printk(“found opcode 0xe9.\n”);
p1++;
buf[0] = p1[0];
buf[1] = p1[1];
buf[2] = p1[2];
buf[3] = p1[3];
offset = *(unsigned int *)buf;
printk(“offset: 0x%08x\n”, offset);
resume_userspace = offset + (unsigned int)p1 + 4;
printk(“resume_userspace_addr: 0x%08x\n”, resume_userspace);
p -= 5;
*p++ = 0×68;
p2 = (unsigned long *)p;
*p2++ = (unsigned long)((void *)asbox_idt_handler);
p = (unsigned char *)p2;
*p = 0xc3;

}
4、 asbox_idt_handler 完成系统调用的监控。
asbox_idt_handler 先完 成系 统调用 号是 否超 出内 核提 供的最 大值 , 超出 则直 接跳到syscall_exit 地址出, 这个地址前面我们已经找到了。 否则跳到 asbox_syscall_hook 出继续执行。
void asbox_idt_handler(void)
{
__asm__(“cmp %0, %%eax\n”
“jae asbox_syscall_bad\n”
“jmp asbox_syscall_hook\n”
“asbox_syscall_bad:\n”
“jmp syscall_exit\n”
::”i”(NR_syscalls));
}
asbox_syscall_hook 调用 asbox_syscall_trace,并使系统调用继续执行或退出。
void asbox_syscall_hook(void)
{
__asm__(“movl %esp, %eax\n”
“call asbox_syscall_trace\n”
“cmpl $0, %eax\n”
“jne asbox_resume_userspace\n”
“movl 0×24(%esp), %eax\n”
“pushl syscall_start\n”
“ret\n”
“pushl syscall_end\n”
“ret\n”
“asbox_resume_userspace:\n”
“pushl syscall_badsys\n”
“ret\n”
);
}
asbox_syscall_trace 是我们的主监控函数:
考虑到系统上的 sandbox 用户非常多, 为了加快监控速度, 我们将为每个 sandbox 用户提供一个哈希表, 根据用户 uid 算出哈希表中对应的地址, 这个地址是一个双向链表,链接着要监控的系统调用号。
struct asbox_uid_node {
int uid;
int sys_bit[MAX_BIT];
struct list_head list;
};
struct asbox_uid {
struct list_head uid_list[ASBOX_MAX_UID];
spinlock_t uid_list_lock[ASBOX_MAX_UID];
}asbox_uid_cache;
asbox_uid_cache 用来管理哈希表, 通过 uid 来得到哈希表中的索引。
int asbox_uid_hash(int uid)
{
return uid;
}
SandboxP2 中直接返回 uid 值。
通过 asbox_uid_cache.uid_list[uid_hash]来访问 asbox_uid_node 结构。
每个系统调用都有一个 asbox_uid_node 的结构。 sys_bit 是个位图数组, 通过移位运算就可以快速的算出这个系统调用号该不该被监控。
asbox_uid_cache
|
V +————————–+ +————————–+ +————————-+
+———–+ | asbox_uid_node1 | -> | asbox_uid_node2 | -> … -> | asbox_uid_nodeN |
| uid1 | ->
+———–+ +————————–+ +————————–+ +————————-+
+———–+ +————————–+ +————————–+ +————————-+
| uid2 | -> | asbox_uid_node1 | -> | asbox_uid_node2 | -> … -> | asbox_uid_nodeN |
+———–+ +————————–+ +————————–+ +————————-+

+———–+
+————————–+ +————————–+ +————————-+
| uidN | -> | asbox_uid_node1 | -> | asbox_uid_node2 | -> … -> | asbox_uid_nodeN |
+———–+ +————————–+ +————————–+ +————————-+
模块通过 init_asbox_hash_list()来初始化嘻哈表,通过/proc 接口来动态增加哈希表项。
check_sys_bit 通过移位运算快速判断一个系统调用是不是需要被监控:
int check_sys_bit(int idx, struct asbox_uid_node *node)
{
int tmp;
if (idx >= NR_syscalls) {
return -1;
}
if ((idx / 32) >= MAX_BIT || (idx / 32) < 0) {
return -1;
}
//sys_bit[idx / 32] >>= (idx % 32);
tmp = (node->sys_bit[idx / 32]) >> (idx % 32);
if (0×1 & tmp) {
printk(“idx: %d was set.\n”, idx);
return 0;
}
return -1;
}
通过用户程序 setproc.c 来完成系统调用的设置。
— [ 2.1 – Protect kernel from kernel
通过监控用户 api, 可以完成 sandbox 功能。 但是不能阻止 zero day 的攻击。 黑客可以使用未公开的系统漏洞来获得权限提升, 然后可以安装 rootkit 来破坏 sandbox 程序, 或完成进程,网络,文件的隐藏等等功能。因此在 sandbox 之后, 还要加入内核自身防护的功
能。 当模块加载的时候,开启一个内核线程,每隔 5 秒中作一次防御。
static int asbox_protect_thread(void *arg)
{
daemonize(“asbox_thread”);
allow_signal(SIGTERM);
while (!signal_pending(current)) {
if (!check_syscall_addr()) {
printk(“syscall table is ok.\n”);
}
if (!check_inline_hook()) {
printk(“syscall opcode is ok.\n”);
}
if (!check_idt_handler()) {
printk(“idt handler is ok.\n”);
}
if (!check_asbox_idt_hook()) {
printk(“asbox idt hook is ok.\n”);
}
set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(5 * HZ);
}
complete_and_exit(&thread_exited, 1);
}
check_syscall_addr 用来检查 system_call 地址是否改变, check_inline_hook 用来检查系统调用函数是否被植 入了 inline hook 钩 子, check_idt_handler 用来完 成 idt 表 的检查,check_asbox_idt_hook 用来完成自身 hook 的防御, 看其有没有被其他模块修改掉。 详细代码可参加 protect.c。
——[ 3 – 源码
附件中提供了 SandboxP2 demo 的完整实现,名为 asbox = as a sandbox, 又为 apsara sandbox。
使用 git 进行版本控制, 有兴趣的同学可以一起来开发:)

相关文章
|
18天前
|
Linux 网络安全 网络虚拟化
Linux虚拟网络设备:底层原理与性能优化深度解析
在深入探讨Linux虚拟网络设备的底层原理之前,重要的是要理解这些设备如何在Linux内核中实现,以及它们如何与操作系统的其他部分交互以提供高效且灵活的网络功能。虚拟网络设备在现代网络架构中发挥着关键作用🔑,特别是在云计算☁️、容器化📦和网络功能虚拟化(NFV)环境中。
Linux虚拟网络设备:底层原理与性能优化深度解析
|
22天前
|
负载均衡 算法 Linux
深度解析:Linux内核调度器的演变与优化策略
【4月更文挑战第5天】 在本文中,我们将深入探讨Linux操作系统的核心组成部分——内核调度器。文章将首先回顾Linux内核调度器的发展历程,从早期的简单轮转调度(Round Robin)到现代的完全公平调度器(Completely Fair Scheduler, CFS)。接着,分析当前CFS面临的挑战以及社区提出的各种优化方案,最后提出未来可能的发展趋势和研究方向。通过本文,读者将对Linux调度器的原理、实现及其优化有一个全面的认识。
|
消息中间件 监控 安全
探究如何在Linux系统中修改进程资源限制:四种方法调整进程限制,让你的系统高效运行(包含应用层getrlimit和setrlimit API)
探究如何在Linux系统中修改进程资源限制:四种方法调整进程限制,让你的系统高效运行(包含应用层getrlimit和setrlimit API)
47 0
|
2月前
|
编解码
请解释一下鸿蒙操作系统的轻量级特性和性能优化。
请解释一下鸿蒙操作系统的轻量级特性和性能优化。
44 3
|
3月前
请解释鸿蒙操作系统的分布式能力是如何实现的。
请解释鸿蒙操作系统的分布式能力是如何实现的。
52 1
|
9月前
|
缓存 前端开发 调度
根据Nehalem架构了解CPU内部细节
根据Nehalem架构了解CPU内部细节
197 0
|
人机交互
请简述操作系统OS是如何介入用户程序的运行过程中。
请简述操作系统OS是如何介入用户程序的运行过程中。
132 0
|
存储 人工智能 JSON
HarmonyOS系统中内核实现智慧烟感控制的方法
大家好,今天主要和大家聊一聊,如何利用鸿蒙系统实现智慧烟感方法
153 0
HarmonyOS系统中内核实现智慧烟感控制的方法
|
Linux 调度
内核开发基础-如何使用内核延时
从事Linux内核开发特别是驱动开发的小伙伴,肯定需要经常使用到定时器,比如,按键的去抖、LED屏幕显存buffer的刷新等。同时,在控制硬件时,可能会用到十分精确地短延时,这时,定时器的精度就不能满足这种需求了,这时就会使用到高精度定时器和忙等延时。今天就来简要说一下如何正确的使用内核提供的delay和sleep函数。
299 0