恶意代码的亲密接触之病毒编程技术

生活在网络时代,无论是作为一名程序员抑或是作为一名普通的电脑使用者,对病毒这个词都已经不再陌生。网络不仅仅是传播信息的快速通道,从另外一个角度来看,也是病毒得以传播和滋生的温床,有资料显示,未安装补丁的Windows操作系统连接至internet平均10-15分钟就会被蠕虫或病毒感染。各种类型的病毒,在人们通过网络查阅信息、交换文件、收听视频时正在悄悄地传播。这些病毒或蠕虫不仅在传播过程中消耗大量的带宽资源,而且会干扰系统功能的正常使用或造成数据丢失、甚至是硬件损坏,每个电脑用户几乎都有过系统被病毒感染而无法正常使用的经历,大部分企业用户也都有过因病毒发作致使业务系统不能正常运行的经历。病毒距离我们,其实并不遥远。

  然而,不只普通用户在面对各种夸大的报道和宣传后感觉到茫然和恐惧,随着计算机各个领域的细分和专业化,就连一些职业的程序员对病毒技术也缺乏深入的了解。病毒,不过是精心设计的一段程序,是编程技巧和优化技术的集中体现,是挑战技术极限、无所不用其极的一种编程技术。其实病毒技术中的优化和各种精巧的构造,也完全可以在一些特殊的情况下使用,使得某些编程工作得以简化;从另外一个角度来看,只有充分了解病毒技术,才能更好地研究应对之策,知己知彼,方能百战不殆。

  病毒不是某个系统下的专属品,事实上现在各种流行的操作系统:从最初的Unix 系统到其各种变体如Linux、Solaris、AIX、OS2 等,从Windows 到CE、Sybian等嵌入式系统,甚至是在某些专业化的大型机系统上,都无一例外地出现了病毒,各种平台下病毒的基本原理类似的,但是针对不同系统的特性,实现可能区别很大,原因在于作为一种无所不用其极的技术,势必利用各种系统相关的功能或弱点以取得各种特权和资源。正如生物的多样性一样,病毒种类繁多:包括源代码病毒、宏病毒、脚本病毒以及与各种系统可执行文件系统相关的病毒等。本文将以使用最为广泛的Windows操作系统下的PE病毒为例,说明病毒技术的原理以及实现技术,驱散笼罩在病毒技术上的迷雾。
  
   病毒、蠕虫、恶意代码

  传统意义上的病毒是具有类似生物病毒特征的特殊代码或程序,具有两个最基本的特点:自我复制和自动传播。

  蠕虫,广义上一般被认为是病毒的子类,同样具有自我复制和传播的特性,但鉴于蠕虫通常利用系统漏洞而非感染文件系统进行传播的特殊性,通常将其单独作为一类。一般认为区分蠕虫和传统病毒的分类标准是看其是否依赖于宿主程序进行感染和传播,如果必须依附于宿主程序才能进行感染和传播的才是病毒。不过定义不是绝对的,当今病毒和蠕虫技术的融合愈益深入,界限愈益模糊。很多病毒采用了很多的蠕虫传播技术,蠕虫也不仅仅通过系统漏洞传播,同时也通过感染文件系统进行传播。此外还有有相当一部分程序虽然不具备自我复制和自我传播的特征,但却执行了未经用户许可的代码、做了未经用户许可的事情,比如特洛伊木马等间谍软件、浏览器恶意脚本、一些广告软
件等,显然无法将其定义为传统的病毒或蠕虫,他们和蠕虫、病毒一样,同属于一个更大的范畴——恶意代码。本文重点阐述传统病毒经常使用的技术。
  
   病毒简史

  谈病毒技术,无法回避病毒产生的历史。早在1949年在冯·诺伊曼的一篇论文《复杂自动装置的理论及组织的行为》中,即预见了可自我繁殖程序出现的可能。而现在众所公认的病毒的萌牙于AT&T(贝尔实验室)几个年轻的天才程序员编制的磁芯大战(CoreWar)游戏程序,已经具备了病毒的一些特征。随后相关的实验和研究在一些学者和天才的程序员中开始展开,正是这些创造了计算机系统的天才们,制造了
