Error[8]: Undefined offset: 8, File: /www/wwwroot/outofmemory.cn/tmp/plugin_ss_superseo_model_superseo.php, Line: 121
File: /www/wwwroot/outofmemory.cn/tmp/plugin_ss_superseo_model_superseo.php, Line: 473, decode(

系统调用ptrace和进程跟踪

为方便应用软件的开发和调试,从Unix的早期版本开始就提供了一种对运行中的进程进行跟踪和控制的手段,那就是系统调用ptrace。通过ptrace,一个进程可以动态地读写另一个进程的内存和寄存器,包括其指令空间、数据空间、堆栈以及所有的寄存器。与信号机制(以及其他手段)相结合,就可以实现让一个进程在另一个进程的控制和跟踪下运行的目的。GNU的调试工具gdb就是一个典型的实例。通过gdb,软件开发人员可以使一个应用程序在gdb的监视和 *** 纵下受控地运行。对于受gdb控制的进程,可以通过在其程序中设置断点,检查其堆栈以确定函数调用路线,检查并改变局部变量或全局变量的内容等等方法,来进行调试。显然,所有这些手段从概念上说都确实属于进程间通信的范畴,但是必须指出,这只是为软件调试而设计和设立的,不应该用于一般的进程间通信。一般而言,通信是要由双方都介入且相协调才能完成的。就拿管道来说,虽然管道时单向的,但一定得由一方写,另一方读才能达到目的。再拿信号来说,虽然信号时异步的,也就是接收信号的一方并不知道信号会在什么时候到来,因而在应用程序中并不主动有意地区检查有否信号到达。但是从总体而言,接收方知道信号可能会到来,并且为此在应用程序中作出了安排。而挡信号真的到来时,接收方也知道其到来,并根据事先的安排做出反应。然而,由ptrace所实现的通信却完全是当方面的,被跟踪的进程甚至并不知道(从应用程序的角度而言)自己是在受到控制和监视的条件下运行。从这个角度将,ptrace其实又不属于进程间通信。

那么。怎样通过ptrace来实现上述这些目的呢?先来看看这个系统调用的格式:

long ptrace(enum __ptrace_request request, pid_t pid,
                   void *addr, void *data);

参数pid为进程号,指明了 *** 作的对象,而request,则是具体的 *** 作,文件include/linux/ptrace.h中定义了所有可能的 *** 作码:

#define PTRACE_TRACEME		   0
#define PTRACE_PEEKTEXT		   1
#define PTRACE_PEEKDATA		   2
#define PTRACE_PEEKUSR		   3
#define PTRACE_POKETEXT		   4
#define PTRACE_POKEDATA		   5
#define PTRACE_POKEUSR		   6
#define PTRACE_CONT		   7
#define PTRACE_KILL		   8
#define PTRACE_SINGLESTEP	   9

#define PTRACE_ATTACH		0x10
#define PTRACE_DETACH		0x11

#define PTRACE_SYSCALL		  24

跟踪着(如gdb)先要通过PTRACE_ATTACH与被跟踪进程建立起关系,或者说attach到被跟踪进程。然后,就可以通过各种PEEK和POKE *** 作来读写被跟踪进程的指令空间、数据空间或者各个寄存器,每次都是一个长字,由addr指明其地址;或者,也可以通过PTRACE_SINGLESTEP、PTRACE_KILL、PTRACE_SYSCALL和PTRACE_CONT等 *** 作来控制被跟踪进程的运行。最后,通过PTRACE_DETACH跟被跟踪进程脱离关系。所有这些 *** 作都是单方面的,被跟踪进程既不能拒绝,也无需合作。唯一例外是PTRACE_TRACEME,用来主动接受跟踪。

像其他系统调用一样,ptrace在内核中的实现是sys_ptrace。其代码如下:

sys_ptrace

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
	struct task_struct *child;
	struct user * dummy = NULL;
	int i, ret;

	lock_kernel();
	ret = -EPERM;
	if (request == PTRACE_TRACEME) {
		
		if (current->ptrace & PT_PTRACED)
			goto out;
		
		current->ptrace |= PT_PTRACED;
		ret = 0;
		goto out;
	}

首先是对PTRACE_TRACEME的处理,那就是设置当前进程task_struct中的标志位PT_PTRACED。这个标志位的作用读者以后会看到。如果不是主动请求跟踪,那就一定有个目标进程了。继续往下看代码:

sys_ptrace

	ret = -ESRCH;
	read_lock(&tasklist_lock);
	child = find_task_by_pid(pid);
	if (child)
		get_task_struct(child);
	read_unlock(&tasklist_lock);
	if (!child)
		goto out;

	ret = -EPERM;
	if (pid == 1)		
		goto out_tsk;

函数find_task_by_pid,顾名思义就是根据进程号找到目标进程的task_struct结构。可是跟踪者怎样才能知道目标进程的进程号呢?以gdb为例有两种情况。一种情况是被跟踪进程本来就是gdb通过fork和exec启动的。这种情况下的命令行为:

gdb prog

执行prog的进程本来就是gdb的子进程,所以gdb当然知道它的进程号。

另一种情况是prog进程在启动gdb 之前已经在运行。在这种情况下 *** 作人员要先弄清楚它的进程号(如通过ps),再把这进程号作为参数在启动gdb的命令行中。此时的命令行类似于:

gdb prog 1234

因为,在这两种情况下gdb都会知道目标进程的进程号。

不过清注意,1号进程,即初始化进程init是不允许跟踪的。

找到目标进程以后,要通过get_task_struct递增对子进程的task_struct所在页面的使用计数,到完成了 *** 作以后再通过后面(468行)的free_task_struct还原。这是因为有些 *** 作在过程中可能会发生进程调度(读者可以自己看一下access_process_vm的代码),需要防止因为子进程先得到机会运行并且exit,从而将其task_struct结构所在页面释放掉的可能。

现在可以执行具体的 *** 作了,先来看PTRACE_ATTACH:

sys_ptrace

	if (request == PTRACE_ATTACH) {
		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;
		
		if (child->ptrace & PT_PTRACED)
			goto out_tsk;
		child->ptrace |= PT_PTRACED;

		write_lock_irq(&tasklist_lock);
		if (child->p_pptr != current) {
			REMOVE_linkS(child);
			child->p_pptr = current;
			SET_linkS(child);
		}
		write_unlock_irq(&tasklist_lock);

		send_sig(SIGSTOP, child, 1);
		ret = 0;
		goto out_tsk;
	}

跟踪不是无条件的。谁可以跟踪谁,需要满足一些条件。首先,自己不允许(也不必要)跟踪自己。除此之外,170行开始的if语句给出了这些条件。一般来说,两个进程要属于同一用户或同一组。读者可以参看文件系统系列的有关内容,搞清楚这些条件的含义。注意这里的capable定义为suser,也就是说如果两个进程不属于同一组,就要将当前进程提升为特权用户进程才行,而这当然也是有条件的。此外,被跟踪的进程必须是尚未受其他进程跟踪的。所谓attach,或者说建立起跟踪关系,就是做三件事:一是将被跟踪进程的PT_PTRACED标志设成1(182行)。还有, 就是如果被跟踪进程不是跟踪者的子进程就是将其收养为跟踪者的子进程(185-189行)。最后,还要向被跟踪进程发送一个SIGSTOP信号(192行),这样被跟踪进程被调度运行时就会对信号作出反应而进入暂停状态。

如果不是PTRACE_ATTACH的话,那就必然是对已经处于被跟踪地位的进程的后续 *** 作了。

sys_ptrace

	ret = -ESRCH;
	if (!(child->ptrace & PT_PTRACED))
		goto out_tsk;
	if (child->state != TASK_STOPPED) {
		if (request != PTRACE_KILL)
			goto out_tsk;
	}
	if (child->p_pptr != current)
		goto out_tsk;

就是说,先要加以核实。目标进程的PT_PTRACED标志位必须是1,目标进程必须是当前进程的子进程,并且处于TASK_STOPPED状态。这也说明,如果目标进程是通过PTRACE_TRACEME *** 作主动接受跟踪的话,只有其父进程才能对其实行跟踪,并且先要向其发送一个SIGSTOP信号。所以跟踪只能对子进程进行,哪怕是临时收养的子进程。

通过了对条件的检验以后,就进入一个switch语句,针对不同的 *** 作码来执行了:

sys_ptrace

	switch (request) {
	
	case PTRACE_PEEKTEXT:  
	case PTRACE_PEEKdata: {
		unsigned long tmp;
		int copied;

		copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0);
		ret = -EIO;
		if (copied != sizeof(tmp))
			break;
		ret = put_user(tmp,(unsigned long *) data);
		break;
	}

PTRACE_PEEKTEXT *** 作从子进程的指令空间,或称代码段中地址为addr处读取一个长字,而PTRACE_PEEKDATA则从子进程的数据空间读一个长字。读者可以回顾一下之前的有关的内容,在linux内核中代码空间和数据空间实际上是一致的,所以二者可以合并在一起处理。函数access_process_vm是个对给定进程的存储空间进行读或写的通用函数。它先通过find_extend_vma找到该进程包含着给定地址的虚存孔家,然后根据需要读写的长度在access_mm中通过access_one_page访问所涉及的各个页面。而access_one_page则是对给定进程的某一页面进行读写的通用函数,它从进程的某一虚存区间,也就是vm_area_struct结构开始,先找到给定页面所在的页面目录项,然后往下找到相应的页面项。找到页面项以后,就可以将其所映射的物理页面临时映射到当前用户空间。也许有读者会问,当父进程正在读子进程的物理空间是,会不会资金车公也正好在同一地址上鞋,从而使读出的数据不正确呢?不会的。首先,前面的检验已经确保了子进程正处于暂停状态TASK_STOPPED(这已经是考虑可多处理器的情况,至于在单处理器系统中,则既然当前进程正在运行,其子进程显然不在运行)。另外,对PTRACE_PEEKTEXT和PTRACE_PEEKDATA而言,所读取的只是一个长字,在32位的CPU中只要一条指令就完成了,是个原子 *** 作。

回顾前面的博客,读者就可以名表,进程的用户空间堆栈也在其数据空间中,所以也可以通过PTRACE_PEEKDATA *** 作来读子进程用户空间的堆栈。当然,先要通过其他 *** 作得到其用户空间的堆栈指针。

最后,还要指出,PTRACE_PEEKDATA和PTRACE_PEEKTEXT只能用来读子进程的用户空间,而不能用来读系统(内核)空间,这是由函数find_extend_vma所保证的。但是,子进程的(与跟踪有关的)有些信息却在系统空间中。例如,当子进程处于睡眠或暂停状态时,其进入系统空间前夕的寄存器内容都保存在它的系统空间堆栈中(pt_regs结构),还有些信息则在它的task_struct结构内别的一个thread_struct结构中。怎样读取这些信息呢?沿着ptrace.c的代码继续往下看:

sys_ptrace

	
	case PTRACE_PEEKUSR: {
		unsigned long tmp;

		ret = -EIO;
		if ((addr & 3) || addr < 0 || 
		    addr > sizeof(struct user) - 3)
			break;

		tmp = 0;  
		if(addr < 17*sizeof(long))
			tmp = getreg(child, addr);
		if(addr >= (long) &dummy->u_debugreg[0] &&
		   addr <= (long) &dummy->u_debugreg[7]){
			addr -= (long) &dummy->u_debugreg[0];
			addr = addr >> 2;
			tmp = child->thread.debugreg[addr];
		}
		ret = put_user(tmp,(unsigned long *) data);
		break;
	}

这个 *** 作有两种作用,第一是用于读取子进程在用户空间运行时(进入系统空间前夕)的某个寄存器的内容(注意此时子进程必定在系统空间中,因为调度和切换只发生于系统空间)。我们先来看这一部分。要读取一个寄存器的内容时,参数addr必须是寄存器号乘以4。对i386处理器而言共有17个这样的寄存器。定义于ptrace.h中。不过,所谓寄存器其实并不完全是字面意义上的,例如EAX和ORIG_EAX就算做两项,因为系统空间堆栈的pt_regs结构中它们是有区别的(系统调用使用EAX来返回出错代码)。当addr之姓名这个17个寄存器之一时,就通过getreg来读取其内容(代码在同一文件arch/i386/kernel/ptrace.c中):

sys_ptrace=>getreg

static unsigned long getreg(struct task_struct *child,
	unsigned long regno)
{
	unsigned long retval = ~0UL;

	switch (regno >> 2) {
		case FS:
			retval = child->thread.fs;
			break;
		case GS:
			retval = child->thread.gs;
			break;
		case DS:
		case ES:
		case SS:
		case CS:
			retval = 0xffff;
			
		default:
			if (regno > GS*4)
				regno -= 2*4;
			regno = regno - sizeof(struct pt_regs);
			retval &= get_stack_long(child, regno);
	}
	return retval;
}

也就是说,除FS和GS的映像在thread_struct结构中外,其余的都在系统空间堆栈的pt_regs结构中。注意,第127行处并无break语句。函数get_stack_long的代码也在同一文件中:

sys_ptrace=>getreg=>get_stack_long

   
static inline int get_stack_long(struct task_struct *task, int offset)
{
	unsigned char *stack;

	stack = (unsigned char *)task->thread.esp0;
	stack += offset;
	return (*((int *)stack));
}

读者也许还记得,一个进程的thread_struct结构中的esp0保存着其系统空间堆栈指针。当进程穿过中断门、陷阱门或调用门进入系统空间时,处理器会从这里恢复其系统空间堆栈。

再来看PTRACE_PEEKUSR的第二种作用,这就要先介绍一些背景知识了。Intel在i386系统结构中首创性地引入了调试寄存器(debug registers),为软件的开发与维护提供了功能很强而且效率很高的调试手段。用户进程可以通过设置一些调试寄存器来使处理器在一定的条件下落入陷阱,从而进入一个断点,即一段调试程序。这些条件包括:当处理器执行到某一指令时;当处理器读某一内存地址时;从处理器写某一内存地址时。而陷阱则是指专门用于虚地址模式程序调试的1号陷阱debug(另有一个用户实地址模式的3号陷阱int 3,在linux中仅用于VM86模式)。内核中对这个陷阱的处理程序为do_debug,其代码在arch/i386/kernel/traps.c中。有关调试寄存器的详情则请参阅Intel的手册或其他技术资料:

do_debug


asmlinkage void do_debug(struct pt_regs * regs, long error_code)
{
	unsigned int condition;
	struct task_struct *tsk = current;
	siginfo_t info;

	__asm__ __volatile__("movl %%db6,%0" : "=r" (condition));

	
	if (condition & (DR_TRAP0|DR_TRAP1|DR_TRAP2|DR_TRAP3)) {
		if (!tsk->thread.debugreg[7])
			goto clear_dr7;
	}

	if (regs->eflags & VM_MASK)
		goto debug_vm86;

	
	tsk->thread.debugreg[6] = condition;

	
	if (condition & DR_STEP) {
		
		if ((tsk->ptrace & (PT_DTRACE|PT_PTRACED)) == PT_DTRACE)
			goto clear_TF;
	}

	
	tsk->thread.trap_no = 1;
	tsk->thread.error_code = error_code;
	info.si_signo = SIGTRAP;
	info.si_errno = 0;
	info.si_code = TRAP_BRKPT;
	
	
	info.si_addr = ((regs->xcs & 3) == 0) ? (void *)tsk->thread.eip : 
	                                        (void *)regs->eip;
	force_sig_info(SIGTRAP, &info, tsk);

	
clear_dr7:
	__asm__("movl %0,%%db7"
		: 
		: "r" (0));
	return;

debug_vm86:
	handle_vm86_trap((struct kernel_vm86_regs *) regs, error_code, 1);
	return;

clear_TF:
	regs->eflags &= ~TF_MASK;
	return;
}

我们不详细讲解这些程序了,但是读者可以看到它对当前进程,也就是引起此次陷阱的进程发出一个SIGTRAP信号(见557行),并且通过siginfo_t数据解耦股载送断点所在的地址(见555行)。当然,引起这次陷阱的进程要事先为处理这个信号做好准备(否则进程就会流产)。这也是为什么在编译供调试程序时要使用-g选择项的原因之一。

回到PTRACE_PEEKUSR的代码中。这里的局部变量dummy是个user结构指针,其值在开头初始化成NULL。第232和233行是对addr的范围进行检查。也就是,假定一个user结构时从地址0开始的,看addr的值是否对应于该结构中u_debugreg数组的偏移量。数据结构struct user 是在进程流产(abort)时转储(dump)内存映像时使用的,定义如下:


struct user{

  struct user_regs_struct regs;		

  int u_fpvalid;		
                                
  struct user_i387_struct i387;	

  unsigned long int u_tsize;	
  unsigned long int u_dsize;	
  unsigned long int u_ssize;	
  unsigned long start_code;     
  unsigned long start_stack;	
  long int signal;     		
  int reserved;			
  struct user_pt_regs * u_ar0;	
				
  struct user_i387_struct* u_fpstate;	
  unsigned long magic;		
  char u_comm[32];		
  int u_debugreg[8];
};

不过,这个数据结构在这里只是用来检查参数addr的范围,而具体调试寄存器的映像则在资金和曾的thread_struct结构中(见236行)。

PTRACE_POKETEXT和PTRACE_POKEDATA是前面两个 *** 作的逆向 *** 作,代码很简单:

sys_ptrace

	
	case PTRACE_POKETEXT: 
	case PTRACE_POKEdata:
		ret = 0;
		if (access_process_vm(child, addr, &data, sizeof(data), 1) == sizeof(data))
			break;
		ret = -EIO;
		break;

PTRACE_POKEUSR则稍微要复杂一点:

sys_ptrace

	case PTRACE_POKEUSR: 
		ret = -EIO;
		if ((addr & 3) || addr < 0 || 
		    addr > sizeof(struct user) - 3)
			break;

		if (addr < 17*sizeof(long)) {
			ret = putreg(child, addr, data);
			break;
		}
		

		  ret = -EIO;
		  if(addr >= (long) &dummy->u_debugreg[0] &&
		     addr <= (long) &dummy->u_debugreg[7]){

			  if(addr == (long) &dummy->u_debugreg[4]) break;
			  if(addr == (long) &dummy->u_debugreg[5]) break;
			  if(addr < (long) &dummy->u_debugreg[4] &&
			     ((unsigned long) data) >= TASK_SIZE-3) break;
			  
			  if(addr == (long) &dummy->u_debugreg[7]) {
				  data &= ~DR_CONTROL_RESERVED;
				  for(i=0; i<4; i++)
					  if ((0x5f54 >> ((data >> (16 + 4*i)) & 0xf)) & 1)
						  goto out_tsk;
			  }

			  addr -= (long) &dummy->u_debugreg;
			  addr = addr >> 2;
			  child->thread.debugreg[addr] = data;
			  ret = 0;
		  }
		  break;

这里的特别之处仅在于对参数addr和data的检查。首先,调试寄存器0至3这四个寄存器是允许设置的,但是要检查所设置的data(实际上是个内存地址)是否越出了用户空间的范围。除此之外,只有调度寄存器7时允许设置的,但是对其数值有些特殊的要求。

*** 作PTRACE_SYSCALL和PTRACE_CONT为一组,分别用来使被跟踪的子进程在下一次系统调用时暂停或继续:

sys_ptrace

	case PTRACE_SYSCALL: 
	case PTRACE_CONT: { 
		long tmp;

		ret = -EIO;
		if ((unsigned long) data > _NSIG)
			break;
		if (request == PTRACE_SYSCALL)
			child->ptrace |= PT_TRACESYS;
		else
			child->ptrace &= ~PT_TRACESYS;
		child->exit_code = data;
	
		tmp = get_stack_long(child, EFL_OFFSET) & ~TRAP_FLAG;
		put_stack_long(child, EFL_OFFSET,tmp);
		wake_up_process(child);
		ret = 0;
		break;
	}

使子进程在下一次进入系统调用时暂停与使子进程群在执行下一条指令后暂停(PTRACE_SINGLESTEP)是互斥的。所以,要将子进程的标志寄存器映像中的TRAP_FLAG标志清0(302~303行)。读者在前面已看到过get_stack_long的代码,而put_stack_long即为其逆向 *** 作。使被跟踪进程在下一次进入系统调用时暂停时通过其task_struct结构中的PT_TRACESYS标志位起作用的。

在前面讲述系统调用过程时我们有意忽略了标志位PT_TRACESYS的作用,现在把它补上。让我们来看看文件arch/i386/kernel/entry.S中的几个片段:

ENTRY(system_call)
	pushl %eax			# save orig_eax
	SAVE_ALL
	GET_CURRENT(%ebx)
	cmpl $(NR_syscalls),%eax
	jae badsys
	testb 
tracesys:
	movl $-ENOSYS,EAX(%esp)
	call SYMBOL_NAME(syscall_trace)
	movl ORIG_EAX(%esp),%eax
	cmpl $(NR_syscalls),%eax
	jae tracesys_exit
	call *SYMBOL_NAME(sys_call_table)(,%eax,4)
	movl %eax,EAX(%esp)		# save the return value
tracesys_exit:
	call SYMBOL_NAME(syscall_trace)
	jmp ret_from_sys_call
x02,tsk_ptrace(%ebx) # PT_TRACESYS jne tracesys call *SYMBOL_NAME(sys_call_table)(,%eax,4)
[+++]

在跳转到各个系统调用的处理程序之前,先要检查当前进程的PT_TRACESYS标志,如果为1就转移到tracesys。转到tracesys以后,首先就是调用syscall_trace,其代码又回到arch/i386/kernel/ptrace.c中:

system_call=>syscall_trace

asmlinkage void syscall_trace(void)
{
	if ((current->ptrace & (PT_PTRACED|PT_TRACESYS)) !=
			(PT_PTRACED|PT_TRACESYS))
		return;
	
	current->exit_code = SIGTRAP | ((current->ptrace & PT_TRACESYSGOOD)
					? 0x80 : 0);
	current->state = TASK_STOPPED;
	notify_parent(current, SIGCHLD);
	schedule();
	
	if (current->exit_code) {
		send_sig(current->exit_code, current, 1);
		current->exit_code = 0;
	}
}

在这里,通过notify_parent,向父进程发送一个SIGCHLD信号,读者已经看过notify_parent的代码。然后就调用schedule进入暂停状态TASK_STOPPED。当然,其父进程必定已经设置好对SIGCHLD的反应。当父进程设置了子进程的PT_TRACESYS标志位,然后又接收到子进程发送过来的SIGCHLD信号时,就知道子进程已经在系统调用的入口处陷入暂停状态。这时候父进程就可以通过PTRACE_POKEUSR等 *** 作来收集或改变有关的数据(如调用参数)。然后,可以通过向子进程发送一个SIGCONT信号让它继续运行,也就是让它从syscall_trace中的schedule返回,而回到entry.S中的tracesys处通过跳转表进入具体系统调用的代码(见250行)。父进程还可以通过PTRACE_POKEUSR等 *** 作将子进程的ORIG_EAX设置成一个大于NT_syscalls的值,使子进程跳过对系统调用本身的执行(见249行)。最后,子进程在执行完系统调用本身以后,在tracesys_exit处还要再调用一次syscall_trace,让父进程有个机会来收集子进程在执行完系统调用后的结果(如返回值或出错代码)。这样,父进程就可以监视子进程的所有系统调用,甚至还能向子进程伪造对系统调用的执行,把子进程的系统调用重定向到父进程的用户空间程序中。

回到arch/i386/kernel/ptrace.c中函数sys_ptrace的代码继续往下看,下面比较简单一些了:

sys_ptrace

	case PTRACE_KILL: {
		long tmp;

		ret = 0;
		if (child->state == TASK_ZOMBIE)	
			break;
		child->exit_code = SIGKILL;
		
		tmp = get_stack_long(child, EFL_OFFSET) & ~TRAP_FLAG;
		put_stack_long(child, EFL_OFFSET, tmp);
		wake_up_process(child);
		break;
	}

PTRACE_KILL *** 作使子进程退出运行。除PTRACE_ATTACH以外,其它的 *** 作一般都要求目标进程处于暂停状态(只有这样,目标进程的内存和寄存器映像才是静态的),只有PTRACE_KILL是个例外(见前面的200-201行所做的检查)。函数wake_up_process将目标进程的状态改成TASK_RUNNING,而不问其原来是什么状态。如果子进程处于PF_TRACESYS状态,则当子进程下一次进行系统调用而在内核中进入syscall_trace以后,会向其自身发送一个SIGKILL信号(见上面的479-481行)。继续往下看:

sys_ptrace

	case PTRACE_SINGLESTEP: {  
		long tmp;

		ret = -EIO;
		if ((unsigned long) data > _NSIG)
			break;
		child->ptrace &= ~PT_TRACESYS;
		if ((child->ptrace & PT_DTRACE) == 0) {
			
			child->ptrace |= PT_DTRACE;
		}
		tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
		put_stack_long(child, EFL_OFFSET, tmp);
		child->exit_code = data;
		
		wake_up_process(child);
		ret = 0;
		break;
	}

除通过前述的调试寄存器可以让寄存器在特定的条件下进入1号陷阱debug外,i386 CPU还提供了单步执行的手段。只要在处理器的标志寄存器中将TRAP_FLAG标志位设成1,处理器就会在每执行完一条机器指令就进入debug陷阱而到达一个断点。这样,跟踪进程就可以像对待子进程的系统调用一样,在子进程每执行完一条指令后就来观察执行的结果。不过,对指令的跟踪与对系统调用的跟踪是互斥的,所以要将子进程的PF_TRACESYS标志清0。主语PF_TRACESYS标志与TRAP_FLAG是两码事。前者是一个软件标志,是进程的task_struct结构内部flags中的一位,这完全是供软件使用的,而与处理器的硬件没有直接的联系。相比之下,TRAP_FLAG是个硬件标志,它是处理器中的标志寄存器EFL的一位,直接影响着处理器的行为。每当调度一个进程进入运行时,就会在返回用户空间前夕将其标志寄存器映像装入CPU的标志寄存器EFL,所以可以实现对该进程的单步跟踪,而并不影响其它进程或者系统空间的运行。但是,直接用TRAP_FLAG标志位来代表ptrace机制的单步跟踪状态是不可靠的。这是因为应用软件也可以改变处理器的标志寄存器,从而造成混淆。所以,ptrace同时还定义了一个永远单步执行的软件标志PT_DTRACE,在通过ptrace的PTRACE_SINGLESTEP来开始单步跟踪时就将这个软件标志也设成1。其余的 *** 作就比较简单了。结合前面一些 *** 作的代码,读者自行约的应该不会有困难。

sys_ptrace

	case PTRACE_DETACH:
		
		ret = ptrace_detach(child, data);
		break;

	case PTRACE_GETREGS: { 
	  	if (!access_ok(VERIFY_WRITE, (unsigned *)data, frame_SIZE*sizeof(long))) {
			ret = -EIO;
			break;
		}
		for ( i = 0; i < frame_SIZE*sizeof(long); i += sizeof(long) ) {
			__put_user(getreg(child, i),(unsigned long *) data);
			data += sizeof(long);
		}
		ret = 0;
		break;
	}

	case PTRACE_SETREGS: { 
		unsigned long tmp;
	  	if (!access_ok(VERIFY_READ, (unsigned *)data, frame_SIZE*sizeof(long))) {
			ret = -EIO;
			break;
		}
		for ( i = 0; i < frame_SIZE*sizeof(long); i += sizeof(long) ) {
			__get_user(tmp, (unsigned long *) data);
			putreg(child, i, tmp);
			data += sizeof(long);
		}
		ret = 0;
		break;
	}

	case PTRACE_GETFPREGS: { 
		if (!access_ok(VERIFY_WRITE, (unsigned *)data,
			       sizeof(struct user_i387_struct))) {
			ret = -EIO;
			break;
		}
		ret = 0;
		if ( !child->used_math )
			load_empty_fpu(child);
		get_fpregs((struct user_i387_struct *)data, child);
		break;
	}

	case PTRACE_SETFPREGS: { 
		if (!access_ok(VERIFY_READ, (unsigned *)data,
			       sizeof(struct user_i387_struct))) {
			ret = -EIO;
			break;
		}
		child->used_math = 1;
		set_fpregs(child, (struct user_i387_struct *)data);
		ret = 0;
		break;
	}

	case PTRACE_GETFPXREGS: { 
		if (!access_ok(VERIFY_WRITE, (unsigned *)data,
			       sizeof(struct user_fxsr_struct))) {
			ret = -EIO;
			break;
		}
		if ( !child->used_math )
			load_empty_fpu(child);
		ret = get_fpxregs((struct user_fxsr_struct *)data, child);
		break;
	}

	case PTRACE_SETFPXREGS: { 
		if (!access_ok(VERIFY_READ, (unsigned *)data,
			       sizeof(struct user_fxsr_struct))) {
			ret = -EIO;
			break;
		}
		child->used_math = 1;
		ret = set_fpxregs(child, (struct user_fxsr_struct *)data);
		break;
	}

	case PTRACE_SETOPTIONS: {
		if (data & PTRACE_O_TRACESYSGOOD)
			child->ptrace |= PT_TRACESYSGOOD;
		else
			child->ptrace &= ~PT_TRACESYSGOOD;
		ret = 0;
		break;
	}

	default:
		ret = -EIO;
		break;
	}
