“远程”获取进程中函数参数值

profile

Posted by bbkgl on December 4, 2019

东风夜放花千树

更吹落

星如雨

获取一个已经在运行的C++进程的函数堆栈信息之前已经讲过了,然后就会想到,怎么获取到对应函数的参数值呢?

这是一个不简单的问题,前面讲过了函数压栈的问题,这次讲一下寄存器。

函数参数传递

程序寄存器

在X64计算机上,有多个寄存器,这些寄存器都用来保存函数调用时的临时信息,还有很重要的一点,寄存器是所有函数调用过程中共享的存储资源

比如之前说到的RBP,RSP寄存器,是存储函数栈帧边界地址的。还有很多其他寄存器,可以见下图:

20200112221112.png

e打头表示是在32位机器上,现在 x86-64中,所有寄存器都是64位,寄存器名字都是r打头,但是同样兼容e打头的标识符用法。

函数参数寄存器就是上图说的6个参数寄存器,什么时候用到这些参数寄存器呢?

再来回顾一下函数压栈过程:

  1. 调用者函数把被调函数的参数从右向左依次压入栈;
  2. 调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
  3. 在被调函数中,被调函数会先保存调用者函数的栈底地址(push rbp),然后再保存调用者函数的栈顶地址,即:当前被调函数的栈底地址(mov rbp,rsp);
  4. 在被调函数中,从rbp的位置处开始存放被调函数中的局部变量和临时变量,并且这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈;

在过程1中把函数实参压栈的时候就会将参数保存在寄存器中,也就是说这些寄存器记录的之前的信息会被覆盖掉,然后在执行某个函数时,会把寄存器中的函数参数取出,在栈帧中保存。

使用GDB查看函数调用过程

我们先给上示例程序,很简单的示例程序:

#include <cstdio>
#include <unistd.h>

void aaaaaaaaaa(int a, int b, int c, int d) {
    int e = 14;
    printf("a(%d):%x, sizeof(&a) == %d, b:%x, sizeof(&b) == %d\n", a, &a, sizeof &a, &b, sizeof &b);
    printf("c = %d, addr = %x  d = %d, addr = %x\n", c, &c, d, &d);
    sleep(100000);
}

int main() {
    int a = 10;
    aaaaaaaaaa(a, 11, 12, 13);
    return 0;
}

使用gdb分别将两个函数转成汇编语句:

gdb <appname>
disassemble /m main   # 查看main函数的汇编执行过程

得到main函数的汇编语句

(gdb) disassemble /m main
Dump of assembler code for function main():
17      int main() {
   0x0000000000000798 <+0>:     push   %rbp
   0x0000000000000799 <+1>:     mov    %rsp,%rbp
   0x000000000000079c <+4>:     sub    $0x10,%rsp

18          int a = 10;
   0x00000000000007a0 <+8>:     movl   $0xa,-0x4(%rbp)

19          aaaaaaaaaa(a, 11, 12, 13);
   0x00000000000007a7 <+15>:    mov    -0x4(%rbp),%eax
   0x00000000000007aa <+18>:    mov    $0xd,%ecx
   0x00000000000007af <+23>:    mov    $0xc,%edx
   0x00000000000007b4 <+28>:    mov    $0xb,%esi
   0x00000000000007b9 <+33>:    mov    %eax,%edi
   0x00000000000007bb <+35>:    callq  0x720 <aaaaaaaaaa(int, int, int, int)>

20          return 0;
   0x00000000000007c0 <+40>:    mov    $0x0,%eax

21      }   0x00000000000007c5 <+45>:   leaveq 
   0x00000000000007c6 <+46>:    retq   

End of assembler dump.

重点关注到调用aaaaaaaaaa函数的过程:

0x00000000000007a0 <+8>:     movl   $0xa,-0x4(%rbp)  
0x00000000000007a7 <+15>:    mov    -0x4(%rbp),%eax
0x00000000000007aa <+18>:    mov    $0xd,%ecx
0x00000000000007af <+23>:    mov    $0xc,%edx
0x00000000000007b4 <+28>:    mov    $0xb,%esi
0x00000000000007b9 <+33>:    mov    %eax,%edi
0x00000000000007bb <+35>:    callq  0x720 <aaaaaaaaaa(int, int, int, int)>

解释一下:

  • 将局部变量a放到rbp-4的位置
  • 将局部变量a拷贝到eax寄存器中
  • 将值10(对应形参d)拷贝到ecx寄存器中
  • 将值11(对应形参c)拷贝到edx寄存器中
  • 将值12(对应形参b)拷贝到esi寄存器中
  • 将局部变量a拷贝到edi寄存器中
  • 然后调用aaaaaaaaaa函数

