linux中是不可以依附别的进程的,更不可能创建远程线程,然而一种不太正规的方式却可以做到这一点,这就是ptrace接口,ptrace可以依附任何用户进程,用特殊的参数甚至可以更改任何进程的寄存器和内存映射,这个功力和创建远程线程不相上下,甚至比其更加灵活,如果理解了elf映像在内存的布局便可以通过ptrace修改被调试进程的任意内存。然而有一个限制就是ptrace接口只能调试属于自己用户的进程,也就是说它不能调试别的用户的进程,一种显而易见的限制就是普通用户的ptrace不能调试root进程,但是2.6.29内核有一个明显的漏洞使得普通用户可以提升本地权限。
简单来讲就是在调用exec的时候需要经历一系列的uid,euid的计算,特别是exec具有suid的程序的时候,这种计算将变得更加繁复,在ptrace的时候需要进行一系列uid,euid的判断,那么如果处理不好这二者之间的同步,必然会导致ptrace的误判,就是说ptrace调试了一个根本不属于自己的进程,甚至可能是root进程,如果ptrace的调用进程更改了被调用的具有suid的进程的内存,使之exec了一个shell,那么该shell将会是属于root的,因为exec的调用进程也就是suid进程的euid是0.
早期的内核心漏洞更加明显,看看2.4.9的内核中的ptrace_attach
int ptrace_attach(struct task_struct *task)
{
task_lock(task);
...
if(((current->uid != task->euid) ||
(current->uid != task->suid) ||
(current->uid != task->uid) ||
(current->gid != task->egid) ||
(current->gid != task->sgid) ||
(!cap_issubset(task->cap_permitted, current->cap_permitted)) ||
(current->gid != task->gid)) && !capable(CAP_SYS_PTRACE))
goto bad;
...
task->ptrace |= PT_PTRACED;
task_unlock(task);
write_lock_irq(&tasklist_lock);
if (task->p_pptr != current) {
REMOVE_LINKS(task);
task->p_pptr = current;
SET_LINKS(task);
}
write_unlock_irq(&tasklist_lock);
send_sig(SIGSTOP, task, 1);
return 0;
bad:
task_unlock(task);
return -EPERM;
}
这个早期的内核显得很整洁,因为都到了很少量的锁,锁的粒度自然很粗,不过还好,看看第一行就锁住了这个进程,然后进行一系列的判断,锁住进程的目的就是阻止别的执行绪在判断期间更改进程的属性,这个想法很好,但是再看一个函数:
void compute_creds(struct linux_binprm *bprm)
{
kernel_cap_t new_permitted, working;
int do_unlock = 0;
new_permitted = cap_intersect(bprm->cap_permitted, cap_bset);
working = cap_intersect(bprm->cap_inheritable,
current->cap_inheritable);
new_permitted = cap_combine(new_permitted, working);
if (bprm->e_uid != current->uid || bprm->e_gid != current->gid ||
!cap_issubset(new_permitted, current->cap_permitted)) {
current->mm->dumpable = 0;
lock_kernel(); //仅仅锁住了kernel而没有锁住task,只要没有执行绪和该执行绪竞争kernel锁,那么谁也不会等待
if (must_not_trace_exec(current) //注意这个函数,见下面。该位置设为A
|| atomic_read(¤t->fs->count) > 1
|| atomic_read(¤t->files->count) > 1
|| atomic_read(¤t->sig->count) > 1) {
if(!capable(CAP_SETUID)) { //千万别通过这个if语句,否则弊大于利,攻击成功的可能性就小了
bprm->e_uid = current->uid;
bprm->e_gid = current->gid;
}
...
}
do_unlock = 1;
}
...//注意,以下就应该设置新进程的各种ID了。下面的位置设置为B。
current->suid = current->euid = current->fsuid = bprm->e_uid;
current->sgid = current->egid = current->fsgid = bprm->e_gid;
if(do_unlock)
unlock_kernel();
current->keep_capabilities = 0;
}
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);
}
注意,在A和B之间并没有进行task本身的保护,最起码没有和ptrace互斥,ptrace为了安全起见被设计为不能跟踪自己用户id之外的进程,比如一个普通用户进程不能跟踪suid进程,但是看看2.4.9的代码,如果在A和B之间,ptrace闯了进来要跟踪正在exec的进程,此时被跟踪的正在 exec的进程尚未设置好新的euid和egid之类的id,那么ptrace进程很容易就得逞了,通过了ptrace_attach中的那么长的判断,从而成功实现一个普通用户进程进程跟踪一个suid进程,以后可以通过ptrace接口修改被跟踪的suid进程的内存实现注入,如果注入一个root的 shell,那么一切就成功了!在后来的内核中,在compute_creds也用了task_lock(task)将进程锁住,那么exec和 ptrace就不能同时进入到竞争态了,如果exec先进去,那么ptrace_attach的一系列判断会将有企图的进程赶出去,如果ptrace先进去,那么must_not_trace_exec会将exec新进程的euid设置成current的uid从而降低了它的权限,仔细看 must_not_trace_exec发现它也有漏洞,如果suid程序本身有漏洞,那么!cap_raised(p->p_pptr->cap_effective, CAP_SYS_PTRACE)这个条件就有可能被利用,从而绕过bprm->e_uid = current->uid,因此后来的版本将该函数的后一个条件去掉了,只要一个进程被跟踪了,那么就尽可能不让它获得特权,除非它的调用者本身就是特权程序,皇帝自杀谁也挡不住啊。
虽然后来的版本在两个地方加上了锁,但是一下子就锁一个进程未免粒度过大,于是2.6的后期内核将锁的粒度减小,引入了ptrace和exec进行互斥的专用锁,这样内核的效率会更高,和这二者无关的操作不会因为锁而被阻拦。