out_tsk:
	free_task_struct(child);
out:
	unlock_kernel();
	return ret;
}

如前所述,ptrace在实际运行中并不是用作进程间通讯手段,而是作为程序调试和维护的手段。作为调试手段,其各方面的作用在早起Unix系统中是无可替代的。不过,随着Unix(以及Linux)的发展,出现了/proc目录下的特殊文件(见文件系统系列的有关内容),使用户可以通过这些特殊文件来读写一个进程的内存空间和其它信息,而且往往更为方便,形式上也更为划一。所以,近年来像gdb一类的调试工具已经倾向于更多地使用这些特殊文件(严格说来这些特殊文件当然也可用于进程间通讯、只不过人们已经有了更好的进程间通讯手段,因为不会这样去用而已)。但是,尽管如此,/proc特殊文件还是不能完全取代ptrace的作用。例如,ptrace有个无可替代的作用,那就是可以通过跟踪应用程序所做的系统调用来监视其运行。我们知道,Linux内核的源代码是公开的,可是应用程序的源码却一般都不公开。拿到一个应用程序以后,如果想要知道它究竟在干什么,最好的办法就是监视它都做了些什么系统调用,调用时的参数都是些什么,返回值又是什么。这时候就要用到ptrace了。为了这个目的,Linux专门提供了一个工具,即shell实用程序strace。读者不妨先体验一下strace的使用,然后,想想它是怎样实现的?

