在linux系统上执行二进制文件一般会用到execve系统调用,比如下面的执行sleep 1000
[root@instance-h9w7mlyv ~]# strace sleep 1000 2>&1|grep execve
execve("/usr/bin/sleep", ["sleep", "1000"], 0x7ffdb98242f8 /* 40 vars */) = 0
其中/usr/bin/sleep
文件因为在本地存储,所以可能被主机上的安全产品做静态分析,如果是恶意样本就有可能暴露攻击行为。
为了对抗静态分析,蓝军可以让攻击样本不落盘,比如用如下memfd_create的方式
fdm = syscall(__NR_memfd_create, "elf", MFD_CLOEXEC);
write(fdm, elfbuf, filesize);
sprintf(cmd, "/proc/self/fd/%d", fdm);
execve(cmd, argv, NULL);
完整代码可以见 https://github.com/QAX-A-Team/ptrace/blob/master/anonyexec.c
但是这种攻击行为会产生memfd_create和execve两个系统调用,特征很明显,于是又有蓝军提到在用户态加载elf并执行,这样既可以样本不落盘,又可以避免用到execve被安全产品采集到进程数据。
https://github.com/anvilsecure/ulexecve/blob/main/ulexecve.py 这个开源项目就实现了用户态的elf装载。
elf装载的原理不复杂,基本步骤是通过mmap、mprotect系统调用申请到"可读可写可执行"的内存,然后将PT_LOAD类型的segment映射到内存中,最后根据e_entry跳转到映射到内存的代码段中执行。
有两个疑问促使我研究,第一个问题是elf装载时内存地址空间不会和装载前的内存地址空间冲突吗,第二个问题是怎么处理动态链接库。
本文记录在我研究过程中学到的"散装知识点",希望对你有点帮助。
python ulexecve.py
加载elf时有可能破坏原来的python程序指令,导致程序崩溃?
实际上不会,ulexecve有一个"jump buffer"的概念,ulexecve.py会先生成"elf loader"指令,然后申请一个"jump buffer"内存,最后跳转到内存执行。
def prepare_jumpbuf(buf):
dst = mmap(0, PAGE_CEIL(len(buf)), PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)
src = ctypes.create_string_buffer(buf)
logging.debug("Memmove(0x%.8x, 0x%.8x, 0x%.8x)" % (dst, ctypes.addressof(src), len(buf)))
memmove(dst, src, len(buf))
ret = mprotect(PAGE_FLOOR(dst), PAGE_CEIL(len(buf)), PROT_READ | PROT_EXEC)
... return ctypes.cast(dst, ctypes.CFUNCTYPE(c_void_p))
cfunction = prepare_jumpbuf(jumpbuf)
cfunction()
处理动态库是"动态链接器"的工作,而不是"程序装载器"的工作。"程序装载器"设置好栈环境、辅助向量(auxilliary vector),就可以把程序控制权交给"动态链接器"。如下
def generate(self, stack, jump_delay=None):
# generate jump buffer with the CPU instructions which copy all
# segments to the right locations in memory, set the correct protection
# flags on those memory segments and then prepare for the actual jump
# into hail mary land. # generate ELF loading code for the executable as well as the
# interpreter if necessary
ret = []
code = self.generate_elf_loader(self.exe) # 1.拷贝elf segment到虚拟内存
ret.append(code)
# fix up the auxv vector with the proper relative addresses too
code = self.generate_auxv_fixup(stack, Stack.OFFSET_AT_PHDR, self.exe.e_phoff) 2.设置辅助向量
ret.append(code)
# fix up the auxv vector with the proper relative addresses too
code = self.generate_auxv_fixup(stack, Stack.OFFSET_AT_ENTRY, self.exe.e_entry, self.exe.is_pie) 3.设置辅助向量
ret.append(code)
if self.interp: # 4.如果有动态链接器,就从动态链接器的入口执行
code = self.generate_elf_loader(self.interp) # 4.1.拷贝动态链接器 segment到虚拟内存
ret.append(code)
code = self.generate_auxv_fixup(stack, Stack.OFFSET_AT_BASE, 0) # 4.2.设置辅助向量
ret.append(code)
entry_point = self.interp.e_entry
else: # 4.如果没有动态链接器,就从elf入口执行
entry_point = self.exe.e_entry
if not self.exe.is_pie:
entry_point -= self.exe.ph_entries[0]["vaddr"]
self.log("Generating jumpcode with entry_point=0x%.8x and stack=0x%.8x" % (entry_point, stack.base))
code = self.generate_jumpcode(stack.base, entry_point, jump_delay) 5.生成"从入口执行"的指令
ret.append(code)
return b"".join(ret)
上面代码中可以看到self.exe.is_pie
影响程序入口地址,这个pie是什么呢?
pie和aslr一样都可以实现地址随机化,防御漏洞利用。区别在于aslr不负责代码段以及数据段的随机化工作,这项工作由pie负责。但是只有在开启aslr之后,pie才会生效。
下面我们可以结合ulexecve代码和动手实践,看一下pie到底是怎么工作的。
如果elf文件有pie机制,mmap第一个地址参数就是0。此时如果开启了aslr,mmap系统调用返回的地址就会一个随机化的地址。
def generate_elf_loader(self, elf):
...
addr = 0x0 if elf.is_pie else elf.ph_entries[0]["vaddr"]
...
code = self.mmap(addr, map_sz, prot, flags)
ret.append(code)
怎么判断elf程序是否开启pie机制呢?从下面代码可以看到,第一个PT_LOAD类型的segment虚拟地址是0时,就说明开启了pie。
def parse_pentry(self):
...
# first PT_LOAD section we use to identifie PIE status
if len(self.ph_entries) == 0:
if p_vaddr != 0x0:
self.log("Identified as a non-PIE executable")
self.is_pie = False
else:
self.log("Identified as a PIE executable")
self.is_pie = True
当你用gcc --pie参数编译时,文件的第一个PT_LOAD类型的segment虚拟地址就会是0。
[root@instance-h9w7mlyv tmp]# gcc -fPIC --pie z.c
[root@instance-h9w7mlyv tmp]# readelf -l ./a.out
...Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
...
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 # 参数带--pie时,VirtAddr为0
0x0000000000000898 0x0000000000000898 R E 0x200000
LOAD 0x0000000000000de0 0x0000000000200de0 0x0000000000200de0
0x0000000000000254 0x0000000000000258 RW 0x200000
[root@instance-h9w7mlyv tmp]# gcc -fPIC z.c
[root@instance-h9w7mlyv tmp]# readelf -l ./a.out
...
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
...
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 # 非--pie时,VirtAddr不为0
0x0000000000000808 0x0000000000000808 R E 0x200000
LOAD 0x0000000000000e00 0x0000000000600e00 0x0000000000600e00
0x000000000000022c 0x0000000000000230 RW 0x200000
文中有一些概念我并没有解释,比如elf文件格式、segment是什么,这一块你可以参考《程序员的自我修养—链接、装载与库》、ELF 格式解析[1],辅助向量的知识你可以参考 https://lwn.net/Articles/519085/
ulexecve代码中的注释非常清晰,原作者还写了一篇博客 Userland Execution of Binaries Directly from Python[2]
感觉"动态链接器"要比"程序装载器"要复杂,以后有场景了再研究。
留一个思考问题:怎么检测elf loader呢,以及作为蓝军可以怎么优化elf loader来避免检测呢?
ELF 格式解析: https://paper.seebug.org/papers/Archive/refs/elf/Understanding_ELF.pdf
[2]Userland Execution of Binaries Directly from Python: https://www.anvilsecure.com/blog/userland-execution-of-binaries-directly-from-python.html