哈喽,各位好!
今天我们要聊聊一个听起来有点神秘,但实际上超级有用的系统调用:ptrace
。 简单来说,ptrace
就像是 C++ 世界里的一个“万能钥匙”,它可以让我们打开进程的大门,窥探里面的运行状态,甚至可以改变进程的行为。 想象一下,你可以像一个“幕后操纵者”一样,控制程序的命运,是不是很酷?
我们今天主要会从这几个方面入手:
ptrace
是什么? 它能干什么?ptrace
的基本用法: 如何使用ptrace
附着到进程、读取和修改内存、设置断点等。- 实现一个简单的调试器: 手把手教你用
ptrace
实现一个能够单步执行、查看变量值的调试器。 - 利用
ptrace
构建沙箱: 限制程序行为,防止恶意代码执行。 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, ®s) == -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, ®s) == -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, ®s) == -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_data
和poke_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;
}
这个沙箱的原理是:
- 父进程
fork
一个子进程。 - 子进程调用
ptrace(PTRACE_TRACEME)
允许父进程跟踪自己。 - 子进程调用
execvp
执行目标程序。 - 父进程使用
PTRACE_SYSCALL
监控子进程的系统调用。 - 在每次系统调用入口,父进程检查系统调用号是否在黑名单中。
- 如果系统调用在黑名单中,父进程阻止该系统调用执行 (通过修改
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
。 下次再见!