% strace echo hello

)
File: /www/wwwroot/outofmemory.cn/tmp/route_read.php, Line: 126, InsideLink()
File: /www/wwwroot/outofmemory.cn/tmp/index.inc.php, Line: 166, include(/www/wwwroot/outofmemory.cn/tmp/route_read.php)
File: /www/wwwroot/outofmemory.cn/index.php, Line: 30, include(/www/wwwroot/outofmemory.cn/tmp/index.inc.php)
系统调用ptrace和进程跟踪_随笔_内存溢出

系统调用ptrace和进程跟踪

系统调用ptrace和进程跟踪,第1张

系统调用ptrace和进程跟踪

为方便应用软件的开发和调试,从Unix的早期版本开始就提供了一种对运行中的进程进行跟踪和控制的手段,那就是系统调用ptrace。通过ptrace,一个进程可以动态地读写另一个进程的内存和寄存器,包括其指令空间、数据空间、堆栈以及所有的寄存器。与信号机制(以及其他手段)相结合,就可以实现让一个进程在另一个进程的控制和跟踪下运行的目的。GNU的调试工具gdb就是一个典型的实例。通过gdb,软件开发人员可以使一个应用程序在gdb的监视和 *** 纵下受控地运行。对于受gdb控制的进程,可以通过在其程序中设置断点,检查其堆栈以确定函数调用路线,检查并改变局部变量或全局变量的内容等等方法,来进行调试。显然,所有这些手段从概念上说都确实属于进程间通信的范畴,但是必须指出,这只是为软件调试而设计和设立的,不应该用于一般的进程间通信。一般而言,通信是要由双方都介入且相协调才能完成的。就拿管道来说,虽然管道时单向的,但一定得由一方写,另一方读才能达到目的。再拿信号来说,虽然信号时异步的,也就是接收信号的一方并不知道信号会在什么时候到来,因而在应用程序中并不主动有意地区检查有否信号到达。但是从总体而言,接收方知道信号可能会到来,并且为此在应用程序中作出了安排。而挡信号真的到来时,接收方也知道其到来,并根据事先的安排做出反应。然而,由ptrace所实现的通信却完全是当方面的,被跟踪的进程甚至并不知道(从应用程序的角度而言)自己是在受到控制和监视的条件下运行。从这个角度将,ptrace其实又不属于进程间通信。

