图书在版编目(C I P)数据
C++黑客编程揭秘与防范 / 冀云编著.-- 北京:人民邮电出版社,2012.6
ISBN 978-7-115-28064-0
I.①C…II.①冀…III.①C语言—程序设计IV.①TP312
中国版本图书馆CIP数据核字(2012)第081089号
内容提要
本书旨在通过简单的语法知识及常用的系统函数编程,完成一些有特定功能的安全工具,让读者对系统知识等各方面有一个全面的了解,并且在笔者的带领下一步步完成书中的实例。本书主要内容为:
第1章了解黑客编程,主要讲解了VC (Visual C++的缩写)和Windwos下安全编程方面的基础知识。第2章从剖析简单的木马说起,讲解有关的网络编程和协议知识。第3章Windows应用编程基础,讲解API编程的技术。第4章加密与解密,讲解PE等加密有关的知识。第5章HOOK编程,讲解了与钩子有关的知识。第6章黑客编程剖析,剖析了病毒的原理和攻防技术,以及安全工具的开发。第7章最后的旅程——简单驱动开发及逆向。
本书适合网络安全人员、黑客爱好者,以及相关的程序员阅读。
C++黑客编程揭秘与防范
♦ 编 著 冀 云
责任编辑 张 涛
♦ 人民邮电出版社出版发行 北京市东城区夕照寺街14号
邮编 100061 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
北京鑫正大印刷有限公司印刷
♦ 开本:787×1092 1/16
印张:17.25
字数:406千字 2012年6月第1版
印数:1 – 3 500册 2012年6月北京第1次印刷
ISBN 978-7-115-28064-0
定价:39.00元
读者服务热线:(010) 67132692 印装质量热线:(010) 67129223
反盗版热线:(010) 67171154
目 录
1.1 编程语言和开发环境的选择
1.1.1 何为SDK、API和MFC
1.1.2 VC6和SDK的配置
1.2 应用程序的调试
1.2.1 编写我们的第一个程序
1.2.2 用VC6调试第一个程序
1.2.3 专业的应用程序调试工具——OllyDbg
1.3 简单API的介绍
1.3.1 复制自身程序到Windows目录和系统目录下
1.3.2 获得系统的相关信息
1.3.3 Debug和Release的编译方式
1.3.4 查看函数定义
1.4 总结
2.1 网络通信基础
2.1.1 IP地址的作用与分类
2.1.2 端口的作用与分类
2.2 网络编程基础知识
2.2.1 通信模型
2.2.2 Winsock
2.2.3 Winsock的相关函数
2.2.4 字节顺序
2.3 简单的通信程序
2.3.1 基于TCP协议的“Hello World!”
2.3.2 基于UDP协议的“Hello World!”
2.4 实现一个C/S模式的简单木马
2.4.1 木马服务器端的实现
2.4.2 木马客户端的实现
2.5 总结
3.1 文件
3.1.1 打开文件
3.1.2 文件操作
3.2 AutoRun免疫程序的编写
3.2.1 AutoRun免疫原理
3.2.2 AutoRun免疫程序的代码实现
3.2.3 界面设置
3.2.4 代码相关部分
3.3 注册表操作
3.3.1 注册表
3.3.2 与注册表操作相关的常用API函数
3.3.3 注册表启动项的管理
3.3.4 程序的界面设置及相关代码
3.3.5 启动项的枚举
3.3.6 添加启动项的代码
3.3.7 删除启动项的代码
3.4 服务相关的编程
3.4.1 如何查看系统服务
3.4.2 服务控制管理器的开发
3.4.3 枚举服务的相关API函数
3.4.4 服务的停止
3.4.5 停止服务的相关API函数
3.4.6 服务的启动
3.5 进程与线程
3.5.1 进程
3.5.2 进程的创建
3.5.3 “下载者”的简单演示
3.5.4 CreateProcess()函数介绍与程序创建
3.5.5 进程的结束
3.5.6 进程的枚举
3.5.7 调整当前进程的权限
3.5.8 进程的暂停与恢复
3.5.9 多线程
3.6 DLL编程
3.6.1 什么是DLL
3.6.2 编写一个简单的DLL程序
3.6.3 对DLL程序的调用方法一
3.6.4 对DLL程序的调用方法二
3.7 远程线程
3.7.1 DLL注入
3.7.2 DLL卸载
3.7.3 无DLL的代码注入
3.8 总结
4.1 PE文件结构
4.1.1 PE文件结构全貌
4.1.2 MZ头部
4.1.3 PE头部
4.1.4 节表
4.1.5 节表数据
4.2 详解PE文件结构
4.2.1 DOS头部详解 IMAGE_D〇S_HEADER
4.2.2 PE头部详解IMAGE_NT_HEADERS
4.2.3 IAMGE_FILE_HEADER
4.2.4 IMAGE_OPTIONAL_HEADER
4.2.5 节区详解IMAGE_SECTION_HEADER
4.2.6 与PE结构相关的3种地址
4.2.7 3种地址的转换
4.3 PE查看器
4.4 简单的查壳工具
4.5 地址转换器
4.6 添加节区
4.6.1 手动添加一个节区
4.6.2 通过编程添加节区
4.7 破解基础知识及调试API函数的应用
4.7.1 CrackMe程序
4.7.2 用OD破解CrackMe
1.破解方法一
2.破解方法二
4.8 文件补丁及内存补丁
4.8.1 文件补丁
4.8.2 内存补丁
4.9 调试API函数的使用
4.9.1 常见的3种断点方法
4.9.2 调试API函数及相关结构体介绍
4.9.3 判断是否处于被调试状态
4.9.4 断点异常函数
4.9.5 调试事件
4.9.6 调试循环
4.9.7 内存的操作
4.9.8 线程环境相关API及结构体
4.10 打造一个密码显示器
4.11 总结
5.1 HOOK知识前奏
5.2 内联钩子——Inline Hook
5.2.1 Inline Hook的原理
5.2.2 Inline Hook的实现
5.2.3 HOOK MessageBoxA
5.2.4 HOOK CreateProcessW
5.2.5 7字节Inline Hook
5.2.6 Inline Hook的注意事项
5.3 导入地址表钩子——IATHOOK
5.3.1 导入表简介
5.3.2 导入表的数据结构定义
5.3.3 手动分析导入表
5.3.4 枚举导入地址表
5.3.5 IAT HOOK介绍
5.3.6 IAT HOOK之 CreateFileW ()
5.4 Windows钩子函数
5.4.1 钩子原理
5.4.2 钩子函数
5.4.3 键盘钩子实例
5.4.4 使用钩子进行DLL注入
5.5 总结
6.1 恶意程序剖析
6.1.1 恶意程序的自启动
6.1.2 木马的配置生成与反弹端口
6.1.3 代码实现剖析
6.2 简单病毒剖析
6.2.1 病毒的感染剖析
6.2.2 缝隙搜索的实现
6.2.3 感染目标程序文件剖析
6.2.4 添加感染标志
6.2.5 自删除功能的实现
6.3 隐藏DLL文件
6.3.1 启动WinDBG
6.3.2 调试步骤
6.3.3 编写枚举进程中模块的函数
6.3.4 指定模块的隐藏
6.4 安全工具开发基础
6.4.1 行为监控工具开发基础
6.4.2 专杀工具
6.4.3 U盘防御软件
6.4.4 目录监控工具
6.5 引导区解析
6.5.1 通过WinHex来手动解析引导区
6.5.2 通过程序解析MBR
6.5.3 自定义MBR的各种结构体
6.5.4 解析MBR的程序实现
6.6 加壳与脱壳
6.6.1 手动加壳
6.6.2 编写简单的加壳工具
7.1 驱动版的 “Hello World”
7.2 驱动下的进程遍历
7.2.1 配置VMware和WinDbg进行驱动调试
7.2.2 EPROCESS和手动遍历进程
7.2.3 编程实现进程遍历
7.3 HOOK SSDT(系统服务描述表)
7.3.1 SSDT简介
7.3.2 HOOK SSDT
7.3.3 Inline HOOK SSDT
7.4 应用程序与驱动程序的通信
7.4.1 创建设备
7.4.2 应用程序与驱动程序的通信方式
7.4.3 应用程序与驱动程序的通信实例
7.5 C语言代码逆向基础
7.5.1 函数的识别
7.5.2 if……else……分支结构
7.5.3 switch分支结构
7.5.4 for循环结构
7.5.5 do while与 while循环结构
什么是黑客?摘自百度百科中的一句话,“黑客一词,原指热心于计算机技术,水平高超的电脑专家,尤其是程序设计人员”。其实,黑客并不利用自己已有的技术去对他人的系统进行渗透并破坏。黑客的为人处世也非常低调,不会整天拿着别人写好的工具去入侵网站或“抓肉鸡”,做这么没意义的事。如果是黑客天天做这些事,怎么可能有多余的时间真正地研究技术?
编程、破解、入侵
编程、破解、入侵是黑客所掌握的技能,但是后两者都是以前者的编程为基础的。破解别人的程序是站在写程序的角度去考虑的,而入侵依靠的是系统的漏洞,发掘漏洞同样是需要编程知识、系统底层知识和调试技术。也就是说,想做一名黑客,在自身的知识体系中编程知识是占据很大份额的。也就应了网上的一句话——“不会编程的黑客就不是黑客”.
黑客编程与普通编程的区别
黑客编程,其实也就是利用普通的编程技术编写一些黑客工具,或者是网络安全工具。该方面的知识是一把双刃剑,无论是编写黑客工具,还是编写安全工具,都离不开这些知识。本书的重点是通过简单的编程知识配合良性的实例让大家了解黑客编程,并对漏洞进行防范,希望大家正确对待技术的合理应用。
本书的前置知识
阅读需要有C、C++语言的基础知识,本书并不是一门编程语言关于语法知识的教科书。如果读者希望能够顺利阅读此书,至少要有阅读C、C++语言编程的能力。如果没有C、 C++语言的基础,而有其他语言的基础,那么也是没有问题的。在掌握了编程思想,或者会使用API函数后,用自己熟悉的语言进行相应的开发也是可以的。但是,为了将来能更好、更深入地学习系统的底层知识,建议学习C、C++和汇编语言。
本书适合的读者
长期研究黑客工具的程序员,且有打算自己开发网络安全工具的人员。
掌握编程基本语法想要进行相关编程实践的读者。
本书的定位
本书并非高深的技术性书籍,市场上较深入的研究系统底层、加解密相关的、网络安全编程相关的书籍非常多。但是,很多并非入门类的书籍。本书旨在,通过简单的语法知识及常用的系统函数完成一些有特定功能的安全工具。在读者有基础的前提下,让读者对系统知识等各方面有一个全面的了解,并且在笔者的带领下一步步地完成书中的实例,也可以增强读者的动手能力,源程序下载地址www. ptpress.com.cn。
需要声明的是:
本书的内容是帮助读者提升防范黑客攻击的能力和技术,普及网络安全知识,绝不是为那些怀有不良动机的人提供支持,也不承担因为技术被滥用所产生的连带责任,请读者自觉遵守国家相关法律。
你是否曾经在用别人开发的工具尝试“入侵”,你是否希望开发出自己的黑器……相信很多人有着这种近似相同的经历。本章将简单介绍黑客编程及工具开发。如果你是初学编程,如果你从来没有接触过黑客软件的开发,如果你急于想了解黑客编程方面的知识……那么就请继续往下阅读。
初学者刚开始学习编程语言最头疼的问题就是如何选择编程语言及合适的开发环境,下面就来具体介绍一下。
有人认为学编程就是学编程语言,而VC、VB这样的开发环境只是工具,不需要学。这个想法是错误的,因为开发环境提供了很多开发工具,如VC这个集成开发环境就提供了与之对应的PSDK、MFC等。除了语言以外,要开发特定的软件是需要开发包和开发工具支持的。况且,编程语言也是一种工具,用于和计算机进行交流的工具。所以我们既要学习编程语言,也要学习开发工具。
对于选择哪种编程语言或者开发环境其实也没有特定的标准。有这样一句话,“真正的程序员用VC,聪明的程序员用Delphi,用VB的不是程序员”。笔者却并不这么认为,因为在很多编程的书籍上常常这样提醒并告诫学习者,编程的精髓是“算法”,而语言是用来描述算法的。因此,大家也不必因为无法选择而无从下手。
黑客一般都掌握多种编程语言,他们不但掌握着与底层相关的如汇编、C之类的编程语言,而且还掌握很多脚本语言,如Python、Perl、Ruby……很多黑客在发现0Day以后用Perl或者Python来写POC; MSF使用的是Ruby来进行开发Exploit;有的黑客在反病毒时竟然写个批处理就搞定了……对于黑客来说,一切语言都是服务于自己的思想的,只要能快速实现自己的想法,能完成自己所要完成的功能就行,从不拘泥于任何语言和工具。在网上有很多学习不同编程语言的人们之间经常互相攻击,这其实是一种极端的行为,大家还是理性地对待这些问题比较好。
前面说了这么多,仿佛是在绕圈子,一直没有介绍到底应该选择什么编程语言和开发环境。我们这里选择使用C语言作为黑客编程的学习语言,选择VC6(Visual C++6.0的缩写)来作为我们的开发环境。VS 6相对于Visual Studio 2005、Visual Studio 2008和Visual Studio2010之类的开发环境来说要小巧很多,当前是可以满足我们的开发需求的。选择C语言的原因是由于Windows的API都是用C语言定义的,相对于使用其他编程语言会方便很多。笔者认为在VB下使用API就非常不方便,尤其是涉及指针这个概念的时候。除了VC6以外,还需要下载新版的PSDK,因为VC6中包含的PSDK过于旧,有些新的API我们无法通过包含头文件而直接使用,因此这个也是必须的。
既然选择VC作为开发环境,那么先来了解一下VC开发环境中今后会用到的一些工具的概念,这些概念都相对比较简单,常见的概念有SDK、API和MFC。
SDK是Software Develop Kits的缩写,即软件开发工具包。SDK是一个泛指,比如对视频采集卡进行二次开发,那么视频采集卡会提供SDK;再比如对动态令牌进行二次开发,那么动态令牌也会提供SDK。操作系统为了程序员在其平台下开发应用程序也会提供SDK,我们对系统提供的开发包称之为PSDK。PSDK是Platform SDK的意思,也就是平台SDK。对于我们来说,平台就是Windows操作系统。Windows下的PSDK包含了进行Windows软件开发的文档和API函数的输入库、头文件等一些与Windows开发相关的工具。PSDK是一个单独的开发包,不过每个不同版本的VC和其他一些开发环境中也已经包含了它。
API是Application Programming Interface的缩写,即应用程序接口。不同的SDK提供不同的API。PSDK提供的API就是操作系统提供的开发应用程序的接口,比如Windows系统下删除文件的API函数是DeleteFile();再比如Windows系统下移动文件的API函数是MoveFile(),而其他一些供二次开发的SDK中附带的API,也是为了进行开发应用程序而提供的接口。
MFC是Microsoft Foundation Class的缩写,即微软基础类库。它是微软为了简化程序员的开发工作量所提供的基于C++类的一套面向对象的库,它封装了常见的API函数,使得开发较为方便。
我们在书中会用到API进行开发,也会使用MFC进行开发。不过对于MFC的使用,基本上用在与界面相关的部分,一般是简单地带过,不会进行过多的讨论。我们的重点是放在API函数的使用上。关于MFC的相关内容,还请大家自行参考学习。
新版的PSDK (Windows Server 2003 SP1 Platform SDK)的下载地址为 http://www.microsoft.com/downloads/en/details.aspx?FamilyID=eba0128f-a770-45fl-86f3-7ab010b398a3。如果此地址过期的话,请大家在网上自行搜索并下载。
SDK和VC6互相是独立的,不需要安装在同一个目录下,根据自己的实际情况安装就可以了。在安装好VC6和新版的SDK后,需要在VC6中进行相应的设置才能使用新版的SDK,否则VC6仍然使用其自带的旧的SDK。SDK和VC6的安装步骤这里就不介绍了(提示:请把VC6安装完整,VC6会提供一些代码,对我们的学习是非常有帮助的),下面介绍新版的SDK如何配置才能在VC6中使用。
启动VC6,单击菜单“Tools”-> “Options”命令,打开“Options”对话框,如图1-1所示。
图1-1 “Options”对话框
选择“Directories”选项卡,在“Show directories for”下拉列表中选择“Include files”,选项并在“Directories”列表框中添加新的PSDK头文件的目录,放在列表的最上面,如图1-2所示。
图1-2 头文件的路径
在“Show directories for”下拉列表中选择“Library files”选项’并在“Directories”列表框中添加新的PSDK库文件的目录,放在列表的最上面,如图1-3所示。
图1-3 库文件的路径
切记要把所添加的目录放到列表的最上边,因为在VC编译代码的时候会搜索这些目录里的文件,如果随便放,编译器会因找不到相关API函数定义而报函数未定义的错误。
另外,还必须下载一个MSDN。MSDN即Microsoft Developer Network,它是微软开发的联机帮助文档,可以帮助我们在使用API的时候进行快速的查阅,以方便我们对API的使用和理解。但是MSDN里的内容全部都是英文的,如果你英文不太好可以借助搜索引擎来学习API的使用。本书只对所提到的API函数常用的参数进行介绍,其他参数需要大家自行进行学习。
在开发程序的过程中,除了编码以外还需要对程序进行调试,当编写的程序出现问题后,就要对程序进行调试。调试不是仅使用一个printf()或MessageBox()进行简单的输出来观察某个函数的返回值(虽然在调试的时候的确是对返回值观察较多),也不是对某个变量、某一时间的具体值的输出。调试是有专业的调试分析工具的,VC6不但提供代码编辑、代码编译、编译连接等功能,还提供了一个非常好用的调试工具。在编写完代码后,如果程序输出的结果是未知的,或者是没有预测到的,都可以通过调试来对代码的逻辑进行分析,以找到问题的所在。掌握调试的技能,对软件的开发有非常大的帮助。掌握好的调试工具,对于调试者来说,也同样会起到事半功倍的作用。下面通过一个简单的例子了解一下VC6提供的调试功能吧。
下面介绍用VC6写一个控制台版的HelloWorld来学习VC6的开发。也许大家认为这个程序很简单,但是请记住,我们的重点是要介绍VC6这个集成开发环境中提供的调试功能。
启动VC6,单击菜单“File” -> “New”命令,在弹出的对话框中选择“Projects”选项卡,然后在左边的列表框中选择“Win32 Console Application”选项,在“Project Name:”文本框中填写“HelloWorld”,如图1-4所示。
图1-4 “Projects”选项卡
单击“OK”按钮,出现如图1-5所示窗口。
图1-5 “Win32 Console Application”项目向导
选择“An empty project”单选项,单击“Finish”按钮,然后在弹出的对话框中单击“OK”按钮。
单击菜单“File” -> “New”命令,选择“Files”选项卡,在左边的列表中选择“C++ Source File”选项’在右边的“File”文本框中填写“HelloWorld”,如图1-6所示。
图1-6 “Files”选项卡
单击“OK”按钮就可以进行代码编辑了。
在代码编辑处录入如下代码:
按F7键进行编译连接(按Ctrl+ F7组合键是只编译不进行连接),按Ctrl+F5组合键进行运行,如图1-7所示。
图1-7 “Hello World”运行界面
这就是我们值得纪念的第一个程序。这个程序很简单,有C语言基础的读者应该都能看懂,这里就不进行介绍了。如果看不懂,请先找本关于C语言入门的书学习一下。
现在来学习如何使用VC6对第一个程序进行调试。在代码编辑状态下,按下键盘上的F10键,进入调试状态,如图1-8所示。
图1-8 VC6处于调试状态
常用的调试窗口有两个,一个是“Watch”窗口(标注“1”的那个窗口),一个是“Memory”窗口(标注“2”的那个窗口)。打开“Watch”窗口的方法是单击“View” -> “Debug Windows” -> “Watch”命令(或按Alt+3组合键)打开。打开“Memory”窗口的方法是单击“View” -> “Debug Windows” -> “Memory”命令(或按Alt+6组合键)打开。“Watch”窗口用来监视我们感兴趣的变量,而当我们有时无法通过变量的值进行判断时,就需要借助“Memory”窗口中的值,比如,指针的值来进行判断。
除了这两个窗口以外,还有“Call Stack”、“Register”和“Disassembly”这3个窗口,分别如图1-9、图1-10和图1-11所示。
图1-9 “Call Stack”窗口
图1-10 “Register”窗口
图1-11 “Disassembly”窗口
“Call Stack”窗口是调用栈窗口,该窗口可以很方便地查看调用关系,很容易通过调用栈来找到上层、上上层的调用者。另外,也可以通过调用栈来定位错误。比如,有时程序会崩溃,但是发生崩溃的地方却在系统提供的代码中,而不在我们编写的代码中,这种错误在通常情况下是我们的程序对于参数的输入有误造成的,我们可以通过调用栈查看是谁调用了该函数,以便进行进一步分析。
“Register”窗口是用来观察寄存器的。有时需要观察返回值或者参数。
“Disassembly”窗口是用来观察C代码对应的反汇编代码的。有时在看C的代码无法解决的问题时,需要查看在底层实现时分析程序的问题。
以上就是VC6下常用的调试窗口,可根据实际情况使用,并不是每次调试都会用到这些窗口。下面再简单介绍一下常用的调试快捷键,以方便今后进行调试时使用。
VC6调试时的常用快捷键如下。
F5键:运行程序。
F9键:设定断点/取消断点。
F10键:单步步过,依次执行每一条代码。
F11键:单步步入,依次执行每一条代码,遇到函数调用时则进入到被调用的函数中。
F7键:停止调试。
在后面的章节中我们会用到这些快捷键来调试程序,让大家在学习的过程中真正地应用起这些调试功能。
OllyDbg,简称OD,是专业的应用程序调试工具。接触过破解,或者做过外挂开发的读者一定对这款工具不陌生。在这里,简单介绍一下这款工具。
让我们先来看看它的界面吧,如图1-12所示。
图1-12 OllyDbg的窗口
OD的大多数情况是在没有源代码的情况下对软件进行调试的。也许没有源代码也就不叫调试了,而叫做动态分析。OD的主界面中有6个主要的窗口,分别是反汇编窗口、寄存器窗口、提示信息窗口、数据窗口(也叫转存窗口)、栈窗口和命令提示窗口。
下面逐个介绍一下各个窗口的作用。
(1) 反汇编窗口:这是调试或动态分析时的主要窗口,我们主要是针对软件的功能实现进行分析,因此主要需查看的就是反汇编窗口的内容。
(2) 寄存器窗口:该窗口的作用是实时地显示寄存器的变化情况。寄存器也可以反映代码的执行情况。例如,我们常常查看返回值的eax的值。
(3) 提示信息窗口:这里往往会显示一些内存地址的值、寄存器的值、调用方的地址等信息。
(4) 数据窗口:该窗口主要是用来显示数据的,单击右键可以把数据按照不同的方式进行解析,对于我们分析程序的过程是非常有用的。
(5) 栈窗口:该窗口可以用来查看函数调用时参数的值。
(6) 命令提示窗口:该窗口是用来输入调试命令的。
OD调试时的常用快捷键如下。
F8键:单步步过,依次执行每一条代码。
F7键:单步步入,依次执行每一条代码,遇到函数调用时则进入到被调用的函数中。
F4键:执行到选中的代码处(前提条件是该条代码在程序的流程中一定会被执行到)。
F2键:断点中断。
F9键:运行程序。
OD的介绍到此为止,在后面的内容中我们会再次提到OD,到那时会有一定的机会练习使用OD。如果有对OD感兴趣的读者,请另行阅读其他书籍。
下面介绍一些在黑客编程中会用到的API函数,尽量排一点简单易用的函数,用简单的几行代码来完成一定的功能,希望大家能在这里体会到编程乐趣,不至于被大段的代码影响了自己前进的心情。
一般的病毒木马都有这种类似的功能,完成这个功能其实并不复杂,我们来拆解思考一下实现这段代码的步骤。
复制是一个拷贝的过程。既然是拷贝,就要知道拷贝的原位置和目的位置。也就是整个过程其实分3步,首先要得到自身程序所在的路径,然后获得Windows目录和系统目录,最后分别拷贝自身程序到这两个目录中。这3个步骤要如何完成,下面我们来看看完成这些功能的API函数。
获得自身程序所在路径的API函数的定义:
该函数有3个参数,分别如下。
(1) hModule:该参数在获得自身程序时使用为NULL。
(2) lpFilename:该参数指定一个字符型的缓冲区,用于保存程序自身所在的路径。
(3) nSize:该参数指定缓冲区的大小。
获得Windows目录的API函数的定义:
该函数有两个参数,分别如下。
(1) lpBuffer:该参数指定一个字符型的缓冲区,用于保存Windows目录的路径。
(2) uSize:该参数指定缓冲区的大小。
获得系统目录的API函数的定义:
该函数有两个参数,分别如下。
(1) lpBuffer:该参数指定一个字符型的缓冲区,用于保存系统目录的路径。
(2) uSize:该参数指定缓冲区的大小。
拷贝文件的API函数的定义:
该函数有3个参数,分别如下。
(1) lpExistingFileName:该参数指向一个已存在文件的路径,即原文件路径。
(2) lpNewFileName:该参数指向一个新的文件的位置,即欲拷贝到的文件的目的路径。
(3) bFailIfExists:该参数是一个布尔型参数,如果参数为TRUE,若目的文件已存在则返回,复制失败;如果参数为FALSE,若目的文件已存在则强行覆盖原有的文件。
需要使用的API函数已经介绍完了,下面就来真正完成这个复制自身程序到Windows目录和系统目录下的程序,代码如下:
该函数需要包含Windows.h这个头文件,也就是在该段程序的最开始处加一句:
了解一个系统相关信息也是一项比较重要的内容,强大的扫描软件Nmap在对目标主机进行扫描时,也能对目标主机的系统等信息进行识别,真的是很强大。这里简单地获取一些与系统相关的信息,主要获取的内容有操作系统的版本、操作系统的名字及当前登录的用户名称。接下来逐个介绍这些API函数。
(1)获取操作系统版本
代码如下:
该函数就一个参数,这个参数是指向一个OSVERSIONINFO结构的指针。看一下OSVERSIONINFO这个结构体。
dwPlatformId的取值有3个,而现在主要使用一个,即VER_PLATFORM_WIN32_NT。
(2)获取计算机名称
代码如下:
该函数有两个参数,介绍如下。
①lpBuffer:保存计算机名称缓冲区。
②lpnSize:保存缓冲区的长度,该参数是一个输入/输出参数。
(3)获取当前用户名称
代码如下:
该函数有两个参数,介绍如下。
①lpBuffer:保存当前用户名称的缓冲区。
②nSize:保存缓冲区的长度,该参数是一个输入/输出参数。
我们封装一个简单的函数来获取系统的这3个信息,代码如下:
将代码进行编译连接并运行,其执行结果如图1-13所示。
图1-13 当前操作系统版本、计算机名及当前用户名
这个程序完成了我们想要的功能,对于编程的部分就介绍到这里。下面介绍Debug和Release方面的内容。
关于获取系统信息的程序,我们编写完成了,也编译连接并运行过了。找到刚才编译的程序,查看一下它的文件大小,如图1-14所示。
图1-14 GetSysInfo的程序大小
从图1-14中可以看出,该程序竟然有153KB大小。是不是很惊人?我们一共写了不过十几行代码,但是却生成了如此大体积的程序,这是为什么呢?因为代码默认编译连接是Debug版本的,如图1-15所示。
图1-15 Win32 Debug方式
从图1-15中可以看出,我们的代码是由Debug方式编译的。Debug被称为调试版本,在这种方式的编译下,可执行程序中会附带很多和调试相关的数据或代码,而且不做任何的优化,以此为开发人员提供大量的调试信息,从而方便了程序的调试工作。除了Debug方式编译以外,还有一种方式是Release方式编译,单击“Win32 Debug”右边的下拉箭头可以选择“Win32 Release”,如图1-16所示。
图1-16 Win32 Release方式
Release方式被称作发布版本,是为最终用户使用的,这种方式对代码做了大量的优化工作,不再包含与调试相关的信息,从而使程序的运行效率更高,体积更小,如图1-17所示。
图1-17 Release版的GetSysInfo的文件大小
从图1-17可以看出,两个程序的文件大小发生了截然不同的变化。因此,当我们自己写程序调试时,应该使用调试版,以方便我们对程序进行调试。当我们的程序已经调试完毕,那么可以使用发布版来与大家进行交流。
很多时候,我们都需要查看函数的定义,而函数的定义都在SDK的头文件中。虽然从MSDN中也能找到函数的定义,但是还是有略微的不同,而且对于查找自定义函数的函数定义也是很方便的。
回到我们的代码当中,随便选中一个API函数,比如GetComputerName()这个函数。加入要查看该函数的定义应该如何查看呢?我们在GetComputerName()这个函数上单击鼠标右键,在弹出的快捷菜单上选择“ Go To Definition Of GetComputerName ”(到GetComputerName函数的定义处)命令,如图1-18所示。
图1-18 “ Go To Definition Of GetComputerName ”命令
当选择“Go To Definition Of GetComputerName”命令以后,会来到“Winbase.h”头文件中的GetComputerName()函数的定义处,如图1-19和图1-20所示。
图1-19 “Winbase.h”头文件
图1-20 GetComputerName()的定义
从图1-20中可以看出,GetComputerName是一个宏,其对应的函数为GetComputerNameA()。关于GetComputerName()和GetComputerNameA(),包括可以看到的GetComputerNameW(),我们都不进行介绍。通过图1-20的函数定义和前面介绍这个函数的定义来比较一下,可以看到,头文件中的定义比MSDN中的定义对于函数的描述更加详细,比如WINAPI表示函数的调用方式。
除了“Go To Definition Of GetComputerName”以外,还有一个“Go To Reference To GetComputerName”,这个是查看何处引用了函数。大家可以自行进行练习。
本书的编程内容主要以C语言为编程语言(本书部分内容会涉及其他语言,但C语言是主要的),以VC6为开发环境,着重介绍了VC6的基本概念及简单的使用,在此基础上带领大家认识了专业的应用程序的调试工具——OD。在最后的内容中介绍了一些简单的API函数的使用。万丈高楼平地起,希望每一位初学者不要过于着急,在后面的章节中我们会慢慢地深入学习黑客编程的内容。希望在每学完一个知识后,大家多思考多动手,这样才能真正地起到学习的效果。
黑客们对系统成功入侵后,为了方便下次登录被入侵的计算机,通常会留下一个后门,或者是木马。国产著名的木马有冰河、广外女生、灰鸽子、上兴、PCShare……这些木马对每个黑客来说都已经很熟悉了,那么接下来就介绍一下它们的共性。不管是哪种木马,它们都要依靠网络来进行客户端与服务器端的通信,也就是说,不管木马有多么复杂的功能,其基础都是建立在网络的通信上。
一台计算机和另一台计算机通信,只需要知道两个内容就可以了,即IP地址和端口号。这两个名词想必大家不但听过,而且已经有感性上的认识了。我们在扫描网络主机的时候,会在扫描器中填写扫描主机的IP地址,然后扫描器会逐个扫描IP地址,并且把指定IP的所有开放端口全部列出来。那么IP地址是什么?端口号是什么?下面简单介绍一下两者的作用。
IP地址的全称是网际协议地址,互联网上的每个主机和路由器都有IP地址,没有两个机器有相同的IP地址,因此它是用来标识一台网络主机的。所有的IP地址都是32位长,其用点分十进制法来表示,比如“10.10.30.12”。IP地址指定的不是主机,而是网络接口设备。因此,一个主机有两个网络接口,那么就会有两个IP地址。通常情况下,一台主机只有一个IP地址。
IP地址被分为5类,分别是A类、B类……E类,每一类IP地址的范围如表2-1所示。
上面的IP地址划分是照搬了其他参考书里面给出的IP地址的划分方式,笔者把这些IP地址重新进行了归类。共分为4类,一类是环回IP地址(表示本机IP地址),一类是内网IP地址,一类是公网IP地址,一类是其他。下面分别进行简短的介绍。
1. 环回IP地址。
A类网络号为127就是为环回地址预留的。通常情况下是127.0.0.1这个地址,或者是localhost,该IP地址不管机主是否联网,对这个IP地址的通信都是可以连通的,因为它就表示本地主机。ping一下环回地址试试,在命令行下输入:ping 127.1,不论是否联网这个地址都是通的。
2. 内网IP地址。
用于局域网内,比如网吧、企业、政府、学校的内部。它主要负责局域网的通信。内网地址开头都是10、192……一些地址。
3. 公网IP地址。
是ISP(网络服务供应商),比如网通、电信、铁通……提供的这些地址都是由动态ISP分配的,这个地址是真正在互联网上进行通信的IP地址。
4. 其他IP地址。
是TCP/IP中保留的用来做实验的一些IP地址,就比如D类和E类这些基本我们普通用户用不到的IP地址。
IP地址可以在互联网上唯一地标识一台主机,那么端口号有什么用呢?两台计算机通信,其实是两台计算机上的不同进程间的通信。也就是说,木马的客户端和木马的服务端都是操作系统下的一个进程。Windows操作系统下是多进程的,即使IP地址可以标识一台主机,却无法得知是和哪个进程在通信,因为打开的网络软件实在太多了,比如QQ、IE……因此,端口号就是用来标识进程的。
端口号是16位的,范围在0〜65535之间。端口号根据传输层的传输协议的不同分为两种,一种是TCP端口,另一种是UDP端口。用扫描端口的软件可以区分出TCP和UDP端口。TCP和UDP是两种不同的协议,TCP协议用于可靠传输中,而UDP协议用于不可靠传输中。TCP的端口在通信过程中始终保持连接,直到通信结束,而UDP的通信是不需要连接的。两种协议各有优缺点,视具体情况而定。
上面对两台计算机之间如何进行通信有了一个大概的了解,现在开始真正学习Windows下网络编程的相关知识。
既然是通信,必然要遵循一个通信模型,常见的通信模型有C/S模型(即客户端/服务端模型)、B/S模型(即浏览器/服务端模型)。常见的木马是C/S模式的,不过也有木马是B/S模式的,只不过这种模式相对少见。接下来主要介绍C/S模型的开发。
C/S模型的开发,就是在服务器端上绑定一个IP地址和一个端口号,然后进行监听,等待客户端的连接,而客户端则是向服务器端指定的IP地址和端口号发起连接,服务器接受客户端的连接后,双方可以进行通信,这是基于TCP协议的通信。而基于UDP协议就简单多了,服务器只要绑定IP地址和端口号就可以了,客户端不需要进行连接直接就可以和服务器进行通信。从基于TCP和基于UDP的服务器可以看出,TCP比UDP要可靠,而UDP要比TCP效率高。
Windows下的网络应用开发大部分是通过Winsock完成的(除了Winsock以外还有其他的),Winsock有两种开发模式,一种是阻塞模式,另一种是非阻塞模式。阻塞模式是基于同步的开发方式,非阻塞模式是基于异步的开发方式。非阻塞模式结合了Windows的消息机制,更符合Windows下的开发。在我们的学习过程中主要讨论非阻塞模式的开发。
下面开始讲解Windows下网络开发所需要了解的API函数。在介绍这些函数的时候,笔者只重点介绍部分参数,详细的参数介绍不是本书的重点,但是很多函数都有其固定的参数,大家会用即可。
每个需要使用Winsock进行网络开发的Windows应用程序都必须包含Winsock2.h(这是第二个版本的Winsock库),除了这个头文件以外,还有一个静态库ws2_32.1ib。在使用它们的时候需要对这个库进行一次初始化,使用完毕后要对该库进行释放。下面分别介绍这两个函数。
首先来看初始化ws2_32.dll动态链接库的函数:
这个函数是用来初始化ws2_32. dll动态链接库的,这个动态链接库是所有网络应用程序会加载的动态链接库,在使用这个动态链接库时就需要用WSAStartup()函数进行初始化。如果不初始化这个动态链接库,其余相关的基于这个动态链接库的网络函数的调用都会失败。
参数说明如下。
(1) wVersionRequested: Windows Sockets API提供的调用方可使用的最高版本号。高位字节指出副版本(修正)号,低位字节指出主版本号。
(2) lpWSAData:指向WSADATA数据结构的指针,用来接收Windows Sockets实现的细节。
释放ws2_32.dll动态链接库:
这个函数是结束这个动态链接库的,一般在程序退出时使用。
创建套接字:
参数说明如下。
(1) af:指定应用程序使用的通信协议族,对于TCP/IP协议族,该参数始终为PF_INET。有的教材里这个参数使用的是AF_INET.AF_INET是地址族,虽然使用这个没错,但还是建议使用PF_INET。
(2) type:指定要创建的套接字的类型,流套接字类型为SOCK_STREAM,数据包套接字类型为SOCK_DGRAM。前者通常是TCP协议使用,后者通常是UDP协议使用;
(3) protocal:指定应用程序所使用的通信协议。该参数根据第二个参数的不同而不同,第二个参数为SOCK_STREAM,该参数为IPPROTO_TCP;如果第二个参数为SOCK_DGRAM,那么该参数为IPPROTO_UDP。
该函数的返回值是一个新创建的类型为SOCKET的套接字的描述符。
关闭套接字:
程序结束时要对Socket创建的套接字进行关闭,完成资源的释放。
参数说明如下。
s: socket()函数创建的套接字描述符。
当创建了一个Socket后,服务器程序必须要绑定一个IP地址和特定的端口号。客户端程序不需要绑定端口号和IP地址,因为Socket会选择合适IP地址及端口来使用。
绑定IP地址和端口号:
参数说明如下。
(1) s:指定待绑定的Socket描述符。
(2) name:指定一个sockaddr结构,该结构的定义如下:
函数中提供的参数类型是sockaddr,在实际使用的过程中,结构体是sockaddr_in,该结构的定义如下:
成员变量sin_family设置为PF_INET; sin_port设置为端口号;sin_addr结构体中只包含一个公用体,in_addr的定义如下:
该成员变量是一个整数,一般用函数inet_addr()把字符串形式的IP地址转换成unsigned long整型的整数值。
namelen:指定name缓冲区的长度。
inet_addr函数的原型如下:
参数cp为一个点分多进制的IP地址。
inet_addr函数的逆函数如下:
参数为一个addr_in类型的变量。
监听端口:
参数说明如下。
(1) s:使流套接字s处于监听状态。
(2) backlog:为处于监听状态的流套接字s维护一个客户连接请求队列。
接受请求:
服务端程序调用该函数从处于监听状态的流套接字的客户端请求队列中取出第一个请求,并创建一个新的套接字与客户端进行连接通信。
参数说明如下。
(1) s:指定处于监听状态的套接字。
(2) addr:用来返回新创建的套接字的地址。
(3) addrlen:用来返回新创建套接字的地址结构的长度。
连接函数如下:
客户端程序调用该函数来完成与远程服务器端的连接。
参数说明如下。
(1) s:客户端创建的套接字。
(2) name:该结构中包含了要服务器端中的IP地址和端口号。
(3) namelen:指定name缓冲区的长度。
具体进行通信的函数分为两类,一类是基于TCP协议的,另一类是基于UDP协议的。数据的通信主要体现在数据的收发上,分别看一下这两种协议的收发数据的函数定义。
基于TCP的发送函数:
参数说明如下。
(1) s:指定发送端套接字描述符。
(2) buf:指明一个存放应用程序要发送数据的缓冲区。
(3) len:指明实际要发送到数据的字节数。
(4) flags:一般设置为0。
基于TCP的接收函数:
参数说明如下。
(1) s:指定接收端套接字描述符。
(2) buf:指定一个缓冲区,用来存放接收到的数据。
(3) len:指定缓冲区的长度。
(4) flags:一般设置为0。
基于UDP的发送函数:
基于UDP的接收函数:
数据在存储器中是按一定方式的,根据不同的CPU架构,其存储方式也不相同。比如Intel ×86CPU架构使用小尾顺序,即高位存放高字节,低位存放低字节。例如0×12345678,在内存里的表示方式为78 56 34 12。
还有一种是大尾方式,即按网络字节顺序,也就是说所有的网络中传输的数据使用的均是大尾方式。大尾方式是高位存放低字节,低位存放高字节。例如0×12345678,在内存里的表示方式为12 34 56 78,如图2-1所示。
图2-1 字节顺序
本地字节顺序根据CPU架构的不同可能是小尾方式,也可能是大尾方式,但是对于网络字节顺序来说,一定是大尾方式。Winsock提供了一些用来处理本地的字节顺序和网络的字节顺序的转换函数。这些函数如下:
本地字节顺序转换为网络字节顺序:
网络字节顺序转换为本地字节顺序:
当我们在调试软件的时候,经常会在内存中查找某一个数据,但是怎么也找不到,在这个时候就应该想到字节顺序的问题。在做破解和外挂的时候,不了解这些基本概念的话经常会感到困惑,请大家一定要注意这个细小的问题。
网络通信方面的基础函数我们已经了解了一些,把这些知识都连起来,就可以编写一个网络程序了。我们分别来写一个基于TCP协议和基于UDP协议的小程序。这是一个非常经典的程序,就是在编程界举世闻名的输出“Hello World!”!的程序,只不过是Winsock版本。
服务器端的代码编写流程如下:
只要把这些函数依次写完,服务器端的代码就写完了。
服务器端代码如下:
客户端的代码编写流程如下:
客户端的流程比服务器端的流程要更少一些,主要是省去了绑定IP和端口、监听等一些步骤。
客户端代码如下:
在上面的代码中,我们只是简单地把所有的API函数都串起来写了一遍,就能实现想要的功能。但是请注意,大家在写的时候一定要把所有函数的返回值加以判断,形成一个好的习惯。
UDP客户端与服务器端的编写方法与TCP的相似,只要注意其中的差别就可以了。
服务端代码如下:
客户端代码如下:
C/S模式的木马的工作方式和网上聊天的工作方式没什么差别,都是基于TCP/IP协议的通信,都是在传递信息。不同的是,木马的客户端向服务器端发送的内容是控制命令,服务器端收到控制命令执行相应的功能,并将执行结果反馈给客户端,这就是远程控制。如果客户端加一些隐藏进程,复制自身到系统目录,然后自动启动……那么服务端就是一个木马了。
这个木马主要实现以下几个简单的功能:
(1)完成帮助命令的发送;
(2)可以查看远程主机的相关信息,以及把自身复制到可执行目录中;
(3)打开光驱和关闭光驱;
(4)交换鼠标左右键和恢复鼠标左右键功能。
这里,由于有一些内容还没有学到,所以暂时不去实现,在后面的章节里,再把木马的内容充实起来。不过这仅仅是学习,要开发一个专业的木马并不是一件容易的事情。最关键的是,我们做的一切都是为了学习编程,为了学习计算机安全,而不是为了搞破坏。希望大家谨记于心。
我们的木马是命令行下的木马,所以当实现功能多了以后很多命令就会忘记。为了方便使用,在木马中加入帮助系统,也就是在输入指定命令后,客户端会把命令发给我们。把相关的命令定义成宏,方便我们修改,定义如下:
这里有个问题,这个命令是定义在客户端好,还是定义在服务器端好?定义在客户端当我们查看命令字的时候,省去了一次网络的通信传输,速度相对较快。但是,我们的木马客户端只是简单地发送命令,服务器端根据不同的命令来完成相应的功能,从这样的角度来看,可能客户端基本上定下来以后就不再变了,而有变化的就是服务器端了。当连接了木马的服务器端以后,要确定当前连接的木马提供多少种功能,为了确定木马能执行哪些功能,因此笔者认为帮助定义在服务器端较好。
我们木马的主体形式是接收命令,其实就是让recv()函数接收字符串,根据接收的字符串进行匹配从而完成相应的动作。代码的大体形式如下:
以上代码是用来进行循环接收命令的,分发命令的部分就是一些判断和比较了,如下:
根据前面列举的那些功能我们逐个实现。
首先是完成帮助命令的发送,这个非常简单,只要调用send()就可以完成了,代码如下:
对于复制到系统目录这个功能大家请参考第1章,这里就不重复了,但是对于获得系统信息,就需要修改一下了。不能直接输出,而是需要定义一个结构体保存获取到的信息,以便发送给客户端,结构体如下:
把获取到的信息保存在该结构体中,发送给客户端就可以了。客户端的显示与第1章介绍的类似。发送代码如下:
对于光驱的打开与关闭、交换与恢复鼠标左右键功能的实现也非常简单,函数原型如下:
不对这两个函数进行介绍了,直接看代码吧。
以上就是木马服务器端的全部代码了。我们只是把一些简单的知识连贯起来使用,就是一个木马了。
木马客户端的代码就是完成字符串的发送,代码非常简单,直接看完整的源代码。
上面是一个最简单的C/S模式的木马。是不是很简单啊?感觉我们的小马很“瘦”是吧?不足以日行千里是吧?有奢求,有欲望是好的。在后面的章节当中大家可以慢慢地补足这匹马儿的功能。再次提醒,本书的目的是和大家学习黑客编程,旨在通过黑客编程学习系统知识和安全知识,并非拿来做坏事的。
本章内容简单易学,不会因为过多复杂难懂的代码而让大家失去了学习的热情。也许你觉得意犹未尽,也许觉得不够深入,我们的学习目标就是通过简单的知识,完成实现特定功能的程序。在后面学习的过程中,我们仍然以这种方式进行学习,也会随着这种学习方式逐步地深入了解编程知识。
刚开始接触编程的“玩黑”的读者可能都会觉得API是一个很神奇的且很万能的东西。其实,API只是Windows下进行开发的基础知识,当然基础并不代表简单,能真正用好API来开发程序也是非常不容易的,本章将重点介绍很多今后在编程中常会用到的API函数,包括进程、线程、文件、注册表、注入等相关的API函数。
在Windows下文件有很多种,比如图片文件、视频文件、音频文件……除了常见的文件格式外,其实对于管道、邮槽,甚至是设备对象,在Windows下也被当做文件来对待,这样对于我们操作管道、邮槽、设备对象就像操作文件一样简单了。
对于操作任何文件,我们最先的操作是对文件的打开,操作结束时为了释放资源要进行关闭。我们依次学习如何使用这些API函数,然后完成一个简单的小例子。从“文件操作”开始,我们要接触MFC方面的编程,但不会很难,都是一些对界面和控件之类的使用。
要对文件进行操作,就要首先对文件进行打开操作,文件打开成功会返回一个句柄,然后通过这个句柄对文件进行读写操作。
打开文件的API函数如下定义:
参数说明如下。
(1) lpFileName:要打开或创建的文件名。这里也可以不是文件名,可以是管道名、设备对象名。
(2) dwDesiredAccess:对文件的访问模式,通常是GENERIC_READ和GENERIC_WRITE。
(3) dwShareMode:对文件的共享模式。Windows是多进程的操作系统,在一个文件被访问时,可能会有另一个进程也访问该文件。这时第一个打开该文件的进程就要对其设置一个访问模式,即打开此文件后,其他进程对该文件可以有哪些操作。
(4) lpSecurityAttributes:安全属性,一般为NULL。
(5) dwCreationDisposition:在创建或打开的文件存在或不存在时该函数的处理方式。
(6) dwFlagsAndAttributes:打开或创建文件时的标志和属性。一般是FILE_ATTRIBUTE_NORMAL。
(7) hTemplateFile:文件模板,一般为NULL。
该函数若执行成功,则返回一个文件句柄;若执行失败,则返回INVALID_HANDLE_VALUE。
在对文件使用完毕后,应该对打开的句柄进行关闭以释放资源,关闭句柄的函数如下:
该函数就一个参数,这个参数就是调用CreateFile()函数时的返回值,也就是文件句柄。
文件操作的种类有4种,分别是“增、删、改、查”。熟悉数据库操作的读者一定感觉这4种操作是针对数据库的,怎么对于文件的操作也是这4种呢?其实,不单单是对文件的操作,对注册表的操作,对服务的操作,对进程的操作……也都存在着“增、删、改、查”这些操作。还是讨论文件的这4种操作,文件的“增”其实就是“创建”文件,文件的“删”就是“删除”文件,文件的“改”就是对文件的“写”操作,文件的“查”就是对文件的“读”操作。当然了,对于文件的“读操作”分为狭义的和广义的两种。狭义的“读文件”就是读取文件的内容,对于广义的“读文件”来说,可以是读取文件的大小、读取文件的创建时间和修改时间……文件的创建是通过CreateFile()函数来完成的,文件的打开也是通过CreateFile()函数来完成。对于删除文件、写文件、读文件……这些API函数,下面将逐一进行介绍。
删除文件的API函数如下:
该函数的参数就只有一个,而且非常简单,就是要删除的文件的文件名。
文件的读函数如下:
参数说明如下。
(1) hFile:该参数是CreateFile()函数返回的句柄。
(2) lpBuffer:指向一个缓冲区,函数会将从文件中读出的数据保存在该缓冲区中。
(3) nNumberOfBytesToRead:要求读入的字节数,通常情况下是缓冲区的大小。
(4) lpNumberOfBytesRead:该参数是一个指向DWORD类型的变量,用于返回实际读入的字节数。
(5) lpOverlapped:一般设为NULL。
文件的写函数如下:
WriteFile()函数与ReadFile()函数的参数意义基本相同,所不同的是第二个参数,第二个参数仍然指向一个缓冲区,函数会将该缓冲区的内容写入到文件中。
设置文件指针的函数如下:
参数说明如下。
(1) hFile:CreateFile()函数打开文件的句柄。
(2) lDistanceToMove:指定要移动的距离。
(3) lpDistanceToMoveHigh:一个指向LONG型的指针,其保存一个要移动的距离的高32位。
(4) dwMoveMethod:指定移动的起始位置。
创建目录的函数如下:
参数说明如下。
(1) lpPathName:创建目录的目录名称;
(2) lpSecurityAttributes:一般为NULL。
移除目录的函数如下:
该函数的参数指定要移除的目录的目录名。
关于文件操作的函数就简单介绍这么几个,在以后的内容中将继续介绍。文件操作是Windows下编程的重要基础,应用非常广泛,希望读者能掌握以上介绍的函数。
我们通过上面学的几个函数来完成一个简单的AutoRun免疫程序。
每当我们打开U盘时,看到有一个AutoRun.inf文件时都会不由地倒吸口凉气,“该死!又中病毒了!”。是的,有一种病毒程序通过AutoRun.inf文件使其自动运行起来,想必这不用做过多的介绍每一位都非常清楚。网上有很多免疫工具,现在一些修改版的操作系统里面也会提供这样一个免疫的小工具。它免疫的原理是建立一个不被删除的文件AutoRun.inf文件夹,以防止病毒生成一个运行病毒的AutoRun.inf。
我们的程序使用MFC编写,上面介绍的API函数会使用到两个,并且还会加入新的API函数。好了,开始我们的代码吧。
启动VC6,选择菜单“File”->“New”命令,在弹出的“New”对话框中选择“Projects”选项卡,在左侧的列表中选择“MFC AppWizard(exe)”,在右侧填写项目的名称“ImmunityU”,如图3-1所示。
图3-1 新建MFC项目工程
单击“OK”按钮后,出现“MFC AppWizard-Step 1”的界面,选择“Dialog based”单选钮,如图3-2所示。
图3-2 “MFC AppWizard-Step 1”界面
这里,我们选择以对话框为基础进行开发。单击“Finish”按钮会出现最后一个对话框,直接单击“OK”按钮关闭对话框。
我们的程序是有界面的,则首先要对界面进行一些设置。先把界面上所有的控件都删除掉,然后添加一个ComBoBox控件和两个按钮控件,如图3-3所示。
给添加的ComBoBox控件重新命名一个ID为:IDC_COMBO_DRIVER,并为其添加一个控件变量。添加的方法如下:
在IDC_COMBO_DRIVER上单击右键,在弹出的菜单上选择“ClassWizard”命令,弹出“MFC ClassWizard”对话框,选择“Member Variables”选项卡,双击IDC_COMBO_DRIVER,弹出“Add Member Variable”对话框,添加变量名为m_ComboDriver,选择类型为“Control”,然后单击“OK”按钮。添加情况如图3-4所示。
图3-3 程序界面
图3-4 给IDC_COMBO_DRIVER添加控件变量m_ComboDriver
我们进行免疫时是对某个分区进行免疫的,因此要在ComboBox控件中显示出所有的磁盘分区供用户选择。在ComboBox中显示所有的磁盘分区,我们定义成一个函数来完成该功能。
把这个函数添加到OnInitDialog()函数的最后面,ComboBox中就会有所有的磁盘分区的盘符了。这个自定义函数中出现了两个我们尚不熟悉的API函数,一个是SetDlgItemText(),另一个是GetLogicalDriveStrings()。下面分别介绍这两个函数。
设置编辑框显示的内容:
该函数的第一个参数用来指定控件变量的ID,第二个参数指定一个字符串。
获取字符串类型的驱动器列表:
该函数的第一个参数指定缓冲区的长度,第二个参数指定一个缓冲区。
了解了这两个函数后,再来看代码就很容易理解了。在while循环中的AddString()是CComboBox类的一个成员函数,该函数是用来向CComboBox添加一个字符串的。如果你不理解那个while循环的话,建议在VC6下调试一下看看szDriverString里保存的内容,就会明白while循环的作用了。这个调试建议大家亲自动手练习一次,这是第一次要求大家动手调试。
接下来添加“免疫”按钮的代码,代码很简单,如下:
这段代码中加了详细的注释,大家应该可以看懂,就不做过多介绍了,如果哪里有疑惑的话,请大家自己动手调试一下。最后看一下“取消”按钮的代码:
整个对Autorun进行免疫的代码就完成了,大家可以自己进行一下试验。启动我们的程序,选择需要进行免疫的盘符,单击“免疫”,打开被免疫的那个磁盘,看看是不是真的免疫了,再试着用右键删除看看效果,可以看到我们的实现是成功的。
免疫Autorun的程序虽然完成了,但是并不是很完整,我们可以在注册表中把“自动播放”禁止掉,这样即使有Autorun.inf文件,系统也不会运行它,系统的安全系数就会更高了。
注册表是Windows操作系统的一个重要的数据库,里面记录了系统几乎所有的信息。当然,由于注册表的功能非常强大,因此,注册表对于病毒、木马来说是非常有利用价值的地方。而对于反病毒软件来说,注册表是其需要加强守卫的地方。注册表,是一个正义与邪恶的必争之处。
注册表对于病毒来说可以利用的地方非常多,比如修改文件关联,增加系统的启动项,映像劫持,篡改浏览器主页……病毒的这些操作让我们不得不了解注册表相关的编程。
对于学习黑客编程来说,注册表的操作当然不能少,现在就来学习关于注册表的操作。注册表的操作和文件的操作类似,也有打开、关闭、写入、查询等操作,也就是我们所说的“增、删、改、查”,只是使用的API函数不同而已。下面分别介绍每个API函数的使用方法。
打开注册表:
参数说明如下。
(1) hKey:指定一个父键句柄。
(2) lpSubKey:指向一个字符串,用来表示要打开的子键名称。
(3) phkResult:返回打开的子键句柄。
关闭注册表:
该函数只有一个参数,参数是打开子键的句柄。
注册表键值的查询:
参数说明如下。
(1) hKey:指向一个已被打开或创建的子键句柄。
(2) lpValueName:指定要被查询的键值的名称。
(3) lpReserved:保留,始终为NULL。
(4) lpType:返回被查询的值的类型。
(5) lpData:指向要查询数据的缓冲区。
(6) lpcbData:缓冲区的长度。
注册表键值的写入:
参数说明如下。
(1) hKey:指向一个已被打开或创建的子键句柄。
(2) lpValueName:指定要被查询或写入的键值的名称。
(3) Reserved:保留,始终为0。
(4) dwType:写入键值的类型。
(5) lpData:写入键值的缓冲区。
(6) cbData:写入键值缓冲区的长度。
注册表键值的枚举:
参数说明如下。
(1) hKey:指向一个已被打开或创建的子键句柄。
(2) dwIndex:查询的索引值。
(3) lpValueName:键名的缓冲区。
(4) lpcValueName:键名缓冲区的长度。
(5) lpReserved:保留,始终为NULL。
(6) lpType:返回被查询的值的类型。
(7) lpData:被查询的键值的缓冲区。
(8) lpcbData:被查询的键值的缓冲区的大小。
注册表键值项的删除:
参数说明如下。
(1) hKey:指向一个已被打开或创建的子键的句柄。
(2) lpValueName:指向欲删除的键值项的名称。
对于Windows操作系统来说,注册表中保存了非常多的系统配置,例如常见的IE主页保存在HKEY_LOCAL_MACHINE\Software\Microsoft\Internet Explorer\Main下的Start Page中;再比如禁止磁盘驱动器的AutoRun功能在注册表的HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Policies\Explorer下的NoDriveTypeAutoRun中进行设置;还有映像劫持、文件关联……很多配置都可以在注册表中完成。
对于安全工具来说,通过注册表发现所有的Windows启动项是十分必要的。在注册表的启动项中,除了正常的系统工具、软件工具外,病毒和木马也会利用注册表的启动项悄然地让自身进行启动。我们通过编写一个枚举注册表启动项的工具,来学习注册表相关的操作。我们的程序依然会使用到MFC中的控件,下面随笔者一步一步地学习吧。
注册表中可以用来完成开机启动的地方非常多,我们不一一介绍了,这里只对注册表中某个可以完成开机启动的地方进行介绍,至于其他的地方大家可以自行搜集并完成。我们的程序依然是使用对话框的形式,其界面如图3-5所示。
图3-5 注册表启动管理器
这个界面就是我们已经编写好的软件的界面。在这个界面中,用到了CListCtrl控件,大家对其进行添加并进行相应的设置即可。在实例的介绍中,尽可能地少提及控件,因为这实非本书应该提及的内容,大家可以自行参考MFC开发相关的书籍。这里,给出一个关于CListCtrl初始化的代码:
在这个实例中,主要是枚举注册表中“HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Run\”这个子键下的键值项。对于我们软件启动后,应该显示出在这个注册表子键位置下的所有启动项的内容,枚举的代码如下:
对于注册表启动项的管理来说,应该具备几个功能,首先是屏蔽启动项,然后是删除启动项,最后是添加启动项。在我们的程序中主要完成后两个功能,一个是删除启动项,另一个是添加启动项,至于屏蔽启动项这个功能就留给大家自己实现。在很多安全软件中,如Windows优化大师、360安全卫士中都有屏蔽启动项的功能,大家自己思考一下屏蔽启动项与删除启动项的区别在哪里,并如何实现。
下面分别来看一下添加启动项和删除启动项的代码。
添加启动项,需要添加一个窗口,至于窗口的添加方法大家自行参考MFC开发相关的介绍,这里就不做过多的介绍了。完整的代码会提供给大家,方便大家进行参考,这里主要是介绍与注册表操作相关的内容。添加启动项的代码如下:
删除启动项的代码比添加启动项的代码要简单,但是在删除的时候要涉及到一个关于CListCtrl控件的编程,也就是选中哪个启动项进行删除,这是一个主要的问题,获取选中的启动项后的删除就非常简单了。代码如下:
对于启动项的管理软件的编写就到这里了,大家也可以在注册表中其他的可以使软件开机启动的位置添加进去,这样我们的软件就更加强大了。在我们不断地在注册表中发现有新的位置可以启动软件时,每次都需要进行代码添加,这样的工作太繁琐了。有没有什么好的方法当每次发现新的位置后可以不改变代码呢?请大家把前面学到的文件操作的知识联系起来进行考虑一下!我们可以把要枚举的注册表的子键保存到一个文件中,然后让程序去读取这些子键,在注册表中分别进行枚举。这样,以后每当在注册表中有新的需要枚举的内容,那么只要对这个文件进行修改就可以了,就不需要对程序本身进行修改了。大家可以试着写一个这样的程序。
服务是一种在操作系统启动时就启动的进程。在操作系统启动时有两种程序会一起随着系统启动,一种是普通的Win32程序,另一种是驱动程序。这里讨论的并不是要如何编写一个系统的服务,而是编写一个如何可以显示出这些随系统启动而启动的服务项。
在Windows下,有很多服务是跟随操作系统一起进行启动的,具体有哪些服务是跟随操作系统一起启动的呢?如何查看呢?其实非常得简单。在“我的电脑”上单击鼠标右键,然后在弹出的菜单上选择“管理”命令会打开“计算机管理”工具,单击左侧树形列表中的“服务和应用程序”会出现一个列表,选择“服务”则在右侧出现了服务项列表,如图3-6所示。
图3-6 Windows下的服务管理程序
在这个列表中,只能查看到Win32应用程序的服务,无法查看关于驱动程序的服务。我们可以借助其他一些小工具来查看驱动服务的程序,如图3-7所示。
图3-7 使用SREng查看驱动服务程序列表
接下来编写一个程序,既可以查看应用程序服务列表,也可以查看驱动程序服务列表。编写的程序如图3-8所示。
图3-8 服务管理程序的界面
这个服务控制管理器依然是使用MFC的对话框进行开发的,其中还是用到了CListCtrl这个控件。让我们现在就动手开始打造一个自己的服务控制管理器吧。
服务控制管理器的开发和注册表启动管理器的开发是差不多的,主要也是枚举服务并将其显示到列表控件中。至于对服务状态的控制,是通过服务相关的API函数来完成的。这次,我们先来看代码,希望通过阅读代码大家能自己掌握与服务相关的API函数。在代码的后面,会对开发服务控制管理器涉及到的API进行相应的解释。
这里笔者强调一下,学习API函数的使用,最好的老师是MSDN。在大家学习编程的道路上,要通过阅读大量的代码来提高自己的编程能力,那时大家就要自行学习API函数的使用,因此希望大家能养成好的学习习惯,也就是自己能够独立查阅MSDN来进行API函数的使用。
服务控制管理器的界面在前面大家已经都见过了,界面的布局方面大家可以按照书中安排的那样来,也可以根据自己的喜好来。我们要枚举的服务分为“Win32服务应用程序”和“驱动程序”两类。对于枚举这两类服务,只是枚举函数的参数有所不同,这个函数参数是系统定义的两个常量,定义分别如下:
看一下服务枚举时的代码:
该成员函数有一个参数,这个参数就是用来指明是枚举“Win32应用程序服务”,还是用来枚举“驱动程序服务”的。也就是我们前面提到的两个系统定义的常量了,参数根据两个常量的不同,枚举到的服务也不同。
打开服务管理器:
参数说明如下。
(1) lpMachineName:指向欲打开服务控制管理器数据库的目标主机名,本机则设置为NULL。
(2) lpDatabaseName:指向了目标主机SCM数据库名字的字符串。
(3) dwDesiredAccess:指定对SCM数据库的访问权限。
这里介绍一下,SCM是服务控制管理器的意思,它是系统服务的一个组成部分,跟我们开发的软件不是一个概念。由于我们不是编写一个具体的服务,而只是对系统现有的服务进行一个枚举,因此,一些概念性的知识希望大家可以自行查阅相关的资料。
该函数调用成功,返回一个SCM句柄,否则返回NULL。
关闭服务句柄:
该函数用来关闭OpenSCManager()和OpenService()打开的句柄。
枚举服务:
参数说明如下。
(1) hSCManager:OpenSCManager()函数返回的句柄。
(2) dwServiceType:指定枚举的服务类型,也就是我们自定义函数的那个参数。
(3) dwServiceState:枚举指定状态的服务。
(4) lpService:指向ENUM_SERVICE_STATUS类型的指针。
(5) cbBufSize:指定缓冲区的大小。
(6) pcbBytesNeeded:返回实际使用的内存空间大小。
(7) lpServicesReturn:返回枚举服务的个数。
(8) lpResumeHandle:返回枚举是否成功。
ENUM_SERVICE_STATUS结构的定义:
SERVICE_STATUS结构的定义:
对于枚举到的服务,对我们来说只有两种操作,一种是启动服务,另一种是停止服务。系统中有很多我们不用的服务,它会随着电脑的启动而启动,这样既会影响系统的启动速度,也会占用宝贵的系统资源,因此,没用的系统服务要停掉。先来看看停止系统服务的代码。
打开服务函数:
参数说明如下。
(1) hSCManager:指定由OpenSCManager()函数打开的服务句柄。
(2) lpServiceName:指向要打开的服务的名称。
(3) dwDesiredAccess:对要打开服务的访问权限。
控制服务函数:
参数说明如下。
(1) hService:指定一个由OpenService()打开的服务句柄。
(2) dwControl:指定了要发送的控制码,ControlService()可以对服务进行多种控制,每种控制对应一种控制码。
(3) lpServiceStatus:用于返回服务的状态。
启动服务的代码与停止服务的代码类似。这里简单地给出启动服务的部分代码,具体的代码自己动手写写。启动服务的部分代码如下:
启动服务相关API函数解释:
参数说明如下。
(1) hService:指定了要启动服务的句柄。
(2) dwNumServiceArgs:指向了启动服务所需的参数个数。
(3) lpServiceArgVectors:指向了启动服务的参数。
该函数最后两个参数类似main(int argc, char *argv[])中的两个参数的作用。
当按下Ctrl+Shift+Esc组合键时,就打开了Windows任务管理器对话框,里面有很多进程列表,如图3-9所示。
图3-9 任务管理器
进程是运行当中的程序,是向操作系统申请资源的基本单位。我们运行一个记事本程序,那么就相应会创建一个记事本的进程。当关闭记事本时,进程也随即结束了。对于这个任务管理器中,我们对进程比较关心的是“映像名称”、“PID”两项。对于进程相关的,我们主要学习进程的启动、结束、枚举等编程。
任何一个计算机文件都是一个二进制文件,对于可执行程序来说,它的二进制数据是可以被CPU执行的。程序的概念是一个静态的概念,程序本身只是存在于硬盘上的一个二进制文件。当用鼠标双击了某个可执行程序以后,这个程序就被加载,如内存,这时就产生了一个进程。进程会向系统申请各种所需的资源,并且会生成一个主线程,线程会拥有CPU执行时间,占用进程申请的内存……那么在编程的时候用API函数启动一个进程也是我们要学习的内容。可以创建进程的API函数有WinExec()、ShellExecute()和CreateProcess()等。这里主要介绍WinExec()和CreateProcess()两个函数。
WinExec()函数介绍:
参数说明如下。
(1) lpCmdLine:指向一个欲执行的可执行文件。
(2) uCmdShow:程序运行后的窗口状态。
对于第一个参数比较好理解,比如要执行“记事本”程序,那么这个参数就可以是“c:\windows\system32\notepad.exe”。对于第二个参数是指程序运行窗口的状态,常用的有两个,一个是SW_SHOW,另一个是SW_HIDE。第一个参数是程序运行后窗口显示,另一个是程序运行后窗口不显示。大家可以试着创建一个不显示窗口的“记事本”程序。代码很简单,如下:
这样创建的“记事本”进程,在任务管理器中可以看到“notepad.exe”这个进程,但是无法看到其窗口界面。
WinExec()这个函数多用在“下载者”中,“下载者”的英文名字叫“Downloader”,也就是下载器的意思,它是一种恶意程序,该恶意程序的功能较为单一(相对木马来说功能单一)。该恶意程序的功能是让受害计算机到黑客指定的URL地址去下载更多的病毒文件或木马文件并运行。下载者的体积较小,容易传播。当“下载者”下载到病毒或木马后,通常都会使用WinExec()来运行病毒。
我们简单地来做一个“下载者”的演示,记住,这只是一个演示。不要企图拿来做坏事,因为我们的演示代码很轻易地会被杀毒软件杀掉,我们的目的是学习编程知识。
我们要完成一个模拟的“下载者”,既然是“下载者”,重要的当然是文件的下载了。文件的下载方式比较多,但是相对简单而又比较常用的函数是URLDownloadToFile(),这个函数也是经常出现在“下载者”中的函数,该函数的定义如下:
参数说明如下。
(1) szURL:指向URL地址的字符串。
(2) szFileName:指向要保存地址的字符串。
其余参数我们不做介绍,如果需要了解大家可以自行查阅MSDN。
对于使用URLDownloadToFile()函数,需要包含Urlmon.h头文件及Urlmon.lib库文件,否则当编译和链接时会无法通过。
既然了解了该函数的使用,那么我们就来完成一个模拟的“下载者”吧。代码如下:
我们的模拟是把C盘系统目录下的记事本程序下载到D盘下并保存为Virus.exe,然后运行它。这里只是一个简单模拟,如果要真正完成一个“下载者”的话,其代码要复杂很多。如果要在源代码上对其进行“免杀”,那么要考虑的问题也会很多。
3.5.4 CreateProcess()函数介绍与程序创建
通常情况下,我们创建一个进程都会选择使用CreateProcess(),该函数的参数非常多,功能更强大,使用也更为灵活。WinExec()函数的使用相对简单,只能完成简单的进程创建工作。如果要对创建的进程有控制的能力,那么必须使用CreateProcess()函数。
在介绍CreateProcess()函数之前,先来考虑一个问题。在我们编写C程序时,如果是控制台下的程序,那么编写程序的入口函数是main()函数,也就是我们通常所说的主函数。如果是一个Windows程序,那么入口函数是WinMain()函数,即使使用MFC进行开发,也是有WinMain()函数的,只不过它被庞大的MFC框架给隐藏了。那么我们的函数真的是从main()函数或者是WinMain()开始执行的吗?我们在写控制台程序时,如果需要给程序提供参数,那么这个参数是从哪里来的呢?
我们使用VC6来写一个简单的程序,并调试一下,看程序是否真的是由main()函数开始的。我们选择第1章介绍的输出“Hello World”的程序来演示。
是否还记得第1章写的在控制台下打印“Hello World”字样的程序呢?好,让我们回忆一下当时写的程序吧,如图3-10所示。
图3-10输出“Hello World”的程序
接下来要怎么做呢?按下F10键,这时,我们的程序在VC6下处于调试状态,打开“CallStack”窗口,如图3-11所示。
图3-11 CallStack窗口内容
双击“mainCRTStartup() line 206 + 25 bytes”这一行,查看代码编辑窗口的内容,如图3-12所示。
图3-12 当前代码编辑窗口内容
可以看到,绿色三角指向那行代码是对main()函数的调用,并且main()函数还有返回值。滚动代码,查看一下mainret变量的类型。该变量的定义如下:
int mainret;
该变量的类型为int型。那么,当定义main()函数时,main()函数的返回值是什么呢?通常情况下,定义main()函数的返回值是int型。但是,也有一部分人喜欢把main()函数的返回值定义为void型,也就是空型。那么,如果定义为void型,mainret接收的返回值是什么呢?在Windows下约定,函数的返回值保存在eax寄存器中。如果对main()函数的返回值定义为void型,那么当main()函数返回后,mainret中保存的是退出main()函数后eax寄存器中的值。
除了这个以外,大家看一下VC6的标题栏,标题栏上给出的这个“.C”文件,我们叫做启动代码,用于在启动一个进程后对全局变量等内容的初始化。再看一下图3-11,CallStack还有一行,双击它,观察代码编辑器中的内容。由于这部分不是我们讨论的重点,因此就不进行介绍了。如果有兴趣的话,大家可以自行学习研究。
下面介绍CreateProcess()函数的使用。
CreateProcess()函数原型:
参数说明如下。
(1) lpApplicationName:指定可执行文件的文件名。
(2) lpCommandLine:指定可执行文件的运行参数。
(3) lpProcessAttributes:进程安全属性,该值通常为NULL,表示为默认安全属性。
(4) lpThreadAttributes:线程安全属性,该值通常为NULL,表示为默认安全属性。
(5) bInheritHandles:指定当前进程中的可继承句柄是否可被新进程继承。
(6) dwCreationFlags:指定新进程的优先级以及其他创建标志。
该参数一般情况下可以为0。
如果要创建一个被调试进程的话,需要把该参数设置为DEBUG_PROCESS。创建进程的进程为父进程,被创建的进程为子进程。也就是说,父进程要对子进程进行调试的话,需要指定DEBUG_PROCESS。在指定了DEBUG_PROCESS后,子进程创建的子进程,同样也处在被调试状态中。
如果不希望子进程创建的子进程处在调试状态,那么需要同时指定DEBUG_ONLY_THIS_PROCESS。
在有些情况下,如果希望被创建的子进程暂时不要运行,那么可以指定CREATE_SUSPENDED参数。事后希望该子进程运行的话,那么可以使用ResumeThread()函数使子进程恢复运行。
(7) lpEnvironment:指定新进程的环境变量。
(8) lpCurrentDirectory:指定新进程使用的当前目录。
(9) lpStartupInfo:指定新进程的启动信息。
该参数是一个结构体,该结构体决定进程启动的状态,该结构体的定义如下:
该结构体在使用前,需要对cb这个成员变量赋值,该成员变量用于保存结构体的大小。该结构体的使用不做过多的介绍了,如果要对新进程的输入输出重定向的话,会用到该结构体。
(10) lpProcessInformation:返回新进程、进程线程等相关信息。
该参数同样是一个结构体,该结构体的定义如下:
该结构体中保存着新创建进程的句柄、进程主线程的句柄,还有进程ID和主线程的ID。
下面进行一个简单的演示,用CreateProcess()创建一个记事本进程代码如下:
对于进程创建后PROCESS_INFORMATION接收的两个句柄,需要进行关闭。
介绍完进程的创建,对于进程的结束,通常情况下希望程序可以自己进行结束,也就是进程正常退出运行状态。在进程正常进行退出时,会调用ExitProcess()函数,该函数使用简单,这里就不进行介绍了。我们主要介绍一下,如何实现类似任务管理器那样结束其他的进程。
结束其他进程需要使用到TerminateProcess()这个函数,该函数的定义如下:
该函数的参数比较少,只有两个,第一个参数是要结束进程的进程句柄,第二个参数通常给0。那么这里遇到一个问题,我们如何获得要结束进程的进程句柄呢?方法有两种,第一种方法是枚举进程列表,找到要结束的进程,枚举进程的内容稍后会介绍。第二种方法是根据要结束进程的窗口标题获得该窗口的句柄,然后通过窗口句柄得到进程的PID,有了进程的PID后,我们可以打开该进程得到进程的句柄,然后将其结束。我们这节主要介绍第二种方法。
下面通过介绍如何结束“记事本”进程来学习笔者所说的第二种方法。先来看一下代码。
我们打开一个“记事本”,然后编译运行这段代码,会发现“记事本”被关闭了。我们逐个介绍我们使用到的这些API函数。
首先来介绍FindWindow()这个函数,FindWindow()函数的原型如下:
该函数有两个参数,第一个参数是类名,第二个参数是窗口名。该函数的返回值就是窗口的句柄。这两个参数只要指定一个就可以了。对于我们来说,希望通过进程的窗口名来获得窗口的句柄,因此这里只要给出窗口名就可以了。在我们的代码中,“记事本”的窗口名为“无标题-记事本”,如果窗口名取得不准确的话,那么将无法获得该窗口的窗口句柄。怎样才能准确无误地获取窗口的名称呢?这里推荐大家使用一款VC6中自带的工具——SPY++。只要安装了VC6后,就会安装该工具,大家可以在开始菜单中找到,其具体位置为:“开始” -> “程序” -> “Microsoft Visual Studio 6.0” -> “Microsoft Visual Studio 6.0 Tools” -> “Spy++”。打开“Spy++”这个工具,如图3-13所示。
单击工具栏中的
按钮,该按钮为“Find”按钮,出现如图3-14所示的“Windows Search”界面。
用鼠标拖动“Finder Tool”后面的那个图标到“记事本”进程的标题栏上,该窗口会显示出“记事本”的窗口名,如图3-15所示。
图3-13 Spy++工具界面
图3-14 “Window Search”界面
图3-15 “Window Search”显示“记事本”的窗口名称
在“Caption”中的内容就是“记事本”程序的窗口名称,我们把它作为FindWindow()函数的第二参数,这样,可以获取该窗口的窗口句柄,再通过窗口句柄获得该窗口所属进程的ID。代码如下:
该函数第一个参数是获取到的窗口句柄,第二个参数是一个输出参数,会返回该进程的ID。
在获得了进程ID后,通常通过使用OpenProcess()来获得进程的句柄。该函数的原型如下:
参数说明如下。
(1) dwDesiredAccess:进程欲获得的访问权限,该参数为了方便可以始终为PROCESS_ ALL_ACCESS。
(2) bInheritHandle:指定获取的句柄是否可以继承,一般情况下为FALSE。
(3) dwProcessId:指定欲打开的进程ID号。
该函数的返回值为进程的句柄,通过这个句柄可以结束进程。
既然已经获得了进程的句柄,那么就可以结束指定的进程了。在结束完进程后,也要记得用CloseHandle()关闭用OpenProcess()打开的进程句柄。
进程的枚举就是把所有的进程都显示出来。当然了,有一些特意隐藏的进程是无法通过常规的枚举方式枚举到的。这里只介绍应用层的进程枚举。在应用层枚举进程有多种方法,这里只介绍相对常见的枚举进程的方法。在学习进程的枚举的过程中,我们会完成一个自己的进程管理器,如图3-16所示。
图3-16 进程管理器
在进程管理器中,除了对进程的枚举,还会对线程进行枚举,还有对进程中加载的DLL进行枚举。要枚举的内容非常多,但枚举的API函数都类似。下面介绍以下几个API函数。
枚举进程需要的API函数分别有CreateToolhelp32Snapshot(),该函数的作用是对当前系统中的进程进行一个快照,在创建快照以后进行逐个进程的枚举。枚举进程的函数是Process32First()/Process32Next()。如果是枚举线程的话,那么枚举函数是Thread32First()/Thread32Next()。如果是枚举进程中的DLL的话,那么枚举函数是Module32First()/Module32Next()。在使用以上这些函数的时候,需要包含Tlhelp32.h头文件,否则在编译的时候会提示使用了未定义的函数。针对以上函数,分别进行一个简单的介绍。
CreateToolhelp32Snapshot()函数的原型:
参数说明如下。
(1) dwFlags:该参数指明要建立系统快照的类型,对于要枚举的内容,该参数可以指定如下值。
①TH32CS_SNAPMODULE:在枚举进程中的DLL时指定。
②TH32CS_SNAPPROCESS:在枚举系统中的进程时指定。
③TH32CS_SNAPTHREAD:在枚举系统中的线程时指定。
(2) Th32ProcessID:该参数根据dwFlags的不同而不同。如果枚举的是系统中的进程,或系统中的线程时,该参数为NULL;如果枚举的是进程中加载的DLL的话,那么该参数为进程ID号。
该函数返回一个快照的句柄,在进行枚举时都会用到该句柄。
Process32First()函数的原型:
参数说明如下。
(1) hSnapshot:该参数为CreateToolhelp32Snapshot()返回的句柄;
(2) lppe:为指向一个PROCESSENTRY32结构体的指针,该结构的定义如下。
在使用该结构体时,需要对该结构体中的成员变量dwSize进行赋值,该变量保存PROCESSENTRY32结构体的大小。
Process32Next()函数的原型:
该函数的使用与Process32Next()类似。对于枚举进程中加载DLL的枚举,对于系统中线程的枚举,都与此函数类似,只是枚举DLL与线程时,XXX32First()与XXX32Next()的第二个参数指向的结构体不同。这4个函数的使用请大家参考MSDN自行学习。
对于枚举进程的API函数已经学习了,下面看一下枚举进程、枚举进程中加载DLL的代码吧。
枚举系统进程的代码:
枚举指定进程中加载DLL的代码:
我们枚举的代码基本上是完成了,到VC6下直接按Ctrl+F5组合键运行程序,可以看到我们枚举的进程都出来了。然后选中“svchost.exe”进程,单击“查看DLL”按钮,“svchost.exe”进程中加载的DLL也都枚举出来了,这样运行是没有问题的。接下来找到编译好的任务管理器运行(不要直接在VC6下运行),可以看到,我们枚举的进程也都显示出来了。仍然选中“svchost.exe”,然后单击“查看DLL”,是不是没有查看到“svchost.exe”进程加载的DLL文件,这是什么原因?换一个其他的进程试试,比如选择自己编写的任务管理器试试,可以查看其DLL文件。通过试验发现,系统文件的DLL我们都无法枚举到,可是在VC6下直接运行是可以枚举到的。不单单是这方面的问题,而且在使用OpenProcess()函数打开如smss.exe、winlogon.exe等系统进程的时候,也同样会导致函数的调用失败,其实这个问题是当前进程权限级别不够所导致的。解决这个问题很容易,只要当前进程具有“SeDebugPrivilege”权限就可以了,接下来就来说明这个调整当前进程的权限。
调整权限其实并不复杂,主要有3个步骤。
(1)使用OpenProcessToken()函数打开当前进程的访问令牌。
(2)使用LookupPrivilegeValue()函数取得描述权限的LUID。
(3)使用AdjustTokenPrivileges()函数调整访问令牌的权限。
调整权限使当前进程拥有“SeDebugPrivilege”权限,拥有这个权限后,当前进程可以访问一些受限的系统资源。在后面讲到远程线程注入的时候,同样需要调整当前进程的访问令牌权限,否则是无法对系统进程进行注入的。因为在进行注入的时候,同样要用到OpenProcess()这个函数。下面给出调整权限的代码,不过在这个代码里面没有做返回值的判断,通常情况下是没有问题的。代码如下:
在有些时候,我们不得不让进程暂停运行。比如,病毒有两个运行的进程,它们在不断的“互相帮助”,当一个病毒进程发现另一个病毒进程被结束了,那么它会再次把那个病毒运行起来。由于两个进程一直在做这样的事情,而且频度很高,因此很难把两个进程都结束掉。这样,就不得不让病毒的进程暂停了,当两个进程都暂停掉以后,就可以把病毒结束掉了。
让进程暂停,通常使用的是SuspendThread()函数。该函数的定义如下:
该函数就一个参数,是一个线程的句柄。获得线程的句柄需要使用OpenThread()来打开一个指定的线程。对于得到线程ID,我们可以对线程进行枚举,然后就可以打开线程并将其暂停了。
注:OpenThread()函数在VC6提供的PSDK中是不存在的,必须更新PSDK才可以使用。如果没有更新PSDK的话,需要使用LoadLibrary()和GetProcAddress()来使用该函数。对于LoadLibrary()和GetProcAddress()函数的使用在DLL编程中将会进行介绍。
枚举线程的函数是Thread32First()和Thread32Next()这两个,对于枚举线程前,我们用CreateToolhelp32Snapshot()只能创建系统的线程快照,不能创建指定进程中的线程的快照。这样在暂停线程时,必须对枚举到的线程进行判断,看其是否为指定进程中的线程。如何判断一个线程是属于哪个进程的呢?我们看一下THREADENTRY32这个结构体。
该结构体中,th32ThreadID标识了当前枚举到的线程的线程ID,th320wnerProcessID标识了该线程归属的进程ID。因此,只要进行一次简单的判断就可以了。看一下暂停的代码:
与进程暂停相对应的是恢复暂停的进程。恢复暂停的进程的函数是使用ResumeThread()函数,该函数原型如下:
该函数的使用方法与SuspendThread()一样。恢复暂停的进程的代码大家可根据暂停进程的代码自行修改,这里就不给出完整的代码了。
在进程相关的最后部分给大家介绍一个不错的工具,该工具的界面如图3-17所示。
图3-17 Process Explorer的界面
该软件的功能非常强大,当启动一个进程或者结束一个进程的时候,该软件会高亮显示被启动或结束的进程。当然了,它的功能非常多,还是大家自己研究挖掘一下。在这里重点介绍该工具中的一个小功能,单击菜单“Options” -> “Replace Task Manager”命令,该功能是用来替换系统的任务管理的,也就是将Process Explorer设为默认的任务管理器。大家替换一下任务管理器,然后按下Ctrl+Shift+Esc组合键试试看,是不是Process Explorer被打开了,原来的任务管理器不见了。如果想要还原到原来的任务管理器,只要再次单击“Replace Task Manager”菜单项就可以了,我们单击一下该菜单还原到原来的任务管理器。
该功能是如何实现的呢?原理其实是对注册表做了手脚,对注册表的哪些地方做了手脚呢?我们介绍另外一个值得推荐的工具,叫做Regmon,它是用来监控注册表的。该软件如图3-18所示。
图3-18 RegMon界面
按Ctrl+L组合键,弹出“Regmon Filter”界面,我们在“Include”文本框中输入“procexp.exe”,如图3-19所示。
图3-19 RegMon Filter界面
输入完后单击“OK”按钮,再单击Process Explorer的“Replace Task Manager”菜单项,看RegMon捕获到的注册表的信息,如图3-20和图3-21所示。
图3-20 修改的注册表项
图3-21 修改的注册表键的值
打开注册表编辑器看一下被修改的内容,如图3-22所示。
图3-22 注册表中被修改的值
将该值删掉,再按下Ctrl + Shift + Esc组合键看一下,默认的任务管理器出现了,这就是注册表中有名的映像劫持。大家可以自己在我们编写的任务管理器中添加这样一个替换系统任务管理器的功能以做练习。关于注册表的操作,大家可以参考前面学习的内容。
线程是进程中的一个执行单位(每个进程都必须有一个主线程),一个进程中可以有多个线程,而一个线程只存在于一个进程中。在数据关系上,这是一对多的关系。线程不拥有系统资源,线程所使用的资源全部由进程向系统申请。
在多处理器中,不同的线程可以同时运行在不同的CPU上,这样可以提高程序运行的效率。除此而外,在有些方面必须要使用多线程。比如,如果扫描磁盘并同时在程序界面上显示当前扫描的位置,这样就必须使用多线程。因为在程序界面上显示和磁盘的扫描工作在同一个线程中,而且界面也不停地进行重新显示,这样就会导致软件看起来像是卡死一样。如果分为两个线程就可以解决该问题。界面的显示由主线程完成,而扫描磁盘的工作由另外一个线程工作,两个线程协同工作,这样就可以达到我们想要的效果了。
首先了解一下线程的创建,线程的创建使用CreateThread()函数,该函数的原型如下:
参数说明如下。
(1) lpThreadAttributes:该函数指向一个安全属性,该参数一般设置为NULL。
(2) dwStackSize:该参数指定线程的栈大小,该参数一般设置为0,表示默认栈大小。
(3) lpStartAddress:该参数指向一个线程函数地址,该函数属于一个回调函数。所谓回调函数,就是由系统去调用该函数,并不是由我们直接调用该函数。
线程函数的定义如下:
线程函数的返回值为DWORD类型,该函数只有一个参数,该参数由CreateThread()函数给定。该函数的函数名称可以任意给定。这里介绍一下WINAPI, WINAPI是一个宏,该宏的定义如下:
这是一种函数的调用约定,该调用约定会在后面的章节中介绍,在这里只需要了解就可以了。
(1) lpParameter:该参数是传递给线程函数的一个参数。
(2) dwCreationFlags:该参数指明创建线程后的线程状态,在创建线程后可以让线程立刻执行,也可以让线程处于暂停状态。如果需要立刻执行,将该参数设置为0;如果要让线程处于暂停状态,那么该参数值设为CREATE_SUSPENDED;需要线程执行时调用我们前面介绍过的ResumeThread()函数。
(3) lpThreadId:该参数用于返回新创建线程的线程ID。
该函数返回新创建线程的句柄,在线程结束后需要使用CloseHandle()函数关闭该句柄以便释放资源。我们来写一个简单的CreateTheadTest例子,代码如下:
从该代码的意图来看,主线程会打印一行“main”,而创建的线程会打印一行“ThreadProc”。编译运行,查看一下运行结果,如图3-23所示。
图3-23 CreateTheadTest运行结果
跟我们想要的结果不一样,这是怎么回事呢?每个线程都有自己的CPU时间片,当主线程创建了新线程后,它的CPU时间片并没有完,它还可以继续执行。由于主线程的代码非常少,在CPU指定的CPU时间片中主线程执行完后就退出了。主线程结束,那么意味着程序也就结束了,所以我们自己创建的线程根本就没有被执行到。如果要等我们自己创建的线程结束后才可以输出“main”的话,主线程要如何等待我们创建的线程呢?答案是使用WaitForSingleObject()函数,该函数的原型如下:
参数说明如下。
(1) hHandle:该参数指向要等待的对象句柄。
(2) dwMilliseconds:该参数指定等待超时的毫秒数,设置为INFINITE则表示一直等待到线程函数返回。INFINITE是系统给出的一个宏,该定义如下。
该函数如果返回为WAIT_OBJECT_0,表明制定的对象已经是处于完成状态;如果返回值为WAIT_TIMEOUT,则表明在指定的时间内没有完成,处于潮湿状态。若该函数调用失败,则返回WAIT_FAILED。
修改上面的代码,在CreateThread()函数的后面加入以下代码:
添加WaitForSingleObject()以后,主线程会等待我们的创建的线程结束后再执行主线程后续的代码。这样,在控制台上就会分别打印“ThreadProc”和“main”了。
WaitForSingleObject()只能等待一个线程,可是在程序中往往要创建很多个线程来执行,那么如果需要等待若干个线程的完成状态的话,WaitForSingleObject()就无能为力了。不过,系统提供了其他的函数来让我们等待多个线程的完成状态,该函数的定义如下:
在使用多线程时常常需要注意很多问题。比如,多个线程同时对某一个共享资源进行操作,那么可能就会出现问题。来看一个简单的例子:
每个线程都有一个CPU时间片,当自己的时间片运行完成后,CPU会停止该线程的运行,并切换到其他线程去运行。当多线程同时操作一个共享资源时,这样的切换会带来隐形的问题。我们的代码比较短,在一个CPU时间片内会完成,因此可能看不出错误,为了达到出错的效果,在代码中加入Sleep(1),主动让出CPU,让CPU进行线程的切换。这里的共享资源就是那个全局变量g_Num_One。调用该线程函数的代码如下:
我们创建10个线程,每个线程都让g_Num_One进行10次自增1的操作,那么g_Num_One的结果应该是100。我们实际运行一下看看结果是多少,如图3-24所示。
这个输出结果并不是我们想要的输出结果。在多线程环境中,对共享资源的操作要进行保护,在这里,我们可以使用临界区对象对该全局变量进行保护。
图3-24 运行结果
临界区对象是一个CRITICAL_SECTION的数据结构,Windows操作系统使用该数据结构来进行对关键代码的保护,以确保多线程下的共享资源。
对于临界区的函数有4个,分别是初始化临界区对象(InitializeCriticalSection())、进入临界区(EnterCriticalSection())、离开临界区(LeaveCriticalSection())和删除临界区对象(DeleteCriticalSection())。这4个函数的定义分别如下:
这4个函数的参数都是指向CRITICAL_SECTION结构体的指针。我们修改的代码如下:
再次编译运行该代码,输出的结果为正确结果,即g_Num_One的值为100。除了临界区对象以外,对于线程的同步还有其他的方法,这里就不进行一一的介绍了。希望大家在今后开发多线程编程时,切记要注意多线程的同步问题。
DLL (Dynamic Link Library,动态链接库)是一个可以被其他应用程序调用的程序模块,其中封装了可以被调用的资源和函数。动态链接库的扩展名一般是DLL,不过有时也可能是其他的。DLL文件也属于可执行文件,只不过它是依附于EXE文件来被执行的。一个DLL文件可以被多个EXE文件加载。
Windows操作系统下有非常多的DLL文件,有的是操作系统的DLL文件,有的是应用程序的DLL文件。DLL文件有什么好处呢? DLL是动态链接库,相对应的有静态链接库。动态链接库是在EXE文件运行时被加载执行的,而静态链接库是在OBJ文件进行连接时同时被保存到程序中。动态链接库可以减少可执行文件的体积,在需要的时候进入内存等很多好处。
我们编写一个简单的DLL程序,并在DLL程序中添加一个导出函数。所谓导出函数,就是DLL提供给外部EXE或其他类型的可执行文件调用的函数,当然DLL本身也可以自己进行调用。我们启动VC6来编写一个DLL程序。
启动VC6程序,单击菜单“文件” -> “新建”命令,在“Projects”选项卡中的左边选择“Win32 Dynamic-Link Library”,在“Project name:”文本框中填写“FirstDll”,如图3-25所示。
单击“OK”按钮,出现“Win32 Dynamic-Link Library-Step 1 of 1”界面,选择“A simple DLL Project”项,单击“Finish”按钮。在然后出现的对话框中直接单击“OK”按钮即可。在创建好该工程后,VC6自动生成如下代码:
图3-25 新建DLL工程
DLL程序的入口函数是DllMain(),该函数有以下3个参数。
(1) hModule: DLL模块的句柄。
(2) ul_reason_for_call: DllMain函数被调用的原因。
该参数的取值有4种,分别是DLL_PROCESS_ATTACH(当DLL被某进程加载时DllMain被调用)、DLL_PROCESS_DETACH(当DLL被某进程卸载时DllMain被调用)、 DLL_THREAD_ATTACH (进程中有线程被创建时DllMain被调用)和DLL_THREAD_DETACH(进程中有线程结束时DllMain被调用)。
(3) lpReserved:保留项,也就是Windows的保留参数。所谓保留参数不是不使用的参数,是Windows不想让我们知道作用的参数。
DllMain()前面有一个APIENTRY,这是一个宏,该宏定义如下:
我们在前面介绍过,WINAPI也是一个宏,该宏表示一种函数调用约定。
DllMain()函数不只一次地被调用,每次的调用可能执行不同的代码,比如当进程加载该DLL时,可能在DLL中要申请一些资源,在进程卸载该DLL时,DLL则要释放自己所申请的资源。处于这些原因,在写DLL程序时,需要这样把DllMain()函数写成如下形式:
这样写就可以达到根据不同的调用原因执行不同的代码。我们添加一个简单的导出函数。
该函数的定义如下:
extern “C”表示该函数以C方式导出,由于源代码是。CPP文件,因此,如果按C方式导出的话,那么在编译后函数名会被名字粉碎,因此在动态调用该函数时就会极为不方便。该函数的实现如下:
运行函数后弹出一个对话框,显示一个字符串,并显示其所在的进程的进程名。我们分别在DLL_PROCESS_ATTACH和DLL_PROCESS_DETACH下加一个对该函数的调用,代码如下:
编译该代码,会生成两个对我们有用的文件,一个是“FirstDll.dll”,另外一个是“FirstDll.lib”,前面的是DLL文件,后面的是库文件,该库文件中包含着导出函数的相关信息。
DLL程序就写到这里,接下来写个调用该DLL的程序测试一下吧。我们需要对该DLL进行两方面的测试,一个是看当加载和卸载该DLL时,是否会弹出对话框;另外一个是调用这个DLL的导出函数看是否能成功。
在工作区的“Workspace ‘FirstDll’:1 project”上单击右键,在弹出的菜单中选择“Add New Project to Workspace …”,如图3-26所示。
弹出“New”对话框,在“Projects”选项卡的左面选择“Win32 Console Application”,在“Project name:”文本框中填写:“DllTestl”。单击“OK”按钮,在出现的对话框中选择“A Simple Application”,该处和前面内容类似。在左面的工作区打开我们新建的工程,在DllTest1.cpp中添加代码:
图3-26 添加测试工程
对该代码进行编译连接,这时并没有生成我们想要的可执行文件,在编译连接过程中出错了,出错信息如图3-27所示。
图3-27 出错信息
这个错误确切地说是连接错误,原因是找不到DLL的Lib文件,我们把“FristDll.lib”文件复制到DllTest1这个工程的目录下,再次编译连接,这次通过了。那么我们就来运行这个DllTest1的程序。不过很可惜,运行出错,错误提示如图3-28所示。
图3-28 运行DllTest1时的错误信息
这个错误的原因是DllTest1找不到FirstDll.dll这个文件,把开始编译好的这个文件也复制到DllTest1的工程目录下,再次运行,这次一切正常,并且看到弹出3次对话框,说明DLL程序已经能够正常运行了。
第一种方法是属于静态调用,现在的第二种方法属于动态调用。静态调用就是在编译测试程序DllTest1时,FirstDll.dll的信息就已经写入了DllTest1的程序中了。对于动态加载的话,就不是编译时完成了,而是在运行时完成,那么FirstDll.dll的信息也不会写入测试程序中了。现在来写一个DllTest2程序,该程序的建立方法与DllTest1的建立方法相同。DllTest2的代码如下:
我们对代码进行编译连接,正常编译通过。那么就运行该程序,提示“FirstDll.dll文件不存在”,这说明DllTest2程序没有找到FirstDll.dll文件。把FirstDll.dll文件拷贝到DllTest2的工程目录下,再次运行该程序,这次运行成功了,并且应该弹出的3个对话框也都正常弹出了。我们的测试也是成功的。
DLL的动态加载是非常有用的,在DllTest1中,如果无法找到DLL文件,系统会直接报错而退出,而在DllTest2中,如果无法找到DLL文件,程序会给出一个错误提示,并且可以继续运行,而不影响其他代码的运行。除此而外,如果知道一些API函数,而这些API函数是未文档化的函数,或者是没有提供头文件的API函数,要怎么办呢?比如在前面的内容中用到的函数OpenThread(),该函数在VC6默认的PSDK中是没有提供定义的,在新的PSDK中才有,那如何使用呢?那就需要用到LoadLibrary()和GetProcAddress()这两个API函数了。看一下LoadLibrary()和GetProcAddress()这两个函数的定义:
该函数只有一个参数,就是要加载的DLL文件的文件名。
该函数有两个参数,hModule是模块的句柄,lpProcName指定要获取函数地址的函数名称。
在关于DLL话题的最后,介绍一下如何查看DLL程序的导出函数。在这里介绍两个工具,一个工具是VC6自带的工具“Depends”,另一个工具是用来查看PE结构(关于PE结构的内容在后面的章节会讲到)的工具“PEID”。
首先用“Depends”来查看DLL的导出函数,如何找到这个工具呢?在VC6的安装菜单下就可以找到该工具。方法如下:单击菜单“开始”-> “程序” -> “Microsoft Visual Studio 6.0” -> “Microsoft Visual Studio 6.0 Tools” -> “Depends”命令,打开该程序,再单击菜单“File”-> “Open…”命令,在“打开”对话框中找到我们写的FirstDll.dll文件并打开,如图3-29所示。
图3-29 Depends界面
右下角这个区域范围就是我们的导出函数部分,这里可以看到导出函数“MsgBox”。
除了这个工具以外,再介绍一个工具——PEID。该工具是用来进行查壳的工具,我们把FirstDll.dll文件拖曳到PEID界面上,PEID会自动解析出该DLL文件的PE结构,该界面如图3-30所示。
图3-30 PEID界面
可以看到,PEID最下方的编辑框处显示出DLL是由VC6开发的,而且版本是Debug版本。单击“子系统”右边的“
”按钮,会显示PE结构的详细信息,在详细信息的下半部分有一个“目录信息”,在“目录信息”中第一个就是我们想要查看的导出函数的内容,单击“导出表”右面的“大于号按钮
”按钮,出现如图3-31所示的界面。
图3-31 导出表信息
因为DLL中只有一个导出函数MsgBox(),那么该导出表中就只有一个导出项。具体关于PE结构的内容在下一章节中进行讲解。
对于DLL的编程就介绍到这里了,后面的内容同样会涉及跟DLL有关的内容,希望大家可以自己动手完成这个简单的DLL文件。
在Windows操作系统下,为了避免每个进程互相影响,每个进程的内存空间都是被隔离的。但是在某种情况下需要两个进程协调工作或者是跨进程的操作。所谓的“远程线程”并不是跨计算机的,而是跨进程的,简单说就是进程A在进程B中创建一个线程,这样就叫做远程线程。关于远程线程的知识,我们从3个例子来学习,一个是DLL的注入,一个是DLL的卸载,一个是不依赖DLL的注入代码,3个例子的原理是一样的。只要掌握其中一个例子就能了解其他的两个例子了,之所以讲述3个例子,是为了起到举一反三的作用。
远程线程不但在木马、外挂方面应用广泛,而且在反病毒软件方面也应用广泛。
木马或者病毒编写质量的好坏取决于其隐藏程度,而不在于其功能多少。无论是病毒或是木马,它们都是可执行程序,如果它们是EXE文件的话,那么就必将会产生一个进程,产生进程就很容易被发现。为了不被发现,可以选择为DLL文件,DLL文件加载到进程的地址空间中,不会有进程名,因此其隐蔽性相对较好。DLL文件如果不被进程加载又如何在进程中呢?方法是强制让某进程加载DLL文件,这个强制的手段就是下面要介绍的通过创建远程线程将DLL注入到某个指定的进程中。
创建远程线程的函数如下:
该函数的功能是创建一个远程的线程。还记得前面介绍过的CreateThread()函数吗?如果还记得CreateThread()的定义的话,那么就来和CreateRemoteThread()进行一个比较吧。其实对于CreateThread()来说,CreateRemoteThread()就比其多了一个hProcess的参数,该参数是指定要创建线程的进程句柄。其实CreateThread()函数的实现就是依赖CreateRemoteThread()函数来完成的,CreateThread()函数的代码实现如下:
在上面代码中GetCurrentProcess()函数的功能是获取当前进程的句柄。Windows并没有提供源代码,而CreateThread()函数的代码为什么是这样的呢?这里提供的代码是有据可依的,这个将在后面的内容中进行介绍。
回到我们的主题,CreateRemoteProcess()函数是给其他进程进行注入的,那其第一个参数是指定某进程的句柄的,那么某进程的句柄是如何取到的呢?只要有该进程的PID(进程ID),就可以通过OpenProcess()获取该进程的句柄。
除了hProcess参数以外,关键的参数就只有lpStartAddress和lpParameter两个了。 lpStartAddress指定线程函数的地址,lpParameter指定传递给线程函数的参数。前面提到,每个进程的地址空间是隔离的,那么新创建的线程函数的地址也应该在目的进程中,而不应该在本进程中,同样的传递给线程函数的参数也应该在目的进程中。
如何让线程函数的地址在目的进程中呢?如何让线程函数的参数也在目的进程中呢?在讨论这个问题以前,先来考虑一下线程函数需要完成的功能。我们主要介绍的是注入DLL文件,那么线程函数的功能也就是加载DLL文件。加载DLL文件的方法前面已经介绍过了,是调用LoadLibrary()函数。LoadLibrary()函数的定义如下:
该函数的参数是一个指针,API函数的默认调用方式是WINAPI方式(WINAPI是一个宏定义,前面已经介绍过了),而线程函数的定义恰巧也是这样,线程函数的定义格式如下:
两个函数除了返回值以外,其余的都是相同的,返回值的问题可以忽略不考虑。那么,可以直接把LoadLibrary()函数作为线程函数创建到指定进程中。LoadLibrary()的参数是要加载的DLL文件的路径。这样,使用CreateRemoteThread()就可以创建一个远程线程了。不过别急,还有一个问题没有解释,那就是如何将LoadLibrary()函数的地址放到目标进程空间中,还有如何把DLL文件路径也保存到目标进程空间中。
LoadLibrary()函数在Kernel32.dll这个系统DLL中,而Kernel32.dll这个DLL文件在任何进程中的加载位置都是相同的,也就是说, LoadLibrary()函数的地址在任何进程中的地址都是相同的,因此只要在进程中获取LoadLibrary()函数的地址后,该地址在目标进程中也是可以使用的。至于要注入的DLL文件如何写入目标进程中,那么就要依赖一个API函数了,该函数的定义如下:
该函数的功能是把lpBuffer中的内容写到hProcess指定进程中的lpBaseAddress地址处。
参数说明如下。
(1) hProcess:指定进程的进程句柄。
(2) lpBaseAddress:指定写入内存的起始地址。
(3) lpBuffer:指定要写入的内容的缓冲区。
(4) nSize:指定写入内容的长度。
(5) lpNumberOfBytesWritten:接收实际写入内容的长度。
这个函数的功能非常强大,比如在破解方面,该函数可以实现一个“内存补丁”;在做单击游戏修改器时,该函数可以修改游戏的值。
使用该函数可以把DLL文件的地址写入目标进程中,这样就可以在目标进程中用LoadLibrary()加载该DLL文件了。是不是非常值得庆祝呢?我们解决了所有的问题,注入终于可以实现了。如果这样想的话,就有过于心急了。WriteProcessMemory()第二个参数是指定写入的起始地址,这个地址明显在目标进程中,那么这个地址是哪个地址?目标进程中的哪块内存允许把DLL文件的路径写进去呢?新的问题来了,我们如何确定应该写入目标进程的哪个地址。我们应该先在目标进程中申请一块内存,然后把DLL文件的路径写入进去,这样就可以解决了。在目标进程申请内存的函数如下:
参数说明如下。
(1) hProcess:指定要申请内存的进程句柄。
(2) lpAddress:指定申请的起始位置。
(3) dwSize:指定申请的长度。
(4) flAllcationType:指定申请内存的状态类型。
(5) flProtect:指定申请内存的内存属性。
该函数的返回值返回目标进程申请到的内存块的起始地址。
现在,所有问题都解决了,看看注入DLL程序的界面吧,如图3-32所示。
图3-32 DLL注入/卸载器
这个程序既可以注入DLL,也可以卸载注入的DLL。注入DLL文件的代码如下:
该函数有两个参数,分别是目标进程PID和要注入的DLL文件。在代码中,获取的不是LoadLibrary()函数的地址,而是LoadLibraryA()函数的地址。在系统中其实没有LoadLibrary()这个函数,只有LoadLibraryA()和LoadLibraryW()这两个函数,这两个函数分别针对的是ANSI字符串和UNICODE字符串,而LoadLibrary()函数只是一个宏。在编写程序的时候,直接使用该宏是可以的,如果要获取其地址,那么就要指定获取具体的函数了。LoadLibrary()宏定义如下:
只有在涉及与字符串有关的函数时才会出现ANSI版本和UNICODE版本,其余不涉及字符串的函数没有ANSI版本和UNICODE版本。
现在,就用前面写的DLL程序做练习,看把它注入到一个记事本中,其效果如图3-33所示。
图3-33 DLL文件注入
这个对话框是DLL程序在DLL_PROCESS_ATTACH中弹出的(如果不记得,请看前面写的DLL程序的代码),其所在进程为notepad.exe,这里的4032为目标PID。我们的注入成功了。
注意:如果要对系统进程进行注入的话,由于有一个OpenProcess()的权限问题,可能会导致无法获得系统进程的句柄,想要获得其句柄,需要用到前面介绍的提升权限的代码。对于DLL卸载也是同样的情况。
DLL注入如果用在木马方面危害很大。下面完成一个如何卸载被注入DLL的程序。卸载被注入DLL的程序的思路和注入的思路是一样,而且代码的改动也非常得小。区别在于一个是卸载,一个是注入。
对于DLL的卸载使用的API函数如下:
该函数的参数是要卸载的模块的句柄。
FreeLibrary()函数使用到的模块句柄可以通过前面介绍的Module32First()/Module32Next()两个函数获取。在介绍进程枚举时使用到了一个PROCESSENTRY32的结构体,在使用Module32First()函数时,我们会使用到一个MODULEENTRY32的结构体,该结构体中保存了模块的句柄。MODULEENTRY32的定义如下:
该结构体中的hModule为模块的句柄,szModule为模块的名称,szExePath是完整的模块的名称(包含路径)。
DLL卸载的代码如下:
DLL卸载的代码比DLL注入的代码还要简单,这里就不做过多介绍了。
大家可以尝试把第2章中介绍的简单的木马服务端写成DLL文件,再将其注入到某个进程中,然后用客户端文件和服务端文件进行通信。这将对我们的所学的知识有一个深入的体会。
DLL文件的注入与卸载都很成功的完成了,整个注入的与卸载的过程其实就是让远程线程执行一次LoadLibray()或FreeLibrary()。主要的功能代码都在DLL文件中,那么可否直接通过WriteProcessMemory()函数把代码直接写入到目标进程中?答案是可以的。
我们要在目标进程中完成一定的功能,那么就要使用到相关的API函数。Kernel32.dll文件在每个进程中的地址是一样的,但是并不代表其他DLL文件在每个进程中的地址都一样。这样,必须在目标进程中获取想要使用的API函数的地址。获取API函数的地址使用的是LoadLibrary()和GetProcAddress()这两个函数,在目标进程中,通过这两个函数就可以获取任何一个想要使用的API函数的地址了。把想要使用的API函数及API函数所在的DLL都封装到一个结构体中,直接写入到目标进程空间中,也可以直接把要在远程执行的代码写入到目标进程空间中,然后使用CreateRemoteThread()运行即可。
接下来写一个例子,让远程线程弹出一个简单对话框,但是不借助于DLL。这个例子将要使用到的API函数也全都在前面的知识中介绍了。根据上面的步骤,先定义一个结构体,结构体如下:
结构体中保存了LoadLibraryA()、GetProcAddress()、GetModuleHandle()和GetModuleFileName()函数的地址。这4个函数都属于Kernel32.dll文件中的函数,因此可以提前获取。User32Dll保存“User32.dll”字符串,因为MessageBoxA()函数在User32.dll文件中。至于Str,是我们想要用MessageBoxA()弹出的字符串。
看一下注入的代码:
卸载代码和注入代码没什么差别,关键是要把线程函数也写入目标进程中,这样在使用CreateRemoteThread()时,直接给出线程函数在目标进程中的地址即可。
线程函数代码如下:
线程函数显得很乱,但是只要仔细看还是能看明白的,线程函数里面的内容都没有超出前面介绍的知识范围。请大家认真地看一遍。
这就是无DLL注入的全部代码了。编译运行,并用“记事本”程序做测试,发现报错了,如图3-34所示。
竟然出错了,问题出在哪里呢?注入器的编译方式是Debug方式,而以Debug方式编译VC会在程序中加入很多与调试相关的内容,而这些内容的地址是相对当前进程地址,这些代码到了别的进程可能就会出错。这里应该使用Release方式编译,因为以Release方式编译不会由于插入与调试相关的代码而导致注入的代码到别的进程会出错。用Release方式编译并运行,如图3-35所示。
图3-34 无DLL注入错误
图3-35 以Release方式编译运行正常
这样运行就正常了,无DLL的代码注入也就成功了。
本章学习了Windows下应用程序的编程基础,包括文件操作、注册表操作、服务枚举、进程、线程、DLL等相关内容。在后面的章节中将会经常使用到这些内容,请大家一定要掌握本章的内容。
本章将学习一些简单的加密与解密的知识,并会接触到PE结构、OD调试器和调试API函数。
PE (Portable Executable),即可移植的执行体。所有的 Windows(包括Win 9x、Win NT、 Win CE……)下的可执行文件(包括EXE、DLL、SYS、OCX、COM……)均使用PE文件结构,这些使用PE文件结构的可执行文件也成为PE文件。
作为一个普通的程序员也许没有必要掌握PE文件结构,因为普通的程序员大多都是开发服务性、决策性、辅助性的软件,比如MIS、HIS等软件。但是对于学习黑客编程,学习安全编程的程序员来说,那么掌握PE文件结构的知识就非常重要了。因此,我们要进行对 PE文件结构地学习。
Windows下的可执行程序有着复杂的文件结构,同样,PE结构是由若干个复杂的结构体组合而成的,不单单是一个结构体那么简单。就像磁盘结构体一样,磁盘结构同样是由非常多的结构体组成的。PE结构包含的结构体有DOS头、PE标识、文件头、可选头、目录结构、节表等。要掌握PE结构,必须对PE结构有一个整体上的认识,要知道PE结构分为哪些部分,这些部分大概是起什么作用的。有了宏观上的概念以后,就可以深入PE结构的各个结构体进行细致地学习了。下面给出一张图,让大家对PE结构有一个大概的了解,如图4-1所示。
图4-1 PE结构总览图
从图中可以看到,在PE文件中,真正包含代码和数据的部分是节表数据,节表数据以上的部分都是PE文件结构。
下面根据这个图给大家介绍PE文件结构的各个结构体,介绍完以后再写一个PE结构的解释器。这里不会介绍PE结构中的每个结构,只针对常用的和相对重要的结构体进行介绍。
MZ头部其实是一个DOS头部,由于其开始处的字符为“MZ”,因此叫做MZ头,也可以叫做DOS头,看个人习惯了。该部分用于在DOS下加载可执行程序,是用 IMAGE_DOS_HEADER来定义的。
DOS残留是一段简单的程序,主要用于输出“This program cannot be run in DOS mode.”类似的字符串。
为什么PE结构的最开始位置有这样一段DOS头部呢?关键是为了该可执行程序可以兼容DOS系统。通常情况下,Win32下的PE程序只是简单地输出一段字符串,提示不能运行于DOS系统下。但是这个DOS程序是可以在程序连接时修改的,不过由于不常使用也就不进行介绍了。
PE头部保存Windows系统加载可执行文件的重要信息。PE头部由 IMAGE_NT_HEADERS来定义,通过该结构体的定义可以看出,IMAGE_NT_HEADERS是由多个结构体组合而成的,具体内容将在后面进行介绍。PE头部在PE文件中的位置是不确定的,PE头部的位置由DOS头部的某个字段给出。
程序的组织按照各属性的不同而被保存在不同的节中,在PE头部之后就是一个数组结构的节表。如果PE文件中有N个节,那么节表就是由N个IMAGE_SECTION_HEADER组成的数组。节表中存储了各个节的属性、位置等相关信息。
真正的程序部分就保存在节数据中。节名称最大长度为8个字节,大于8个字节将被截取。节的划分是根据各组数据的属性划分的,每个节拥有共同的属性。
在PSDK的头文件Winnt.h中,包含了PE文件结构的定义格式。PE头文件分为32位和 64位。64位的PE结构是对32位的PE结构做了扩展,这里主要讨论32位的PE文件结构。对于64位的PE文件结构大家可以自行查阅资料进行学习。
4.2.1 DOS头部详解 IMAGE_D〇S_HEADER
对于一个PE文件来说,最开始的位置就是一个DOS程序。DOS程序包含一个DOS头和一个DOS程序体。DOS头是用来装载DOS程序的,DOS程序也就是图4-1中的那个DOS存根,也就是说DOS头是用来装载DOS存根的。保留这部分内容是为了与DOS系统做兼容。一般在PE结构中的DOS存根,只会输出一个“This program cannot be run in DOS mode.”字符串。
虽然DOS头是为了装载DOS程序的,但是DOS头部中的一个字段保存着指向PE头部的位置。DOS头在Winnt.h头文件中被定义为IMAGE_DOS_HEADER,该结构体的定义如下:
在该结构体中,有两个字段需要注意,分别是第一个字段e_magic,和最后一个字段e_lfanew字段。
e_magic字段是一个DOS可执行文件的标识符,该字段占用两个字节,该位置保存着的字符是“MZ”。该标识符在Winnt.h头文件中有一个宏定义,定义如下所示:
e_lfanew字段中保存着PE文件的起始位置。
在VC下创建一个简单的“Win32 Application”程序(注意,这次创建的不是控制台程序,创建程序的方法类似,这里就不再介绍创建工程的步骤了),用来学习PE结构。
程序代码如下:
由于创建的工程是“Win32 Application”的程序,而不是控制台的程序,因此主函数是 WinMain(),而不是main()了。WinMain()函数有4个参数,具体参数的含义请参考MSDN,这里就不进行介绍了。
该程序的功能是弹出一个Messagebox对话框,使用“Win32 Release”方式进行编译连接,并把编译好的程序用C32Asm打开。C32Asm是一个反汇编与十六进制编辑于一体的程序,其界面如图4-2所示。
图4-2 C32Asm程序界面
在这里选择“十六进制模式”单选项,单击“确定”按钮,打开十六进制编辑模式,如图4-3所示。
图4-3 十六进制编辑状态下的C32Asm
在图中可以看到,在文件偏移为0×00000000的位置处保存着两个字节的内容0×5A4D,用ASCII表示则是“MZ”。图中明明写着是4D 5A,为什么说的是0×5A4D,大家到上面看 Winnt.h头文件中定义的那个宏,也写着是0×5A4D,这是为什么呢?这就是在前面章节中讲解的字节顺序,高位保存在高地址,地位保存在低地址。这个概念是很重要的,希望大家能够掌握。
在图中0×0000003c的位置处,就是e_lfanew字段,该字段保存着PE结构的起始位置,该字段的值为多少呢?保存的是0×C800000000吗?如果是这样就错了,CPU架构使用的是小尾方式,系统对数据的存放始终是高位存放高字节,低位存放低字节,因此该处保存的是 0×000000C8。大家查看一下0×000000C8这个位置保存的内容是50 45 00 00,与之对应的 ASCII字符为“PE\0\0”。这里就是PE结构开始的位置。
“PE\0\0”和IMAGE_DOS_HEADER之间的内容就是DOS存根,就是一个没什么太大用处的DOS程序。由于这个程序本身没什么利用的价值,因此这里就不做介绍了。选中DOS存根程序,也就是从0×40000000处一直到0×000000C7处的内容。然后单击右键选择“填充”命令,在弹出的“填充数据”的对话框中,选中“使用十六进制填充”单选钮,在其后的编辑框中输入“00”,单击“OK”按钮,该过程如图4-4、图4-5所示。
图4-4 填充数据
图4-5 填充后的数据
把DOS存根部分填充完以后,单击工具栏上的“保存”按钮对修改内容进行保存。保存时会提示是否备份,选择“是”,这样文件就保存了。找到我们的文件,然后运行,对话框依旧弹出了,说明这里的内容是无关紧要的。
注:DOS存根部分经常由于某种需要而保存其他数据,因此该操作较为常见。
DOS头是为了兼容DOS系统而遗留的,在DOS头中的最后一个字段给出了 PE头的位置。PE头部是真正用来装载Win32程序的头部,PE头的定义为IMAGE_NT_HEADERS,该结构体包含PE标识符、文件头与可选头这三部分。IMAGE_NT_HEADERS是一个宏,其定义如下:
该头分为32位与64位,看是否定义了_WIN64。我们只讨论32位的PE文件格式,因此来看一下IMAGE_NT_HEADER32的定义,该定义如下:
该结构体中的Signature就是PE标识符,该标识符标识该文件是否是PE文件。该部分占4个字节,即“50 45 00 00”,该部分可以参看图4-3。这里在Winnt.h中有一个宏定义,定义如下:
该值非常重要,简单地判断一个文件是否是PE文件,首先要判断DOS头部的开始字节是否是“MZ”,然后通过DOS头部找到PE头,接着判断PE头部的前四个字节是否为“PE\0\0”,如果是的话,则可以说明该文件是一个有效的PE文件。
在PE头中,除了IMAGE_NT_SIGNATURE以外,还有两个重要的结构体,分别是 IMAEG_FILE_HEADER(文件头)和IMAGE_OPTIONAL_HEADER32(可选头)。这两个头在PE头中占据重要的位置,因此需要详细介绍这两个结构体。
该结构体是IMAGE_NT_HEADERS中的第一个结构体,该结构紧接在PE标识符的后面。该结构体大小为20个字节,起始位置为0×000000CC,结束位置为0×000000DF,如图4-6所示。
图4-6 IMAGE_FILE_HEADER在PE文件中的位置
该结构包含了PE文件的一些基础信息,其结构体的定义如下:
下面介绍一下该结构各字段。
(1) Machine:该字段是WORD类型,占用两个字节。该字段表示可执行文件的目标CPU类型。该字段的取值如图4-7所示。
图4-7 CPU类型取值
在图4-6中,Machine字段的值为“4C 01”,即0×014C,也就是支持Intel类型的CPU。
(2) NumberOfSection:该字段是WORD类型,占用两个字节。该字段表示PE文件的节区的个数。在图4-6中,该字段值为“03 00”,即为0×0003,也就是说明该PE文件的节区有3个。
(3) TimeDataStamp:该字段表明文件是何时被创建的,这个值是自1970年1月1日以来用格林威治时间计算的秒数。
(4) PointerToSymbolTable:该字段很少被使用,这里不做介绍。
(5) NumberOfSymbols:该字段很少被使用,这里不做介绍。
(6) SizeOfOptionalHeader:该字段为WORD类型,占用两个字节。该字段指定 IMAGE_OPTIONAL_HEADER结构的大小。在图4-6中,该字段的值为“E0 00”,即“0×00E0”,也就是说IMAGE_OPTIONAL_HEADER的大小为 0×E0。注意,在计算 IMAGE_OPTIONAL_HEADER的大小时应该从IMAGE_FILE_HEADER结构中的 SizeOfOptionalHeader获取,而不应该直接使用sizeof(IMAGE_OPTIONAL_HEADER)获取。由该字段可以看出IMAGE_OPTIONAL_HEADER结构体的大小可能是会改变的。
(7) Characteristics:该字段为WORD类型,占用两个字节。该字段指定文件的类型。该字段取值如图4-8所示。
图4-8 文件类型取值
从图4-6可知,该字段的值为“0F 01”,即“0×010F”。该值表示该文件运行的目标平台为32位平台,是一个可执行文件,且不存在重定位信息,行号信息和符号信息已从文件中移除。
IMAGE_OPTIONAL_HEADER在几乎所有的参考书中都被称作“可选头”。虽然被称作可选头,但是该头不是一个可选头,而是一个必须存在的头,不可以没有。该头被称作“可选头”认为是在该头的数据目录数组中,有的数据目录项是可有可无的,这部分内容是可选的,因此成为可选头。而我觉得如果称之为“选项头”是否会更好一点?不管如何称呼,只要大家能够理解该头是必须存在的,就可以了。
可选头紧挨着文件头,文件头的结束位置为0×000000DF,那么可选头的起始位置为 0×000000E0。可选头的大小在文件头中给出,其大小为0×00E0字节(十进制为224个字节),其结束位置为0×000000E0+ 0×00E0-1=0×000001BF,如图4-9所示。
图4-9 可选头的内容
可选头是文件头的一个补充,其中的字段除了对文件的一些定义外,还为操作系统提供了装载PE文件的相关定义。该头同样有32位与64位版本之分,IMAGE_OPTIONAL_HEADER是一个宏,该宏的定义如下:
32位版本与64位版本的选择是根据是否定义了_WIN64而决定的,这里只讨论其32位的版本,IMAGE_OPTIONAL_HEADER32的定义如下:
该结构体的成员变量非常之多,为了能够很好地掌握该结构体,这里对该结构体的成员变量进行一一介绍。
(1) Magic:该成员变量指定了文件的状态类型,状态类型如图4-10所示。
图4-10 Magic变量取值
(2) MajorLinkerVersion:主链接版本号。
(3) MinorLinkerVersion:次链接版本号。
(4) SizeOfCode:代码节的大小,如果有多个代码节的话,就是它们的总和。该处是指所有包含可执行属性的节的大小。
(5) SizeOfInitializedData:已初始化数据块的大小。
(6) SizeOfUninitializedData:未初始化数据块的大小。
(7) AddressOfEntryPoint:程序执行的入口地址,该地址是一个相对虚拟地址,该地址简称EP。如果加壳后,找到了该地址,就被称作了 OEP。该地址指向的不是main(),也不是 WinMain()的地址,该地址指向了运行库代码的地址。对于DLL这个值的意义不大,因为DLL甚至可以没有DllMain()函数,没有DllMain()是无法捕获DLL的4个消息的。
(8) BaseOfCode:代码段的起始相对虚拟地址。
(9) BaseOfData:数据段的起始相对虚拟地址。
(10) ImageBase:文件被装入内存后的首选建议装载地址。对于EXE文件来说,通常情况下该地址就是装载地址;对于DLL来说,可能就不是其装入内存后的地址了。
(11) SectionAlignment:节被装入内存后的对齐值。节被映射到内存中需要对齐的单位。通常情况下0×1000,也就是4KB大小。Windows操作系统的内存分页一般为4KB。
(12) FileAlignment:节在文件中的对齐值。节在磁盘上是对齐单位。
(13) MajorOperatingSystemVersion:要求最低操作系统的主版本号。
(14) MinorOperatingSystemVersion:要求最低操作系统的次版本号。
(15) MajorImageVersion:可执行文件的主版本号。
(16) MinorImageVersion:可执行文件的次版本号。
(17) MajorSussystemVersion:要求最低子系统的主版本号。
(18) MinorSubsystemVersion:要求最低子系统的次版本号。
(19) Win32VersionValue:该成员变量是被保留的。
(20) SizeOfImage:可执行文件装入内存后的总大小。该大小按内存对齐方式对齐。
(21) SizeOfHeaders: PE头的大小,这个PE头泛指DOS头、PE头、节表的总和大小。
(22) CheckSum:校验和。对于EXE文件通常为0,对于SYS文件则必须有一个校验和。
(23) Subsystem:可执行文件的子系统类型。该值如图4-11所示。
图4-11 Subsystem值的意义
(24) DllCharacteristics:指定DLL文件的属性。该值大部分时候为0。
(25) SizeOfStackReserve:为线程保留的栈大小。
(26) SizeOfStackCommit:为线程已经提交的栈大小。
(27) SizeOfHeapReserve:为线程保留的堆大小。
(28) SizeOfHeapCommit:为线程已经提交的堆大小。
(29) LoaderFlags:被废弃的成员值。MSDN上的原话为“This member is obsolete”。但是该值在某些情况下还是会被用到的,比如针对旧版OD时,修改该值会起到反调试的作用。
(30) NumberOfRvaAndSizes:数据目录项的个数。该个数在PSDK中有一个宏定义,定义如下:
(31)DataDirectory:数据目录表,由NumberOfRvaAndSize个IMAGE_DATA_DIRECTORY结构体组成。该数组中包含了输入表、输出表、资源等数据的RVA。 IMAGE_DATA_DIRECTORY的定义如下:
该结构体的第一个变量为该目录的相对虚拟地址的起始值,第二个是该目录的长度。数据目录中的部分成员在数组中的索引如图4-12所示。
图4-12 数据目录部分成员在数组中的索引
在数据目录中,并不是所有的目录项都会有值,有很多目录项的值都为0。
这个可选头的结构体就介绍完了,希望大家按照对结构体中各成员变量的掌握自行学习可选头中的十六进制的字段,这样有助于我们对PE文件格式的分析,加快对PE文件格式的掌握。
4.2.5 节区详解IMAGE_SECTION_HEADER
节表的位置在IMAGE_OPTIONAL_HEADER的后面,节表中的每个 IMAGE_SECTION_HEADER中都存放着可执行文件被映射到内存中所在位置的信息,节的个数由IMAGE_FILE_HEADER中的NumberOfSections给出,希望大家没有忘记。该内容如图4-13所示。
图4-13 IMAGE_SECTION_HEADER位置的数据内容
IMAGE_SECTION_HEADER的结构体起始位置在0×000001CO处,结束位置在 0×00000237处,IMAGE_SECTION_HEADER的大小为40个字节,该文件有3个节表,因此占用了120个字节。
IMAGE_SECTION_HEADER结构体的定义如下:
这个结构体相对于IMAGE_OPTIONAL_HEADER来说成员变量少很多,下面逐一进行介绍。
(1)Name:该成员变量保存节的名称,节的名称用ASCII编码来保存。节名的长度为 IMAGE_SIZEOF_SHORT_NAME,这是一个宏,该宏的定义如下:
节名的长度为8个字节,多余的字节会被自动截断。通常情况下节名以“.”为开始,当然这是编译器的习惯。我们看一下图4-13的前8个字节的内容为“2E 74 65 78 74 00 00 00”,其对应ASCII字符为“.text”。
(2) VirtualSize:该值为数据实际的节区大小,该值不一定为对齐后的值。
(3) VitualAddress:该节区载入到内存后的相对虚拟地址。这个地址是按内存进行对齐的。
(4) SizeOfRawData:该节区在磁盘上的大小,该值通常是对齐后的值,但是也有例外。
(5) PointerOfRawData:该节区在磁盘文件上的偏移值。
(6) Characteristics:节区属性。该属性的值如图4-14所示。
图4-14 节区属性取值
关于IMAGE_SECTION_HEADER的介绍就到这里了。
下面介绍一下与PE结构相关的3种地址,分别是VA(虚拟地址)、RVA(相对虚拟地址)和FileOffset(文件偏移地址)。
VA(虚拟地址):PE文件映射到内存后的地址。
RVA(相对虚拟地址):相对虚拟地址是内存地址相对于映射基地址的偏移地址。
FileOffset(文件偏移地址):相对PE文件在磁盘上的文件开头的偏移量。
这3个地址都是和PE文件结构密切相关的,前面简单地引用过这几个地址,但是前面只是个概念。从了解节区开始,这3个地址的概念就非常重要了,否则后面的很多内容都将没法理解。
这3个概念之所以重要,是因为我们要不断地使用它们,而且三者之间的关系也很重要。每个地址之间的转换也很重要,尤其是VA和FileOffset的转换,还有RVA和FileOffset之间的转换。这两个转换不能说是复杂,但是需要一定的公式。而VA和RVA的转换就非常得简单与容易了。
PE文件在磁盘上和在内存中的结构是一样的。所不同的是,在磁盘上文件是按照IMAGE_OPTIONAL_HEADER的FileAlignment值进行对齐的。而在内存中,映像文件是按照IMAGE_OPTINAL_HEADER的SectionAlignment进行对齐的。这两个值前面已经介绍过了,这里再进行一个简单的回顾。FileAlignment是按照磁盘上的扇区为单位的,也就是说FileAlignment最小为512字节,十六进制的0×200字节。而SectionAlignment是按照内存分页为单位来对齐的,其值为4KB,也就是十六进制的0×1000字节。一般情况下,FileAlignment的值会与SectionAlignment的值相同,这样磁盘文件和内存映像的结构是完全一样的。当FileAlignment的值和SectionAlignment的值不相同的时候就存在一些细微的差异了,其差异的主要区别在于,根据对齐的实际情况而多填充了很多0值。PE文件映射如图4-15所示。
图4-15 PE文件映像
当FileAlignment和SectionAlignment的值不相同时,磁盘文件与内存映像的同一数据在磁盘和内存中的偏移也不相同,这样两个偏移就发生了一个需要转换的问题。当你知道某数据的RVA的时候,想要在文件中读取同样的数据,就必须将RVA转换为FileOffset。反之,也是同样的情况。
下面用一个例子来学习如何进行转换。还记得前面为了分析PE文件结构而写的那个用 MessageBox()输出“Hello World”的例子程序吧?我们用PEDI打开它,查看它的节表情况,如图4-16所示。
图4-16 节表内容
从图4-16的标题栏可以看到,这里不叫“节表”而叫“区段”,还有别的资料上称之为 “区块”,这个只是叫法不同,内容都是一样的。
从图4-16中可以看到,节表的第一个节区的节名称为“.text”。通常情况下,第一个节区都是代码区,入口点也通常指向这个节区。在早期壳不流行时,通过判断入口点是否在第一个节区来判断该程序是否为病毒,如今这种做法就不可靠了。我们关键要看的是“R.偏移”,这个表明了该节区在文件中的起始位置。PE头部,包括DOS头、PE头和节表,通常不会超过512个字节,也就是说,不会超过0×200的大小。如果这个“R.偏移”为0×00001000,那么通常情况下可以确定该文件的磁盘对齐大小为0×1000。测试验证一下这个程序,看到“V.偏移”与“R.偏移”相同,则说明磁盘对齐与内存对齐是一样的,这样,就没办法完成演示转换的工作了。不过,可以人为地修改磁盘对齐大小,也可以通过工具来修改磁盘对齐的大小。这里,借助LordPE来修改其大小,修改方法很简单。先将要修改的测试文件复制一份,以与修改后的文件做对比。打开LordPE,单击“重建PE”按钮,然后再选择刚才复制的那个测试文件,如图4-17、图4-18所示。
PE重建功能中会压缩文件大小的功能,这里的压缩也就是修改磁盘文件的对齐值,避免过多的因对齐而进行补0,使其少占用磁盘空间。用PEID查看一下这个进行重建的PE文件的节表,如图4-19所示。
现在可以看到“V.偏移”与“R.偏移”的值不相同了,它们的对齐值也不相同了,大家可以自己验证一下FileAlignment和SectionAlignment的值是否相同。
现在我们有两个功能完全一样,而且PE结构也一样的两个文件了,唯一的不同就是其磁盘对齐大小不同。现在在这两个程序中分别寻找相同的数据,来学习不同地址之间的转换。
图4-17 LordPE界面
图4-18 重建PE功能结果
图4-19 重建PE后的节表
先用OD打开未进行重建PE的测试程序,找到MessageBox()处要弹出的两个对话框的地址,如图4-20、图4-21所示。
图4-20 MessageBox0函数中所使用的字符串地址
图4-21 两个字符串的地址
从图4-20和图4-21中可以看到,字符串“hello world!”的虚拟地址(VA)为0×00405030。
相对虚拟地址(RVA)为VA减去ImageBase(映像文件的装载虚拟地址),则RVA= VA-ImageBase=0×00405030-0×00400000=0×00005030。由于 SectionAlignment与 FileAlignment的值相同,因此其FileOffset的值也为0×00005030,用C32ASM打开该文件查看0×00005030处,如图4-22所示。
图4-22 文件偏移0×00005030处的内容为“hello world!”字符串
从这个例子中可以看出,当SectionAlignment和FileAlignment相同时,同一数据的RVA (相对虚拟地址)和FileOffset(文件偏移地址)相同。RVA的值是使用VA-ImageBase得到的。
再次用OD打开“重建PE”后的测试程序,同样找到MessageBox()函数使用的那个字符串“hello world!”,看其虚拟地址是多少。可以告诉大家,它的虚拟地址仍然是0×00405030。虚拟地址是0×00405030,那么同样的用虚拟地址减去装载地址,相对地址的值仍然为 0×00005030。用C32ASM打开该文件,看一下0×00005030地址处的内容,如图4-23所示。
图4-23 文件的末尾地址
从图中可以看到,用C32ASM打开该文件后,文件的末尾偏移为0000379C,根本没有 0×00005030这个偏移地址。这就是文件对齐与内存对齐的差异而引起的。这个时候,就要通过一些简单的计算把RVA转换FileOffset。
把RVA转换为FileOffset的方法很简单,首先要看一下当前的RVA或者是FileOffset属于哪个节。0×00005030这个RVA属于.data节。0×00005030这个RVA相对于该节的起始RVA地址0×00005000来说偏移0×30个字节。再看.data节在文件中的起始位置为0×00003400,以.data节的文件起始偏移0×00003400加上0×30个字节的值为0×00003430。用C32ASM看一下0×00003430这个地址处的内容,如图4-24所示。
图4-24 0×00003430文件偏移处的数据内容
从图4-24可以看出,该文件偏移处保存着“hello world!”字符串,也就是说将RVA转换为FileOffset是正确的。通过LordPE工具来验证一下,如图4-25所示。
图4-25 用LordPE计算0×00005030的文件偏移
我们再来回顾一下这个过程。
某数据的文件偏移=该数据所在节的起始文件偏移+(某数据的RVA-该数据所在节的起始RVA)。
除了上面的计算方法以外,还有一种计算方法,把节的起始RVA值减去节的起始文件偏移值,得到一个差值。然后再用RVA减去这个得到的差值就可以得到其所对应的FileOffset了。大家可以对这种方法自行验证。
这3种地址相互的转换方法就介绍完了。如果没有理解,那么就反复地按照公式进行学习和计算。只要在头脑中建立了关于磁盘文件和内存映像的结构,那么理解起来就不会太吃力。在后面的例子当中,将会写一个类似LordPE中那样转换3种地址的程序,来加强理解。
写PE查看器并不是件复杂的事情,只要按照PE结构一步一步地解析就可以了。下面简单地解析其中几个字段内容,显示一下节表的信息。写的PE查看器如图4-26所示。
图4-26 PE查看器解析记事本程序
PE查看器的界面就按照如图4-26所示的那样来设置,不过这个可以按照个人的偏好进行设置。编写该PE查看器的步骤为打开文件并创建文件内存映像,判断文件是否为PE文件并获得PE格式相关结构体的指针,解析基本的PE字段,枚举节表,最后关闭文件。我们需要在类中添加几个成员变量及成员函数,添加的内容如图4-27所示。
图4-27 在类中添加的成员变量及成员函数
按照前面所说的顺序,依次实现添加的各个成员函数。
这个函数的主要功能是打开文件并创建内存文件映像。通常对文件进行连续读写时直接使用ReadFile()和WriteFile()两个函数,当不连续操作文件时,每次在ReadFile()或者WriteFile()后就要使用SetFilePointer()来调整文件指针的位置,这样的操作较为繁琐。内存文件映像的作用是把整个文件映射入进程的虚拟空间中,这样操作文件就像操作内存变量或数据一样的方便。
创建内存文件映像所使用的函数有两个,分别是CreateFileMapping()和MapViewOfFile()。下面分别介绍这两个函数,CreateFileMapping()函数的定义如下:
参数说明如下。
(1) hFile:该参数是CreateFile()函数返回的句柄。
(2) lpAttributes:是安全属性,该值通常是NULL。
(3) flProtect:创建文件映射后的属性,通常设置为可读可写PAGE_READWRITE,如果需要按照装载可执行文件那样把文件映射入内存的话,那么需要使用SEC_IMAGE;
最后3个参数在这里为0。如果创建的映射需要在多进程中共享数据的话,那么最后一个参数设定为一个字符串,以便通过该名称找到该块共享内存。
该函数的返回值为一个内存映射的句柄。
MapViewOfFile()函数的定义如下:
参数说明如下。
(1) hFileMappingObject:该参数为CreateFileMapping()返回的句柄。
(2) dwDesiredAccess:想获得的访问权限,通常情况下也是可读可写FILE_MAP_READ、 FILE_MAP_WRITE。
最后3个参数一般给0值就可以了。
按照编程的规矩,打开要关闭,申请要释放。那么对于CreateFileMapping()的关闭需要使用CloseHandle()函数,对于MapViewOfFile()来说,要使用UnmapViewOfFile,该函数的定义如下:
该函数的参数就是MapViewOfFile()函数的返回值。
接着说PE查看器,文件已经打开,那么就要判断文件是否为有效的PE文件了,如果是有效的PE文件,那么就把解析PE格式的相关结构体的指针也得到。代码如下:
这段代码应该非常容易理解,继续看解析PE格式的部分吧。
对于PE格式的基础信息来说,就是简单地获取结构体的成员变量,没有过多复杂的内容。获取导入表、导出表就要比获取基础信息要复杂了。关于导入表、导出表的内容将在后面介绍。接下来进行节表的枚举,代码如下:
最后的动作就是释放动作了,因为很简单,这里就不给出代码了。整个PE查看器就算是写完了。
前面的内容介绍了通过编程解析了PE文件格式的基础数据,对于PE文件格式的解析其实并不难,难点在于兼容性。我们从前面的学习中可以看到,在PE文件结构中大多用的都是偏移地址,因此,只要偏移地址和实际的数据相符,那么PE文件格式有可能是嵌套的。也就是说PE文件是可以变形的,只要保证其偏移地址和PE文件格式的结构基本就没多大问题。
对于PE可执行文件来说,为了保护可执行文件或者是压缩可执行文件,通常会对该文件进行加壳。接触过软件破解的人,应该都是清楚壳的概念的。关于壳的概念就不多说了,下面来写一个查壳的工具。
首先,用ASPack给前面写的程序加个壳,打开ASPack加壳工具,如图4-28所示。
图4-28 ASPack加壳工具界面
对测试用的软件进行一次加壳,不过在加壳前先用PEiD查看一下,如图4-29所示。
图4-29 PEiD查壳
从图4-29可以看出,该程序是Visual C++ 5.0 Debug版的程序,其实该程序是用Visual C++6.0写的,这是PEiD识别有误的原因。不过只要用Visual C++6.0进行编译选择Release版时,PEiD是可以正确进行识别的。使用ASPack对该程序进行加壳,加壳后再用PEiD查壳,如图4-30所示。
从图4-30中可以看出,PEiD识别出来文件被加过壳,且是用ASPack进行加壳的。PEiD是如何识别程序被加壳,加了哪种壳呢?在PEiD的目录下,有一个特征码文件,名为 “userdb.txt”。打开这个文件,看一下大概内容,里边保存了壳的特征码。我们的任务就是来做一个这个壳的识别工具。
图4-30 用PEiD查看加壳后的文件
壳的识别是通过特征码来进行的,特征码的提取通常是选择文件的入口处。壳会修改程序的入口处,因此对于壳的特征码来说选择入口处比较合适。我们的工具主要是用来学习和演示用的,因此写的查壳工具要能识别两种类型,第一种类型是可以识别用Visual C++6.0编译出来的文件,第二种类型就是可以识别ASPack加壳后的程序,当然ASPack加壳工具的版本众多,这里只要能识别上面演示的那个版本的ASPack就可以了。
如何提取特征码呢?程序无论是在磁盘上,还是在内存中,都是以二进制的形式存在的。前面也提到过,特征码是从程序的入口处进行提取的,那么可以使用C32ASM以十六进制的形式打开这些文件,在入口处提取特征码,也可以用OD将程序载入内存后提取特征码。这里选择使用OD提取特征码。用OD载入未加壳的程序,如图4-31所示。
图4-31 OD载入未加壳文件的入口处
可以看到这就是未加壳程序的入口处代码,在图4-31中,“十六进制”列中就是代码对应的十六进制编码,我们就是要提取这些十六进制编码。提取结果如下:
根据这个步骤,把ASPack的特征码也提取出来,提取结果如下:
有了这些特征码,就可以开始编程了。先来定义一个数据结构,用来保存特征码,该结构如下:
利用该数据结构定义两个保存特征码的全局变量,定义如下:
程序界面是在PE查看器的基础上完成的,如图4-32所示。
提取了特征码,对于查壳工作就剩特征码匹配了,这个非常简单,只要用文件的入口处代码和特征码进行匹配,匹配相同就会给出相应的信息。查壳的代码如下:
这样查壳的功能就已经完成了。
图4-32 查壳界面