这也是说明函数确实是从右到左进栈的,但不一定进栈,应该先进入寄存器。

然后是aaaaaaaaaa函数

disassemble /m aaaaaaaaaa   # 查看main函数的汇编执行过程

得到main函数的汇编语句

(gdb) disassemble /m aaaaaaaaaa
Dump of assembler code for function aaaaaaaaaa(int, int, int, int):
10      void aaaaaaaaaa(int a, int b, int c, int d) {
   0x0000000000000720 <+0>:     push   %rbp
   0x0000000000000721 <+1>:     mov    %rsp,%rbp
   0x0000000000000724 <+4>:     sub    $0x20,%rsp
   0x0000000000000728 <+8>:     mov    %edi,-0x14(%rbp)
   0x000000000000072b <+11>:    mov    %esi,-0x18(%rbp)
   0x000000000000072e <+14>:    mov    %edx,-0x1c(%rbp)
   0x0000000000000731 <+17>:    mov    %ecx,-0x20(%rbp)

11          int e = 14;
   0x0000000000000734 <+20>:    movl   $0xe,-0x4(%rbp)

这里只截取前一部分,以便于观察参数是如何进栈的。

我们依旧解释上述汇编过程:

  • 前两句依旧是建立新栈帧
  • 然后是让rsp寄存器存储栈顶地址
  • 接下来四句就是形参入栈了:
    • rbp-14存储第一个参数
    • rbp-18存储第二个参数
    • rbp-1c存储第三个参数
    • rbp-20存储第四个参数
  • 然后是创建局部变量,注意这里重点是局部变量在形参前面

上述中重点就是栈帧中局部变量在形参前面

也就是这么个结构:

rbp
局部变量
12字节
形参
rsp

于是我们就可以明白,获取函数某个参数,需要去计算在rbp上偏移多少个单位。

这里还有个疑问,为什么不能直接用存储形参的那几个寄存器来获取呢?

如果我们同时把mainaaaaaaaaaa放一起观察,就会发现rdi、rsi等几个寄存器是不断在存储新的值的,如果用gdb每次进入新的栈帧去观察这些寄存器的值,会发现rdi和rsi等参数寄存器中存储的永远是顶层栈的信息,而rsp和rbp存储的就是每个栈帧的信息。

所以,如果我们不在调用某个函数的时候要得到该函数的参数,通过这几个参数寄存器显然是不行的,除非在这之前,就已经存储了这些寄存器的值,比如gdb就是这么做的。

获取函数参数

首先来认识一个系统调用:

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

第一个参数枚举类型如果是 PTRACE_PEEKDATA 就可以从对方进程栈地址中读取一个字节,然后返回,其中addr是要读取的地址,data可以为空。

第一个参数枚举类型如果是PTRACE_PEEKDATA就可以从对方进程栈地址中读取一个单位(a word)的值,然后返回,其中addr是要读取的地址,data可以为空。

这里会郁闷,什么叫一个单位(a word)?

根据stackoverflow中的解答,a word的长度就是操作系统的位数,比如32位系统中就是32位,4字节;64位系统中就是64位,8个字节。其实也正对应了在不同位数的操作系统中,long类型的长度。

至于返回值:

  • 如果是执行失败,则返回值会小于0
  • 执行成功则得到读取的数值
  • 关于执行失败,errno有多个可能的值:

  • EBUSY:分配和释放调试寄存器时出错

  • EFAULT(5):读写不可访问的内存空间

  • EINVAL:尝试设置无效选项

  • EIO:请求无效,或者尝试读写父子进程不可访问的空间

  • EPERM:没有权限追踪指定的进程

  • ESRCH:指定的子进程不存在,或者当前正由调用者追踪

需要根据errno的取值去知晓是否发生错误以及错误的种类。

获取整形参数值

这是一件可以很简单也可以很复杂的事情,具体得根据函数栈帧中局部变量以及形参的数量和类型来分析。我们从一个最简单的类型入手,获取无局部变量的栈帧中的第一个整形参数值。

首先给出被跟踪进程的程序:

#include <cstdio>
#include <unistd.h>
#include <vector>

void func(int a, int b, int c, int d) {
    sleep(100000);
}

int main() {
    printf("pid = %d\n", getpid());
    test a;
    aaaaaaaaaa(129, 11, 12, 13);
    return 0;
}

分析该程序,如果要获得参数a的值,要经过以下步骤:

  1. 程序因为sleep停在了函数func的执行过程中,得到进程ID,pid

  2. 通过使用libunwind遍历整个pid进程的程序栈,找到func的栈帧

  3. 获得该栈帧中RBP寄存器记录的栈基地址rbp

  4. 使用rbp向低地址偏移一个整型,得到形参a的地址addr

  5. 使用上述ptrace就能从addr中读取到a的值了

根据上述步骤,就可以写出跟踪程序了:

#include <iostream>
#include <sys/ptrace.h>
#include <array>
#include <algorithm>
#include <libunwind-ptrace.h>
#include <libunwind.h>
#include <sys/wait.h>
#include <cxxabi.h>
#include <lua/lua.h>
#include <vector>
#include <cerrno>

int wait4stop(pid_t pid) {
    int status = 99;
    do {
        if (waitpid(pid, &status, 0) == -1 || WIFEXITED(status) || WIFSIGNALED(status))
            return 0;
    } while(!WIFSTOPPED(status));
    return 1;
}

void get_backtrace(pid_t pid) {
    unw_cursor_t cursor, resume_cursor;
    unw_context_t context;
    unw_word_t ip, sp, off;
    unw_addr_space_t addr_space = unw_create_addr_space(&_UPT_accessors, __BYTE_ORDER__);
    if (!addr_space)
        std::cerr << "Failed to create address space" << std::endl;
    unw_getcontext(&context);
    if (-1 == ptrace(PTRACE_ATTACH, pid, nullptr, nullptr))
        std::cerr << "Failed to ptrace" << std::endl;
    if (!wait4stop(pid))
        std::cerr << "wait SIGSTOP of ptrace failed" << std::endl;
    void *rctx = _UPT_create(pid);
    if (rctx == nullptr)
        std::cerr << "Failed to _UPT_create" << std::endl;
    if (unw_init_remote(&cursor, addr_space, rctx))
        std::cerr << "unw_init_remote failed" << std::endl;
    resume_cursor = cursor;
    const size_t bufflen = 1024;
    char *buff = new char[bufflen];
    do {
        char *name = "23333";
        unw_get_reg(&cursor, UNW_REG_IP, &ip);
        unw_get_reg(&cursor, UNW_REG_SP, &sp);
        // 获取函数名字
        int flag = unw_get_proc_name(&cursor, buff, bufflen, &off);
        if (0 == flag) {
            int status = 99;
            if ((name = abi::__cxa_demangle(buff, nullptr, nullptr, &status)) == 0)
                name = buff;
            if (std::string(name).substr(0, 4) == "func") {
                uintptr_t rbp, rsp;
                unw_get_reg(&cursor, UNW_X86_64_RBP, &rbp);
                unw_get_reg(&cursor, UNW_X86_64_RSP, &rsp);
                printf("ip: 0x%016lx, %s + 0x%016lx, sp: 0x%016lx\n", ip, name, off, sp);
                int *addr = reinterpret_cast<int *>(rbp - 4);
                std::cout << std::hex << rbp << "->";
                std::cout << std::hex << rsp << std::endl;
                std::cout << std::hex << addr << std::endl;
                long rev = ptrace(PTRACE_PEEKDATA, pid, addr, nullptr);
                printf("%d -- %d\n", static_cast<int>(rev), errno);
            }
        } else printf("error!(%d)\n", flag);
    } while (unw_step(&cursor) > 0);
    delete[] buff;
    _UPT_resume(addr_space, &resume_cursor, rctx);
    _UPT_destroy(rctx);
    // 然后是将进程结束中断
    ptrace(PTRACE_DETACH, pid, nullptr, nullptr);
}

int main() {
    pid_t pid = XXXXX;
    get_backtrace(pid);
    return 0;
}

这是我的输出:

ip: 0x0000564b1977a8ce, func(int, int, int, int) + 0x000000000000001e, sp: 0x00007fff0ba93260
7fff0ba93270->7fff0ba93260
0x7fff0ba9326c
129 -- 0

Tips:如果在printf中使用%ld的格式去打印结果rev,可能会得到一个不可预知的结果,这应该和ptrace存储结果的方式有关系,所以获取ptrace结果的时候,需要特别注意这点。

获取指针参数值

基于前面整型参数的获取方法,指针参数也就比较明了了,首先可以明白,一个指针变量的占用空间大小和一个long类型的占用空间大小是一样的。

和前面同样的思路,我们需要把从rbp偏移的量变成一个指针大小的值。

给出被跟踪进程的程序:

#include <cstdio>
#include <unistd.h>
#include <vector>

void func(int *a, int b, int c, int d) {
    sleep(100000);
}

int main() {
    printf("pid = %d\n", getpid());
    int a = 129;
    printf("%ld:%d\n", &a, a);
    func(&a, 11, 12, 13);
    return 0;
}

分析一下,我们首先要获得参数a的值,也就是一个地址,同样我们还可以通过这个地址,拿到前一个栈中a的值,也就是129,最后输出的就是129。

需要提醒的是,任何时候都不能通过***这个解引用符号去获得其他进程地址上的值**,ptrace作为系统调用提供了这么一种方法(就是好麻烦)。

然后我们就能写出跟踪进程的代码了:

#include <iostream>
#include <sys/ptrace.h>
#include <array>
#include <algorithm>
#include <libunwind-ptrace.h>
#include <libunwind.h>
#include <sys/wait.h>
#include <cxxabi.h>
#include <vector>
#include <cerrno>

int wait4stop(pid_t pid) {
    int status = 99;
    do {
        if (waitpid(pid, &status, 0) == -1 || WIFEXITED(status) || WIFSIGNALED(status))
            return 0;
    } while(!WIFSTOPPED(status));
    return 1;
}
void get_backtrace(pid_t pid) {
    unw_cursor_t cursor, resume_cursor;
    unw_context_t context;
    unw_word_t ip, sp, off;
    unw_addr_space_t addr_space = unw_create_addr_space(&_UPT_accessors, __BYTE_ORDER__);
    if (!addr_space)
        std::cerr << "Failed to create address space" << std::endl;
    unw_getcontext(&context);
    if (-1 == ptrace(PTRACE_ATTACH, pid, nullptr, nullptr))
        std::cerr << "Failed to ptrace" << std::endl;
    if (!wait4stop(pid))
        std::cerr << "wait SIGSTOP of ptrace failed" << std::endl;
    void *rctx = _UPT_create(pid);
    if (rctx == nullptr)
        std::cerr << "Failed to _UPT_create" << std::endl;
    if (unw_init_remote(&cursor, addr_space, rctx))
        std::cerr << "unw_init_remote failed" << std::endl;
    resume_cursor = cursor;
    const size_t bufflen = 1024;
    char *buff = new char[bufflen];
    do {
        char *name = "23333";
        unw_get_reg(&cursor, UNW_REG_IP, &ip);
        unw_get_reg(&cursor, UNW_REG_SP, &sp);
        // 获取函数名字
        int flag = unw_get_proc_name(&cursor, buff, bufflen, &off);
        if (0 == flag) {
            int status = 99;
            if ((name = abi::__cxa_demangle(buff, nullptr, nullptr, &status)) == 0)
                name = buff;
            if (std::string(name).substr(0, 4) == "func") {
                uintptr_t rbp, rsp;
                unw_get_reg(&cursor, UNW_X86_64_RBP, &rbp);
                unw_get_reg(&cursor, UNW_X86_64_RSP, &rsp);
                printf("ip: 0x%016lx, %s + 0x%016lx, sp: 0x%016lx\n", ip, name, off, sp);
                int *addr = reinterpret_cast<int *>(rbp - sizeof(int *));
                std::cout << std::hex << rbp << "->";
                std::cout << std::hex << rsp << std::endl;
                std::cout << std::hex << addr << std::endl;
                int *addr_a = reinterpret_cast<int *>(ptrace(PTRACE_PEEKDATA, pid, addr, nullptr));
                printf("%ld -- %d\n", reinterpret_cast<long>(addr_a), errno);
                int a = static_cast<int>(ptrace(PTRACE_PEEKDATA, pid, addr_a, nullptr));
                printf("%ld:%d -- %d\n", reinterpret_cast<long>(addr_a), a, errno);
            }
        } else printf("error!(%d)\n", flag);
    } while (unw_step(&cursor) > 0);
    delete[] buff;
    _UPT_resume(addr_space, &resume_cursor, rctx);
    _UPT_destroy(rctx);
    // 然后是将进程结束中断
    ptrace(PTRACE_DETACH, pid, nullptr, nullptr);
}

int main() {
    get_backtrace(1627628);
    return 0;
}

这里我的输出是这样子的:

ip: 0x000055db2d52a77f, func(int*, int, int, int) + 0x000000000000001f, sp: 0x00007ffd537314b0
7ffd537314d0->7ffd537314b0
0x7ffd537314c8
140726003504364 -- 0
140726003504364:129 -- 0

可以看到确实输出了129,说明读取指针参数是成功的。