计算机病毒。

  很难考证第一个真正的病毒出现在何时何地,但在20 世纪80 年代,随着个人计算机的普及,病毒已经开始流行了,早期的计算机病毒是和当时的文件交换方式和操作系统特点联系在一起的,那个时候发行软件或交换文件主要通过软盘进行,系统是基于文本界面的Unix 或DOS,网络尚未普及,因此这一时期的病毒大都是引导区病毒和文件型病毒,前者通过替换系统引导区代码在系统启动时获取执行权,后者通过修改可执行文件嵌入代码以在可执行文件执行时获取控制权,更多病毒的则是二者的结合。

  IBM-PC 的流行和MS DOS系统的普及使得DOS病毒在这一阶段逐渐占据了统治地位。80年代后期因特网开始进入人们的视野,这时也出现了第一个因特网蠕虫——莫里斯蠕虫,借助于系统漏洞通过网络进行快速传播。90 年代随着电脑及网络的进一步普及,病毒技术也有了很大的进步,这在很大程度上也是由于病毒受社会的关注程度以及反病毒软件的进步,进一步刺激了病毒制作者群体的创造欲望,多态和变形技术开始出现,以对抗杀毒软件的特征码扫描。DOS操作系统病毒的绝对数量出现了爆炸性增长,但90年代后期随着Windows的出现,DOS病毒和引导区病毒逐渐走向消亡,Windows 病毒随之则开始大量涌现,随着微软Office 软件的普及宏病毒出现了,各种脚本病毒也日益增多。因特网的普及在给人们带来便利的同时也加快了病毒传播的速度和范围,靠Emai传播的蠕虫开始增多,时至今日仍然是蠕虫的重要传播途径。从2000 年至今,在进入21世纪的头几年里,Windows下PE病毒技术已经日益纯熟、数量日益增多,但病毒排行榜的首位已经让位给利用各种系统漏洞进行传播的蠕虫了,安全研究的深入、各种安全漏洞的大量披露给蠕虫作者提供了很好的素材,特洛依木马等恶意软件数量呈现几何级数的增长,病毒作者的关注点重新从Windows 桌面系统转向Unix 系统、手机等嵌入移动设备上。安全研究也愈益受到社会的关注,病毒和反病毒的战争仍在继续,在可预见的将来,仍将继续。

  不过,Windows PE文件病毒仍然占有非常大的比重。Windows 平台和PE 文件格式Windows 平台是当今最为流行的桌面系统,在服务器
市场上,也占有相当的份额。其可执行文件(普通的用户程序、共享库以及NT系统的驱动文件)采用的是PE(Portable Executebale)文件格式。病毒要完成各种操作,在Windows 系统上一般都是通过调用系统提供的API进行的,以保证在各种Windows版本上都能运行,因此读者应对基本的API比较熟悉。病毒要实现对宿主程序的感染,就不可避免地要修改PE文件,因此要求读者对PE文件格式有一定的了解,PE文件格式是一种复杂的文件格式,本文并不准备详细讲述PE文件格式,仅作在必要处简单的介绍,如必要可进一步参阅相关资料。PE文件结构和头部部分主要域的格式如下图1所示。


图1:PE 文件结构及部分主要域的定义

  由图1 可见,PE文件是由文件头、节表、包含各种代码和数据的节构成。文件头中定义了PE文件的引入函数表、引出函数表、节数目、文件版本、文件大小、所属子系统等相关的重要信息。节表则定义了实际数据节的大小、对齐、内存到文件如何进行映射等信息。后面的各个节则包含了实际的可执行代码或数据。(见图1) 

