C++ `ptrace` 系统调用:实现自定义调试器与沙箱

哈喽,各位好!

今天我们要聊聊一个听起来有点神秘,但实际上超级有用的系统调用:ptrace。 简单来说,ptrace 就像是 C++ 世界里的一个“万能钥匙”,它可以让我们打开进程的大门,窥探里面的运行状态,甚至可以改变进程的行为。 想象一下,你可以像一个“幕后操纵者”一样,控制程序的命运,是不是很酷?

我们今天主要会从这几个方面入手:

  1. ptrace 是什么? 它能干什么?
  2. ptrace 的基本用法: 如何使用 ptrace 附着到进程、读取和修改内存、设置断点等。
  3. 实现一个简单的调试器: 手把手教你用 ptrace 实现一个能够单步执行、查看变量值的调试器。
  4. 利用 ptrace 构建沙箱: 限制程序行为,防止恶意代码执行。
  5. ptrace 的一些高级用法和注意事项: 比如处理多线程程序、处理信号等。

1. ptrace 是什么?

ptrace (process trace) 是一个强大的 Unix 系统调用,它允许一个进程 (称为 tracer) 控制另一个进程 (称为 tracee) 的执行。 tracer 可以读取和修改 tracee 的内存和寄存器,接收 tracee 发出的信号,甚至可以改变 tracee 的执行流程。

ptrace 能干什么?用处可大了!

  • 调试器: gdb 就是基于 ptrace 实现的。
  • 沙箱: 限制程序行为,防止恶意代码执行。
  • 系统调用跟踪: 监控程序执行期间调用的系统调用。
  • 代码覆盖率测试: 确定代码的哪些部分被执行过。
  • 用户空间仿真: 模拟内核行为。

2. ptrace 的基本用法

ptrace 的函数原型是这样的:

#include <sys/ptrace.h>

long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
  • request: 指定要执行的操作。
  • pid: 要控制的进程的 ID。
  • addr: 操作的地址 (取决于 request)。
  • data: 操作的数据 (取决于 request)。

下面是一些常用的 request

request 描述
PTRACE_TRACEME 允许父进程跟踪当前进程。
PTRACE_ATTACH 附着到指定的进程。
PTRACE_DETACH 从指定的进程分离。
PTRACE_PEEKTEXT 读取 tracee 内存中的数据 (代码段)。
PTRACE_PEEKDATA 读取 tracee 内存中的数据 (数据段)。
PTRACE_POKETEXT 写入 tracee 内存中的数据 (代码段)。小心使用!
PTRACE_POKEDATA 写入 tracee 内存中的数据 (数据段)。小心使用!
PTRACE_CONT 继续执行 tracee
PTRACE_SINGLESTEP 单步执行 tracee
PTRACE_GETREGS 获取 tracee 的寄存器值。
PTRACE_SETREGS 设置 tracee 的寄存器值。
PTRACE_GETREGSET 获取 tracee 的寄存器集合(例如, NT_PRSTATUS)。 比 PTRACE_GETREGS 更通用,可以支持更多寄存器。
PTRACE_SETREGSET 设置 tracee 的寄存器集合。
PTRACE_SYSCALL 继续执行 tracee,直到下一次系统调用入口或出口。

一个简单的例子:附着到进程并打印寄存器

#include <iostream>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/reg.h> // For register definitions

int main(int argc, char *argv[]) {
  if (argc < 2) {
    std::cerr << "Usage: " << argv[0] << " <pid>" << std::endl;
    return 1;
  }

  pid_t pid = std::stoi(argv[1]);

  // 附着到进程
  if (ptrace(PTRACE_ATTACH, pid, nullptr, nullptr) == -1) {
    perror("ptrace(ATTACH) failed");
    return 1;
  }

  // 等待进程停止
  int status;
  waitpid(pid, &status, 0);

  // 获取寄存器值
#ifdef __x86_64__
  user_regs_struct regs;
  if (ptrace(PTRACE_GETREGS, pid, nullptr, &regs) == -1) {
    perror("ptrace(GETREGS) failed");
    ptrace(PTRACE_DETACH, pid, nullptr, nullptr); // 分离进程
    return 1;
  }

  std::cout << "RIP: 0x" << std::hex << regs.rip << std::endl;
  std::cout << "RAX: 0x" << std::hex << regs.rax << std::endl;
  std::cout << "RBX: 0x" << std::hex << regs.rbx << std::endl;
  std::cout << "RCX: 0x" << std::hex << regs.rcx << std::endl;
  std::cout << "RDX: 0x" << std::hex << regs.rdx << std::endl;
  std::cout << "RSI: 0x" << std::hex << regs.rsi << std::endl;
  std::cout << "RDI: 0x" << std::hex << regs.rdi << std::endl;
  std::cout << "RBP: 0x" << std::hex << regs.rbp << std::endl;
  std::cout << "RSP: 0x" << std::hex << regs.rsp << std::endl;
#else
  std::cerr << "This example only supports x86_64 architecture." << std::endl;
  ptrace(PTRACE_DETACH, pid, nullptr, nullptr);
  return 1;
#endif

  // 分离进程
  if (ptrace(PTRACE_DETACH, pid, nullptr, nullptr) == -1) {
    perror("ptrace(DETACH) failed");
    return 1;
  }

  return 0;
}

