x86架构 指令INT3只有一个字节的原因

一、单字节原因简介

在这里插入图片描述
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, &regs);

#if __WORDSIZE == 64
    regs.rip = (long)addr;
#else
    regs.eip = (long)addr;
#endif
    ptrace(PTRACE_SETREGS, pid, 0, &regs);

}

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/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值