linux提供了很多调试工具,比如我喜欢用的Systemtap,用起来很方便,几乎不用大动干戈就可以洞察到内核的一些很重要的行为,这一切是怎么做到的呢?本文带你在内核调试接口的冰山下面走一遭。
很多人都知道,所谓的调试技术无非就两种,一个是下断点,另一个是单步。一般都是在断点的位置开始单步的,这二者十分容易被混淆,很多人认为下了断点就是单步,实际上不是这样的。x86提供了丰富的功能来支持调试,对于断点的支持就是一个编码为0xcc的指令,也就是汇编码:int3,实际上是个中断,当 程序执行过程中遇到0xcc的操作码时,那么它就会触发一个中断,最终由中断处理程序来解决,而中断处理程序往往就是给调试器发信号,并且挂起当前线程,由调试器开始执行,而调试器的行为大家就都知道了,这主要用于用户进程/线程调试,具体看一下代码:
当int3发生时,陷入内核执行到do_int3
void __kprobes do_int3(struct pt_regs *regs, long error_code)
{
trace_hardirqs_fixup();
//以下就是调试内核相关的了,一会说内核,现在先说用户空间的0xcc
if (notify_die(DIE_INT3, "int3", regs, error_code, 3, SIGTRAP)
== NOTIFY_STOP)
return;
/*
* This is an interrupt gate, because kprobes wants interrupts
* disabled. Normal trap handlers don't.
*/
restore_interrupts(regs);
//关键操作,在这个do_trap里给自己发送一个SIGTRAP信号,在返回用户空间的路径上处理该信号,并且调用ptrace_notify来将控制权交给它的调试器。
do_trap(3, SIGTRAP, "int3", 1, regs, error_code, NULL);
}
在handle_signal中,返回用户处理程序之前要调用以下代码,从而不等该进程返回用户空间就将其挂起,开始调试器的调试。
if (test_thread_flag(TIF_SINGLESTEP))
ptrace_notify(SIGTRAP);
不光是上面的单步检查,在get_signal_to_deliver中有个ptrace_signal才是真正的查点,所以说只要一个进程进入了int3中断处理,那么它就别指望回来了,马上调试器就要接管它了。也就是说只要用户代码中被插入了0xcc,那么就会进入do_int3,然后就会给自己发送一个信号,这样在do_int3返回的时候要执行do_signal。在其中会转入到调试器去执行。
以上说的就是用户的断点操作,那么单步是什么呢?单步实际上也是硬件的机制,只不过它不再是简单一条代码指令的问题
了,在硬件中有个flags标志寄存器,其中TF标志代表单步执行,那么cpu在每执行完一条指令的时候都会检查这个标志位,一旦设置了这个标志位,那么cpu当即触发一个1号中
断就是int1,这样cpu转入到中断处理,在linux中就是debug程序,就是在
初始化的时候设置的:set_intr_gate(1, &debug);,那debug函数在哪里呢?在entry_32.S里面:
KPROBE_ENTRY(debug)
RING0_INT_FRAME
cmpl $ia32_sysenter_target,(%esp)
jne debug_stack_correct
FIX_STACK(12, debug_stack_correct, debug_esp_fix_insn)
debug_stack_correct:
pushl $-1 # mark this as an int
CFI_ADJUST_CFA_OFFSET 4
SAVE_ALL
xorl %edx,%edx # error code 0
movl %esp,%eax # pt_regs pointer
call do_debug
jmp ret_from_exception
CFI_ENDPROC
KPROBE_END(debug)
实际执行的是do_debug函数,它本质上也是一个向自己发送信号,然后在返回的途中被调试器拦截,从而进入调试器执行的一个过程,需要注意的是,在进入单步以后,一般要清除被中断的线程的单步寄存器标志位,否则就会无休止的进入单步中断处理这是不希望的,一般情况就是把控制权交给调试器,由调试器来做决定。
经过上面的叙述,很能看过的人就知道断点和单步的区别了,但是为何要有单步呢?你在某条指令下断,然后调试器执行,接着调试器再在下一条指令下断,不就完了吗?但是要知道,一条指令的执行分为pre,in,post三个阶段,分别为指令执行前,执行中,执行后,一般的调试器都要在前后两个位置安放自己的钩子函数,0xcc只能实现pre钩子,post却无法实现,那么这时就需要int1单步来帮忙了,在一条指令处下断,然后进入调试器,然后在调试器中设置单步,之后恢复原始指令的执行,这条原始指令执行完毕之后因为单步就会进入int1的do_debug进行处理,之后尽可能执行事先安置好的post钩子。用户线程的调试器比如gdb如果不方便看的话,我们下面就来看一下内核的调试器,专门调试内核的,用的就是上面我所叙述的方法,只不过在内核中没有所谓的调试进程,而是内核本身处理这发生的一切。linux内核中的调试接口就是一个叫kprobe的结构,该结构如下:
struct kprobe {
struct hlist_node hlist;
/* list of kprobes for multi-handler support */
struct list_head list;
/* Indicates that the corresponding module has been ref counted */
unsigned int mod_refcounted;
/*count the number of times this probe was temporarily disarmed */
unsigned long nmissed;
/* location of the probe point */
kprobe_opcode_t *addr;
/* Allow user to indicate symbol name of the probe point */
const char *symbol_name;
/* Offset into the symbol */
unsigned int offset;
/* Called before addr is executed. */
kprobe_pre_handler_t pre_handler;
/* Called after addr is executed, unless... */
kprobe_post_handler_t post_handler;
/* ... called if executing addr causes a fault (eg. page fault).
* Return 1 if it handled fault, otherwise kernel will see it. */
kprobe_fault_handler_t fault_handler;
/* ... called if breakpoint trap occurs in probe handler.
* Return 1 if it handled break, otherwise kernel will see it. */
kprobe_break_handler_t break_handler;
/* Saved opcode (which has been replaced with breakpoint) */
kprobe_opcode_t opcode;
/* copy of the original instruction */
struct arch_specific_insn ainsn;
};
可 以看出,上面的结构包含一切调试内核所需要的信息,可以说就是一个调试器了,它起码包含有pre,post两个回调函数,作为钩子函数调用,实际过程就是 先注册一个kprobe,然后将需要调试的地址的前面改称0xcc,在int3处理函数中回调pre钩子函数,同时使能单步,之后恢复原始地址的指令执行,执行完后因为单步的原因进入了int1,在do_debug中执行post函数,执行完毕后冻结单步,恢复原始执行流,看似复杂,实际上和用户空间的 病毒是一样的道理,就是先跳入自己的钩子,等待执行完毕后再恢复原来的执行流,呵呵,可别得罪写内核的这帮家伙,他们发彪了能黑掉全世界的电脑...
本文不分析代码,只是简要讲述了调试执行的流程,可以自行研究代码的实现。实际上想要彻底理解调试器的原理,必须做的两件事就是:1.理解硬件调试寄存器 的功能和配置;2.理解代码。其实真正的接口是硬件提供的,代码只是实现了一个现在很时髦的“业务流程”。下面简要介绍一下代码中关键的部分:
1.注册:
static int __kprobes __register_kprobe(struct kprobe *p,
unsigned long called_from)
{
int ret = 0;
struct kprobe *old_p;
struct module *probed_mod;
kprobe_opcode_t *addr;
addr = kprobe_addr(p);//根据p的symbol_name和offset找到对应的内核空间地址
if (!addr)
return -EINVAL;
p->addr = addr;
if (!kernel_text_address((unsigned long) p->addr) ||
in_kprobes_functions((unsigned long) p->addr))
return -EINVAL;
p->mod_refcounted = 0;
...//我们忽略内核模块的调试
p->nmissed = 0;
INIT_LIST_HEAD(&p->list);
mutex_lock(&kprobe_mutex);
...//我们忽略重复调试
ret = arch_prepare_kprobe(p);//备份原来的指令到一个地方,以便钩子执行完后恢复,是不是和病毒很像。
if (ret)
goto out;
INIT_HLIST_NODE(&p->hlist);
hlist_add_head_rcu(&p->hlist,
&kprobe_table[hash_ptr(p->addr, KPROBE_HASH_BITS)]);
if (kprobe_enabled)
arch_arm_kprobe(p);//下断点,就是将指令的前两字节改为0xcc
out:
mutex_unlock(&kprobe_mutex);
if (ret && probed_mod)
module_put(probed_mod);
return ret;
}
2.触发int3:
这里用到了linux内核的通知链技术,在kprobe初始化的时候在die_chain通知链上注册了一个函数,每当函数执行die_notify的时 候就会调用这个函数,我想不通调试就调试呗,为啥注册这么恐怖一个通知链上,还die,吓谁呢?在int3处理的一开始就调用了die_notify,这 样就执行了下面的函数 :
static int __kprobes kprobe_handler(struct pt_regs *regs)
{
kprobe_opcode_t *addr;
struct kprobe *p;
struct kprobe_ctlblk *kcb;
addr = (kprobe_opcode_t *)(regs->ip - sizeof(kprobe_opcode_t));
if (*addr != BREAKPOINT_INSTRUCTION) {//这种情况说明断点已经被移出了
regs->ip = (unsigned long)addr;
return 1;
}
preempt_disable();
kcb = get_kprobe_ctlblk();
p = get_kprobe(addr);
if (p) {
if (kprobe_running()) {
if (reenter_kprobe(p, regs, kcb))
return 1;
} else {
set_current_kprobe(p, regs, kcb);
kcb->kprobe_status = KPROBE_HIT_ACTIVE;
if (!p->pre_handler || !p->pre_handler(p, regs))
setup_singlestep(p, regs, kcb);//设置单步,具体就是设置regs的eflags
return 1;
}
} else if (kprobe_running()) {
p = __get_cpu_var(current_kprobe);
if (p->break_handler && p->break_handler(p, regs)) {
setup_singlestep(p, regs, kcb);
return 1;
}
} /* else: not a kprobe fault; let the kernel handle it */
preempt_enable_no_resched();
return 0;
}
3.触发int1
由于int3中设置了单步,那么int1在执行完一条指令后就要执行了,在int1的处理中主要就是恢复原始执行流,并且恢复之前调用post钩子,逻辑 上就是这么简单,代码就不分析了。今天十分不想写东西,但是还是将自己的想法写了出来,脑子越来越不好使,记忆力啊!