编译运行:

g++ -o ptrace_example ptrace_example.cpp
# 找到要trace的进程的PID
ps aux | grep <你的目标进程名>
./ptrace_example <pid>

这个例子首先附着到指定的进程,然后等待进程停止。 接着,它使用 PTRACE_GETREGS 获取进程的寄存器值,并打印 RIP (指令指针) 和通用寄存器 (RAX, RBX, RCX 等) 的值。 最后,它使用 PTRACE_DETACH 从进程分离。

3. 实现一个简单的调试器

现在,我们来用 ptrace 实现一个简单的调试器。 这个调试器可以:

  • 附着到进程
  • 设置断点
  • 单步执行
  • 查看内存值
  • 分离进程
#include <iostream>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/reg.h>
#include <iomanip>
#include <sstream>
#include <string>
#include <vector>
#include <algorithm>

// 从 tracee 进程内存读取一个 word (long)
long peek_data(pid_t pid, unsigned long addr) {
  errno = 0;
  long val = ptrace(PTRACE_PEEKDATA, pid, (void*)addr, nullptr);
  if (errno != 0) {
    perror("ptrace(PEEKDATA) failed");
    return -1; // 或者抛出异常
  }
  return val;
}

// 向 tracee 进程内存写入一个 word (long)
bool poke_data(pid_t pid, unsigned long addr, long value) {
  if (ptrace(PTRACE_POKEDATA, pid, (void*)addr, (void*)value) == -1) {
    perror("ptrace(POKEDATA) failed");
    return false;
  }
  return true;
}

// 设置断点
bool set_breakpoint(pid_t pid, unsigned long addr, std::vector<unsigned char>& original_bytes) {
  // 读取原始字节
  long data = peek_data(pid, addr);
  if (data == -1) return false;

  original_bytes.resize(sizeof(long));
  memcpy(original_bytes.data(), &data, sizeof(long));
  // 保存断点处的原始字节,以便恢复
  //用int 3 (0xCC) 替换最低有效字节
  long breakpoint = (data & ~0xFF) | 0xCC;
  return poke_data(pid, addr, breakpoint);
}

// 恢复断点
bool restore_breakpoint(pid_t pid, unsigned long addr, const std::vector<unsigned char>& original_bytes) {
    long data;
    memcpy(&data, original_bytes.data(), original_bytes.size());
    return poke_data(pid, addr, data);
}

