相信大家对GDB都不陌生,GDB原理也能说个一二,什么ptrace、调试寄存器等说的头头是道,也有很多朋友用ptrace亲手实现过简单的gdb调试功能。今天就让我用一种简单的方式,在目标进程添加一个系统调用并执行。
需要解决的问题有:
- 如何跟踪目标进程;
- 如何注入代码;
- 输入什么代码;
- 如何让目标进程执行注入的代码;
- 如何恢复现场;
如何跟踪目标进程 - ptrace
#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
从ptrace函数声明看出,需要我们传入一个请求枚举值,我们要用到的是:
PTRACE_ATTACH - 跟踪一个进程,被跟踪进程将会因此停止运行
PTRACE_GETREGS - 获取被跟踪进程所有寄存器信息
PTRACE_SETREGS - 设置被跟踪进程所有寄存器信息
PTRACE_CONT - 使被跟踪进程继续运行
PTRACE_DETACH - 取消跟踪目标进程
如何注入代码
为了简单,我们只是简单的注入一个系统调用,所以需要写入的东西并不多,可以使用当前进程现有的地址空间已经映射的内存区域,比如说libc开始映射的位置(其实这个位置并不重要,随便一个代码段就可以),但需要注意的是,在替换代码->执行代码这两步结束后,别忘了最后一步,将原始代码替换回去。
那么如何注入代码呢?
我们看两个系统调用:
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
再看两个proc文件系统下的文件/proc/PID/mem和/proc/PID/maps。
将上面两个proc文件和两个系统调用结合起来,就可以访问和修改目标进程的内存。
【文章福利】小编推荐自己的Linux内核源码交流群:【869634926】整理了一些个人觉得比较好的Linux学习书籍、视频资料共享在里面,有需要的同学可以自行添加哦!
/proc/PID/maps
这个文件大家比较熟悉了,先看看他长啥样:
上面每一部分都是一个非常重要的知识点,比如:vDSO、vsyscall、vvar等,这个文件保存了一个进程镜像的布局,通过展现每个内存映射来实现,熟悉内核的朋友可能很熟悉VMA,没错,上面的每一行都是一个VMA结构(关于内核VMA本文当然也不介绍,内容太多泰国庞杂)。
这里先简单提一嘴,我们可以选地址范围为7fc0530b2000-7fc0530de000的libc的vma作为在目标进程执行系统调用的“中转”,也就是说,每次我们想要在目标进程中执行一个系统调用时,先将这个libc的vma对应的大小的内存保存起来,然后将程序写入这个vma起始位置,然后跳转到那个位置执行,最后,将第一步保存的代码写回到这个vma对应位置。
/proc/PID/mem
这个文件怎么解释呢?不好解释,还是看内核代码吧!
首先,每个进程都有一个/proc/PID/mem文件,在内核源码中,定位到代码 fs/proc/base.c,在创建新的进程时候,都会创建一个新的/proc/PID/mem文件。
REG("mem", S_IRUSR|S_IWUSR, proc_mem_operations),
对应的操作符(只关注打开,读,写):
static int mem_open(struct inode *inode, struct file *file)
{
int ret = __mem_open(inode, file, PTRACE_MODE_ATTACH);
/* OK to pass negative loff_t, we can catch out-of-range */
file->f_mode |= FMODE_UNSIGNED_OFFSET;
return ret;
}
static ssize_t mem_read(struct file *file, char __user *buf,
size_t count, loff_t *ppos)
{
return mem_rw(file, buf, count, ppos, 0);
}
static ssize_t mem_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos)
{
return mem_rw(file, (char __user*)buf, count, ppos, 1);
}
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
.release = mem_release,
};
我们知道,在内核中一个打开的文件用 struct file 描述,同时,通过 file.private_data 来传递数据,常见的 file.private_data数据如下:
/**
* epoll(2) 中对应 struct eventpoll 结构
* socket(2) 中对应 struct socket 结构
* perf_event_open(2) 中对应 struct perf_event *group_leader 结构
* io_uring(2) 中对应 struct io_ring_ctx * 结构
* __bpf_map_get() 中对应 struct bpf_map * 结构
* ____bpf_prog_get() 中对应 struct bpf_prog * 结构
* seq_release() 中对应 struct seq_file * 结构
* pid 中对应 struct pid * 结构
* userfaultfd(2) 中对应 struct userfaultfd_ctx * 结构,见 userfaultfd(2)
* /proc/PID/mem 中对应 struct mm_struct * 结构,见 mem_rw()
* [...]
*/
void *private_data;
该数据在下面函数中设置:
static int __mem_open(struct inode *inode, struct file *file, unsigned int mode)
{
struct mm_struct *mm = proc_mem_open(inode, mode);
if (IS_ERR(mm))
return PTR_ERR(mm);
file->private_data = mm;
return 0;
}
在 mem_rw() 函数中,首先就是获取这个数据结构:
static ssize_t mem_rw(struct file *file, char __user *buf,
size_t count, loff_t *ppos, int write)
{
struct mm_struct *mm = file->private_data;
看到这里,就不用继续看内核代码了,因为有了 mm_struct 结构,对 进程地址空间的读写那不就是易如反掌了。
所以,我们利用 /proc/PID/mem、 /proc/PID/maps 文件,pread 和 pwrite 系统调用对目标进程的地址空间进行读写,来实现代码注入。
注入什么代码
首先,代码在内存里,这是毋庸置疑的。为了简化注入的代码,我以注入最小的代码为例,注入一个系统调用,并让目标进程执行这个系统调用,如:打开一个文件、进程文件映射等。
那么又该以什么姿势让目标进程执行呢?
看看syscall(2)这个系统调用
#include <unistd.h>
#include <sys/syscall.h> /* For SYS_xxx definitions */
long syscall(long number, ...);
写个最简单的系统调用:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/syscall.h>
int main()
{
syscall(SYS_chmod, "-x", "a.out");
return 0;
}
编译后查看反汇编:
0000000000401126 <main>:
401126: 55 push %rbp
401127: 48 89 e5 mov %rsp,%rbp
40112a: ba 10 20 40 00 mov $0x402010,%edx
40112f: be 16 20 40 00 mov $0x402016,%esi
401134: bf 5a 00 00 00 mov $0x5a,%edi
401139: b8 00 00 00 00 mov $0x0,%eax
40113e: e8 ed fe ff ff callq 401030 <syscall@plt>
401143: b8 00 00 00 00 mov $0x0,%eax
401148: 5d pop %rbp
401149: c3 retq
这里注意到,syscall(2)实际上在动态库里实现(具体可参见syscall@pltPLT相关知识,此处不做展开,可参考文章《[Linux基础]ELF重定位解析》对重定位的介绍),那么如何查看真正的系统调用对应的汇编代码呢?那我们得换个例子,看下面的hello.asm代码:
global _start
section .text
_start:
; write(1, message, 13)
mov rax, 1 ; 1 号系统调用是写操作
mov rdi, 1 ; 1 号文件系统调用是标准输出
mov rsi, message ; 输出字符串的地址
mov rdx, 13 ; 字符串的长度
syscall ; 调用系统执行写操作
; exit(0)
mov eax, 60 ; 60 号系统调用是退出
xor rdi, rdi ; 0 号系统调用作为退出
syscall ; 调用系统执行退出
section .data
message:
db "Hello, World", 10 ; 注意到最后的换行
进行编译链接,生成可执行文件,并运行:
$ nasm -felf64 hello.asm
$ ld hello.o
$ ./a.out
Hello, World
我们再看看这个可执行文件的反汇编:
Disassembly of section .text:
0000000000401000 <_start>:
401000: b8 01 00 00 00 mov $0x1,%eax
401005: bf 01 00 00 00 mov $0x1,%edi
40100a: 48 be 00 20 40 00 00 movabs $0x402000,%rsi
401011: 00 00 00
401014: ba 0d 00 00 00 mov $0xd,%edx
401019: 0f 05 syscall
40101b: b8 3c 00 00 00 mov $0x3c,%eax
401020: 48 31 ff xor %rdi,%rdi
401023: 0f 05 syscall
所以,这里请记住syscall系统调用的机器码为0f 05。
再来看一眼syscall(2)系统调用:
#include <unistd.h>
#include <sys/syscall.h> /* For SYS_xxx definitions */
long syscall(long number, ...);
有人好奇,参数列表“...”是个啥?
在内核中,有三个宏定义:
#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)
说明,系统调用最多传参六个,这好像和编译器在处理函数参数传递时候是一样的(见《一文搞懂x86-64函数调用参数传递》)。我们看个参数相对比较多的系统调用吧:
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
mmap(2) 系统调用参数为六个,应该也是系统调用参数极限了。
回到注入什么代码上来,看看上面的汇编代码调用exit(0)系统调用:
; exit(0)
mov eax, 60 ; 60 号系统调用是退出
xor rdi, rdi ; 0 号系统调用作为退出
syscall ; 调用系统执行退出
syscall系统调用的机器码为0f 05,那么我们只需要再传递一个退出状态就可以调用exit(0)系统调用了不是吗!
如何让目标进程执行注入的代码
在上文中的完整介绍后,代码注入的整体流程相对明朗了:
1. ptrace attach 目标进程;
2. 获取当前进程的所有寄存器,用于恢复现场;
3. 将目标进程将要被覆盖的内存保存到本地进程;
4. 将要被执行的代码拷贝到目标进程;
5. 设置目标进程IP寄存器;
6. 让目标进程继续执行(需要注意PTRACE_CONT停止条件);
7. 恢复目标进程寄存器;
8. 让目标进程继续执行;
Talk is cheap, show me the code.
核心函数:
int
remote_syscall(struct process *task, int nr,
unsigned long arg1, unsigned long arg2, unsigned long arg3,
unsigned long arg4, unsigned long arg5, unsigned long arg6,
unsigned long *res)
{
int ret;
struct user_regs_struct old_regs, regs, __unused syscall_regs;
unsigned char __syscall[] = {SYSCALL_INSTR};
SYSCALL_REGS_PREPARE(syscall_regs, nr, arg1, arg2, arg3, arg4, arg5, arg6);
unsigned char orig_code[sizeof(__syscall)];
unsigned long libc_base = task->libc_base->start;
/* 1. Get old register */
ret = ptrace(PTRACE_GETREGS, task->pid, NULL, &old_regs);
if (ret == -1) {
lerror("ptrace(PTRACE_GETREGS, %d, ...) failed, %s\n",
task->pid, strerror(errno));
}
/* 2. Read original code from libc */
memcpy_from_remote(task, orig_code, libc_base, sizeof(__syscall));
/* 3. Write syscall instruments to libc */
memcpy_to_remote(task, libc_base, __syscall, sizeof(__syscall));
/* 4. */
regs = old_regs;
/* 5. Execute from syscall, see step 3. */
SYSCALL_IP(regs) = libc_base;
copy_regs(®s, &syscall_regs);
/* 6. Set target process run from (syscall) */
ret = ptrace(PTRACE_SETREGS, task->pid, NULL, ®s);
if (ret == -1) {
lerror("ptrace(PTRACE_SETREGS, %d, ...) failed, %s\n",
task->pid, strerror(errno));
}
/* 7. Run target process */
ret = wait_for_stop(task, NULL);
if (ret < 0) {
lerror("failed call to func\n");
goto poke_back;
}
/* 8. Get syscall return regs */
ret = ptrace(PTRACE_GETREGS, task->pid, NULL, ®s);
if (ret == -1) {
lerror("ptrace(PTRACE_GETREGS, %d, ...) failed, %s\n",
task->pid, strerror(errno));
}
/* 9. Restorage original registers */
ret = ptrace(PTRACE_SETREGS, task->pid, NULL, &old_regs);
if (ret == -1) {
lerror("ptrace(PTRACE_SETREGS, %d, ...) failed, %s\n",
task->pid, strerror(errno));
}
/* 10. Get return value */
syscall_regs = regs;
*res = SYSCALL_RET(syscall_regs);
poke_back:
/* 11. Poke libc code back */
memcpy_to_remote(task, libc_base, orig_code, sizeof(__syscall));
return ret;
}
SYSCALL_INSTR是啥?
#define INST_SYSCALL 0x0f, 0x05 /* syscall */
#define INST_INT3 0xcc /* int3 */
#define INST_CALLQ 0xe8 /* callq */
#define INST_JMPQ 0xe9 /* jmpq */
#define SYSCALL_INSTR \
INST_SYSCALL, /* syscall */ \
INST_INT3, /* int3 */
我给他封装一层,举个例子:
unsigned long
remote_mmap(struct process *task,
unsigned long addr, size_t length, int prot, int flags,
int fd, off_t offset)
{
int ret;
unsigned long result;
ret = remote_syscall(task,
__NR_mmap, addr, length, prot, flags, fd, offset, &result);
if (ret < 0) {
return 0;
}
return result;
}
int remote_munmap(struct process *task, unsigned long addr, size_t length)
{
int ret;
unsigned long result;
ret = remote_syscall(task,
__NR_munmap, addr, length, 0, 0, 0, 0, &result);
if (ret < 0) {
return 0;
}
return result;
}
再举一个复杂的例子:
int remote_open(struct process *task, char *pathname, int flags, mode_t mode)
{
char maybeislink[PATH_MAX], path[PATH_MAX];
int ret;
unsigned long result;
unsigned long remote_fileaddr;
ssize_t remote_filename_len = 0;
if (!(flags|O_CREAT)) {
ret = readlink(pathname, maybeislink, sizeof(maybeislink));
if (ret < 0) {
lwarning("readlink(3) failed.\n");
return -1;
}
maybeislink[ret] = '\0';
if (!realpath(maybeislink, path)) {
lwarning("realpath(3) failed.\n");
return -1;
}
ldebug("%s -> %s -> %s\n", pathname, maybeislink, path);
pathname = path;
}
remote_filename_len = strlen(pathname) + 1;
remote_fileaddr = remote_mmap(task,
0UL, remote_filename_len,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy_to_remote(task, remote_fileaddr, pathname, remote_filename_len);
#if defined(__x86_64__)
ret = remote_syscall(task,
__NR_open, remote_fileaddr, flags, mode, 0, 0, 0, &result);
#elif defined(__aarch64__)
ret = remote_syscall(task,
__NR_openat, AT_FDCWD, remote_fileaddr, flags, mode, 0, 0, &result);
#else
# error "Error arch"
#endif
remote_munmap(task, remote_fileaddr, remote_filename_len);
return result;
}
-- 链接 --
部分代码已开源:
https://github.com/Rtoax/test/tree/master/uftrace/attach_process