典型的后渗透活动包括侦察、信息收集和权限提升。有时,攻击者可能需要额外的功能,例如当目标系统默认情况下不提供必要的工具,或者当需要加快这些后渗透操作的速度时。
大多数情况下,专业工具会被上传到目标系统并运行。这种方法最大的弊端在于,如果检测到残留在磁盘上的痕迹,可能会向防御者泄露额外信息,并有可能危及整个行动。
近年来,针对Windows操作系统如何在不触及磁盘的情况下进行代码注入,已有大量研究。*NIX 系统(尤其是 Linux)的情况则有所不同。
想象一下,你在一个闪烁的光标前,正在使用一台刚刚被攻破的Linux服务器上的shell,你想在不留下任何痕迹的情况下继续操作。您需要运行一些额外的工具,但您不想向机器上传任何内容。或者,你根本无法运行任何程序,因为挂载的分区设置了noexec选项。你还有哪些选择?
本文将展示如何绕过执行限制,仅使用系统上自带的工具在机器上运行代码。在“一切皆文件”的操作系统中,这具有挑战性,但只要跳出固有思维模式,充分利用系统提供的强大功能,就能实现。
对于进攻方而言,找到一种可靠且隐蔽的方式将有效载荷/工具送达目标机器始终是一个挑战。
最常见的方法是与托管所需工具的C2服务器或第三方服务器建立新的连接,并将工具下载到受害者的设备上。这可能会在网络基础设施上产生额外的痕迹(例如,网络流、代理日志等)。
在许多情况下,攻击者会忘记目标机器上已经存在一个开放的控制通道——shell 会话。该会话可用作数据链路,无需与外部系统建立新的 TCP 连接即可向受害者上传有效载荷。这种方法的缺点是,网络故障可能导致数据传输和控制通道同时丢失。
本文中,将两种传输方式分别称为带外传输和带内传输。后一种方式将作为传输(shellcode)工具的主要方式。
我们的演示和实验将使用以下环境:
受害机器运行的是最新版本的 Kali Linux 虚拟机
攻击者机器运行宿主机上的虚拟机 Arch Linux 系统
攻击者通过 SSH 连接到受害者,模拟 shell 访问
适用于 x86_64 架构的简单“Hello world” shellcode(参考附录 A)
不通过磁盘,只使用内存的方法
tmpfs
攻击者可以保存文件的一个位置是tmpfs。它会将所有内容放入内核内部缓存中,并根据其中包含的文件大小进行增长和收缩。此外,从 glibc 2.2 开始,tmpfs 会被挂载到 /dev/shm 以用于 POSIX 共享内存(使用 shm_open() 和 shm_unlink() 函数)。
以下是已挂载的 tmpfs 虚拟文件系统的示例视图(来自 Kali):
默认情况下,/dev/shm挂载时未设置noexec标志。如果管理员过于谨慎地启用了该标志,则会破坏这种方法——我们可以存储数据,但无法执行代码(execve() 会失败)。
我们稍后会再讨论 /dev/shm。
GDB
GNU Debugger 是 Linux 的默认调试工具。它通常不会安装在生产服务器上,但有时会在开发环境和一些嵌入式/专用系统中看到。
GDB的一个功能是可以在内存中运行shellcode,无需访问磁盘。
首先,我们将 shellcode 转换为字符串字节:
然后,在 gdb 的控制下运行/bin/bash,在 main() 函数处设置断点,注入 shellcode 并继续执行。以下是一个单行脚本示例:
python
Python是一种非常流行的解释型编程语言,与 GDB 不同,它常见于许多默认的 Linux 部署中。
它的功能可以通过许多模块进行扩展,包括ctypes,该模块提供与 C 语言兼容的数据类型,并允许调用 DLL 或共享库中的函数。换句话说,ctypes能够构建类似 C 语言的脚本,结合了外部库的强大功能和对内核系统调用的直接访问。
为了使用 Python 在内存中运行我们的shellcode,我们的脚本必须:
将 libc 库加载到 Python 进程中
使用mmap() 为 shellcode 创建一个新的 W+X 内存区域
将 shellcode 复制到新分配的缓冲区中
使缓冲区“可调用”(类型转换)
调用缓冲区
以下是完整的脚本(Python 2):
将整个脚本转换成 Base64 编码的字符串:
并用一行命令将代码发送到目标机器:
使用dd进行自修改
在极少数情况下,如果上述方法均不可行,许多 Linux 系统默认安装的另一个工具(属于 coreutils 软件包)或许可以派上用场。该工具名为dd,通常用于文件转换和复制。如果我们将其与procfs文件系统和/proc/self/mem特殊文件(用于暴露进程自身的内存)结合使用,或许就能找到一个短暂的窗口期,使 shellcode 直接在内存中运行。为此,我们需要强制 dd 动态修改自身内存。
默认的 dd 运行时行为如下所示:
自我修改的 dd 运行时环境的样子:
首先需要在dd进程内部找到一个地方来存放shellcode。整个过程必须在每次运行中保持稳定可靠,因为它是一个不断覆盖自身内存的进程。
一个候选位置是复制/覆盖成功后执行的代码。Shellcode注入可以在PLT(过程链接表)中完成,也可以在主代码段中的 exit() 调用处完成,或者在 exit() 调用之前完成。
覆盖 PLT 非常不稳定,因为如果我们的 shellcode 太长,它可能会覆盖在调用 exit() 之前使用的一些关键部分。
经过一番调查,发现 fclose() 函数是在 exit() 之前调用的:
fclose()函数仅在以下两个地方被调用:
进一步的测试表明,0x9c2b处的代码(jmp 1cb0)是运行时使用的代码,后面是一大段代码,这些代码被覆盖后可能不会导致进程崩溃。
要使这项技术奏效,我们还需要解决两个障碍:
复制完成后,dd 命令会关闭 stdin、stdout 和 stderr 文件描述符:
地址空间布局随机化(ASLR)
第一个问题可以通过借助 bash 创建重复的 stdin 和 stdout 文件描述符来解决(参考bash(1)):
Duplicating File Descriptors The redirection operator [n]<&word is used to duplicate input file descriptors. If word expands to one or more digits, the file descriptor denoted by n is made to be a copy of that file descriptor.
并在我们的 shellcode 前加上 dup() 系统调用:
第二个问题更为复杂。如今,在一些 Linux 发行版中,二进制文件被编译成 PIE(位置无关可执行文件)对象:
并且ASLR默认开启:
幸运的是,Linux为每个进程支持不同的执行域(也称为“个性”)。执行域的作用之一是告诉 Linux 如何将信号编号映射到信号动作。执行域系统使得 Linux 能够为在其他类 UNIX 操作系统下编译的二进制文件提供有限的支持。自 Linux 2.6.12 起,可以使用 `ADDR_NO_RANDOMIZE` 标志来禁用运行进程中的地址空间布局随机化(ASLR)。
要在运行时关闭用户空间中的ASLR,可以使用 setarch 工具设置不同的个性化标志:
现在所有必要的组件都已就绪,可以运行自修改的 dd 命令了:
系统调用
以上方法都有一个缺点(tmpfs除外)——它们允许执行shellcode,但不能执行可执行对象(ELF文件)。纯汇编shellcode的使用范围有限,而且如果需要更复杂的功能,则无法扩展。
从Linux 3.17开始,引入了一个名为memfd_create()的新系统调用。它创建一个匿名文件并返回指向该文件的文件描述符。该文件的行为与普通文件相同。但是,它只驻留在内存中,当所有对它的引用都被删除时,它会自动释放。换句话说,Linux 内核提供了一种创建只存于内存中文件的方法,该文件看起来和感觉起来都像一个普通文件,并且可以进行mmap()/execve()操作。
以下步骤在虚拟内存中创建一个基于 memfd 的文件,并最终将我们选择的工具上传到受害机器,而无需将其存储在磁盘上:
生成一个shellcode,该 shellcode 将在内存中创建一个 memfd 文件
将 shellcode 注入到 dd 进程中(参考“自修改dd”部分)
挂起dd进程(这也是shellcode的功能)
准备需要上传的工具(以静态链接的 uname 为例)
通过带内数据链路(在 shell 会话中)将 base64 编码的工具直接传输到受害机器的 memfd 文件中
最后,运行该工具
首先,我们需要创建一个新的shellcode(参考附录 B)。这个新的 shellcode 会重新打开已关闭的 stdin 和 stdout 文件描述符,调用 memfd_create() 创建一个名为 AAAA 的内存专用文件,并调用 pause() 系统调用来挂起调用进程(dd)。挂起进程是必要的,因为我们需要阻止dd进程退出,让它的 memfd 文件对其他进程(通过procfs)开放。shellcode中的 exit() 系统调用不应该被执行到。
然后我们对 dd 进程进行自修改,暂停该进程,并检查 memfd 文件是否在内存中暴露出来:
下一步是准备上传我们的工具。请注意,攻击者的工具必须采用静态链接,或者使用与目标机器上相同的动态库。
现在只需将 Base64 编码的工具echo到 memfd 文件中并运行它:
请注意,memfd文件可以重用;如果需要,同一个文件描述符可以存储下一个工具(覆盖前一个工具):
如果受害机器运行的内核版本低于 3.17 怎么办?
C语言库中有一个名为 shm_open(3) 的函数。它会在内存中创建一个新的 POSIX 共享对象。POSIX共享内存对象实际上是一个句柄,不相关的进程可以使用该句柄通过 mmap() 函数访问同一块共享内存区域。
让我们来看看 Glibc 的源代码。shm_open()会调用 open() 函数来打开某个shm_name(glibc/sysdeps/posix/shm_open.c):
而 shm_dir 又会动态分配内存(glibc/sysdeps/posix/shm-directory.h):
shm_dir 是 _PATH_DEV 与 "shm/" 的连接(glibc/sysdeps/posix/shm_open.c):
而_PATH_DEV被定义为/dev/。
所以,事实证明 shm_open() 只是在 tmpfs 文件系统上创建/打开一个文件,但这已经在 tmpfs 部分中介绍过了。
总结
对目标机器进行任何攻击性活动都必须考虑其副作用。即使我们尽量避免用任何代码触及磁盘,我们的行为仍然可能留下一些痕迹。其中包括(但不限于):
日志(即 shell 历史记录)。在这种情况下,攻击者必须确保日志被删除或覆盖(有时由于权限不足而无法做到)
进程列表—有时用户查看受害机器上运行的进程时,可能会发现一些奇怪的进程名称(例如 /proc/< num >/fd/3)。可以通过更改目标进程中的 argv[0] 字符串来规避此问题
交换空间—即使我们的数据驻留在虚拟内存中,在大多数情况下它们也可以被交换到磁盘(交换空间的分析是另一个话题)。可以通过以下方式避免这种情况:
3.1 使用mlock()、mlockall()、mmap() 函数,需要 root 权限或至少具备 CAP_IPC_LOCK 权限
3.2 修改sysctl vm.swappiness 或 /proc/sys/vm/swappiness – 需要 root 权限
3.3 cgroups(memory.swappiness)—需要 root 权限才能修改 cgroup
cgroups并不能保证在高负载情况下内存管理器不会将进程交换到磁盘(例如,root cgroup允许交换并且需要内存)。
附录A
实验中使用的“Hello world”示例shellcode:
附录B
Memfd-create() shellcode: