如何使用ptrace查找CALL和RET号码?

5
我正在尝试在x86_64(Intel语法)的运行时动态查找程序调用和返回的函数数量。为此,我使用ptrace(不使用PTRACE_SYSCALL),并检查RIP寄存器(其中包含下一条指令地址)以及检查其操作码。我知道如果LSB等于0xE8(根据Intel文档或 http://icube-avr.unistra.fr/fr/images/4/41/253666.pdf第105页),可以找到函数调用。我在 http://ref.x86asm.net/coder64.html上找到了每个指令。因此,在我的程序中,每次发现0xE8、0x9A、0xF1等指令时,我都会找到一个函数入口(CALL或INT指令),如果是0xC2、0XC3等,则是函数离开(RET指令)。目标是在运行时在每个程序中找到它,我无法访问测试程序的编译、插装或使用gcc的魔术工具。

我写了一个小程序,可以使用gcc -Wall -Wextra your_file.c进行编译,并通过键入./a.out a_program来启动。

以下是我的代码:

#include <sys/ptrace.h>
#include <sys/signal.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <stdint.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

typedef struct user_regs_struct    reg_t;

static int8_t       increase(pid_t pid, int32_t *status)
{
        if (WIFEXITED(*status) || WIFSIGNALED(*status))
                return (-1);
        if (WIFSTOPPED(*status) && (WSTOPSIG(*status) == SIGINT))
                return (-1);
        if (ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL) == -1)
                return (-1);
        return (0);
}

int                 main(int argc, char *argv[])
{
    size_t          pid = fork();
    long            address_rip;
    uint16_t        call = 0;
    uint16_t        ret = 0;
    int32_t         status;
    reg_t           regs;

    if (!pid) {
            if ((status = ptrace(PTRACE_TRACEME, 0, NULL, NULL)) == -1)
                    return (1);
            kill(getpid(), SIGSTOP);
            execvp(argv[1], argv + 1);
    } else {
            while (42) {
                    waitpid(pid, &status, 0);
                    ptrace(PTRACE_GETREGS, pid, NULL, &regs);
                    address_rip = ptrace(PTRACE_PEEKDATA, pid, regs.rip, NULL);
                    address_rip &= 0xFFFF;
                    if ((address_rip & 0x00FF) == 0xC2 || (address_rip & 0x00FF) == 0xC3 ||
                        (address_rip & 0x00FF) == 0xCA || (address_rip & 0x00FF) == 0xCB ||
                        (address_rip & 0x00FF) == 0xCF)
                            ret += 1;
                    else if ((address_rip & 0x00FF) == 0xE8 || (address_rip & 0x00FF) == 0xF1 ||
                             (address_rip & 0x00FF) == 0x9A || (address_rip & 0x00FF) == 0xCC ||
                             (address_rip & 0x00FF) == 0xCD || (address_rip & 0x00FF) == 0xCF)
                            call += 1;
                    if (increase(pid, &status) == -1) {
                            printf("call: %i\tret: %i\n", call, ret);
                            return (0);
                    }
            }
    }
    return (0);
}

当我使用a_program运行它时(它是一个自定义程序,只是进入一些本地函数并执行一些写入系统调用,目的只是跟踪该程序进入/离开函数的次数),没有发生错误,它工作得很好,但是我的CALL和RET数量不同。

用户>./a.out基本程序

call: 636 ret: 651

(大量的CALL和RET是由LibC引起的,在开始您的程序之前,LibC会进入许多函数,请参见使用ptrace解析Call和Ret。)

实际上,就像我的程序进入了更多的返回而不是函数调用,但我发现0xFF指令用于在(r/m64或r/m16/m32)中进行CALL或CALLF,但也用于其他指令,如DEC、INC或JMP(这些非常常见)。

那么,我该如何区分呢?根据http://ref.x86asm.net/coder64.html的"opcode fields",但我怎样才能找到它呢?

如果我在条件语句中加入0xFF:

else if ((address_rip & 0x00FF) == 0xE8 || (address_rip & 0x00FF) == 0xF1 ||
         (address_rip & 0x00FF) == 0x9A || (address_rip & 0x00FF) == 0xCC ||
         (address_rip & 0x00FF) == 0xCD || (address_rip & 0x00FF) == 0xCF ||
         (address_rip & 0x00FF) == 0xFF)
                call += 1;

如果我启动它:

用户> ./a.out basic_program

调用:1152 返回:651

对我来说,这似乎很正常,因为它会计算每个JMP、DEC或INC,所以我需要区分每个0xFF指令。我尝试像这样做:

 else if ((address_rip & 0x00FF) == 0xE8 || (address_rip & 0x00FF) == 0xF1 ||
         (address_rip & 0x00FF) == 0x9A || (address_rip & 0x00FF) == 0xCC ||
         (address_rip & 0x00FF) == 0xCD || (address_rip & 0x00FF) == 0xCF ||
         ((address_rip & 0x00FF) == 0xFF && ((address_rip & 0x0F00) == 0X02 ||
         (address_rip & 0X0F00) == 0X03)))
                call += 1;

但是它给了我同样的结果。我哪里做错了?如何找到相同数量的调用和返回?

你可以通过插桩编译你的代码,而不是使用ptrace。 - Eugene Sh.
3
@VolontéDuPeuple,"reg"字段仅有三个位,而不是四个。此外,它并不位于最低有效位,而是位于第3到第5位。0到2位的字段是“r/m”字段,这不是您需要的。有关详细信息,请参阅Intel手册。 - fuz
3
你是否尝试按照我在之前评论中告诉你的方式修复你的代码了?@VolontéDuPeuple - fuz
1
@fuz 但他的问题中提到他正在使用GCC。因此,语法不会是他的问题,但他使用的语法在GCC扩展中是有效的。 - Michael Petch
1
如果您要跟踪的代码使用异常、longjmp,甚至是exit,那么执行的调用指令数量将不等于执行的返回指令数量。如果您要跟踪的代码有任何防止被逆向工程的防御措施,那么这些指令也不太可能成对出现。 - Ross Ridge
显示剩余17条评论
2个回答

5

以下是如何编写程序的示例。请注意,由于x86指令最长可达16个字节,因此必须查看16个字节以确保获取完整的指令。由于每个查看操作读取8个字节,因此您需要进行两次查看操作,一次在regs.rip处,另一次在8个字节后:

peek1 = ptrace(PTRACE_PEEKDATA, pid, regs.rip, NULL);
peek2 = ptrace(PTRACE_PEEKDATA, pid, regs.rip + sizeof(long), NULL);

请注意,这段代码忽略了很多关于前缀如何处理以及将一些无效指令检测为函数调用的细节。另请注意,如果您想将其用于32位代码,则需要更改代码以包含更多CALL指令并删除REX前缀的检测。
int iscall(long peek1, long peek2)
{
        union {
                long longs[2];
                unsigned char bytes[16];
        } data;

        int opcode, reg; 
        size_t offset;

        /* turn peeked longs into bytes */
        data.longs[0] = peek1;
        data.longs[1] = peek2;

        /* ignore relevant prefixes */
        for (offset = 0; offset < sizeof data.bytes &&
            ((data.bytes[offset] & 0xe7) == 0x26 /* cs, ds, ss, es override */
            || (data.bytes[offset] & 0xfc) == 0x64 /* fs, gs, addr32, data16 override */
            || (data.bytes[offset] & 0xf0) == 0x40); /* REX prefix */
            offset++)
                ;

        /* instruction is composed of all prefixes */
        if (offset > 15)
                return (0);

        opcode = data.bytes[offset];


        /* E8: CALL NEAR rel32? */
        if (opcode == 0xe8)
                return (1);

        /* sufficient space for modr/m byte? */
        if (offset > 14)
                return (0);

        reg = data.bytes[offset + 1] & 0070; /* modr/m byte, reg field */

        if (opcode == 0xff) {
                /* FF /2: CALL NEAR r/m64? */
                if (reg == 0020)
                        return (1);

                /* FF /3: CALL FAR r/m32 or r/m64? */
                if (reg == 0030)
                        return (1);
        }

        /* not a CALL instruction */
        return (0);
}

@VolontéDuPeuple 不行。请仔细阅读答案:您需要查看两次(一次在regs.rip,一次在regs.rip + sizeof(long)),并将两个值都提供给iscall函数。我不知道result & 0x00000000FFFFFFFF的目的是什么。 - fuz
哦,我忘了这是用来检查指令的,因为你已经知道你在指令边界上,而不是作为反汇编器。所以你不需要担心找到作为其他isns立即数埋藏的操作码等问题。这看起来符合问题的要求,+1。 - Peter Cordes
2
如果在您链接的目标文件中找到了foo,则ld将会放宽gcc -fno-pltcall *foo@GOTPCREL(%rip)67 call foo。请参见Can't call C standard library function on 64-bit Linux from assembly (yasm) code底部的示例,其中67 e8作为call rel32的开始。(@Paul-Marie)。在我的Arch GNU/Linux系统上,这种机器码在一些真实二进制文件的反汇编中并不罕见,例如objdump -drwC -Mintel /bin/bash | grep '67 e8 '。但在其他二进制文件中很少见;这取决于编译/链接选项和可能的源代码。 - Peter Cordes
@PeterCordes 抱歉,但那是5年前的事了,我过去几年没有写太多汇编语言,所以只能相信你而不做考虑。 - Paul-Marie
2
@Paul-Marie:我只是想联系你,以防你仍在使用这个代码并希望修复可能存在的错误。如果你对这个问题不再感兴趣,也没关系;主要是针对fuz和未来的读者。 - Peter Cordes
显示剩余4条评论

2
我个人建议将跟踪指令“延迟”一步,保留前一步骤的riprsp。为简单起见,假设curr_ripcurr_rsp是最近一次PTRACE_GETREGS获取的riprsp寄存器,而prev_ripprev_rsp是上一次获取的。

如果(curr_rip < prev_rip || curr_rip > prev_rip + 16),则指令指针向后或向前移动了超过最长有效指令长度。如果是这样,则:

  • 如果(curr_rsp > prev_rsp),则最后一个指令是某种ret,因为数据也从堆栈中弹出。

  • 如果(curr_rsp < prev_rsp),则最后一个指令是某种call,因为数据也被推入堆栈。

  • 如果(curr_rsp == prev_rsp),则该指令是某种跳转;无条件跳转或分支。

换句话说,只有在(curr_rsp != prev_rsp && curr_rip > prev_rip && curr_rip <= prev_rip + 16)时,您才需要检查指令(curr_rip - prev_rip字节长,介于1到16之间)从prev_rip开始。为此,我会使用Intel XED,但您当然可以自己实现调用/返回指令识别器。


看起来没问题,但是使用你的技巧,如何区分CALL/RET和PUSH/POP?有些程序自然而然地使用堆栈不是为了保存前一个函数,而是为了使用它,那么你如何区分呢?我个人会检查助记符参数的符号地址,以确定它属于哪个函数;即使目前还没有起作用,我觉得这样更“安全”。 - Paul-Marie
话虽如此,我还没有检查过在被追踪进程中信号传递到信号处理程序的情况。 - Nominal Animal
那可能行得通,但对于非常短的函数似乎会产生一些误报。 - fuz
@fuz:怎么做?调用被视为对rsp的更改。如果rip变化足够大,则会直接检测到,否则需要解码导致更改的指令(位于prev_rip)。返回时同样如此。我不明白函数长度如何影响这一切。或者我的最后一段话不清楚吗?它应该描述需要检查指令的情况;仅检查riprsp并不总是决定性的。 - Nominal Animal
1
说实话,我实现了一个简单的ret/call检测器,并比较了使用我的答案中描述的寄存器优化前后的调用和返回次数。在amd64上使用来自coreutils-8.25-2ubuntu3~16.04的date命令,始终检查指令的方法检测到1522个调用和1409个返回;本答案中概述的方法检测到1533个调用和1529个返回。我不确定差异是否表示我的指令检测器存在错误或其他问题...应该使用Intel XED。 - Nominal Animal
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接