文章目录
- 一、单字节原因简介
- 二、断点原理
- 三、单字节具体原因
- 参考资料
一、单字节原因简介
INT3指令生成一个特殊的单字节操作码(CC),用于调用调试异常处理程序。(这种单字节形式很有价值,因为它可以用来用断点替换任何指令的第一个字节,包括其他单字节指令,而不会重写其他代码)。
软件断点指令应该是最小的指令大小,这样它就不会覆盖可能成为跳转目标的指令,并且当程序跳到断点指令的中间时会导致灾难。(严格来说,断点必须不大于可能成为跳转目标的指令之间的最小间隔
二、断点原理
必须首先了解调试器是如何在程序中插入断点的。下面是gdb如何实现断点。
(1)当我们在gdb中键入“break OFFSET”时,其中OFFSET是一个指令地址,gdb将存储在OFFSET的字节的值存储起来,并将该字节的值设置为INT3(0xcc)。
假设OFFSET处的原始指令是0x8345fc01(addl $0x1,-0x8(%ebp))。gdb将记住该字的最后一个字节(0x01),并将该字更改为(0x8345fccc),这可能不是真正的指令。但这并不重要,正如我们将在第3步中看到的那样:
OFFSET 01 # original
OFFSET+1 fc
OFFSET+2 45
OFFSET+3 83
OFFSET cc # breakpoint inserted
OFFSET+1 fc
OFFSET+2 45
OFFSET+3 83
(2)接下来,假设我们继续调试程序。当该程序命中调试器刚刚插入的INT3指令时,被调试的程序将陷入内核,而内核将反过来向GDB程序发出信号,告诉被调试的程序已经进入了断点调试位置。
(3)gdb将使用它存储的原始值恢复OFFSET处的字节,并将指令指针EIP(RIP)移回OFFSET以在OFFSET处重新启动指令。它需要移动EIP(RIP),EIP(RIP)指令指针保存的地址需要减1,因为CPU执行INT3指令后,EIP(RIP)已经增加了1。
使用相同的示例,调试器将用原始指令0x8345fc01替换可能无效的指令0x8345 fccc,并将EIP(RIP)设置为OFFSET。
OFFSET cc # after breakpoint
OFFSET+1 fc # <---- EIP points here
OFFSET+2 45
OFFSET+3 83
OFFSET 01 # <--- EIP points here, after gdb restores instruction and EIP
OFFSET+1 fc
OFFSET+2 45
OFFSET+3 83
这里给出一个简单的演示示例:
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
//设置断点,将断点处的第一条指令改为 0xcc
void setbp(pid_t pid, void *addr, long* orig)
{
union {
long word;
unsigned char bytes[4];
}u;
//保存断点处的原始指令
*orig = u.word = ptrace(PTRACE_PEEKTEXT, pid, addr, NULL);
u.bytes[0] = 0xcc; // 0xcc is INT3
ptrace(PTRACE_POKETEXT, pid, addr, u.word);
printf("set breakpoint (0x%lx) at 0x%lx.\n",
u.word, (unsigned long)addr);
}
//恢复断点
void unsetbp(pid_t pid, void *addr, long orig)
{
printf("remove breakpoint at 0x%lx.\n",
(unsigned long)addr);
ptrace(PTRACE_POKETEXT, pid, addr, orig);
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, pid, 0, ®s);
#if __WORDSIZE == 64
regs.rip = (long)addr;
#else
regs.eip = (long)addr;
#endif
ptrace(PTRACE_SETREGS, pid, 0, ®s);
}
void breakpoint(pid_t pid, void* addr)
{
long orig;
while(1) {
setbp(pid, addr, &orig);
printf("executing...\n");
ptrace(PTRACE_CONT, pid, 0, 0);
wait(NULL);
printf("breakpoint hit. press return to continue\n");
getchar();
unsetbp(pid, addr, orig);
// single step to next instruction, so we can set
// breakpoint again
ptrace(PTRACE_SINGLESTEP, pid, 0, 0);
wait(NULL);
}
}
int main(int ac, char *av[])
{
if(ac == 2) { // test loop, executed by child process.
int i = 0;
while(1) {
printf ("debugee: %d\n", i++);
bp_addr:
sleep(2);
}
return 0;
}
int pid;
switch(pid=fork()) {
case -1:
perror("fork");
break;
//子进程
case 0:
ptrace(PTRACE_TRACEME, 0, 0, 0);
// exec myself, but with one argument
execlp(av[0], av[0], "loop", NULL);
break;
//父进程
default:
wait(NULL);
ptrace(PTRACE_SETOPTIONS, pid, 0, PTRACE_O_TRACEEXEC);
breakpoint(pid, &&bp_addr);
break;
}
return 0;
}
sys/user.h 文件有一个注释,该文件只是用于GDB:
此文件的全部目的仅用于GDB。不要读太多。除非你知道自己在做什么,否则不要把它用于GDB之外的任何事情。
// sys/user.h
#ifdef __x86_64__
struct user_regs_struct
{
__extension__ unsigned long long int r15;
__extension__ unsigned long long int r14;
__extension__ unsigned long long int r13;
__extension__ unsigned long long int r12;
__extension__ unsigned long long int rbp;
__extension__ unsigned long long int rbx;
__extension__ unsigned long long int r11;
__extension__ unsigned long long int r10;
__extension__ unsigned long long int r9;
__extension__ unsigned long long int r8;
__extension__ unsigned long long int rax;
__extension__ unsigned long long int rcx;
__extension__ unsigned long long int rdx;
__extension__ unsigned long long int rsi;
__extension__ unsigned long long int rdi;
__extension__ unsigned long long int orig_rax;
__extension__ unsigned long long int rip;
__extension__ unsigned long long int cs;
__extension__ unsigned long long int eflags;
__extension__ unsigned long long int rsp;
__extension__ unsigned long long int ss;
__extension__ unsigned long long int fs_base;
__extension__ unsigned long long int gs_base;
__extension__ unsigned long long int ds;
__extension__ unsigned long long int es;
__extension__ unsigned long long int fs;
__extension__ unsigned long long int gs;
};
三、单字节具体原因
现在,让我们看看为什么INT3应该是一个单字节指令,即所有x86指令的最小长度。假设INT3比某些x86指令长。当我们使用上面的方法插入断点时,我们可能会覆盖多个指令,这可能会导致问题。考虑以下带有两个单字节指令的示例:
OFFSET <instruction 1, one byte>
OFFSET+1 <instruction 2, one byte>
假设我们想在指令1处设置一个断点。为此,我们必须用INT3覆盖指令1和指令2:
OFFSET <INT3...................
OFFSET+1 ......................>
在大多数情况下,这将是很好的,因为我们可以在OFFSET命中INT3之后恢复这两个指令。然而,如果某些代码想要跳转到“OFFSET+1”,我们将遇到麻烦;它实际上会跳到INT3指令的中间,它可以创建未定义的行为。如果INT3是一个字节,即所有x86指令的最小长度,我们就不会有这个问题。
为什么调试器要覆盖指令以插入断点?难道他们不能插入一个断点并将所有后续指令移位一个字节吗?这样做很复杂,因为它会干扰指令的偏移量,并导致跳转指令跳到错误的目标。重复使用上面的例子,如果我们在指令1之前插入断点,我们将把原始代码转换为:
OFFSET INT3
OFFSET+1 <instruction 1>
OFFSET+2 <instruction 2>
如果某个代码想跳到原始代码中OFFSET+1处的指令2,它将跳到转换后的代码中的指令1,从而计算错误的结果。
参考资料
https://www.cs.columbia.edu/~junfeng/09sp-w4118/lectures/int3/