那么。怎样通过ptrace来实现上述这些目的呢?先来看看这个系统调用的格式:

long ptrace(enum __ptrace_request request, pid_t pid,
                   void *addr, void *data);

参数pid为进程号,指明了 *** 作的对象,而request,则是具体的 *** 作,文件include/linux/ptrace.h中定义了所有可能的 *** 作码:

#define PTRACE_TRACEME		   0
#define PTRACE_PEEKTEXT		   1
#define PTRACE_PEEKDATA		   2
#define PTRACE_PEEKUSR		   3
#define PTRACE_POKETEXT		   4
#define PTRACE_POKEDATA		   5
#define PTRACE_POKEUSR		   6
#define PTRACE_CONT		   7
#define PTRACE_KILL		   8
#define PTRACE_SINGLESTEP	   9

#define PTRACE_ATTACH		0x10
#define PTRACE_DETACH		0x11

#define PTRACE_SYSCALL		  24

跟踪着(如gdb)先要通过PTRACE_ATTACH与被跟踪进程建立起关系,或者说attach到被跟踪进程。然后,就可以通过各种PEEK和POKE *** 作来读写被跟踪进程的指令空间、数据空间或者各个寄存器,每次都是一个长字,由addr指明其地址;或者,也可以通过PTRACE_SINGLESTEP、PTRACE_KILL、PTRACE_SYSCALL和PTRACE_CONT等 *** 作来控制被跟踪进程的运行。最后,通过PTRACE_DETACH跟被跟踪进程脱离关系。所有这些 *** 作都是单方面的,被跟踪进程既不能拒绝,也无需合作。唯一例外是PTRACE_TRACEME,用来主动接受跟踪。