PE 病毒技术剖析

  典型的PE病毒修改PE文件,将病毒体代码写入PE 文件文件中,更新头部相关的数据结构,使得修改后的PE文件仍然是合法PE文件,然后将PE入口指针改为指向病毒代码入口,这样在系统加载PE文件后,病毒代码就首先获取了控制权,在执行完感染或破坏代码后,再将控制权转移给正常的程序代码,这样病毒代码就神不知鬼不觉地悄悄运行了。

  这只是最常见的执行流程,事实上,随着反病毒技术的进展,更多的病毒并不是在程序的入口获取控制权,而是在程序运行中或退出时获取控制权,以逃避杀毒软件的初步扫描,这种技术又被称为EPO 技术,将在本文后半部分进行介绍。病毒代码一般分成几个主要功能模块:解码模块、重定位模块、文件搜索模块、感染模块、破坏模块、加密变形模块等,不同的病毒包含模块不一定相同,比如解码、加密变形等就是可选的;但文件搜索和感染模块是几乎每个PE病毒都具备的,因为自我复制我传播是病毒的最基本的特征。有些病毒还可能实现了其他的模块,比如Email 发送、网络扫描、内存感染等。一段典型的PE病毒代码执行流程大致如图2所示:


图2:一段典型的病毒代码执行流程

  从原理上看病毒非常简单,但实现起来还有不少困难,其实如果解决了这些技术难点,一个五脏俱全的病毒也就形成了,本文后面将从一个病毒编写者的角度就各个难点分别予以介绍。病毒可采用的技术几乎涉及到Windows程序设计的所有方面,但限于篇幅,本文亦不可能全部介绍,本文将重点介绍Win32 用户模式病毒所常用的一些技术。

   编程语言

  任何语言只要表达能力足够强,都可用于编写PE 病毒。但现存的绝大部分PE病毒都是直接用汇编编写的,一方面是因为汇编编译后的代码短小精悍,可以充分进行人工优化,以满足隐蔽性的要求;另外一方面之所以用汇编是因为其灵活和可控,病毒要同系统底层有时甚至是硬件打交道,由于编译器的特点不尽相同,用高级语言实现某些功能甚至会更加麻烦,比如用汇编很方便地就可以直接进行自身重定位、自身代码修改以及读写IO 端口等操作,而用高级语言实现则相对烦琐。用汇编还可以充分利用底层硬件支持的各种特性,限制非常少。但是用汇编编写病毒的主要缺点就是编写效率低,加上使用各种优化手段使得代码阅读起来相当困难,不过作为一种极限编程技术,对病毒作者而言,这些似乎都已经不再重要。本文假设读者熟悉汇编语言,各种举例使用Intel 格式的汇编代码,编译器可使用MASM或FASM进行编译,由于汇编语言表述算法较为不便,因此算法和原理性表述仍然采用C 语言。在讲述各种技术时,部分代码直接取自病毒Elkern的源代码,该病毒在2002年曾经大规模流行,其代码被收录于著名病毒杂志29A 第7 期中,有兴趣的读者可参阅其完整代码。

重定位

  病毒自身的重定位是病毒代码在得以顺利运行前应解决的最基本问题。病毒代码在运行时同样也要引用一些数据,比如API 函数的名字、杀毒软件的黑名单、系统相关的特殊数据等,由于病毒代码在宿主进程中运行时的内存地址是在编译汇编代码时无法预知的,而病毒在感染不同的宿主时其位于宿主中的准确位置同样也无法提前预知,因此病毒就要在运行时动态确定其引用数据的地址,否则,引用数据时几乎肯定会发生错误。对于普通的PE文件比如动态链接库而言,在被加载到不同地址处时由加载器根据PE中一个被称为重定位表的特殊结构动态修正引用数据指令的地址,而重定位表是由编译器在编译阶段生成的,因此动态链接库本身无需为此做任何额外处理。病毒代码则不同,必须自己动态确定需引用数据的地址。比如一段病毒代码被加载在0x400000处,地址0x401000处的一条语句及其引用的数据定义如下所示,相关地址是编译器在编译时计算得到的,这里假设编译时预设的基地址也是0x400000:

401000:
mov eax,dword ptr [402035]
......
402035:
db "hello world!",0

  如果病毒代码在宿主中也加载到基地址0x400000,显然是能够正常执行的,但如果这段代码被加载在基地址0x500000 运行时则出错,对病毒而言,这是大多数时候都会遇到的情况,因为指令中引用的仍然是0x402035这个地址。如果病毒代码不是在宿主进程中而是作为一个具有重定位表的独立PE文件运行,正常情况下由系统加载器根据重定位表表项将 mov eax,dword ptr [402035]中的0x402035修改为正确值0x502305,这样这句代码就变成了mov eax, dword ptr [5402035],程序也就能准确无误地运行了。不过很可惜,对在其它进程内运行病毒代码而言,必须采取额外的手段、付出额外的代价感染宿主PE文件时就及时加以解决,否则将导致宿主进程无法正常运行。

  至少有两种方法可以解决重定位的问题:

  A)第一种方法就是利用上述PE 文件重定位表项的特殊作用构造相应的重定位表项。在感染目标PE文件时,将引用自身数据的需要被重定位的地址全部写入目标PE文件的重定位表中,如果目标PE 无任何重定位表项(如用MS linker 的/fixed)则创建重定位表节并插入新的重定位项;若已经存在重定位表项,则在修改已存在的重定位表节,在其中插入包含了这些地址的新表项。重定位的工作就完全由系统加载器在加载PE文件的时候自动进行了。重定位表项由PE 文件头的DataDirectory数据中的第6 个成员
IMAGE_DIRECTORY_ENTRY_BASERELOC 指向。该方法需要的代码稍多,实现起来也相对比较复杂,另外如果目标文件无重定位表项(为了减小代码体积,这种情况也不少见),处理起来就比较麻烦,只有用高级语言编写病毒才常用该种方法,在一般的PE 病毒中很少使用。

  B)利用Intel X86体系结构的特殊指令,call 或fnstenv等指令动态获取当前指令的运行时地址,计算该地址与编译时预定义地址的差值(被称为delta offset),再将该差值加到原编译时预定的地址上,得到的就是运行时数据的正确地址。对于intel x86指令集而言,在书写代码时,通过将delta offset 放在某个寄存器中,然后通过变址寻址引用数据就可以解决引用数据重定位的难题。还以上例说明,假如上述指令块被操作系统映射在0x500000处那么代码及其在内存中的地址将变为:

501000:
mov eax,dword ptr [402035]
......
502035:
db "hello world!",0

  显然,mov 指令引用的操作数地址是不正确的,如果我们知道了mov指令运行时地址是0x501000,那么计算该地址和编译时该指令预设地址的差值:0x501000-0x401000 = 0x100000。很显然指令引用的实际数据地址应该为0x402035+0x100000 = 0x502035。从上例可以看出,只要能够在运行时确定某条指令动态运行时的地址,而其编译时地址已知,我们就能够通过将delta offset 加到相应的地址上正确重定位任何代码或数据的运行时地址。原理如图3 所示:


图3:delta iffset

  通常只要在病毒代码的开始计算出delta offset,通过变址寻址的方式书写引用数据的汇编代码,即可保证病毒代码在运行时被正确重定位。假设ebp 包含了delta offset,使用如下变址寻址指令则可保证在运行时引用的数据地址是正确的:

;ebp 包含了delta offset 值
401000:
mov eax,dword ptr [ebp+0x402035]
......
402035:
db "hello world!",0

  在书写源程序时可以采用符号来代替硬编码的地址值,上述的例子中给出的不过是编译器对符号进行地址替换后的结果。现在的问题就转换成如何获取delta offset的值了,显然:

call delta
delta:
pop ebp
sub ebp,offset delta

  在运行时就动态计算出了delta offset 值,因为call要将其后的第一条指令的地址压入堆栈,因此pop ebp 执行完毕后ebp 中就是delta的运行时地址,减去delta的编译时地址“offset delta”就得到了delta offset 的值。除了用明显的call 指令外,还可以使用不那么明显的fstenv、fsave、fxsave、fnstenv等浮点环境保存指令进行,这些指令也都可以获取某条指令的运行时地址。以fnstenv 为例,该指令将最后执行的一条FPU 指令相关的协处理器的信息保存在指定的内存中,结构如下图4 所示:


图4:浮点环境块的结构

  该结构偏移12字节处就是最后执行的浮点指令的运行时地址,因此我们也可以用如下一段指令获取delta offset:

fpu_addr:
fnop
call GetPhAddr
sub ebp,fpu_addr
GetPhAddr:
sub esp,16
fnstenv [esp-12]
pop ebp
add esp,12
ret

  delta offset 也不一定非要放在ebp 中,只不过是ebp 作为栈帧指针一般过程都不将该寄存器用于其它用途,因此大部分病毒作者都习惯于将delta offset 保存在ebp 中,其实用其他寄存器也完全可以。

  在优化过的病毒代码中并不经常直接使用上述直接计算delta offset 的代码,比如在Elkern开头写成了类似如下的代码:

call _start_ip
_start_ip:
pop ebp
;...
;使用
call [ebp+addrOpenProcess-_start_ip]
;...
addrOpenProcess dd 0
;而不是
call _start_ip
_start_ip:
pop ebp
sub ebp,_start_ip
call [ebp+addrOpenProcess]

  为什么不采用第二种书写代码的方式?其原因在于尽管第一种格式在书写源码时显得比较罗嗦, 但是addrOpenProcess-_start_ip 是一个较小相对偏移值,一般不超过两个字节,因此生成的指令较短,而addrOpenProcess在32 Win32编译环境下一般是4 个字节的地址值,生成的指令也就较长。有时对病毒对大小要求很苛刻,更多时候也是为了显示其超俗的编程技巧,病毒作者大量采用这种优化,对这种优化原理感兴趣的读者请参阅Intel手册卷2中的指令格式说明。

   API 函数地址的获取

  在能够正确重定位之后,病毒就可以运行自己代码了。但是这还远远不够,要搜索文件、读写文件、进行进程枚举等操作总不能在有Win32 API 的情况下自己用汇编完全重新实现一套吧,那样的编码量过大而且兼容性很差。

  Win9X/NT/2000/XP/2003系统都实现了同一套在各个不同的版本上都高度兼容的Win32 API,因此调用系统提供的Win32 API实现各种功能对病毒而言就是自然而然的事情了。所以接下来要解决的问题就是如何动态获取Win32 API的地址。最早的PE病毒采用的是预编码的方法,比如Windows 2000 中CreateFileA 的地址是0x7EE63260,那么就在病毒代码中使用call [7EE63260h]调用该API,但问题是不同的Windows 版本之间该API 的地址并不完全相同,使用该方法的病毒可能只能在Windows 2000的某个版本上运行。

  因此病毒作者自然而然地回到PE结构上来探求解决方法,我们知道系统加载PE 文件的时候,可以将其引入的特定DLL 中函数的运行时地址填入PE的引入函数表中,那么系统是如何为PE引入表填入正确的函数地址的呢?答案是系统解析引入DLL 的导出函数表,然后根据名字或序号搜索到相应引出函数的的RVA(相对虚拟地址),然后再和模块在内存中的实际加载地址相加,就可以得到API 函数的运行时真正地址。在研究操作系统是如何实现动态PE文件链接的过程中,病毒作者找到了以下两种解决方案:

  A)在感染PE 文件的时候,可以搜索宿主的函数引入表的相关地址,如果发现要使用的函数已经被引入,则将对该API 的调用指向该引入表函数地址,若未引入,则修改引入表增加该函数的引入表项,并将对该API 的调用指向新增加的引入函数地址。这样在宿主程序启动的时候,系统加载器已经把正确的API 函数地址填好了,病毒代码即可正确地直接调用该函数。

  B)系统可以解析DLL 的导出表,自然病毒也可以通过这种手段从DLL 中获取所需要的API地址。要在运行时解析搜索DLL 的导出表,必须首先获取DLL 在内存中的真实加载地址,只有这样才能解析从PE 的头部信息中找到导出表的位置。应该首先解析哪个DLL 呢?我们知道Kernel32.DLL几乎在所有的Win32 进程中都要被加载,其中包含了大部分常用的API,特别是其中的LoadLibrary 和GetProcAddress 两个API可以获取任意DLL 中导出的任意函数,在迄今为止的所有Windows 平台上都是如此。只要获取了Kernel32.DLL在进程中加载的基址,然后解析Kernel32.DLL 的导出表获取常用的API 地址,如需要可进一步使用Kernel32.DLL 中的LoadLibrary 和GetProcAddress 两个API 更简单地获取任意其他DLL 中导出函数的地址并进行调用。


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值