蓝军和elf loader
2022-11-16 19:7:22 Author: leveryd(查看原文) 阅读量:5 收藏

在linux系统上执行二进制文件一般会用到execve系统调用,比如下面的执行sleep 1000

[[email protected] ~]# 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。

[[email protected] tmp]# gcc -fPIC --pie z.c
[[email protected] 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
[[email protected] tmp]# gcc -fPIC z.c
[[email protected] 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来避免检测呢?

参考资料

[1]

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


文章来源: http://mp.weixin.qq.com/s?__biz=MzkyMDIxMjE5MA==&mid=2247485257&idx=1&sn=7d1e956b2eff72df00496ca320fcecb0&chksm=c19700f8f6e089ee8fe1602a2495761ba2594d61a7f15fb2c32d22d754238b26ea54880858eb#rd
如有侵权请联系:admin#unsafe.sh