像其他系统调用一样,ptrace在内核中的实现是sys_ptrace。其代码如下:

sys_ptrace

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
	struct task_struct *child;
	struct user * dummy = NULL;
	int i, ret;

	lock_kernel();
	ret = -EPERM;
	if (request == PTRACE_TRACEME) {
		
		if (current->ptrace & PT_PTRACED)
			goto out;
		
		current->ptrace |= PT_PTRACED;
		ret = 0;
		goto out;
	}

首先是对PTRACE_TRACEME的处理,那就是设置当前进程task_struct中的标志位PT_PTRACED。这个标志位的作用读者以后会看到。如果不是主动请求跟踪,那就一定有个目标进程了。继续往下看代码:

sys_ptrace

	ret = -ESRCH;
	read_lock(&tasklist_lock);
	child = find_task_by_pid(pid);
	if (child)
		get_task_struct(child);
	read_unlock(&tasklist_lock);
	if (!child)
		goto out;

	ret = -EPERM;
	if (pid == 1)		
		goto out_tsk;

函数find_task_by_pid,顾名思义就是根据进程号找到目标进程的task_struct结构。可是跟踪者怎样才能知道目标进程的进程号呢?以gdb为例有两种情况。一种情况是被跟踪进程本来就是gdb通过fork和exec启动的。这种情况下的命令行为:

gdb prog

执行prog的进程本来就是gdb的子进程,所以gdb当然知道它的进程号。

另一种情况是prog进程在启动gdb 之前已经在运行。在这种情况下 *** 作人员要先弄清楚它的进程号(如通过ps),再把这进程号作为参数在启动gdb的命令行中。此时的命令行类似于:

gdb prog 1234

因为,在这两种情况下gdb都会知道目标进程的进程号。

不过清注意,1号进程,即初始化进程init是不允许跟踪的。

找到目标进程以后,要通过get_task_struct递增对子进程的task_struct所在页面的使用计数,到完成了 *** 作以后再通过后面(468行)的free_task_struct还原。这是因为有些 *** 作在过程中可能会发生进程调度(读者可以自己看一下access_process_vm的代码),需要防止因为子进程先得到机会运行并且exit,从而将其task_struct结构所在页面释放掉的可能。

现在可以执行具体的 *** 作了,先来看PTRACE_ATTACH:

sys_ptrace

	if (request == PTRACE_ATTACH) {
		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;
		
		if (child->ptrace & PT_PTRACED)
			goto out_tsk;
		child->ptrace |= PT_PTRACED;

		write_lock_irq(&tasklist_lock);
		if (child->p_pptr != current) {
			REMOVE_linkS(child);
			child->p_pptr = current;
			SET_linkS(child);
		}
		write_unlock_irq(&tasklist_lock);

		send_sig(SIGSTOP, child, 1);
		ret = 0;
		goto out_tsk;
	}

跟踪不是无条件的。谁可以跟踪谁,需要满足一些条件。首先,自己不允许(也不必要)跟踪自己。除此之外,170行开始的if语句给出了这些条件。一般来说,两个进程要属于同一用户或同一组。读者可以参看文件系统系列的有关内容,搞清楚这些条件的含义。注意这里的capable定义为suser,也就是说如果两个进程不属于同一组,就要将当前进程提升为特权用户进程才行,而这当然也是有条件的。此外,被跟踪的进程必须是尚未受其他进程跟踪的。所谓attach,或者说建立起跟踪关系,就是做三件事:一是将被跟踪进程的PT_PTRACED标志设成1(182行)。还有, 就是如果被跟踪进程不是跟踪者的子进程就是将其收养为跟踪者的子进程(185-189行)。最后,还要向被跟踪进程发送一个SIGSTOP信号(192行),这样被跟踪进程被调度运行时就会对信号作出反应而进入暂停状态。

如果不是PTRACE_ATTACH的话,那就必然是对已经处于被跟踪地位的进程的后续 *** 作了。

sys_ptrace

	ret = -ESRCH;
	if (!(child->ptrace & PT_PTRACED))
		goto out_tsk;
	if (child->state != TASK_STOPPED) {
		if (request != PTRACE_KILL)
			goto out_tsk;
	}
	if (child->p_pptr != current)
		goto out_tsk;