int main(int argc, char *argv[]) {
  if (argc < 2) {
    std::cerr << "Usage: " << argv[0] << " <pid>" << std::endl;
    return 1;
  }

  pid_t pid = std::stoi(argv[1]);

  // 附着到进程
  if (ptrace(PTRACE_ATTACH, pid, nullptr, nullptr) == -1) {
    perror("ptrace(ATTACH) failed");
    return 1;
  }

  std::cout << "Attached to process " << pid << std::endl;

  int status;
  waitpid(pid, &status, 0);

  // 断点地址
  unsigned long breakpoint_address;
  std::cout << "Enter breakpoint address (in hex): 0x";
  std::cin >> std::hex >> breakpoint_address;

  std::vector<unsigned char> original_bytes;
  if (!set_breakpoint(pid, breakpoint_address, original_bytes)) {
    std::cerr << "Failed to set breakpoint at 0x" << std::hex << breakpoint_address << std::endl;
    ptrace(PTRACE_DETACH, pid, nullptr, nullptr);
    return 1;
  }

  std::cout << "Breakpoint set at 0x" << std::hex << breakpoint_address << std::endl;

  while (true) {
    // 继续执行,直到断点
    if (ptrace(PTRACE_CONT, pid, nullptr, nullptr) == -1) {
      perror("ptrace(CONT) failed");
      break;
    }

    waitpid(pid, &status, 0);

    if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) {
      std::cout << "Hit breakpoint at 0x" << std::hex << breakpoint_address << std::endl;

      // 获取寄存器值 (RIP)
#ifdef __x86_64__
      user_regs_struct regs;
      if (ptrace(PTRACE_GETREGS, pid, nullptr, &regs) == -1) {
        perror("ptrace(GETREGS) failed");
        break;
      }
      // RIP 指向断点指令的下一条指令,需要减1
      unsigned long rip = regs.rip - 1;
      std::cout << "RIP: 0x" << std::hex << rip << std::endl;

      // 恢复断点处的原始字节
      if (!restore_breakpoint(pid, breakpoint_address, original_bytes)) {
        std::cerr << "Failed to restore breakpoint." << std::endl;
        break;
      }

      // 单步执行
       regs.rip = breakpoint_address; //将 RIP 恢复到断点地址
      if (ptrace(PTRACE_SETREGS, pid, nullptr, &regs) == -1) {
          perror("ptrace(SETREGS) failed");
          break;
      }
      if (ptrace(PTRACE_SINGLESTEP, pid, nullptr, nullptr) == -1) {
          perror("ptrace(SINGLESTEP) failed");
          break;
      }

      waitpid(pid, &status, 0);

      // 重新设置断点
      if (!set_breakpoint(pid, breakpoint_address, original_bytes)) {
        std::cerr << "Failed to reset breakpoint at 0x" << std::hex << breakpoint_address << std::endl;
        break;
      }
#else
      std::cerr << "This example only supports x86_64 architecture." << std::endl;
      break;
#endif
      std::cout << "Continue? (y/n): ";
      char choice;
      std::cin >> choice;
      if (choice != 'y') break;

    } else if (WIFEXITED(status)) {
      std::cout << "Process exited with status " << WEXITSTATUS(status) << std::endl;
      break;
    } else if (WIFSIGNALED(status)) {
      std::cout << "Process terminated by signal " << WTERMSIG(status) << std::endl;
      break;
    }
  }

  // 分离进程
  if (ptrace(PTRACE_DETACH, pid, nullptr, nullptr) == -1) {
    perror("ptrace(DETACH) failed");
    return 1;
  }

  std::cout << "Detached from process " << pid << std::endl;

  return 0;
}

这个代码有点长,我们来分段解释:

  • peek_datapoke_data 函数: 这两个函数分别用于从 tracee 进程的内存中读取和写入数据。
  • set_breakpoint 函数: 这个函数在指定的地址设置断点。 它首先读取该地址的原始字节,然后用 0xCC (int 3 指令,用于触发断点) 替换最低有效字节。保存原始字节以便后续恢复。
  • restore_breakpoint 函数: 这个函数恢复断点处的原始字节。
  • main 函数:
    • 附着到进程。
    • 提示用户输入断点地址。
    • 设置断点。
    • 在一个循环中:
      • 继续执行 tracee 进程,直到遇到断点。
      • 当遇到断点时,打印 RIP 寄存器的值。
      • 恢复断点处的原始字节。
      • 单步执行。
      • 重新设置断点。
      • 询问用户是否继续。
    • 分离进程。

编译运行:

g++ -o debugger debugger.cpp
# 找到要debug的进程的PID
ps aux | grep <你的目标进程名>
./debugger <pid>

4. 利用 ptrace 构建沙箱

ptrace 也可以用来构建沙箱,限制程序可以执行的操作。 例如,我们可以监控程序调用的系统调用,并阻止某些危险的系统调用。

#include <iostream>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/syscall.h> // For SYS_* definitions
#include <errno.h>

// 系统调用黑名单
bool is_syscall_allowed(long syscall_number) {
  // 禁止 execve, fork, clone 等创建新进程的系统调用
  if (syscall_number == SYS_execve || syscall_number == SYS_fork || syscall_number == SYS_clone) {
    return false;
  }
  // 禁止打开文件
  if (syscall_number == SYS_open || syscall_number == SYS_openat) {
    return false;
  }
  //允许其他系统调用
  return true;
}

int main(int argc, char *argv[]) {
  if (argc < 2) {
    std::cerr << "Usage: " << argv[0] << " <program> [args...]" << std::endl;
    return 1;
  }

  pid_t child_pid = fork();

  if (child_pid == 0) {
    // 子进程

    // 允许父进程跟踪
    if (ptrace(PTRACE_TRACEME, 0, nullptr, nullptr) == -1) {
      perror("ptrace(TRACEME) failed");
      return 1;
    }

    // 执行目标程序
    execvp(argv[1], &argv[1]);
    perror("execvp failed"); // 如果 execvp 失败,打印错误信息
    return 1;
  } else if (child_pid > 0) {
    // 父进程 (沙箱)
    int status;
    long syscall_number;

    waitpid(child_pid, &status, 0); // 等待子进程停止

    // 设置 PTRACE_O_TRACESYSGOOD 选项, 这样系统调用停止时,status 的高位会被设置
    ptrace(PTRACE_SETOPTIONS, child_pid, nullptr, PTRACE_O_TRACESYSGOOD);

    while (true) {
      // 继续执行,直到系统调用入口
      if (ptrace(PTRACE_SYSCALL, child_pid, nullptr, nullptr) == -1) {
        perror("ptrace(SYSCALL) failed");
        break;
      }

      waitpid(child_pid, &status, 0);

      if (WIFSTOPPED(status) && (WSTOPSIG(status) == (SIGTRAP | 0x80))) { // 检查是否是系统调用入口
        // 获取系统调用号
#ifdef __x86_64__
        syscall_number = ptrace(PTRACE_PEEKUSER, child_pid, (void*)(sizeof(long) * ORIG_RAX), nullptr);
#else
        syscall_number = ptrace(PTRACE_PEEKUSER, child_pid, (void*)(sizeof(long) * ORIG_EAX), nullptr);
#endif
        if (syscall_number == -1 && errno != 0) {
          perror("ptrace(PEEKUSER) failed");
          break;
        }

        std::cout << "Syscall number: " << syscall_number << std::endl;

        // 检查系统调用是否允许
        if (!is_syscall_allowed(syscall_number)) {
          std::cerr << "Syscall " << syscall_number << " is blocked!" << std::endl;

          // 阻止系统调用执行 (通过修改 RAX 寄存器,让系统调用返回错误)
          ptrace(PTRACE_POKEUSER, child_pid, (void*)(sizeof(long) * ORIG_RAX), (void*)-1);
        }

        // 继续执行,直到系统调用出口
        if (ptrace(PTRACE_SYSCALL, child_pid, nullptr, nullptr) == -1) {
          perror("ptrace(SYSCALL) failed");
          break;
        }

        waitpid(child_pid, &status, 0);

        if (WIFSTOPPED(status) && (WSTOPSIG(status) == (SIGTRAP | 0x80))) {
          // 系统调用出口
        } else {
          break;
        }
      } else if (WIFEXITED(status)) {
        std::cout << "Child process exited with status " << WEXITSTATUS(status) << std::endl;
        break;
      } else if (WIFSIGNALED(status)) {
        std::cout << "Child process terminated by signal " << WTERMSIG(status) << std::endl;
        break;
      } else {
          std::cout << "Unexpected status: " << status << std::endl;
          break;
      }
    }
  } else {
    perror("fork failed");
    return 1;
  }

  return 0;
}

这个沙箱的原理是:

  1. 父进程 fork 一个子进程。
  2. 子进程调用 ptrace(PTRACE_TRACEME) 允许父进程跟踪自己。
  3. 子进程调用 execvp 执行目标程序。
  4. 父进程使用 PTRACE_SYSCALL 监控子进程的系统调用。
  5. 在每次系统调用入口,父进程检查系统调用号是否在黑名单中。
  6. 如果系统调用在黑名单中,父进程阻止该系统调用执行 (通过修改 RAX 寄存器,让系统调用返回错误)。

编译运行:

g++ -o sandbox sandbox.cpp
./sandbox /bin/ls  # 尝试运行 ls 命令
./sandbox /bin/cat /etc/passwd # 尝试打开文件

5. ptrace 的一些高级用法和注意事项

  • 多线程程序: ptrace 对多线程程序的支持比较复杂。 默认情况下,ptrace 只会跟踪主线程。 可以使用 PTRACE_THREAD_EVENTS 选项来跟踪其他线程的创建和退出事件。
  • 信号: ptrace 可以截获 tracee 发出的信号。 tracer 可以选择将信号传递给 tracee,或者阻止信号传递。
  • 安全性: ptrace 具有一定的安全风险。 一个恶意程序可以使用 ptrace 来窃取其他进程的敏感信息。 因此,在使用 ptrace 时,需要谨慎考虑安全问题。通常,只有 root 用户才能 ptrace 其他进程。
  • 性能: ptrace 会显著降低 tracee 的性能。 因为每次 tracee 停止执行,都需要 tracer 来处理。
  • 架构差异: 不同架构的寄存器定义和系统调用号可能不同。 因此,在使用 ptrace 时,需要注意架构差异。

总结

ptrace 是一个非常强大的系统调用,它可以让我们深入了解进程的运行状态,甚至可以改变进程的行为。 但是,ptrace 也是一个复杂的工具,需要谨慎使用。

希望今天的讲座能帮助你更好地理解 ptrace。 下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注