如何在运行的进程中添加执行系统调用

相信大家对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(&regs, &syscall_regs);

    /* 6. Set target process run from (syscall) */
    ret = ptrace(PTRACE_SETREGS, task->pid, NULL, &regs);
    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, &regs);
    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

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值