就是说,先要加以核实。目标进程的PT_PTRACED标志位必须是1,目标进程必须是当前进程的子进程,并且处于TASK_STOPPED状态。这也说明,如果目标进程是通过PTRACE_TRACEME *** 作主动接受跟踪的话,只有其父进程才能对其实行跟踪,并且先要向其发送一个SIGSTOP信号。所以跟踪只能对子进程进行,哪怕是临时收养的子进程。

通过了对条件的检验以后,就进入一个switch语句,针对不同的 *** 作码来执行了:

sys_ptrace

	switch (request) {
	
	case PTRACE_PEEKTEXT:  
	case PTRACE_PEEKdata: {
		unsigned long tmp;
		int copied;

		copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0);
		ret = -EIO;
		if (copied != sizeof(tmp))
			break;
		ret = put_user(tmp,(unsigned long *) data);
		break;
	}

PTRACE_PEEKTEXT *** 作从子进程的指令空间,或称代码段中地址为addr处读取一个长字,而PTRACE_PEEKDATA则从子进程的数据空间读一个长字。读者可以回顾一下之前的有关的内容,在linux内核中代码空间和数据空间实际上是一致的,所以二者可以合并在一起处理。函数access_process_vm是个对给定进程的存储空间进行读或写的通用函数。它先通过find_extend_vma找到该进程包含着给定地址的虚存孔家,然后根据需要读写的长度在access_mm中通过access_one_page访问所涉及的各个页面。而access_one_page则是对给定进程的某一页面进行读写的通用函数,它从进程的某一虚存区间,也就是vm_area_struct结构开始,先找到给定页面所在的页面目录项,然后往下找到相应的页面项。找到页面项以后,就可以将其所映射的物理页面临时映射到当前用户空间。也许有读者会问,当父进程正在读子进程的物理空间是,会不会资金车公也正好在同一地址上鞋,从而使读出的数据不正确呢?不会的。首先,前面的检验已经确保了子进程正处于暂停状态TASK_STOPPED(这已经是考虑可多处理器的情况,至于在单处理器系统中,则既然当前进程正在运行,其子进程显然不在运行)。另外,对PTRACE_PEEKTEXT和PTRACE_PEEKDATA而言,所读取的只是一个长字,在32位的CPU中只要一条指令就完成了,是个原子 *** 作。

回顾前面的博客,读者就可以名表,进程的用户空间堆栈也在其数据空间中,所以也可以通过PTRACE_PEEKDATA *** 作来读子进程用户空间的堆栈。当然,先要通过其他 *** 作得到其用户空间的堆栈指针。

最后,还要指出,PTRACE_PEEKDATA和PTRACE_PEEKTEXT只能用来读子进程的用户空间,而不能用来读系统(内核)空间,这是由函数find_extend_vma所保证的。但是,子进程的(与跟踪有关的)有些信息却在系统空间中。例如,当子进程处于睡眠或暂停状态时,其进入系统空间前夕的寄存器内容都保存在它的系统空间堆栈中(pt_regs结构),还有些信息则在它的task_struct结构内别的一个thread_struct结构中。怎样读取这些信息呢?沿着ptrace.c的代码继续往下看:

sys_ptrace

	
	case PTRACE_PEEKUSR: {
		unsigned long tmp;

		ret = -EIO;
		if ((addr & 3) || addr < 0 || 
		    addr > sizeof(struct user) - 3)
			break;

		tmp = 0;  
		if(addr < 17*sizeof(long))
			tmp = getreg(child, addr);
		if(addr >= (long) &dummy->u_debugreg[0] &&
		   addr <= (long) &dummy->u_debugreg[7]){
			addr -= (long) &dummy->u_debugreg[0];
			addr = addr >> 2;
			tmp = child->thread.debugreg[addr];
		}
		ret = put_user(tmp,(unsigned long *) data);
		break;
	}

这个 *** 作有两种作用,第一是用于读取子进程在用户空间运行时(进入系统空间前夕)的某个寄存器的内容(注意此时子进程必定在系统空间中,因为调度和切换只发生于系统空间)。我们先来看这一部分。要读取一个寄存器的内容时,参数addr必须是寄存器号乘以4。对i386处理器而言共有17个这样的寄存器。定义于ptrace.h中。不过,所谓寄存器其实并不完全是字面意义上的,例如EAX和ORIG_EAX就算做两项,因为系统空间堆栈的pt_regs结构中它们是有区别的(系统调用使用EAX来返回出错代码)。当addr之姓名这个17个寄存器之一时,就通过getreg来读取其内容(代码在同一文件arch/i386/kernel/ptrace.c中):

sys_ptrace=>getreg

static unsigned long getreg(struct task_struct *child,
	unsigned long regno)
{
	unsigned long retval = ~0UL;

	switch (regno >> 2) {
		case FS:
			retval = child->thread.fs;
			break;
		case GS:
			retval = child->thread.gs;
			break;
		case DS:
		case ES:
		case SS:
		case CS:
			retval = 0xffff;
			
		default:
			if (regno > GS*4)
				regno -= 2*4;
			regno = regno - sizeof(struct pt_regs);
			retval &= get_stack_long(child, regno);
	}
	return retval;
}

也就是说,除FS和GS的映像在thread_struct结构中外,其余的都在系统空间堆栈的pt_regs结构中。注意,第127行处并无break语句。函数get_stack_long的代码也在同一文件中:

sys_ptrace=>getreg=>get_stack_long

   
static inline int get_stack_long(struct task_struct *task, int offset)
{
	unsigned char *stack;

	stack = (unsigned char *)task->thread.esp0;
	stack += offset;
	return (*((int *)stack));
}

读者也许还记得,一个进程的thread_struct结构中的esp0保存着其系统空间堆栈指针。当进程穿过中断门、陷阱门或调用门进入系统空间时,处理器会从这里恢复其系统空间堆栈。

再来看PTRACE_PEEKUSR的第二种作用,这就要先介绍一些背景知识了。Intel在i386系统结构中首创性地引入了调试寄存器(debug registers),为软件的开发与维护提供了功能很强而且效率很高的调试手段。用户进程可以通过设置一些调试寄存器来使处理器在一定的条件下落入陷阱,从而进入一个断点,即一段调试程序。这些条件包括:当处理器执行到某一指令时;当处理器读某一内存地址时;从处理器写某一内存地址时。而陷阱则是指专门用于虚地址模式程序调试的1号陷阱debug(另有一个用户实地址模式的3号陷阱int 3,在linux中仅用于VM86模式)。内核中对这个陷阱的处理程序为do_debug,其代码在arch/i386/kernel/traps.c中。有关调试寄存器的详情则请参阅Intel的手册或其他技术资料:

do_debug


asmlinkage void do_debug(struct pt_regs * regs, long error_code)
{
	unsigned int condition;
	struct task_struct *tsk = current;
	siginfo_t info;

	__asm__ __volatile__("movl %%db6,%0" : "=r" (condition));

	
	if (condition & (DR_TRAP0|DR_TRAP1|DR_TRAP2|DR_TRAP3)) {
		if (!tsk->thread.debugreg[7])
			goto clear_dr7;
	}

	if (regs->eflags & VM_MASK)
		goto debug_vm86;

	
	tsk->thread.debugreg[6] = condition;

	
	if (condition & DR_STEP) {
		
		if ((tsk->ptrace & (PT_DTRACE|PT_PTRACED)) == PT_DTRACE)
			goto clear_TF;
	}

	
	tsk->thread.trap_no = 1;
	tsk->thread.error_code = error_code;
	info.si_signo = SIGTRAP;
	info.si_errno = 0;
	info.si_code = TRAP_BRKPT;
	
	
	info.si_addr = ((regs->xcs & 3) == 0) ? (void *)tsk->thread.eip : 
	                                        (void *)regs->eip;
	force_sig_info(SIGTRAP, &info, tsk);

	
clear_dr7:
	__asm__("movl %0,%%db7"
		: 
		: "r" (0));
	return;

debug_vm86:
	handle_vm86_trap((struct kernel_vm86_regs *) regs, error_code, 1);
	return;

clear_TF:
	regs->eflags &= ~TF_MASK;
	return;
}

我们不详细讲解这些程序了,但是读者可以看到它对当前进程,也就是引起此次陷阱的进程发出一个SIGTRAP信号(见557行),并且通过siginfo_t数据解耦股载送断点所在的地址(见555行)。当然,引起这次陷阱的进程要事先为处理这个信号做好准备(否则进程就会流产)。这也是为什么在编译供调试程序时要使用-g选择项的原因之一。

回到PTRACE_PEEKUSR的代码中。这里的局部变量dummy是个user结构指针,其值在开头初始化成NULL。第232和233行是对addr的范围进行检查。也就是,假定一个user结构时从地址0开始的,看addr的值是否对应于该结构中u_debugreg数组的偏移量。数据结构struct user 是在进程流产(abort)时转储(dump)内存映像时使用的,定义如下:


struct user{

  struct user_regs_struct regs;		

  int u_fpvalid;		
                                
  struct user_i387_struct i387;	

  unsigned long int u_tsize;	
  unsigned long int u_dsize;	
  unsigned long int u_ssize;	
  unsigned long start_code;     
  unsigned long start_stack;	
  long int signal;     		
  int reserved;			
  struct user_pt_regs * u_ar0;	
				
  struct user_i387_struct* u_fpstate;	
  unsigned long magic;		
  char u_comm[32];		
  int u_debugreg[8];
};

不过,这个数据结构在这里只是用来检查参数addr的范围,而具体调试寄存器的映像则在资金和曾的thread_struct结构中(见236行)。

PTRACE_POKETEXT和PTRACE_POKEDATA是前面两个 *** 作的逆向 *** 作,代码很简单:

sys_ptrace

	
	case PTRACE_POKETEXT: 
	case PTRACE_POKEdata:
		ret = 0;
		if (access_process_vm(child, addr, &data, sizeof(data), 1) == sizeof(data))
			break;
		ret = -EIO;
		break;

PTRACE_POKEUSR则稍微要复杂一点:

sys_ptrace

	case PTRACE_POKEUSR: 
		ret = -EIO;
		if ((addr & 3) || addr < 0 || 
		    addr > sizeof(struct user) - 3)
			break;

		if (addr < 17*sizeof(long)) {
			ret = putreg(child, addr, data);
			break;
		}
		

		  ret = -EIO;
		  if(addr >= (long) &dummy->u_debugreg[0] &&
		     addr <= (long) &dummy->u_debugreg[7]){

			  if(addr == (long) &dummy->u_debugreg[4]) break;
			  if(addr == (long) &dummy->u_debugreg[5]) break;
			  if(addr < (long) &dummy->u_debugreg[4] &&
			     ((unsigned long) data) >= TASK_SIZE-3) break;
			  
			  if(addr == (long) &dummy->u_debugreg[7]) {
				  data &= ~DR_CONTROL_RESERVED;
				  for(i=0; i<4; i++)
					  if ((0x5f54 >> ((data >> (16 + 4*i)) & 0xf)) & 1)
						  goto out_tsk;
			  }

			  addr -= (long) &dummy->u_debugreg;
			  addr = addr >> 2;
			  child->thread.debugreg[addr] = data;
			  ret = 0;
		  }
		  break;

这里的特别之处仅在于对参数addr和data的检查。首先,调试寄存器0至3这四个寄存器是允许设置的,但是要检查所设置的data(实际上是个内存地址)是否越出了用户空间的范围。除此之外,只有调度寄存器7时允许设置的,但是对其数值有些特殊的要求。

*** 作PTRACE_SYSCALL和PTRACE_CONT为一组,分别用来使被跟踪的子进程在下一次系统调用时暂停或继续:

sys_ptrace

	case PTRACE_SYSCALL: 
	case PTRACE_CONT: { 
		long tmp;

		ret = -EIO;
		if ((unsigned long) data > _NSIG)
			break;
		if (request == PTRACE_SYSCALL)
			child->ptrace |= PT_TRACESYS;
		else
			child->ptrace &= ~PT_TRACESYS;
		child->exit_code = data;
	
		tmp = get_stack_long(child, EFL_OFFSET) & ~TRAP_FLAG;
		put_stack_long(child, EFL_OFFSET,tmp);
		wake_up_process(child);
		ret = 0;
		break;
	}

使子进程在下一次进入系统调用时暂停与使子进程群在执行下一条指令后暂停(PTRACE_SINGLESTEP)是互斥的。所以,要将子进程的标志寄存器映像中的TRAP_FLAG标志清0(302~303行)。读者在前面已看到过get_stack_long的代码,而put_stack_long即为其逆向 *** 作。使被跟踪进程在下一次进入系统调用时暂停时通过其task_struct结构中的PT_TRACESYS标志位起作用的。

在前面讲述系统调用过程时我们有意忽略了标志位PT_TRACESYS的作用,现在把它补上。让我们来看看文件arch/i386/kernel/entry.S中的几个片段:

ENTRY(system_call)
	pushl %eax			# save orig_eax
	SAVE_ALL
	GET_CURRENT(%ebx)
	cmpl $(NR_syscalls),%eax
	jae badsys
	testb 
tracesys:
	movl $-ENOSYS,EAX(%esp)
	call SYMBOL_NAME(syscall_trace)
	movl ORIG_EAX(%esp),%eax
	cmpl $(NR_syscalls),%eax
	jae tracesys_exit
	call *SYMBOL_NAME(sys_call_table)(,%eax,4)
	movl %eax,EAX(%esp)		# save the return value
tracesys_exit:
	call SYMBOL_NAME(syscall_trace)
	jmp ret_from_sys_call
x02,tsk_ptrace(%ebx) # PT_TRACESYS jne tracesys call *SYMBOL_NAME(sys_call_table)(,%eax,4)

在跳转到各个系统调用的处理程序之前,先要检查当前进程的PT_TRACESYS标志,如果为1就转移到tracesys。转到tracesys以后,首先就是调用syscall_trace,其代码又回到arch/i386/kernel/ptrace.c中:

system_call=>syscall_trace

asmlinkage void syscall_trace(void)
{
	if ((current->ptrace & (PT_PTRACED|PT_TRACESYS)) !=
			(PT_PTRACED|PT_TRACESYS))
		return;
	
	current->exit_code = SIGTRAP | ((current->ptrace & PT_TRACESYSGOOD)
					? 0x80 : 0);
	current->state = TASK_STOPPED;
	notify_parent(current, SIGCHLD);
	schedule();
	
	if (current->exit_code) {
		send_sig(current->exit_code, current, 1);
		current->exit_code = 0;
	}
}

在这里,通过notify_parent,向父进程发送一个SIGCHLD信号,读者已经看过notify_parent的代码。然后就调用schedule进入暂停状态TASK_STOPPED。当然,其父进程必定已经设置好对SIGCHLD的反应。当父进程设置了子进程的PT_TRACESYS标志位,然后又接收到子进程发送过来的SIGCHLD信号时,就知道子进程已经在系统调用的入口处陷入暂停状态。这时候父进程就可以通过PTRACE_POKEUSR等 *** 作来收集或改变有关的数据(如调用参数)。然后,可以通过向子进程发送一个SIGCONT信号让它继续运行,也就是让它从syscall_trace中的schedule返回,而回到entry.S中的tracesys处通过跳转表进入具体系统调用的代码(见250行)。父进程还可以通过PTRACE_POKEUSR等 *** 作将子进程的ORIG_EAX设置成一个大于NT_syscalls的值,使子进程跳过对系统调用本身的执行(见249行)。最后,子进程在执行完系统调用本身以后,在tracesys_exit处还要再调用一次syscall_trace,让父进程有个机会来收集子进程在执行完系统调用后的结果(如返回值或出错代码)。这样,父进程就可以监视子进程的所有系统调用,甚至还能向子进程伪造对系统调用的执行,把子进程的系统调用重定向到父进程的用户空间程序中。

回到arch/i386/kernel/ptrace.c中函数sys_ptrace的代码继续往下看,下面比较简单一些了:

sys_ptrace

	case PTRACE_KILL: {
		long tmp;

		ret = 0;
		if (child->state == TASK_ZOMBIE)	
			break;
		child->exit_code = SIGKILL;
		
		tmp = get_stack_long(child, EFL_OFFSET) & ~TRAP_FLAG;
		put_stack_long(child, EFL_OFFSET, tmp);
		wake_up_process(child);
		break;
	}

PTRACE_KILL *** 作使子进程退出运行。除PTRACE_ATTACH以外,其它的 *** 作一般都要求目标进程处于暂停状态(只有这样,目标进程的内存和寄存器映像才是静态的),只有PTRACE_KILL是个例外(见前面的200-201行所做的检查)。函数wake_up_process将目标进程的状态改成TASK_RUNNING,而不问其原来是什么状态。如果子进程处于PF_TRACESYS状态,则当子进程下一次进行系统调用而在内核中进入syscall_trace以后,会向其自身发送一个SIGKILL信号(见上面的479-481行)。继续往下看:

sys_ptrace

	case PTRACE_SINGLESTEP: {  
		long tmp;

		ret = -EIO;
		if ((unsigned long) data > _NSIG)
			break;
		child->ptrace &= ~PT_TRACESYS;
		if ((child->ptrace & PT_DTRACE) == 0) {
			
			child->ptrace |= PT_DTRACE;
		}
		tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
		put_stack_long(child, EFL_OFFSET, tmp);
		child->exit_code = data;
		
		wake_up_process(child);
		ret = 0;
		break;
	}

除通过前述的调试寄存器可以让寄存器在特定的条件下进入1号陷阱debug外,i386 CPU还提供了单步执行的手段。只要在处理器的标志寄存器中将TRAP_FLAG标志位设成1,处理器就会在每执行完一条机器指令就进入debug陷阱而到达一个断点。这样,跟踪进程就可以像对待子进程的系统调用一样,在子进程每执行完一条指令后就来观察执行的结果。不过,对指令的跟踪与对系统调用的跟踪是互斥的,所以要将子进程的PF_TRACESYS标志清0。主语PF_TRACESYS标志与TRAP_FLAG是两码事。前者是一个软件标志,是进程的task_struct结构内部flags中的一位,这完全是供软件使用的,而与处理器的硬件没有直接的联系。相比之下,TRAP_FLAG是个硬件标志,它是处理器中的标志寄存器EFL的一位,直接影响着处理器的行为。每当调度一个进程进入运行时,就会在返回用户空间前夕将其标志寄存器映像装入CPU的标志寄存器EFL,所以可以实现对该进程的单步跟踪,而并不影响其它进程或者系统空间的运行。但是,直接用TRAP_FLAG标志位来代表ptrace机制的单步跟踪状态是不可靠的。这是因为应用软件也可以改变处理器的标志寄存器,从而造成混淆。所以,ptrace同时还定义了一个永远单步执行的软件标志PT_DTRACE,在通过ptrace的PTRACE_SINGLESTEP来开始单步跟踪时就将这个软件标志也设成1。其余的 *** 作就比较简单了。结合前面一些 *** 作的代码,读者自行约的应该不会有困难。

sys_ptrace

	case PTRACE_DETACH:
		
		ret = ptrace_detach(child, data);
		break;

	case PTRACE_GETREGS: { 
	  	if (!access_ok(VERIFY_WRITE, (unsigned *)data, frame_SIZE*sizeof(long))) {
			ret = -EIO;
			break;
		}
		for ( i = 0; i < frame_SIZE*sizeof(long); i += sizeof(long) ) {
			__put_user(getreg(child, i),(unsigned long *) data);
			data += sizeof(long);
		}
		ret = 0;
		break;
	}

	case PTRACE_SETREGS: { 
		unsigned long tmp;
	  	if (!access_ok(VERIFY_READ, (unsigned *)data, frame_SIZE*sizeof(long))) {
			ret = -EIO;
			break;
		}
		for ( i = 0; i < frame_SIZE*sizeof(long); i += sizeof(long) ) {
			__get_user(tmp, (unsigned long *) data);
			putreg(child, i, tmp);
			data += sizeof(long);
		}
		ret = 0;
		break;
	}

	case PTRACE_GETFPREGS: { 
		if (!access_ok(VERIFY_WRITE, (unsigned *)data,
			       sizeof(struct user_i387_struct))) {
			ret = -EIO;
			break;
		}
		ret = 0;
		if ( !child->used_math )
			load_empty_fpu(child);
		get_fpregs((struct user_i387_struct *)data, child);
		break;
	}

	case PTRACE_SETFPREGS: { 
		if (!access_ok(VERIFY_READ, (unsigned *)data,
			       sizeof(struct user_i387_struct))) {
			ret = -EIO;
			break;
		}
		child->used_math = 1;
		set_fpregs(child, (struct user_i387_struct *)data);
		ret = 0;
		break;
	}

	case PTRACE_GETFPXREGS: { 
		if (!access_ok(VERIFY_WRITE, (unsigned *)data,
			       sizeof(struct user_fxsr_struct))) {
			ret = -EIO;
			break;
		}
		if ( !child->used_math )
			load_empty_fpu(child);
		ret = get_fpxregs((struct user_fxsr_struct *)data, child);
		break;
	}

	case PTRACE_SETFPXREGS: { 
		if (!access_ok(VERIFY_READ, (unsigned *)data,
			       sizeof(struct user_fxsr_struct))) {
			ret = -EIO;
			break;
		}
		child->used_math = 1;
		ret = set_fpxregs(child, (struct user_fxsr_struct *)data);
		break;
	}

	case PTRACE_SETOPTIONS: {
		if (data & PTRACE_O_TRACESYSGOOD)
			child->ptrace |= PT_TRACESYSGOOD;
		else
			child->ptrace &= ~PT_TRACESYSGOOD;
		ret = 0;
		break;
	}

	default:
		ret = -EIO;
		break;
	}
out_tsk:
	free_task_struct(child);
out:
	unlock_kernel();
	return ret;
}

如前所述,ptrace在实际运行中并不是用作进程间通讯手段,而是作为程序调试和维护的手段。作为调试手段,其各方面的作用在早起Unix系统中是无可替代的。不过,随着Unix(以及Linux)的发展,出现了/proc目录下的特殊文件(见文件系统系列的有关内容),使用户可以通过这些特殊文件来读写一个进程的内存空间和其它信息,而且往往更为方便,形式上也更为划一。所以,近年来像gdb一类的调试工具已经倾向于更多地使用这些特殊文件(严格说来这些特殊文件当然也可用于进程间通讯、只不过人们已经有了更好的进程间通讯手段,因为不会这样去用而已)。但是,尽管如此,/proc特殊文件还是不能完全取代ptrace的作用。例如,ptrace有个无可替代的作用,那就是可以通过跟踪应用程序所做的系统调用来监视其运行。我们知道,Linux内核的源代码是公开的,可是应用程序的源码却一般都不公开。拿到一个应用程序以后,如果想要知道它究竟在干什么,最好的办法就是监视它都做了些什么系统调用,调用时的参数都是些什么,返回值又是什么。这时候就要用到ptrace了。为了这个目的,Linux专门提供了一个工具,即shell实用程序strace。读者不妨先体验一下strace的使用,然后,想想它是怎样实现的?

% strace echo hello

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5703941.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存