C++ 黑客编程揭秘与防范(第2版)

978-7-115-38057-9
作者: 冀云
译者:
编辑: 张涛

图书目录:

详情

本书内容通俗易懂,实例配合基础,且大部分内容可以用在防御外挂、木马、病毒的攻击与防护上。读者通过学习,不但了解病毒的原理,可以做到的很好的防范。本书内容通俗易懂,实例配合基础,且大部分内容可以用在防御外挂、木马、病毒的攻击与防护上。读者通过学习,不但了解病毒的原理,可以做到的很好的防范。

图书摘要

C++黑客编程揭秘与防范(第2版)
冀云 编著
人民邮电出版社

北京

图书在版编目(CIP)数据

C++黑客编程揭秘与防范/冀云编著.--2版.--北京:人民邮电出版社,2015.2

ISBN 978-7-115-38057-9

Ⅰ.①C… Ⅱ.①冀… Ⅲ.①C语言—程序设计 Ⅳ.①TP312

中国版本图书馆CIP数据核字(2015)第018573号

内容提要

市面上关于黑客入门的书籍较为繁多,比如黑客图解入门、黑客工具详解、黑客木马攻防等。但是,对于很多读者来说,可能并不是单单掌握简单的工具使用就能满足的。很多读者学习黑客知识是为了真正掌握与安全相关的知识。与安全相关的知识涉及面比较广,包括数据安全、存储安全、系统安全、Web安全、网络安全等,本书围绕Windows系统下应用层的开发来介绍一些关于安全方面的知识。

本书是《C++黑客编程揭秘与防范》的升级版,在前书的基础上新添加了一些内容,同时也删除了一些过时的内容。本书以Win32应用层下安全开发为中心,介绍Windows系统下的安全开发。

本书介绍了操作系统的相关操作,比如进程、线程、注册表等知识。当读者掌握了关于进程、线程、注册表等相关的开发知识后,就可以把一些常规的操作进程、注册表、文件等用代码进行实现,这样,一些日常的操作可与学习的编程知识相结合。除了操作的知识外,本书还介绍了关于网络应用程序的开发,了解 Winsock 的开发后,读者就会明白在应用层客户端与服务器端通信的原理。当然,本书除了介绍 Win32 开发外,还介绍了 PE 结构、调试接口、逆向等相关的知识。本书的最后部分介绍了关于恶意程序、专杀工具、扫描器等工具的开发。读者只要将前面章节的知识掌握后,后面的实例部分就水到渠成了。

◆编著 冀云

责任编辑 张涛

责任印制 张佳莹 焦志炜

◆人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

北京昌平百善印刷厂印刷

◆开本:787×1092 1/16

印张:26

字数:622千字  2015年2月第2版

印数:9501-13000册  2015年2月北京第1次印刷

定价:55.00元

读者服务热线:(010)81055410 印装质量热线:(010)81055316

反盗版热线:(010)81055315

我与冀云兄因黑客反病毒论坛结识于一年前,在认识初期就能感觉到冀云兄是一个非常踏实且又富有思想的人,对安全编程的诸多方面也有自己独到的认识,这点令我十分欣赏。认识几个月后,通过一次无意的聊天,我有幸读到冀云兄《C++黑客编程揭秘与防范》第1版,而后承冀云兄高看,才得以诞生此序。

通过阅读《C++黑客编程揭秘与防范》第1版,我有一种相见恨晚的感觉。这本书从最基本的Windows编程到Windows下的各种安全编程技术都有涉及,例如PE文件、DLL注入技术、各种 Hook 技术、后门编写的技术关键点,乃至像 MBR 的解析这种很难涉及的点与Rootkit编程这样比较深入的面,都有恰到好处的介绍与详解。

因此,就整书而言,将诸如文件/注册表操作、网络通信、PE文件、Rootkit、逆向工程等数个知识点有效组织在一起,是一个非常巨大的工程。这一点对于有过类似写作经验的我来说,体会尤其深刻。但是不得不说,作为读者,我真的非常幸运,这本书基本上完成了这个艰巨的任务。就我个人而言,这本书至少可以被当成一部“技术字典”来使用。当我在实际的工作中对某种技术生疏后,可以拿起这本书翻一翻,顿时会感觉受益匪浅。

从整书的结构以及知识的组织方式来看,不难发现,这其实是一本相当重视初学者技术的图书。作者在第1章中对于工作环境的搭建以及对应IDE的使用都做了必要的介绍,而后又通过第 2 章使用一个非常有趣且简单的例子教读者如何打造一个木马的雏形,这些无不体现出了作者对于基础薄弱的读者的细心照顾。

除此之外,当前的政策环境以及社会整体的大环境都对信息安全产业释放了大量的利好信号,无论是十八大时国家将信息安全提到国家战略层面,还是随后发生的著名“棱镜门”事件,抑或是当前势不可挡的移动互联网大潮,都在预示着信息安全领域在未来势必将摆脱“边缘群体”,进而成为“主流群体”中重要的一员,这些改变势必将极大地加剧当前信息安全领域人才的稀缺现状。但是,我相信本书定会为中国的信息安全领域崛起贡献一份力量,进而使得更多的读者从信息安全的“门外汉”成为“圈内人”,以缓解现在信息安全领域人才稀缺的现状。

——任晓珲[A1Pass],北京蓝森科技有限公司创始人,15PB计算机高端培训品牌创始人,《黑客免杀攻防》作者

甲午年正月十二于北京朝阳

前言

备受关注的黑客到底是什么

“黑客”已经成为一个热门的话题,“黑客事件”更是已经受到各大媒体的关注,甚至很多媒体对黑客事件进行不负责任的报道与炒作。从传统媒体到互联网媒体,从平面媒体到多元化的媒体平台,都在报导黑客事件,报道新爆发的蠕虫、病毒等一系列相关事件。

在各种媒体的炒作下,越来越多的年轻人都在追逐“当黑客的潮流”,很多热爱计算机的年轻人都追求自己能成为黑客。很多人都期望自己能一蹴而就成为一名顶级的黑客。很多人读着“头号电脑骇客”凯文米特尼克的传奇故事,在波涛汹涌的心情下,幻想着自己也能成为网络上出神入化的黑客风云人物。为此,很多“追黑”的网友深入各大黑客网站下载各种黑客工具来进行入侵、破坏(在用别人工具的同时,说不定也会遭到一些不怀好意的黑客网站的暗算,比如自己用的黑客工具本身就被放置了后门),从而满足自己追求成为黑客的心理。

在追求成为黑客的过程中真地学到了多少知识,自身的技术水平距离真正的黑客到底还有多远,这是很多只会使用工具的黑客应该冷静下来认真思考的问题。他们应该思考自己是否了解 TCP、UDP、ICMP 等 TCP/IP 中常用协议的结构与协议原理,是否知道ARP欺骗的原理,是否能独立开发并完成一个后门等。

什么是黑客?百度百科里黑客的含义如下(摘自百度百科,且有改动):

“热衷研究、撰写程序的专才,精通各种计算机语言和系统,且必须具备乐于追根究底、穷究问题的特质。‘黑客’一词是由英语 Hacker 音译出来的,是指专门研究、发现计算机和网络漏洞的计算机爱好者。早期在美国的电脑界是带有褒义的。”

看到上面百度百科给出的黑客含义后,是不是很多只会使用工具的所谓的黑客能明白一个道理,即黑客是要会编写程序的?

再看一下百度百科里对只会使用工具的黑客的解释(摘自百度百科):

“脚本小子,英文 script kiddie 或 script boy。脚本小子指的是用别人写的程序的人。脚本小子是一个贬义词,用来描述以黑客自居并沾沾自喜的初学者。”

那些自以为是的工具黑客只不过是一个“脚本小子”,还是一个被大家所“鄙视”的“小子”。是不是心里觉得不是很舒服了?是不是觉得自己应该提高了?如果是的话,那么就请抛开以前当工具黑客的想法,开始编写黑客工具吧!

心态影响成长

新手可能会问:编写自己的黑客工具是不是很难,是不是要懂编程语言,要懂哪种编程语言呢?笔者的回答是肯定的。抛开用工具当黑客的想法,其实是让大家抛开浮躁的想法,认真地学一些真正的技术,哪怕只是一些入门的基础。想做黑客就要有创新、研发的精神,如果只是做一个只会用软件的应用级的计算机使用者,那么必定永远达不到黑客级的水平,因为工具人人都会用,只是比别人多知道几个工具而已。抛开浮躁,静下心来从头开始学习基础,为将来的成长做好足够的准备。

攻防的广义性

黑客做得最多的就是“入侵”,这里所说的入侵不是一个狭义上的入侵,因为它不单单针对网络、系统的入侵。这里说的是一个广义上的入侵,“入侵”一词是指“在非授权的情况,试图存取信息、处理信息或破坏系统以使系统不可靠、不可用的故意行为。”由此可以看出,入侵并非单指网络或系统。这里说的“入侵”包括两个方面,一个是针对网络(系统)方面的入侵,另一个是针对软件的入侵。网络的入侵是通常意义上的入侵,而软件的入侵通常就是人们说的软件破解(包括漏洞挖掘等内容)。无论是侵入别人系统,还是破解某款软件,都是在非授权的情况下得到相应的权限,比如系统权限或者软件的使用权限。

本书讲点什么

本书针对“网络入侵”和“软件入侵”两方面来介绍黑客编程,从攻防两个角度来学习黑客编程的知识,通过一系列知识体系完成“黑客编程”的养成计划。

本书会介绍大量的基础知识,这些基础知识看起来与普通的应用程序编程没有什么差别。其实,所谓“黑客编程”(也称为“安全编程”),“是指采用常规的编程技术,编写网络安全、黑客攻防类的程序、工具”。因此,普通的编程技术与黑客编程技术并没有本质的差别,只是开发的层面不同。普通的编程注重的是客户的需求,而黑客编程注重的则是攻与防。

黑客编程有其两面性,按照攻防角度可以分为“攻击类入侵编程”和“防范类安全编程”。结合上面提到的“网络”和“软件”两方面来说,常见的“网络攻击”程序有扫描器、嗅探器、后门等;常见的“软件攻击”程序有查壳器、动态调试器、静态分析器、补丁等(这些工具是一些调试工具和逆向分析工具,因为软件破解、漏洞挖掘等会用到这些调试工具,因此称其为“软件攻击”工具)。常见的“网络(系统)防范”程序有“杀毒软件”、“防火墙”、“主动防御系统”等;常见的“软件防范”程序有“壳”、“加密狗”、“电子令牌”等。

根据前面提到的攻防两方面的内容,本书会涉及扫描器的开发、后门的开发、应用层抓包器的开发等黑客攻防方面的相关内容。本书还会讲解关于软件方面的知识,主要涉及PE结构、加壳、脱壳、逆向分析等一系列相关知识。由于技术的两面性,希望读者有一个良性的学习心态。

读者能从本书中得到什么

通过本书,读者能学到Windows下基于消息的软件开发、基于Winsock的网络应用程序的开发、软件逆向分析和调试知识等一系列的编程、调试及安全知识。在学习的过程中,读者应该大量阅读和参考其他相关资料,并且一定要亲自动手进行编程。编程绝对不是靠看书能够学会的!

通过本书的指导,再加上自身实践和练习,读者可以具备基本的 Windows 下的应用程序开发、网络程序开发的能力,基本的系统底层开发能力。除了相关开发能力外,读者还能具备初级的病毒分析能力、软件保护等相关的安全知识。

如何无障碍阅读此书

阅读本书的读者最好具有C和C++编程的基础知识,有其他编程语言基础知识的读者也可以无障碍阅读。无编程知识的读者阅读本书的同时,只要学习了本书中涉及的相关基础知识,同样可以阅读本书。

本书涉及范围较多,知识面比较杂,但是本书属于入门级读物,专门为新手准备,只要读者具备一定的基础知识,即可顺利进行阅读。在阅读本书的基础上,读者可以接着学习更深层次的知识,希望本书能帮助读者提高自身的能力。

建议:请读者深入学习操作系统原理、数据结构、编译原理、计算机体系结构等重要的计算机基础知识。

免责

本书属于入门级图书,无法保证读者成为黑客。作者本人也不是黑客,但是至少要有一个成为黑客的想法和成为黑客的动力。因此,如果本书没能达到读者所期待的目标,那么也请恕笔者无奈,笔者只是带领读者入门。

本书中内容主要用于教学,指导新手如何入门、如何学习编程知识,从编程的过程中了解黑客编程的基础知识。请勿使用自己的知识做出有碍公德之事,在准备通过技术手段进行蓄意破坏时,请想想无数“高手”的下场。读者如若作奸犯科,与作者本人和出版社无任何关系,请读者自觉遵守国家法律。

由于作者水平有限,书中难免会有差错,敬请谅解。中肯取代无礼,客观代替谩骂。

编辑联系邮箱:zhangtao@ptpress.com.cn。

第6章 加密与解密

本章介绍的是关于加密与解密的知识,但是从整篇的内容上来看很难找到具体的加密与解密的知识。本章主要介绍PE结构、调试API函数等相关的内容。加密与解密,简单来说,主要就是逆向与调试。这些知识在前面的章节已经介绍过了,而掌握本章的知识以后会提高逆向与调试的能力。

PE结构是Windows下可执行文件的标准结构,可执行文件的装载、内存分布、执行等都依赖于PE结构,而在逆向分析软件时,为了有目的、更高效地了解程序,必须掌握PE结构。要掌握反病毒、免杀、反调试、壳、PEDIY等相关知识,PE结构更是重中之重。

调试API函数是Windows系统给程序员提供的调试接口,掌握调试API函数即掌握了Windows的调试原理。利用调试API函数可以做到加载程序、调试程序、获取进程的底层信息、线程的运行环境等信息。

6.1 PE文件结构

PE(Portable Executable),即可移植的执行体。在Windows 平台(包括Win 9x、Win NT、Win CE……)下,所有的可执行文件(包括EXE 文件、DLL 文件、SYS 文件、OCX 文件、COM文件……)均使用PE文件结构。这些使用PE文件结构的可执行文件也称为PE文件。

普通的程序员也许没有必要掌握 PE 文件结构,因为其大多是开发服务性、决策性、辅助性的软件,比如MIS、HIS、CRM等软件。但是对于学习黑客编程和学习安全编程的Hacker、Cracker和Programmer的人而言,掌握PE文件结构的知识就非常重要了。

6.1.1 PE 文件结构全貌

Windows系统下的可执行文件中包含着各种数据,包括代码、数据、资源等。虽然Windows系统下的可执行文件中包含着如此众多类型的数据,但是其存放都是有序、结构化的,这完全依赖于PE文件结构对各种数据的管理。同样,PE结构是由若干个复杂的结构体组合而成的,不是单单的一个结构体那么简单,它的结构就像文件系统的结构是由多个结构体组成的。

PE结构包含的结构体有DOS头、PE标识、文件头、可选头、目录结构、节表等。要掌握PE结构必须对PE结构有一个整体上的认识,要知道PE结构分为哪些部分,这些部分大概是起什么作用的。有了宏观上的概念以后,就可以深入地对 PE 结构的各个结构体进行细致的学习了。下面给出一张图,让读者对PE结构有个大概的了解,如图6-1所示。

从图6-1中可以看出,PE结构分为4大部分,其中每个部分又进行了细分,存在若干个小的部分。从数据管理的角度来看,可以把PE文件大致分为两部分,DOS头、PE头和节表属于PE文件的数据管理结构或数据组织结构部分,而节表数据才是PE文件真正的数据部分,其中包含着代码、数据、资源等内容。

前面的章节进行逆向分析时,是对其代码、数据、资源等具体数据进行分析,也就是图6-1 的“节表数据”部分。程序在内存中或文件中的组织结构是如何规划的,并没有去具体了解,而这部分内容正是图6-1的上面3部分内容。本章中关于PE结构的内容主要就是针对图6-1的上面3部分进行介绍的。

6.1.2 PE 结构各部分简介

根据图6-1给出的PE结构总览图先来大致了解一下每部分的作用,然后进行深入讲解,最后完成一个PE结构的解析器。这里不会介绍PE结构中的每个结构,只针对常用和相对重要的结构体进行介绍。

1.DOS头

DOS头分为两部分,分别是“MZ头部”和“DOS存根”。MZ头部是真正的DOS头部,由于其开始处的两个字节为“MZ”,因此DOS头也可以叫作MZ头。该部分用于程序在DOS系统下加载,它的结构被定义为IMAGE_DOS_HEADER。

DOS 残留是一段简单的程序,主要用于输出“This program cannot be run in DOS mode.”类似的提示字符串。

为什么PE结构的最开始位置有这样一段DOS头部呢?关键是为了该可执行程序可以兼容DOS系统。通常情况下,Win32下的PE程序不能在DOS下运行,因此保留了这样一个简单的DOS程序用于提示“不能运行于DOS模式下”。不过该DOS存根是可以通过连接参数进行修改的,具体请参考相关的连接器的参数。

2.PE头

PE 头部保存着 Windows 系统加载可执行文件的重要信息。PE 头部由 IMAGE_NT_HEADERS定义。从该结构体的定义名称可以看出,IMAGE_NT_HEADERS由多个结构体组合而成,包括 IMAGE_NT_SIGNATRUE,IMAGE_FILE_HEADER 和 IMAGE_OPTIO NAL_HEADER三部分。PE头部在PE文件中的位置不是固定不变的,PE头部的位置由DOS头部的某个字段给出。

3.节表

程序的组织按照各属性的不同而被保存在不同的节中,在 PE 头部之后就是一个数组结构的节表。描述节表的结构体是IMAGE_SECTION_HEADER,如果PE文件中有N个节,那么节表就是由N个IMAGE_SECTION_HEADER组成的数组。节表中存储了各个节的属性、文件位置、内存位置等相关的信息。

4.节表数据

PE文件的真正程序部分就保存在节数据中。在PE结构中,有几个节表,就对应有几个节表的数据。根据节表的属性、地址等信息,程序的数据就分布在节表指定的位置中。

6.2 详解PE文件结构

PSDK的头文件Winnt.h包含了PE文件结构的定义格式。PE头文件分为32位和64位版本。64位的PE结构是对32位的PE结构做了扩展,这里主要讨论32位的PE文件结构。对于64位的PE文件结构,读者可以自行查阅资料进行学习。

6.2.1 DOS 头部详解IMAGE_DOS_HEADER

对于一个PE文件来说,最开始的位置就是一个DOS程序。DOS程序包含了一个DOS头部和一个DOS程序体。DOS头部是用来装载DOS程序的,DOS程序也就是如图6-1中的那个 DOS 存根。也就是说,DOS 头是用来装载 DOS 存根用的。保留这部分内容是为了与DOS系统相兼容。当Win32程序在DOS下被执行时,DOS存根程序会有礼貌地输出“This program cannot be run in DOS mode.”字样对用户进行提示。

虽然DOS头部是为了装载DOS程序的,但是DOS头部中的一个字段保存着指向PE头部的位置。DOS头在Winnt.h头文件中被定义为IMAGE_DOS_HEADER,其定义如下:

typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header

WORD e_magic;      // Magic number

WORD e_cblp;      // Bytes on last page of file

WORD e_cp;      // Pages in file

WORD e_crlc;      // Relocations

WORD e_cparhdr;     // Size of header in paragraphs

WORD e_minalloc;     // Minimum extra paragraphs needed

WORD e_maxalloc;     // Maximum extra paragraphs needed

WORD e_ss;      // Initial (relative) SS value

WORD e_sp;      // Initial SP value

WORD e_csum;      // Checksum

WORD e_ip;      // Initial IP value

WORD e_cs;      // Initial (relative) CS value

WORD e_lfarlc;     // File address of relocation table

WORD e_ovno;      // Overlay number

WORD e_res[4];     // Reserved words

WORD e_oemid;      // OEM identifier (for e_oeminfo)

WORD e_oeminfo;     // OEM information; e_oemid specific

WORD e_res2[10];     // Reserved words

LONG e_lfanew;     // File address of new exe header

} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

该结构体中需要掌握的字段只有 2 个,分别是第一个字段 e_magic 和最后一个字段e_lfanew字段。

e_magic 字段是一个 DOS 可执行文件的标识符,占用 2 字节。该位置保存着的字符是“MZ”。该标识符在Winnt.h头文件中有一个宏定义,如下:

#define IMAGE_DOS_SIGNATURE    0x5A4D // MZ

e_lfanew字段中保存着PE头的起始位置。

在VC 下创建一个简单的“Win32 Application”程序,然后生成一个可执行文件,用于学习和分析PE文件结构的组织。

程序代码如下:

#include <Windows.h>

int WINAPI WinMain(IN HINSTANCE hInstance,

IN HINSTANCE hPrevInstance,

IN LPSTR lpCmdLine,

IN int nShowCmd)

{

MessageBox(NULL, "hello world!", "hello", MB_OK);

return 0;

}

该程序的功能只是弹出一个 MessageBox 对话框。为了减小程序的体积,使用“Win32 Release”方式进行编译连接,并把编译好的程序用C32Asm打开。C32Asm是一个反汇编与十六进制编辑于一体的程序,其界面如图6-2所示。

在图6-2上选择“十六进制模式”单选按钮,单击“确定”按钮,程序就被C32Asm程序以十六进制的模式打开了,如图6-3所示。

在图6-3中可以看到,在文件偏移为0x00000000的位置处保存着2字节的内容0x5A 4D,用ASCII 码表示则是“MZ”。图6-3 中的前两个字节明明写着“4D 5A”,为什么说的是0x5A4D呢?到上面看Winnt.h头文件中定义的那个宏,也写着0x5A4D,这是为什么呢?如果读者还记得前面章节中介绍的字节顺序的内容,那么就应该明白为什么这么写了。这里使用的系统是小尾方式存储,即高位保存高字节,低位保存低字节。这个概念是很重要的,希望读者不要忘记。

注意:在这里,如果以ASCII码的形式去考察e_magic字段的话,那么值的确是“4D 5A”两个字节,但是为什么宏定义是“0x5A4D”呢?因为IMAGE_DOS_HEADER对于e_magic的定义是一个WORD类型。定义成 WORD 类型,在代码中进行比较时可以直接使用数值比较;而如果定义成 CHAR 型,那么比较时就相对不是太方便了。

在图6-3中0x0000003C的位置处,就是IMAGE_DOS_HEADER的e_lfanew字段,该字段保存着PE头部的起始位置。PE头部的地址是多少呢?是0xC8000000吗?如果是,就错了,原因还是字节序的问题。因此,e_lfanew的值为0x000000C8。在文件偏移为0x000000C8处保存着“50 45 00 00”,与之对应的ASCII 字符为“PE\0\0”。这里就是PE 头部开始的位置。

“PE\0\0”和 IMAGE_DOS_HEADER 之间的内容是 DOS 存根,就是一个没什么太大用处的DOS程序。由于这个程序本身没有什么利用的价值,因此这里就不对这个DOS程序做介绍了。在免杀技术、PE文件大小优化等技术中会对该部分进行处理,可以将该部分直接删除,然后将PE头部整体向前移动,也可以将一些配置数据保存在此处等。选中DOS存根程序,也就是从0x00000040处一直到0x000000C7处的内容,然后单击右键选择“填充”命令,在弹出的“填充数据”对话框中,选中“使用16进制填充”单选按钮,在其后的编辑框中输入“00”,单击“确定”按钮,该过程如图6-4和图6-5所示。

把DOS存根部分填充完毕以后,单击工具栏上的“保存”按钮对修改后的内容进行保存。保存时会提示“是否进行备份”,选择“是”,这样修改后的文件就被保存了。找到文件然后运行,程序中的MessageBox对话框依旧弹出,说明这里的内容的确无关紧要了。DOS存根部分经常由于各种需要而保存其他数据,因此这种填充操作较为常见。具体填充什么数据,请读者在今后的学习中自行发挥想象。

6.2.2 PE 头部详解IMAGE_NT_HEADERS

DOS头是为了兼容DOS系统而遗留的,DOS头中的最后一个字节给出了PE头的位置。PE头部是真正用来装载Win32程序的头部,PE头的定义为IMAGE_NT_HEADERS,该结构体包含PE标识符、文件头IMAGE_FILE_HEADER和可选头IMAGE_OPTIONAL_HEADER 3部分。IMAGE_NT_HEADERS是一个宏,其定义如下:

#ifdef _WIN64

typedef IMAGE_NT_HEADERS64     IMAGE_NT_HEADERS;

typedef PIMAGE_NT_HEADERS64    PIMAGE_NT_HEADERS;

#define IMAGE_FIRST_SECTION(ntheader)  IMAGE_FIRST_SECTION64(ntheader)

#else

typedef IMAGE_NT_HEADERS32     IMAGE_NT_HEADERS;

typedef PIMAGE_NT_HEADERS32    PIMAGE_NT_HEADERS;

#define IMAGE_FIRST_SECTION(ntheader)  IMAGE_FIRST_SECTION32(ntheader)

#endif

该头分为32位和64位两个版本,其定义依赖于是否定义了_WIN64。这里只讨论32位的PE文件格式,来看一下IMAGE_NT_HEADERS32的定义,如下:

typedef struct _IMAGE_NT_HEADERS {

DWORD Signature;

IMAGE_FILE_HEADER FileHeader;

IMAGE_OPTIONAL_HEADER32 OptionalHeader;

} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

该结构体中的Signature就是PE标识符,标识该文件是否是PE文件。该部分占4字节,即“50 45 00 00”。该部分可以参考图6-3。Signature 在Winnt.h中有一个宏定义如下:

#define IMAGE_NT_SIGNATURE    0x00004550 // PE00

该值非常重要。如果要简单地判断一个文件是否是PE文件,首先要判断DOS头部的开始字节是否是“MZ”。如果是“MZ”头部,则通过DOS头部找到PE头部,接着判断PE头部的前四个字节是否为“PE\0\0”。如果是的话,则说明该文件是一个有效的PE文件。

在PE头中,除了IMAGE_NT_SIGNATURE以外,还有两个重要的结构体,分别是IMAGE _FILE_HEADER(文件头)和IMAGE_OPTIONAL_HEADER(可选头)。这两个头在PE头部中占据重要的位置,因此需要详细介绍这两个结构体。

6.2.3 文件头部详解IMAGE_FILE_HEADER

文件头结构体IMAGE_FILE_HEADER是IMAGE_NT_HEADERS结构体中的一个结构体,紧接在PE标识符的后面。IMAGE_FILE_HEADER结构体的大小为20字节,起始位置为0x000000CC,结束位置在0x000000DF,如图6-6所示。

IMAGE_FILE_HEADER的起始位置取决于PE头部的起始位置,PE头部的位置取决于IMAGE_DOS_HEADER中e_lfanew的位置。除了IMAGE_DOS_HEADER的起始位置外,其他头部的位置都依赖于PE头部的起始位置。

IMAEG_FILE_HEADER结构体包含了PE文件的一些基础信息,其结构体的定义如下:

//

//File header format.

//

typedef struct _IMAGE_FILE_HEADER {

WORD Machine;

WORD NumberOfSections;

DWORD TimeDateStamp;

DWORD PointerToSymbolTable;

DWORD NumberOfSymbols;

WORD SizeOfOptionalHeader;

WORD Characteristics;

} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

#define IMAGE_SIZEOF_FILE_HEADER   20

下面介绍该结构的各字段。

Machine:该字段是WORD类型,占用2字节。该字段表示可执行文件的目标CPU类型。该字段的取值如图6-7所示。

在图6-6 中,Machine 字段的值为“4C 01”,即0x014C,也就是支持Intel 类型的CPU。

NumberOfSections:该字段是WORD类型,占用两个字节。该字段表示PE文件的节区的个数。在图6-6中,该字段的值为“03 00”,即为0x0003,也就是说明该PE文件的节区有3个。

TimeDataStamp:该字段表明文件是何时被创建的,这个值是自1970年1月1日以来用格林威治时间计算的秒数。

PointerToSymbolTable:该字段很少被使用,这里不做介绍。

NumberOfSymbols:该字段很少被使用,这里不做介绍。

SizeOfOptionalHeader:该字段为WORD类型,占用两个字节。该字段指定IMAGE_OPTION AL_ HEADER结构的大小。在图6-6中,该字段的值为“E0 00”,即0x00E0,也就是说IMAGE_OPTIONAL_HEADER的大小为0x00E0。注意,在计算IMAGE_OPTIONAL_HEADER的大小时,应该从IMAGE_FILE_HEADER结构中的SizeOfOptionalHeader字段指定的值来获取,而不应该直接使用 sizeof ( IMAGE_OPTIONAL_HEADER )来计算。由该字段可以看出, IMAGE_OPTIONAL _HEADER结构体的大小可能是会改变的。

Characteristics:该字段为WORD类型,占用2字节。该字段指定文件的类型,其取值如图6-8所示。

从图6-6 中可知,该字段的的值为“0F 01”,即“0x010F”。该值表示该文件运行的目标平台为32位平台,是一个可执行文件,且不存在重定位信息,行号信息和符号信息已从文件中移除。

6.2.4 可选头详解IMAGE_OPTIONAL_HEADER

IMAGE_OPTINAL_HEADER在几乎所有的参考书中都被称作“可选头”。虽然被称作可选头,但是该头部不是一个可选的,而是一个必须存在的头,不可以没有。该头被称作“可选头”的原因是在该头的数据目录数组中,有的数据目录项是可有可无的,数据目录项部分是可选的,因此称为“可选头”。而笔者觉得如果称之为“选项头”会更好一点。不管程序如何,只要读者能够知道该头是必须存在的,且数据目录项部分是可选的,就可以了。

可选头紧挨着文件头,文件头的结束位置在 0x000000DF,那么可选头的起始位置为0x000000E0。可选头的大小在文件头中已经给出,其大小为0x00E0字节(十进制为224字节),其结束位置为0x000000E0 + 0x00E0 – 1 = 0x000001BF,如图6-9 所示。

可选头的定位有一定的技巧性,起始位置的定位相对比较容易找到,按照 PE 标识开始寻找是非常简单的。可选头结束位置其实也非常容易找到。通常情况下(注意这里是指通常情况下,不是手工构造的PE文件),可选头的结尾后面跟的是第一项节表的名称。观察图6-9,文件偏移 0x000001C0 处的节名称为“.text”,也就是说,可选头的结束位置在 0x000001C0偏移的前一字节,即0x000001BF处。

可选头是对文件头的一个补充。文件头主要描述文件的相关信息,而可选头主要用来管理 PE 文件被操作系统装载时所需要的信息。该头同样有 32 位版本与 64 位版本之分。

IMAGE_OPTIONAL_HEADER是一个宏,其定义如下:

#ifdef _WIN64

typedef IMAGE_OPTIONAL_HEADER64   IMAGE_OPTIONAL_HEADER;

typedef PIMAGE_OPTIONAL_HEADER64   PIMAGE_OPTIONAL_HEADER;

#define IMAGE_SIZEOF_NT_OPTIONAL_HEADER IMAGE_SIZEOF_NT_OPTIONAL64_HEADER

#define IMAGE_NT_OPTIONAL_HDR_MAGIC  IMAGE_NT_OPTIONAL_HDR64_MAGIC

#else

typedef IMAGE_OPTIONAL_HEADER32   IMAGE_OPTIONAL_HEADER;

typedef PIMAGE_OPTIONAL_HEADER32   PIMAGE_OPTIONAL_HEADER;

#define IMAGE_SIZEOF_NT_OPTIONAL_HEADER IMAGE_SIZEOF_NT_OPTIONAL32_HEADER

#define IMAGE_NT_OPTIONAL_HDR_MAGIC  IMAGE_NT_OPTIONAL_HDR32_MAGIC

#endif

32位版本和64位版本的选择是根据是否定义了_WIN64而决定的,这里只讨论其32位的版本。IMAGE_OPTIONAL_HEADER32的定义如下:

//

//Optional header format.

//

typedef struct _IMAGE_OPTIONAL_HEADER {

//

//Standard fields.

//

WORD Magic;

BYTE MajorLinkerVersion;

BYTE MinorLinkerVersion;

DWORD SizeOfCode;

DWORD SizeOfInitializedData;

DWORD SizeOfUninitializedData;

DWORD AddressOfEntryPoint;

DWORD BaseOfCode;

DWORD BaseOfData;

//

//NT additional fields.

//

DWORD ImageBase;

DWORD SectionAlignment;

DWORD FileAlignment;

WORD MajorOperatingSystemVersion;

WORD MinorOperatingSystemVersion;

WORD MajorImageVersion;

WORD MinorImageVersion;

WORD MajorSubsystemVersion;

WORD MinorSubsystemVersion;

DWORD Win32VersionValue;

DWORD SizeOfImage;

DWORD SizeOfHeaders;

DWORD CheckSum;

WORD Subsystem;

WORD DllCharacteristics;

DWORD SizeOfStackReserve;

DWORD SizeOfStackCommit;

DWORD SizeOfHeapReserve;

DWORD SizeOfHeapCommit;

DWORD LoaderFlags;

DWORD NumberOfRvaAndSizes;

IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];

} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

该结构体的成员变量非常多,为了能够更好地掌握该结构体,这里对结构体的成员变量一一进行介绍。

Magic:该成员变量指定了文件的状态类型,状态类型部分取值如图6-10所示。

MajorLinkerVersion:主连接版本号。

MinorLinkerVersion:次连接版本号。

SizeOfCode:代码节的大小。如果有多个代码节的话,该值是所有代码节大小的总和(通常只有一个代码节),该处是指所有包含可执行属性的节的大小。

SizeOfInitializedData:已初始化数据块的大小。

SizeOfUninitializedData:未初始化数据块的大小。

AddressOfEntryPoint:程序执行的入口地址。该地址是一个相对虚拟地址,简称 EP (EntryPoint),这个值指向了程序第一条要执行的代码。程序如果被加壳后会修改该字段的值。在脱壳的过程中找到了加壳前该字段的值,就说明找到了原始入口点,原始入口点被称为OEP。该字段的地址指向的不是 main()函数的地址,也不是 WinMain()函数的地址,而是运行库的启动代码的地址。对于DLL来说,这个值的意义不大,因为DLL甚至可以没有DllMain()函数,没有DllMain()只是无法捕获装载和卸载DLL时的4个消息。如果在DLL装载或卸载时没有需要进行处理的事件,可以将DllMain()函数省略掉。

BaseOfCode:代码段的起始相对虚拟地址。

BaseOfData:数据段的起始相对虚拟地址。

ImageBase:文件被装入内存后的首选建议装载地址。对于EXE文件来说,通常情况下,该地址就是装载地址:对于DLL文件来说,可能就不是其装入内存后的地址了。

SectionAlignment:节表被装入内存后的对齐值。节表被映射到内存中需要对其的单位。在Win32下,通常情况下,该值为0x1000,也就是4KB大小。Windows操作系统的内存分页一般为4KB。

FileAlignment:节表在文件中的对齐值。通常情况下,该值为0x1000或0x200。在文件对齐值为 0x1000 时,由于与内存对齐值相同,可以加快装载速度。而文件对齐值为 0x200时,可以占用相对较少的磁盘空间。0x200是512字节,通常磁盘的一个扇区即为512字节。

注:程序无论是在内存中还是磁盘上,都无法恰好满足SectionAlignment和FileAlignment值的倍数,在不足的情况下需要补0值,这样就导致节与节之间存在了无用的空隙。这些空隙对于病毒之类程序而言就有了可利用的价值。

MajorOperatingSystemVersion:要求最低操作系统的主版本号。

MinorOperatingSystemVersion:要求最低操作系统的次版本号。

MajorImageVersion:可执行文件的主版本号。

MinorImageVersion:可执行文件的次版本号。

Win32VersionValue:该成员变量是被保留的。

SizeOfImage:可执行文件装入内存后的总大小。该大小按内存对齐方式对齐。

SizeOfHeaders:整个PE头部的大小。这个PE头部泛指DOS头、PE头、节表的总和大小。

CheckSum:校验和值。对于EXE文件通常为0;对于SYS文件,则必须有一个校验和。

SubSystem:可执行文件的子系统类型。该值如图6-11所示。

DllCharacteristics:指定DLL文件的属性,该值大部分时候为0。

SizeOfStackReserve:为线程保留的栈大小。

SizeOfStackCommit:为线程已提交的栈大小。

SizeOfHeapReserve:为线程保留的堆大小。

SizeOfHeapCommit:为线程已提交的堆大小。

LoaderFlags:被废弃的成员值。MDSN 上的原话为“This member is obsolete”。但是该值在某些情况下还是会被用到的,比如针对原始的低版本的OD来说,修改该值会起到反调试的作用。

NumberOfRvaAndSizes:数据目录项的个数。该个数在PSDK中有一个宏定义,如下:

#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16

DataDirectory:数据目录表,由 NumberOfRvaAndSize 个 IMAGE_DATA_DIRECTORY结构体组成。该数组包含输入表、输出表、资源、重定位等数据目录项的RVA(相对虚拟地址)和大小。IMAGE_DATA_DIRECTORY结构体的定义如下:

//

//Directory format.

//

typedef struct _IMAGE_DATA_DIRECTORY {

DWORD VirtualAddress;

DWORD Size;

} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

该结构体的第一个变量为该目录项的相对虚拟地址的起始值,第二个是该目录项的长度。数据目录中的部分成员在数组中的索引如图6-12所示,详细的索引定义请参考Winnt.h头文件。

在数据目录中,并不是所有的目录项都会有值,很多目录项的值都为 0。因为很多目录项的值为0,所以说数据目录项是可选的。

可选头的结构体就介绍完了,希望读者按照该结构体中各成员变量的含义自行学习可选头中的十六进制值的含义。只有参考结构体的说明去对照分析PE文件格式中的十六进制值,才能更好、更快地掌握PE结构。

6.2.5 节表详解IMAGE_SECTION_HEADER

节表的位置在IMAGE_OPTIONAL_HEADER的后面,节表中的每个IMAGE_SECTION _HEADER 中都存放着可执行文件被映射到内存中所在位置的信息,节的个数由 IMAGE_FILE_HEADER中的NumberOfSections给出。节表数据如图6-13所示。

由IMAGE_SECTION_HEADER结构体构成的节表起始位置在0x000001C0处,最后一个节表项的结束位置在0x00000237处。IMAGE_SECTION_HEADER的大小为40字节,该文件有3个节表,因此共占用了120字节。

IMAGE_SECTION_HEADER结构体的定义如下:

typedef struct _IMAGE_SECTION_HEADER {

BYTE Name[IMAGE_SIZEOF_SHORT_NAME];

union {

DWORD PhysicalAddress;

DWORD VirtualSize;

} Misc;

DWORD VirtualAddress;

DWORD SizeOfRawData;

DWORD PointerToRawData;

DWORD PointerToRelocations;

DWORD PointerToLinenumbers;

WORD NumberOfRelocations;

WORD NumberOfLinenumbers;

DWORD Characteristics;

} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

#define IMAGE_SIZEOF_SECTION_HEADER  40

这个结构体相对于IMAGE_OPTIONAL_HEADER结构体来说,成员变量少很多。下面介绍IMAGE_SECTION_HEADER结构体的各个成员变量。

Name:该成员变量保存着节表项的名称,节的名称用ASCII编码来保存。节名称的长度为IMAGE_SIZEOF_SHORT_NAME,这是一个宏,其定义如下:

#define IMAGE_SIZEOF_SHORT_NAME   8

节名的长度为8字节,多余的字节会被自动截断。通常情况下,节名“.”为开始。当然,这是编译器的习惯,并非强制性的约定。下面来看图6-13中文件偏移0x000001C0处的前8字节的内容“2E 74 65 78 74 00 00 00”,其对应的ASCII 字符为“.text”。

VirtualSize:该值为数据实际的节表项大小,不一定是对齐后的值。

VirtualAddress:该值为该节表项载入内存后的相对虚拟地址。这个地址是按内存进行对齐的。

SizeOfRawData:该节表项在磁盘上的大小,该值通常是对齐后的值,但是也有例外。

PointerToRawData:该节表项在磁盘文件上的偏移地址。

Characteristics:节表项的属性,该属性的部分取值如图6-14所示。

IMAGE_SECTION_HEADER结构体主要用到的成员变量只有这6个,其余不是必须要了解的,这里不做介绍。关于IMAGE_SECTION_HEADER结构体的介绍就到这里。

6.3 PE结构的3种地址

在上一章中用OD调试器调试程序时看到的地址与本章使用C32Asm以十六进制形式查看程序时的地址形式有所差异。程序在内存中与在文件中有着不同的地址形式,而且 PE 相关的地址不只有这两种形式。与PE结构相关的地址形式有3种,且这3种地址形式可以进行转换。

6.3.1 与PE 结构相关的3 种地址

与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_OPTIONAL_HEADER的SectionAlignment进行对齐的。这两个值前面已经介绍过了,这里再进行简单的回顾。FileAlignment 是以磁盘上的扇区为单位的,也就是说, FileAlignment 最小为 512 字节,十六进制的 0x200 字节。而SectionAlignment 是以内存分页为单位来对齐的,通常 Win32平台一个内存分页为4K,也就是十六进制的0x1000字节。一般情况下,FileAlignment的值会与SectionAlignment的值相同,这样磁盘文件和内存映像的结构是完全一样的。当FileAlignment的值和SectionAlignment的值不相同的时候,就存在一些细微的差异了,其主要区别在于,根据对齐的实际情况而多填充了很多0值。PE文件映射如图6-15所示。

除了文件对齐与内存对齐的差异以外,文件的起始地址从0地址开始,用C32Asm的十六进制模式查看 PE 文件时起始位置是 0x00000000。而在内存中,它的起始地址为IMAGE_OPTIONAL_HEADER结构体的ImageBase字段(该说法只针对EXE文件,DLL文件的映射地址不一定固定,但是绝对不会是0x00000000地址)。

6.3.2 3 种地址的转换

当FileAlignment和SectionAlignment的值不相同时,磁盘文件与内存映像的同一节表数据在磁盘和内存中的偏移也不相同,这样两个偏移就发生了一个需要转换的问题。当知道某数据的RVA,想要在文件中读取同样的数据的时候,就必须将RVA转换为FileOffset。反之,也是同样的情况。

下面用一个例子来介绍如何进行转换。还记得前面为了分析 PE 文件结构而写的那个用MessageBox()输出“Hello World”的例子程序吗?用PEID 打开它,查看它的节表情况,如图6-16所示。

从图6-16的标题栏可以看到,这里不叫“节表”,而叫“区段”。还有别的资料上称之为“区块”或“节区”,只是叫法不同,内容都是一样的。

从图 6-16 中可以看到,节表的第一个节区的节名称为“.text”。通常情况下,第一个节表项都是代码区,入口点也通常落在这个节表项。在早期壳不流行时,通过判断入口点是否在第一个节区就可以判断该程序是否被病毒感。如今,由于壳的流行,这种判断方法就不可靠了。关键要看的是“R.偏移”,表明了该节区在文件中的起始位置。PE头部包括DOS头、PE 头和节表,通常不会超过 512 字节,也就是说,不会超过 0x200 的大小。如果这个“R.偏移”为0x00001000,那么通常情况下可以确定该文件的磁盘对齐大小为0x1000(注意:这个测试程序是笔者自己写的,因此比较熟悉程序的 PE 结构。而且这也是一种经验的判断。严格来讲,还是要去查看IMAGE_OPTIONAL_HEADER的SectionAlignment和FileAlignment两个成员变量的值)。测试验证一下这个程序,看到“V.偏移”与“R.偏移”相同,则说明磁盘对齐与内存对齐是一样的,这样就没办法完成演示转换的工作了。不过,可以人为地修改文件对齐大小。也可以通过工具来修改文件对齐的大小。这里借助 LordPE 来修改其文件对齐大小。修改方法很简单,先将要修改的测试文件复制一份,以与修改后的文件做对比。打开LordPE,单击“重建PE”按钮,然后选择刚才复制的那个测试文件,如图6-17和图6-18所示。

PE重建功能中有压缩文件大小的功能,这里的压缩也就是修改磁盘文件的对齐值,避免过多地因对齐而进行补0,使其少占用磁盘空间。用PEID查看这个进行重建的PE文件的节表,如图6-19所示。

现在可以看到“V.偏移”与“R.偏移”的值不相同了,它们的对齐值也不相同了,大家可以自己验证一下FileAlignment和SectionAlignment的值是否相同。

现在有两个功能完全一样,而且 PE 结构也一样的两个文件了,唯一的不同就是其磁盘对齐大小不同。现在在这两个程序中分别寻找一个节表中的数据,学习不同地址之间的转换。

先用OD打开未进行重建PE结构的测试程序,找到反汇编中调用MessageBox()处要弹出对话框的两个字符串参数的地址,如图6-20和图6-21所示。

从图6-20 和图6-21 中可以看到,字符串“hello world !”的地址为0x00406030,字符串“hello”的地址为0x00406040。这两个地址都是虚拟地址,也就是VA。

将 VA(虚拟地址)转换为 RVA(相对虚拟地址)是很容易的,RVA(相对虚拟地址)为VA(虚拟地址)减去IMAGE_OPTIONAL_HEADER结构体中的ImageBase(映像文件的装载虚拟地址)字段的值,即RVA = VA – ImageBase = 0x00406030 – 0x00400000 = 0x0000 6030。由于IMAGE_OPTIONAL_HEADER中的SectionAlignment和FileAlignment的值相同,因此其FileOffset的值也为0x00006030。用C32Asm打开该文件查看文件偏移地址0x00006030处的内容,如图6-22所示。

从这个例子中可以看出,当SectionAlignment和FileAlignment相同时,同一节表项中数据的 RVA(相对虚拟地址)和 FileOffset(文件偏移地址)是相同的。RVA 的值是用 VA –ImageBase计算得到的。

再用OD打开“重建PE”后的测试程序,同样找到反汇编中调用MessageBox()函数使用的那个字符串“hello world !”,看其虚拟地址是多少。它的虚拟地址仍然是0x00406030。同样,用虚拟地址减去装载地址,相对虚拟地址的值仍然为0x00006030。不过用C32Asm打开该文件查看的话会有所不同。用C32Asm看一下0x00006030地址处的内容,如图6-23所示。

从图6-23中可以看到,用C32Asm打开该文件后,文件偏移0x00006030处并没有“hello world!”和“hello”字符串。这就是由文件对齐与内存对齐的差异所引起的。这时就要通过一些简单的计算把RVA转换为FileOffset。

把RVA转换为FileOffset的方法很简单,首先看一下当前的RVA或者是FileOffset属于哪个节。0x00006030这个RVA属于.data节。0x00006030这个RVA相对于该节的起始RVA地址0x00006000来说偏移0x30字节。再看.data节在文件中的起始位置为0x00004000,以.data节的文件起始偏移 0x00004000 加上 0x30 字节的值为 0x00004030。用 C32Asm 看一下0x00004030地址处的内容,如图6-24所示。

从图6-24 中可以看出,该文件偏移处保存着“hello world !”字符串,也就是说,将RVA转换为FileOffset是正确的。通过LordPE工具来验证一下,如图6-25所示。

再来回顾一下这个过程。

某数据的文件偏移 = 该数据所在节的起始文件偏移 + (某数据的RVA –该数据所在节的起始RVA)。

除了上面的计算方法以外,还有一种计算方法,即用节的起始RVA值减去节的起始文件偏移值,得到一个差值,再用RVA减去这个得到的差值,就可以得到其所对应的FileOffset。读者可以使用例子程序进行手工计算,然后通过LordPE进行验证。

知道如何通过RVA转换为文件偏移,那么通过文件偏移转换为RVA的方法也就不难了。这3种地址相互的转换方法就介绍完了。读者如果没有理解,就可以反复地按照公式进行学习和计算。只要在头脑中建立关于磁盘文件和内存映像的结构,那么理解起来就不会太吃力。在后面的例子中,将会写一个类似LordPE中转换3种地址的程序,以帮助读者加强理解。

6.4 PE相关编程实例

前面讲的都是概念性的知识,本节主要编写一些关于 PE 文件结构的程序代码,以帮助读者加强对PE结构的了解。

6.4.1 PE 查看器

写PE查看器并不是件复杂的事情,只要按照PE结构一步一步地解析就可以了。下面简单地解析其中几个字段内容,显示一下节表的信息,其余的内容只要稍作修改即可。PE查看器的界面如图6-26所示。

PE查看器的界面按照图6-26所示的设置,不过这个可以按照个人的偏好进行布局设置。编写该 PE 查看器的步骤为打开文件并创建文件内存映像,判断文件是否为 PE 文件并获得PE格式相关结构体的指针,解析基本的PE字段,枚举节表,最后关闭文件。需要在类中添加几个成员变量及成员函数,添加的内容如图6-27所示。

按照前面所说的顺序,依次实现添加的各个成员函数。

BOOL CPeParseDlg::FileCreate(char *szFileName)

{

BOOL bRet = FALSE;

m_hFile = CreateFile(szFileName,

GENERIC_READ | GENERIC_WRITE,

FILE_SHARE_READ,

NULL,

OPEN_EXISTING,

FILE_ATTRIBUTE_NORMAL,

NULL);

if ( m_hFile == INVALID_HANDLE_VALUE )

{

return bRet;

}

m_hMap = CreateFileMapping(m_hFile, NULL,

PAGE_READWRITE | SEC_IMAGE,

0, 0, 0);

if ( m_hMap == NULL )

{

CloseHandle(m_hFile);

return bRet;

}

m_lpBase = MapViewOfFile(m_hMap,

FILE_MAP_READ | FILE_SHARE_WRITE,

0, 0, 0);

if ( m_lpBase == NULL )

{

CloseHandle(m_hMap);

CloseHandle(m_hFile);

return bRet;

}

bRet = TRUE;

return bRet;

}

这个函数的主要功能是打开文件并创建内存文件映像。通常对文件进行连续读写时直接使用ReadFile()和WriteFile()两个函数。当不连续操作文件时,每次在ReadFile()或者WriteFile()后就要使用 SetFilePointer()来调整文件指针的位置,这样的操作较为繁琐。内存文件映像的作用是把整个文件映射入进程的虚拟空间中,这样操作文件就像操作内存变量或内存数据一样方便。

创建内存文件映像所使用的函数有两个,分别是CreateFileMapping()和MapViewOfFile()。CreateFileMapping()函数的定义如下:

HANDLE CreateFileMapping(

HANDLE hFile,       // handle to file

LPSECURITY_ATTRIBUTES lpAttributes,  // security

DWORD flProtect,       // protection

DWORD dwMaximumSizeHigh,     // high-order DWORD of size

DWORD dwMaximumSizeLow,     // low-order DWORD of size

LPCTSTR lpName       // object name

);

参数说明如下。

hFile:该参数是CreateFile()函数返回的句柄。

lpAttributes:是安全属性,该值通常是NULL。

flProtect:创建文件映射后的属性,通常设置为可读可写 PAGE_READWRITE。如果需要像装载可执行文件那样把文件映射入内存的话,那么需要使用SEC_IMAGE。

最后3个参数在这里为0。如果创建的映射需要在多进程中共享数据的话,那么最后一个参数设定为一个字符串,以便通过该名称找到该块共享内存。

该函数的返回值为一个内存映射的句柄。

MapViewOfFile()函数的定义如下:

LPVOID MapViewOfFile(

HANDLE hFileMappingObject,   // handle to file-mapping object

DWORD dwDesiredAccess,    // access mode

DWORD dwFileOffsetHigh,   // high-order DWORD of offset

DWORD dwFileOffsetLow,   // low-order DWORD of offset

SIZE_T dwNumberOfBytesToMap  // number of bytes to map

);

参数说明如下。

hFileMappingObject:该参数为CreateFileMapping()返回的句柄。

dwDesiredAccess:想获得的访问权限,通常情况下也是可读可写 FILE_MAP_READ、FILE_MAP_WRITE。

最后3个参数一般给0值就可以了。

按照编程的规矩,打开要关闭,申请要释放。CreateFileMapping()的关闭需要使用CloseHandle()函数。MapViewOfFile()的关闭,要使用 UnmapViewOfFile()函数,该函数的定义如下:

BOOL UnmapViewOfFile(

LPCVOID lpBaseAddress // starting address

);

该函数的参数就是MapViewOfFile()函数的返回值。

接着说PE查看器,文件已经打开,就要判断文件是否为有效的PE文件了。如果是有效的PE文件,就把解析PE格式的相关结构体的指针也得到。代码如下:

BOOL CPeParseDlg::IsPeFileAndGetPEPointer()

{

BOOL bRet = FALSE;

//判断是否为MZ头

m_pDosHdr = (PIMAGE_DOS_HEADER)m_lpBase;

if ( m_pDosHdr->e_magic != IMAGE_DOS_SIGNATURE )

{

return bRet;

}

//根据IMAGE_DOS_HEADER的e_lfanew的值得到PE头的位置

m_pNtHdr = (PIMAGE_NT_HEADERS)((DWORD)m_lpBase + m_pDosHdr->e_lfanew);

//判断是否为PE\0\0

if ( m_pNtHdr->Signature != IMAGE_NT_SIGNATURE )

{

return bRet;

}

//获得节表的位置

m_pSecHdr = (PIMAGE_SECTION_HEADER)((DWORD)&(m_pNtHdr->OptionalHeader)+ m_pNtHdr->FileHeader.SizeOfOptionalHeader);

bRet = TRUE;

return bRet;

}

这段代码应该非常容易理解,继续看解析PE格式的部分。

VOID CPeParseDlg::ParseBasePe()

{

CString StrTmp;

//入口地址

StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.AddressOfEntryPoint);

SetDlgItemText(IDC_EDIT_EP, StrTmp);

//映像基地址

StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.ImageBase);

SetDlgItemText(IDC_EDIT_IMAGEBASE, StrTmp);

//连接器版本号

StrTmp.Format("%d.%d",

m_pNtHdr->OptionalHeader.MajorLinkerVersion,

m_pNtHdr->OptionalHeader.MinorLinkerVersion);

SetDlgItemText(IDC_EDIT_LINKVERSION, StrTmp);

//节表数量

StrTmp.Format("%02X", m_pNtHdr->FileHeader.NumberOfSections);

SetDlgItemText(IDC_EDIT_SECTIONNUM, StrTmp);

//文件对齐值大小

StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.FileAlignment);

SetDlgItemText(IDC_EDIT_FILEALIGN, StrTmp);

//内存对齐值大小

StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.SectionAlignment);

SetDlgItemText(IDC_EDIT_SECALIGN, StrTmp);

}

PE格式的基础信息,就是简单地获取结构体的成员变量,没有过多复杂的内容。获取导入表、导出表比获取基础信息复杂。关于导入表、导出表的内容将在后面介绍。接下来进行节表的枚举,具体代码如下:

VOID CPeParseDlg::EnumSections()

{

int nSecNum = m_pNtHdr->FileHeader.NumberOfSections;

int i = 0;

CString StrTmp;

for ( i = 0; i < nSecNum; i ++ )

{

m_SectionLIst.InsertItem(i, (const char *)m_pSecHdr[i].Name);

StrTmp.Format("%08X", m_pSecHdr[i].VirtualAddress);

m_SectionLIst.SetItemText(i, 1, StrTmp);

StrTmp.Format("%08X", m_pSecHdr[i].Misc.VirtualSize);

m_SectionLIst.SetItemText(i, 2, StrTmp);

StrTmp.Format("%08X", m_pSecHdr[i].PointerToRawData);

m_SectionLIst.SetItemText(i, 3, StrTmp);

StrTmp.Format("%08X", m_pSecHdr[i].SizeOfRawData);

m_SectionLIst.SetItemText(i, 4, StrTmp);

StrTmp.Format("%08X", m_pSecHdr[i].Characteristics);

m_SectionLIst.SetItemText(i, 5, StrTmp);

}

}

最后的动作是释放动作,因为很简单,这里就不给出代码了。将这些自定义函数通过界面上的“查看”按钮联系起来,整个PE查看器就算是写完了。

6.4.2 简单的查壳工具

前面介绍了通过编程解析PE文件格式的基础数据,对于PE文件格式的解析其实并不难,难点在于兼容性。从前面的内容中可以看到,PE文件结构中大多用的是偏移地址,因此,只要偏移地址和实际的数据相符,那么PE文件格式有可能是嵌套的。也就是说,PE文件是可以变形的,只要保证其偏移地址和PE文件格式的结构基本就没多大问题。

对于 PE 可执行文件来说,为了保护可执行文件或者是压缩可执行文件,通常会对该文件进行加壳。接触过软件破解的人应该都清楚壳的概念。关于壳的概念,这里就不多说了。下面来写一个查壳的工具。

首先,用ASPack给前面写的程序加个壳。打开ASPack加壳工具,如图6-28所示。

对测试用的软件进行一次加壳,不过在加壳前先用PEiD查看一下,如图6-29所示。

从图6-29 可以看出,该程序是Visual C++ 5.0 Debug 版的程序。其实该程序是用Visual C++ 6.0 写的,这里是PEiD识别有误。不过只要用Visual C++ 6.0 进行编译选择Release 版时, PEiD是可以正确进行识别的。使用ASPack对该程序进行加壳,然后用PEiD查壳,如图6-30所示。

从图6-30中可以看出,PEiD识别出文件被加过壳,且是用ASPack进行加壳的。PEiD如何识别程序被加壳,以及加了哪种壳呢?在 PEiD 的目录下有一个特征码文件,名为“userdb.txt”。打开这个文件,看大概内容就能知道里边保存了壳的特征码。程序员的任务就是自己实现一个这个壳的识别工具。

壳的识别是通过特征码进行的,特征码的提取通常是选择文件的入口处。壳会修改程序的入口处,因此对于壳的特征码来说,选择入口处比较合适。这里的工具主要是用来学习和演示用的,因此写的查壳工具要能识别两种类型,第一种类型是可以识别用 Visual C++ 6.0编译出来的文件,第二种类型是可以识别ASPack加壳后的程序。当然,ASPack加壳工具的版本众多,这里只要能识别上面所演示版本的ASPack就可以了。

如何提取特征码呢?程序无论是在磁盘上还是在内存中,都是以二进制的形式存在的。前面也提到,特征码是从程序的入口处进行提取的,那么可以使用C32Asm以十六进制的形式打开这些文件,在入口处提取特征码,也可以用OD将程序载入内存后提取特征码。这里选择使用OD提取特征码。用OD载入未加壳的程序,如图6-31所示。

可以看到,这就是未加壳程序的入口处代码。在图6-31中,“HEX数据”列中就是代码对应的十六进制编码,这里要做的就是提取这些十六进制编码。提取结果如下:

"\x55\x8B\xEC\x6A\xFF\x68\x00\x65\x41\x00" \

"\x68\xE8\x2D\x40\x00\x64\xA1\x00\x00\x00" \

"\x00\x50\x64\x89\x25\x00\x00\x00\x00\x83" \

"\xC4\x94"

根据这个步骤,把ASPack的特征码也提取出来,提取结果如下:

"\x60\xE8\x03\x00\x00\x00\xE9\xEB\x04\x5D" \

"\x45\x55\xC3\xE8\x01\x00\x00\x00\xEB\x5D" \

"\xBB\xED\xFF\xFF\xFF\x03\xDD\x81\xEB\x00"

"\xC0\x01"

有了这些特征码,就可以开始编程了。先来定义一个数据结构,用来保存特征码,该结构如下:

#define NAMELEN 20

#define SIGNLEN 32

typedef struct _SIGN

{

char szName[NAMELEN];

BYTE bSign[SIGNLEN + 1];

}SIGN, *PSIGN;

利用该数据结构定义2个保存特征码的全局变量,如下:

SIGN Sign[2] =

{

{

//VC6

"VC6",

"\x55\x8B\xEC\x6A\xFF\x68\x00\x65\x41\x00" \

"\x68\xE8\x2D\x40\x00\x64\xA1\x00\x00\x00" \

"\x00\x50\x64\x89\x25\x00\x00\x00\x00\x83" \

"\xC4\x94"

},

{

//ASPACK

"ASPACK",

"\x60\xE8\x03\x00\x00\x00\xE9\xEB\x04\x5D" \

"\x45\x55\xC3\xE8\x01\x00\x00\x00\xEB\x5D" \

"\xBB\xED\xFF\xFF\xFF\x03\xDD\x81\xEB\x00"

"\xC0\x01"

}};

程序界面是在PE查看器的基础上完成的,如图6-32所示。

提取特征码后,查壳工作只剩特征码匹配了。这非常简单,只要用文件的入口处代码和特征码进行匹配,匹配相同就会给出相应的信息。查壳的代码如下:

VOID CPeParseDlg::GetPeInfo()

{

PBYTE pSign = NULL;

//定位文件入口位置

pSign = (PBYTE)((DWORD)m_lpBase

+ m_pNtHdr->OptionalHeader.AddressOfEntryPoint);

//比较入口特征码

if ( memcmp(Sign[0].bSign, pSign, SIGNLEN) == 0 )

{

SetDlgItemText(IDC_EDIT_PEINFO, Sign[0].szName);

}

else if ( memcmp(Sign[1].bSign, pSign, SIGNLEN) == 0 )

{

SetDlgItemText(IDC_EDIT_PEINFO, Sign[1].szName);

}

else

{

SetDlgItemText(IDC_EDIT_PEINFO, "未知");

}

}

这样,查壳程序的功能就完成了。在程序中提取的特征码的长度为32字节,由于这里只是一个简单的例子,读者在提取特征码的时候,为了提高准确率,需要多进行一些测试。

6.4.3 地址转换器

前面介绍了关于PE文件的3种地址,分别是VA(虚拟地址)、RVA(相对虚拟地址)和FileOffset(文件偏移地址)。这3种地址的转换如果始终使用手动来计算会非常累,因此通常的做法是借助工具来完成。前面介绍了使用LordPE 来计算这3 种地址的转换,现在来编写一个对这3种地址进行转换的工具。该工具如图6-33所示。

这个工具是在前两个工具的基础上完成的。因此,在进行计算的时候,应该先要进行“查看”,再进行“计算”。否则,该获取的指针还没有获取到。

在界面上,左边的3个按钮是“单选框”,单选框的设置方法如图6-34所示。

3 个单选框中只能有一个是选中状态,为了记录哪个单选框是选中状态,在类中定义一个成员变量m_nSelect。对3个单选框,分别使m_nSelect值为1、2和3。关于界面的编程,请读者参考源代码,这里就不进行过多的介绍了。下面来看主要的代码。

在单击“计算”按钮后,响应该按钮的代码如下:

void CPeParseDlg::OnBtnCalc()

{

//TODO: Add your control notification handler code here

DWORD dwAddr = 0;

//获取的地址

dwAddr = GetAddr();

//地址所在的节

int nInNum = GetAddrInSecNum(dwAddr);

//计算其他地址

CalcAddr(nInNum, dwAddr);

}

分别看一下GetAddr()、GetAddrInSecNum()和CalcAddr()的实现。

获取在编辑框中输入的地址内容的代码如下:

DWORD CPeParseDlg::GetAddr()

{

char szAddr[10] = { 0 };

DWORD dwAddr = 0;

switch ( m_nSelect )

{

case 1:

{

GetDlgItemText(IDC_EDIT_VA, szAddr, 10);

HexStrToInt(szAddr, &dwAddr);

break;

}

case 2:

{

GetDlgItemText(IDC_EDIT_RVA, szAddr, 10);

HexStrToInt(szAddr, &dwAddr);

break;

}

case 3:

{

GetDlgItemText(IDC_EDIT_FILEOFFSET, szAddr, 10);

HexStrToInt(szAddr, &dwAddr);

break;

}

}

return dwAddr;

}

获取该地址所属的第几个节的代码如下:

int CPeParseDlg::GetAddrInSecNum(DWORD dwAddr)

{

int nInNum = 0;

int nSecNum = m_pNtHdr->FileHeader.NumberOfSections;

switch ( m_nSelect )

{

case 1:

{

DWORD dwImageBase = m_pNtHdr->OptionalHeader.ImageBase;

for ( nInNum = 0; nInNum < nSecNum; nInNum ++ )

{

if ( dwAddr >= dwImageBase + m_pSecHdr[nInNum].VirtualAddress&& dwAddr <= dwImageBase + m_pSecHdr[nInNum].VirtualAddress+ m_pSecHdr[nInNum].Misc.VirtualSize)

{

return nInNum;

}

}

break;

}

case 2:

{

for ( nInNum = 0; nInNum < nSecNum; nInNum ++ )

{

if ( dwAddr >= m_pSecHdr[nInNum].VirtualAddress&& dwAddr <= m_pSecHdr[nInNum].VirtualAddress+ m_pSecHdr[nInNum].Misc.VirtualSize)

{

return nInNum;

}

}

break;

}

case 3:

{

for ( nInNum = 0; nInNum < nSecNum; nInNum ++ )

{

if ( dwAddr >= m_pSecHdr[nInNum].PointerToRawData&& dwAddr <= m_pSecHdr[nInNum].PointerToRawData+ m_pSecHdr[nInNum].SizeOfRawData)

{

return nInNum;

}

}

break;

}

}

return -1;

}

计算其他地址的代码如下:

VOID CPeParseDlg::CalcAddr(int nInNum, DWORD dwAddr)

{

DWORD dwVa = 0;

DWORD dwRva = 0;

DWORD dwFileOffset = 0;

switch ( m_nSelect )

{

case 1:

{

dwVa = dwAddr;

dwRva = dwVa - m_pNtHdr->OptionalHeader.ImageBase;

dwFileOffset = m_pSecHdr[nInNum].PointerToRawData+ (dwRva - m_pSecHdr[nInNum].VirtualAddress);

break;

}

case 2:

{

dwVa = dwAddr + m_pNtHdr->OptionalHeader.ImageBase;

dwRva = dwAddr;

dwFileOffset = m_pSecHdr[nInNum].PointerToRawData+ (dwRva - m_pSecHdr[nInNum].VirtualAddress);

break;

}

case 3:

{

dwFileOffset = dwAddr;

dwRva = m_pSecHdr[nInNum].VirtualAddress+ (dwFileOffset - m_pSecHdr[nInNum].PointerToRawData);

dwVa = dwRva + m_pNtHdr->OptionalHeader.ImageBase;

break;

}

}

SetDlgItemText(IDC_EDIT_SECTION, (const char *)m_pSecHdr[nInNum].Name);

CString str;

str.Format("%08X", dwVa);

SetDlgItemText(IDC_EDIT_VA, str);

str.Format("%08X", dwRva);

SetDlgItemText(IDC_EDIT_RVA, str);

str.Format("%08X", dwFileOffset);

SetDlgItemText(IDC_EDIT_FILEOFFSET, str);

}

代码都不复杂,关键就是CalcAddr()中3种地址的转换。如果读者没能理解代码,请参考前面手动转换3种地址的方法,这里就不进行介绍了。

6.4.4 添加节区

添加节区在很多场合都会用到,比如在加壳中、在免杀中都会经常用到对 PE 文件添加一个节区。添加一个节区的方法有4步,第1步是在节表的最后面添加一个IMAGE_SECTI ON_HEADER,第2步是更新IMAGE_FILE_HEADER中的NumberOfSections字段,第3步是更新IMAGE_OPTIONAL_HEADER 中的SizeOfImage字段,最后一步则是添加文件的数据。当然,前3步是没有先后顺序的,但是最后一步一定要明确如何改变。

注:某些情况下,在添加新的节区项以后会向新节区项的数据部分添加一些代码,而这些代码可能要求在程序执行之前就被执行,那么这时还需要更新 IMAGE_OPTIONAL _HEADER中的AddressOfEntryPoint字段。

1.手动添加一个节区

先来进行一次手动添加节区的操作,这个过程是个熟悉上述步骤的过程。网上有很多现成的添加节区的工具。这里自己编写工具的目的是掌握和了解其实现方法,锻炼编程能力;手动添加节区是为了巩固前面的知识,熟悉添加节区的步骤。

接下来还是使用前面的测试程序。使用C32Asm用十六进制编辑方式打开这个程序,并定位到其节表处,如图6-35所示。

从图6-35中可以看到,该PE文件有3个节表。直接看十六进制信息可能很不方便(看多了就习惯了),为了直观方便地查看节表中 IMAGE_SECTION_HEADER 的信息,那么使用LordPE进行查看,如图6-36所示。

用LordPE工具查看的确直观多了。对照LordPE显示的节表信息来添加一个节区。回顾一下IMAGE_SECTION_HEADER结构体的定义,如下:

typedef struct _IMAGE_SECTION_HEADER {

BYTE Name[IMAGE_SIZEOF_SHORT_NAME];

union {

DWORD PhysicalAddress;

DWORD VirtualSize;

} Misc;

DWORD VirtualAddress;

DWORD SizeOfRawData;

DWORD PointerToRawData;

DWORD PointerToRelocations;

DWORD PointerToLinenumbers;

WORD NumberOfRelocations;

WORD NumberOfLinenumbers;

DWORD Characteristics;

} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

IMAGE_SECTION_HEADER结构体的成员很多,但是真正要使用的只有6个,分别是Name、VirtualSize、VritualAddress、SizeOfRawData、PointerToRawData 和 Characteristics。这6项刚好与LordPE显示的6项相同。其实IMAGE_SECTION_HEADER结构体中其余的成员几乎不被使用。下面介绍如何添加这些内容。

IMAGE_SECTION_HEADER的长度为40字节,是十六进制的0x28,在C32Asm中占用2行半的内容,这里一次把这两行半的内容手动添加进去。回到C32Asm中,在最后一个节表的位置处开始添加内容,首先把光标放到右边的ASCII字符中,输入“.test”,如图6-37所示。

接下来在00000240位置处添加节的大小,该大小直接是对齐后的大小即可。由于文件对齐是0x1000字节,也就是4096字节,那么采用最小值即可,使该值为0x1000。不知道读者是否还记得前面提到的字节顺序的问题,在C32Asm中添加时,正确的添加应当是“00 10 00 00”,以后添加时也要注意字节顺序。在添加后面几个成员时,不再提示注意字节顺序,读者应时刻清楚这点。在添加该值时,应当将光标定位在十六进制编辑处,而不是刚才所在的ASCII字符处。顺便要把VirutalAddress也添加上,VirtualAddress的值是前一个节区的起始位置加上上一个节对齐后的长度的值,上一个节区的起始位置为0x6000,上一个节区对齐后的长度为0x3000,因此新节区的起始位置为0x9000。添加VirtualSize和VirtualAddress后如图6-38所示。

接下来的两个字段分别是SizeOfRawData和PointerToRawData,其添加方法类似前面两个字段的添加方法,这里就不细说了。分别添加“0x9000”和“0x1000”两个值,如图6-39所示。

PointerToRawData 后面的 12 字节都可以为 0,只要修改最后 4 字节的内容,也就是Characteristics的值即可。这个值直接使用上一个节区的值即可,实际添加时应根据所要节的属性给值。这里为了省事而直接使用上一个节区的属性,如图6-40所示。

整个节表需要添加的地方就添加完成了,接下来需要修改该 PE 文件的节区数量。当前节区数量是3,这里要修改为4。虽然可以通过LordPE等修改工具完成,但是这里仍然使用手动修改。对于修改的位置,请读者自行定位找到,修改如图6-41所示。

除了节区数量以外,还要修改文件映像的大小,也就是前面提到的 SizeOfImage 的值。由于新添加了节区,那么应该把该节区的大小加上SizeOfImage的大小,即为新的SizeOfImage的大小。现在的SizeOfImage的大小为0x9000,加上新添加节区的大小为0xa000。SizeOfImage的位置请读者自行查找,修改如图6-42所示。

修改 PE 结构字段的内容都已经做完了,最后一步就是添加真实的数据。由于这个节区不使用,因此填充0值就可以了,文件的起始位置为0x9000,长度为0x1000。把光标移到文件的末尾,单击“编辑”->“插入数据”命令,在“插入数据大小”文本框中输入十进制的4096,也就是十六进制的0x1000,如图6-43所示。

单击“确定”按钮,可以看到在刚才的光标处插入了很多0值,这样工作也完成了。单击“保存”按钮进行保存,提示是否备份,选择“是”。然后用LordPE查看添加节区的情况,如图6-44所示。

对比前后两个文件的大小,如图6-45所示。

从图6-45中可以看出,添加节区后的文件比原来的文件大了4KB,这是由于添加了4096字节的0值。也许读者最关心的不是大小问题,而是软件添加了大小后是否真的可以运行。其实试运行一下,是可以运行的。

上面的整个过程就是手动添加一个新节区的全部过程,除了特有的几个步骤以外,要注意新节区的内存起始位置和文件起始位置的值。相信通过上面手动添加节区,读者对此已经非常熟悉了。下面就开始通过编程来完成添加节区的任务。

补充:在C32Asm软件中可以快速定位PE结构的各个结构体和字段的位置,在菜单栏单击“查看(V)”->“PE信息(P)”即可在C32Asm工作区的左侧打开一个PE结构字段的解析面板,在面板上双击PE结构的每个字段则可在C32Asm工作区中定位到十六进制形式的PE结构字段的数据。

2.通过编程添加节区

通过编程添加一个新的节区无非就是文件相关的操作,只是多了一个对 PE 文件的解析和操作而已。添加节区的步骤和手动添加节区的步骤是一样的,只要一步一步按照上面的步骤写代码就可以了。在开始写代码前,首先修改FileCreate()函数中的部分代码,如下:

m_hMap = CreateFileMapping(m_hFile, NULL,

PAGE_READWRITE /*| SEC_IMAGE*/,

0, 0, 0);

if ( m_hMap == NULL )

{

CloseHandle(m_hFile);

return bRet;

}

这里要把SEC_IMAGE宏注释掉。因为要修改内存文件映射,有这个值会使添加节区失败,因此要将其注释掉或者直接删除掉。

程序的界面如图6-46所示。

首先编写“添加”按钮响应事件,代码如下:

void CPeParseDlg::OnBtnAddSection()

{

//TODO: Add your control notification handler code here

//节名

char szSecName[8] = { 0 };

//节大小

int nSecSize = 0;

GetDlgItemText(IDC_EDIT_SECNAME, szSecName, 8);

nSecSize = GetDlgItemInt(IDC_EDIT_SEC_SIZE, FALSE, TRUE);

AddSec(szSecName, nSecSize);

}

按钮事件中最关键的地方是AddSec()函数。该函数有2个参数,分别是添加节的名称与添加节的大小。这个大小无论输入多大,最后都会按照对齐方式进行向上对齐。看一下AddSec()函数的代码,如下:

VOID CPeParseDlg::AddSec(char *szSecName, int nSecSize)

{

int nSecNum = m_pNtHdr->FileHeader.NumberOfSections;

DWORD dwFileAlignment = m_pNtHdr->OptionalHeader.FileAlignment;

DWORD dwSecAlignment = m_pNtHdr->OptionalHeader.SectionAlignment;

PIMAGE_SECTION_HEADER pTmpSec = m_pSecHdr + nSecNum;

//拷贝节名

strncpy((char *)pTmpSec->Name, szSecName, 7);

//节的内存大小

pTmpSec->Misc.VirtualSize = AlignSize(nSecSize, dwSecAlignment);

//节的内存起始位置

pTmpSec->VirtualAddress=m_pSecHdr[nSecNum-1].VirtualAddress+AlignSize(m_pSecHdr[nSecNum - 1].Misc.VirtualSize, dwSecAlignment);

//节的文件大小

pTmpSec->SizeOfRawData = AlignSize(nSecSize, dwFileAlignment);

//节的文件起始位置

pTmpSec->PointerToRawData=m_pSecHdr[nSecNum-1].PointerToRawData+AlignSize(m_pSecHdr[nSecNum - 1].SizeOfRawData, dwSecAlignment);

//修正节数量

m_pNtHdr->FileHeader.NumberOfSections ++;

//修正映像大小

m_pNtHdr->OptionalHeader.SizeOfImage += pTmpSec->Misc.VirtualSize;

FlushViewOfFile(m_lpBase, 0);

//添加节数据

AddSecData(pTmpSec->SizeOfRawData);

EnumSections();

}

代码中每一步都按照相应的步骤来完成,其中用到的 2 个函数分别是 AlignSize()和AddSecData()。前者是用来进行对齐的,后者是用来在文件中添加实际的数据内容的。这两个函数非常简单,代码如下:

DWORD CPeParseDlg::AlignSize(int nSecSize, DWORD Alignment)

{

int nSize = nSecSize;

if ( nSize % Alignment != 0 )

{

nSecSize = (nSize / Alignment + 1) * Alignment;

}

return nSecSize;

}

VOID CPeParseDlg::AddSecData(int nSecSize)

{

PBYTE pByte = NULL;

pByte = (PBYTE)malloc(nSecSize);

ZeroMemory(pByte, nSecSize);

DWORD dwNum = 0;

SetFilePointer(m_hFile, 0, 0, FILE_END);

WriteFile(m_hFile, pByte, nSecSize, &dwNum, NULL);

FlushFileBuffers(m_hFile);

free(pByte);

}

整个添加节区的代码就完成了,仍然使用最开始的那个简单程序进行测试,看是否可以添加一个节区,如图6-47所示。

从图6-47中可以看出,添加节区是成功的。试着运行一下添加节区后的文件,可以正常运行,而且添加节区的文件比原文件大了4KB,和前面手动添加的效果是一样的。

至此,对PE文件结构的介绍就结束了。其实,PE文件结构还有很多比较重要的内容,但是这里只介绍了一些基础的知识。至于其他的内容,请读者自行学习。PE结构查看器最关键的是兼容性,PE结构是可以进行各种变形的。常规的PE结构也许比较好解析,但是经过变形的 PE 结构解析起来就可能会出错,因此要不断地尝试去解析不同的 PE 文件结构,PE查看器兼容性才会不断地完善。前面介绍了通过C32Asm手动分析PE文件结构,这种方法有助于完善 PE 查看器。这就好比数据恢复一样,数据恢复高手绝对不是简单地通过数据恢复工具来进行。虽然高手也在使用工具,但是如果遇到较为复杂的情况,数据恢复工具可能就会显得无力,那么手动分析文件系统格式将是唯一的方法。

6.5 破解基础知识及调试API函数的应用

在介绍完PE文件结构以后,接下来介绍调试API。调试API是系统留给用户进行程序调试的接口,其功能非常强大。在介绍调试API以前,先来回顾一下OD的使用。OD是用来调试应用程序的强大的工具。第5章中对其进行了简单的介绍,本章中将通过实例来回顾其强大功能。同样,为了后续的部分较容易理解,这里写一个简单程序,用OD来进行调试。除了介绍调试API以外,还会介绍一些简单的与破解相关的内容。当然,破解是一个技术的积累,也是要靠多方面技术的综合应用,希望这些简单的基础知识能给读者起到一个抛砖引玉的作用。

6.5.1 CrackMe 程序的编写

下面将写一个 CrackMe程序,CrackMe 的意思是“来破解我”。这里提到的破解是针对软件方面来说的,不是网络中的破解。对于软件的破解来说,主要是解除软件中的各种限制,比如时间限制、序列号限制……对于破解来说,无疑与逆向工程有着密切的关系,想要突破任何一种限制都要去了解该种限制的保护方式或保护机制。

破解别人的软件属于侵权行为,尽管有很多人在做这样的事情,但是大多人数是为了进行学习研究,而非用于牟取商业利益。因此,为了尊重他人的劳动成果,也为了避免给自己带来不必要的麻烦,读者尽可能找一些CrackMe来进行学习和研究。

下面来写一个非常简单的CrackMe程序,并进行“破解”。自己写 CrackMe 并破解,虽然这样省去了很多问题的思考,但是对于初学者来说仍然是一件非常有趣的事情。

这个程序使用MFC来编写,界面如图6-48所示。

从图6-48中可以看出,整个程序只有两个可以输入内容的文本编辑框和两个可以单击的按钮,除此之外什么都没有,更不会有什么提示。基本上这就是一个CrackMe的样子。不过有的人习惯在CrackMe中添加一个美女的照片,让界面显得美观诱惑,有的人喜欢给CrackMe加层壳来增加破解的难度,不过这些不重要,关键是要进行学习。在界面上输入一个账号和一个密码,当单击“确定”按钮后,该按钮会执行以下代码:

void CEasyCrackMeDlg::OnBtnReg()

{

//TODO: Add your control notification handler code here

char szUser[MAXBYTE] = { 0 };

char szPassword[MAXBYTE] = { 0 };

char szTmpPassword[MAXBYTE] = { 0 };

//获取输入的账号和密码

GetDlgItemText(IDC_EDIT_USER, szUser, MAXBYTE);

GetDlgItemText(IDC_EDIT_PASSWORD, szPassword, MAXBYTE);

//判断账号是否为空

if ( strlen(szUser) == 0 )

{

return ;

}

//判断密码是否为空

if ( strlen(szPassword) == 0 )

{

return ;

}

//判断账号长度是否小于7

if ( strlen(szUser) < 7 )

{

return ;

}

//根据账号生成密码

for ( int i = 0; i < strlen(szUser); i ++ )

{

if ( szUser[i] == 'Z'

|| szUser[i] == 'z'

|| szUser[i] == '9' )

{

szTmpPassword[i] = szUser[i];

}

else

{

szTmpPassword[i] = szUser[i] + 1;

}

}

//把生成的密码和输入的密码进行匹配

if ( strcmp(szTmpPassword, szPassword) == 0 )

{

MessageBox("密码正确");

}

else

{

MessageBox("密码错误");

}

}

整个代码非常简单。这段代码是通过输入的账号来生成密码的,而不是有固定的账号和固定的密码进行一一对应。生成密码的算法非常简单,把输入的账号的每一个ASCII码进行加1运算,但是有几个ASCII值除外。如果该ASCII码是字符大写“Z”、小写“z”或者是数字“9”,就不会进行加1运算。除了这点外,要求账号的长度必须大于7位,这也算是一个小小的要求了。

测试一下。输入一个小于7位的账号,再随便输入一个密码,单击“确定”按钮,这时程序不会有任何反应。那么,这次输入一个超过7位的账号,单击“确定”按钮来试试,如图6-49所示。

CrackMe提示“密码错误”。当然,密码是根据账号算出来的,而且跟账号的长度是相等的。那么,该如何获得这个CrackMe的密码呢?如果这个CrackMe不是自己写的,那该怎么办?想必读者都知道该怎么办,接下来的工作就交给OD来完成。

}

6.5.2 用OD破解CrackMe

对于破解来说,总是要找到一个突破点。而对于这个简单的CrackMe来说,突破点是非常多的。下面会以不同的方式来开始这次破解之旅,不需要有太多的汇编知识,毕竟这里是基础性的知识。要想深入学习破解,对破解有所了解的话,那么学习和掌握汇编是必修课。

1.破解方法一

现在用OD打开所编写的CrackMe,如图6-50所示。

还记得OD中各个窗口的作用吗?如果忘记,请参考第5章的内容。用OD打开CrackMe以后会看到很多汇编代码,这部分内容可以通过前面学习的汇编语言和逆向知识来进行阅读。这里会利用前面介绍的一些基本的破解技巧,通过这些技巧来完成破解工作。

首先来梳理一下思路,梳理思路的时候可以参考上面写的代码。输入“账号”及“密码”后,首先程序会从编辑框处获得“账号”的字符串及“密码”的字符串,然后进行一系列的比较验证,再通过“账号”来计算出正确的“密码”,最后来匹配正确的“密码”与输入的“密码”是否一致,根据匹配结果给出相应的提示。

上面是编写代码的流程和思路,也可根据这个思路合理地设置断点(设置断点也叫下断点)。“断点”就是产生中断的位置。通过下断点,可以让程序中断在需要调试或分析的地方。下断点在调试中起着非常大的作用,学会在合理的地方下断点也是一个技巧性的知识,合理地下断点有助于对软件进行分析和调试,读者应该学着掌握它。断点的分类很多,有内存断点、硬件断点、INT 3 断点……关于断点的知识和原理,将在稍后的内容中进行介绍,这里就不介绍了。

现在就可以选择合适的地方设置断点了。可以在 API 函数上设置断点,比如在GetDlgItemText()行设置断点,也可以选择在strlen()上设置断点,还可以在strcmp()上设置断点,甚至可以在MessageBox()上设置断点。上面的这些API函数都是可以设置断点的,但是对于GetDlgItemText()和MessageBox()I函数来说,需要下断点的时候指定是ANSI版本还是UNICODE 版本。也就是说,系统中是没有这两个函数的,根据版本的不同存在系统的函数只有GetDlgItemTextA()、GetDlgItemTextW()、MessageBoxA()和MessageBoxW()。通常使用ANSI版本的即可。

上面有如此多的 API 函数可供设置断点,那么要选择哪个进行设置呢?最好的选择是strcmp()函数,因为在比较函数处肯定会出现正确的“密码”。而在GetDlgItemTextA()和strlen()上设置断点,需要使用F8进行跟踪。如果在MessageBoxA()上设置断点,那么就不容易找到正确的“密码”存放的位置。所以选择在strcmp()上设置断点。在“命令”窗口中输入“bp strcmp”,然后按回车键,如图6-51所示。

如何知道断点是否设置成功呢?按下Alt + B 组合键,打开断点窗口可以查看,如图6-52所示。

断点已经设置好了,那么就按 F9 键来运行程序。CrackMe 启动了,输入一个长度大于等于7位的“账号”:“testtest”,然后随便输入一个“密码”:“123456”,单击“确定”按钮, OD中断在断点处,如图6-53所示。

从图6-53中可以看到,OD断在了strcmp函数的首地址处,地址为10217570。当OD被断下后,在菜单栏的下方会看到“暂停”字样的状态。断在这里如何找到真正的密码呢?其实在提示的地方已经显示出了正确的密码,也可以通过查看栈窗口来找到正确的密码。函数参数的传递是依赖于栈的。对于 C 语言来说,函数的参数是从右往左依次入栈的。strcmp()函数有两个参数,分别是要进行比较的字符串。在栈窗口中可以看到输入的密码及正确的密码,如图6-54所示。

可以看到,在调用 strcmp()时,传递的两个参数的值分别是“123456”和“uftuuftu”两个字符串。前面的字符串肯定是输入的密码,那么后面的字符串肯定就是正确的密码了。按F9键运行程序,会出现对话框提示密码错误。现在关闭OD,直接打开CrackMe。仍然用刚才的账号“testtest”,然后输入密码“uftuuftu”,单击“确定”按钮,会提示“密码正确”,如图6-55所示。

这样就完成了破解。这种方法比较简单,只要在 strcmp()函数处设置断点即可。读者可以试着在其他几个API函数处设置断点,然后试着找到正确的注册码。接下来,尝试使用另外的方法来对CrackMe进行破解。

2.破解方法二

在上一种方法中,通过对API函数设置断点找到了正确的密码。现在通过提示字符串来完成破解。在不知道正确密码的情况下输入密码,通常会得到的提示字符串是“密码错误”。只要在程序中寻找该字符串,并且查看是何处使用了该字符串,那么就可以对破解起到提示性的作用。

用OD打开CrackMe程序,然后在反汇编界面处单击鼠标右键,在弹出的菜单中依次选择“Ultra String Reference”->“Find ASCII”命令,会出现“Ultra String Reference”窗口,如图6-56所示。

在“Ultra String Reference”窗口中可以看到两个非常熟悉的字符串,双击“密码正确”字符串,来到00401EAE地址处,该地址内容如图6-57所示。

从图6-57中可以看到3处比较关键性的内容,第一个是strcmp()函数,第二个是字符串“密码正确”,第三个是字符串“密码”错误。由这3个内容可以联想到,这和C代码基本上是对应的。根据 strcmp()的比较结果,if…else…会选择不同的流程执行。也就是说,只要改变比较的结果或者更换比较的条件,都可以改变程序的流程。下面主要讲述修改比较条件的方法,拿具体的例子来解释,具体代码如下:

//把生成的密码和输入的密码进行匹配

if ( strcmp(szTmpPassword, szPassword) == 0 )

{

MessageBox("密码正确");

else

{

MessageBox("密码错误");

}

strcmp()是字符串比较函数。如果两个字符串相等,也就是说,输入的密码与正确的密码匹配,则执行“密码正确”流程;否则反之。修改一下比较的条件,也就是说,两个密码匹配不成功,使其执行“密码成功”的流程。这样,输入错误的密码也会提示“密码正确”。在C语言中的修改很简单,只要修改为如下代码即可:

if ( strcmp(szTmpPassword, szPassword) != 0 )

但是对于反汇编应该如何做呢?其实非常简单,再看一下图 6-57 中的那几条反汇编代码。想要修改其判断条件,只要修改00401EA8处的指令代码JNZ SHORT 00401EBD即可。该指令的意思是如果比较结果不为 0,则跳转到 00401EBD 处执行。JNZ 指令是结果不为 0则跳转,只要把 JNZ 修改为 JZ 即可,JZ 的意思刚好与 JNZ 相反。修改方法很简单,选中00401EA8地址所在的行,按下空格键即可进行编辑,如图6-58所示。

单击窗口上的“汇编”按钮,然后按 F9 键运行,随便输入一个长度大于 7 位的账号,再输入一个密码,然后单击“确定”按钮,会提示“密码正确”,如图6-59所示。

关掉OD和CrackMe,然后直接运行CrackMe,随便输入账号和密码,单击“确定”按钮后提示密码错误。为什么呢?因为刚才只是在内存中进行了修改,需要对修改后的文件进行存盘,这样在以后运行时,该修改才有效。修改后的存盘方法为:选中修改的反汇编代码(可以多选几行,只要修改的那行被选中即可),然后单击右键,在弹出的菜单中选择“复制到可执行文件”->“选定内容”命令,会出现“文件”对话框,如图6-60所示。

在这个对话框中单击鼠标右键,在弹出的菜单中选择“保存文件”命令,然后进行保存。这样修改就存盘了。下次在执行该程序时,随便输入大于7位的账号和密码,都会提示“密码正确”。如果输入了正确的密码,那么会提示“密码错误”。

上面就是两种破解CrackMe的方法,这两种方法都是极其简单的方法,现在可能已经很不实用了。这里是为了学习,提高动手能力,而采用了这两种方法。

6.5.3 文件补丁及内存补丁

有时破解一个程序后可能会将其发布,而往往被破解的程序只是修改了其中一个程序而已,无须将整个软件都进行打包再次发布,只需要发布一个补丁程序即可。发布补丁常见的有三种情况,第一种情况是直接把修改后的文件发布出去,第二种情况是发布一个文件补丁,它去修改原始的待破解的程序,最后一种情况是发布一个内存补丁,它不修改原始的文件,而是修改内存中的指定部分。

3 种情况各有好处。第一种情况将已经修改后的程序发布出去,使用者只需要简单进行替换就可以了。但是有个问题,如果程序的版本较多,直接替换可能就会导致替换后的程序无法使用。第二种方法是发布文件补丁,该方法需要编写一个简单的程序去修改待破解的程序,在破解以前可以先对文件的版本进行判断,如果补丁和待破解程序的版本相同则进行破解,否则不进行破解。但是有时候修改了文件以后,程序可能无法运行,因为有的程序会对自身进行校验和比较,当校验和发生变化后,程序则无法运行。最后一种方式是内存补丁,也需要自己动手写程序,并且写好的补丁程序需要和待破解的程序放在同一个目录下,执行待破解的程序时,需要执行内存补丁程序,内存补丁程序会运行待破解的程序,然后比较补丁与程序的版本,最后进行破解。同样,如果有内存校验的话,也会导致程序无法运行。不过,无论是文件校验还是内存校验,都可以继续对被校验的部分进行打补丁来突破程序校验的部分。不过这不是本部分的重点,这里的重点是编写一个针对上一节程序的文件补丁程序和内存补丁程序。

1.文件补丁

用OD修改CrackMe是比较容易的,如果脱离OD该如何修改呢?其实在OD中修改反汇编的指令以后,对应地,在文件中修改的是机器码。只要在文件中能定位到指令对应的机器码的位置,那么直接修改机器码就可以了。JNZ对应的机器码指令为0x75,JZ对应的机器码指令为0x74。也就是说,只要在文件中找到这个要修改的位置,用十六进制编辑器把0x75修改为0x74即可。如何能把这个内存中的地址定位到文件地址呢?这就是前面介绍的PE文件结构中把VA转换为FileOffset的知识了。

具体的手动步骤,请读者自己尝试,这里直接通过写代码进行修改。为了简单起见,这里使用控制台来编写,而且直接对文件进行操作,省略中间的步骤。想必有了思路以后,对于读者来说就不是难事。

关于文件补丁的代码如下:

#include <windows.h>

#include <stdio.h>

int main(int argc, char* argv[])

{

//VA = 00401EA8

//FileOffset = 00001EA8

DWORD dwFileOffset = 0x00001EA8;

BYTE bCode = 0;

DWORD dwReadNum = 0;

//判断参数

if ( argc != 2 )

{

printf("Please input two argument \r\n");

return -1;

}

//打开文件

HANDLE hFile = CreateFile(argv[1],

GENERIC_READ | GENERIC_WRITE,

FILE_SHARE_READ,

NULL,

OPEN_EXISTING,

FILE_ATTRIBUTE_NORMAL,

NULL);

if ( hFile == INVALID_HANDLE_VALUE )

{

return -1;

}

SetFilePointer(hFile, dwFileOffset, 0, FILE_BEGIN);

ReadFile(hFile, (LPVOID)&bCode, sizeof(BYTE), &dwReadNum, NULL);

//比较当前位置是否为JNZ

if ( bCode != '\x75' )

{

printf("%02X \r\n", bCode);

CloseHandle(hFile);

return -1;

}

//修改为JZ

bCode = '\x74';

SetFilePointer(hFile, dwFileOffset, 0, FILE_BEGIN);

WriteFile(hFile, (LPVOID)&bCode, sizeof(BYTE), &dwReadNum, NULL);

printf("Write JZ is Successfully ! \r\n");

CloseHandle(hFile);

//运行

WinExec(argv[1], SW_SHOW);

getchar();

return 0;

}

代码给出了详细的注释,只需要把CrackMe文件拖放到文件补丁上或者在命令行下输入命令即可,如图6-61所示。

通常,在做文件补丁以前一定要对打算进行修改的位置进行比较,以免产生错误的修改。程序使用的方法是将要修改的部分读出来,看是否与用OD调试时的值相同,如果相同则打补丁。由于这里只是介绍编程知识,针对的是一个CrackMe。如果对某个软件进行了破解,自己做了一个文件补丁发布出去给别人使用,不进行相应的判断就直接进行修改,很有可能导致软件不能使用,因为对外发布以后不能确认别人所使用的软件的版本等因素。因此,在进行文件补丁时最好判断一下,或者是用CopyFile()对文件进行备份。

2.内存补丁

相对文件补丁来说,还有一种补丁是内存补丁。这种补丁是把程序加载到内存中以后对其进行修改,也就是说,本身是不对文件进行修改的。要将CrackMe载入内存中,载入内存可以调用CreateProcess()函数来完成,这个函数参数众多,功能强大。使用CreateProcess()创建一个子进程,并且在创建的过程中将该子进程暂停,那么就可以安全地使用WriteProcessMemory()函数来对 CrackMe 进行修改了。整个过程也比较简单,下面直接来阅读源代码:

#include <Windows.h>

#include <stdio.h>

int main(int argc, char* argv[])

{

//VA = 004024D8

DWORD dwVAddress = 0x00401EA8;

BYTE bCode = 0;

DWORD dwReadNum = 0;

//判断参数数量

if ( argc != 2 )

{

printf("Please input two argument \r\n");

return -1;

}

STARTUPINFO si = { 0 };

si.cb = sizeof(STARTUPINFO);

si.wShowWindow = SW_SHOW;

si.dwFlags = STARTF_USESHOWWINDOW;

PROCESS_INFORMATION pi = { 0 };

BOOL bRet = CreateProcess(argv[1],

NULL,

NULL,

NULL,

FALSE,

CREATE_SUSPENDED, // 将子进程暂停

NULL,

NULL,

&si,

&pi);

if ( bRet == FALSE )

{

printf("CreateProcess Error ! \r\n");

return -1;

}

ReadProcessMemory(pi.hProcess,

(LPVOID)dwVAddress,

(LPVOID)&bCode,

sizeof(BYTE),

&dwReadNum);

//判断是否为JNZ

if ( bCode != '\x75' )

{

printf("%02X \r\n", bCode);

CloseHandle(pi.hThread);

CloseHandle(pi.hProcess);

return -1;

}

//将JNZ修改为JZ

bCode = '\x74';

WriteProcessMemory(pi.hProcess,

(LPVOID)dwVAddress,

(LPVOID)&bCode,

sizeof(BYTE),

&dwReadNum);

ResumeThread(pi.hThread);

CloseHandle(pi.hThread);

CloseHandle(pi.hProcess);

printf("Write JZ is Successfully ! \r\n");

getchar();

return 0;

}

代码中的注释也比较详细,代码的关键是要进行比较,否则会造成程序的运行崩溃。在进行内存补丁前需要将线程暂停,这样做的好处是有些情况下可能没有机会进行补丁就已经执行完需要打补丁的地方了。当打完补丁以后,再恢复线程继续运行就可以了。

文件补丁与内存补丁已经介绍完了。这两种补丁,都是通过前面学到的知识来完成的,可见前面的基础知识的用处还是非常广泛的。用了这么多的篇幅来介绍使用 OD 破解CrackMe,也介绍了文件补丁和内存补丁,那么,接下来就开始学习调试API。掌握调试API以后,就可以打造一个类似于OD的应用程序调试器,下面来一步一步学习。

6.6 调试API函数的使用

Windows 中有些API 函数是专门用来进行调试的,被称作Debug API,或者是调试API。利用这些函数可以进行调试器的开发,调试器通过创建有调试关系的父子进程来进行调试,被调试进程的底层信息、即时的寄存器、指令等信息都可以被获取,进而用来分析。

上面介绍的OllyDbg调试器的功能非常强大,虽然有众多的功能,但是其基础的实现就是依赖于调试API。调试API函数的个数虽然不多,但是合理使用会产生非常大的作用。调试器依赖于调试事件,调试事件有着非常复杂的结构体。调试器有着固定的流程,由于实时需要等待调试事件的发生,其过程是一个调试循环体,非常类似于SDK开发程序中的消息循环。无论是调试事件还是调试循环,对于调试或者说调试器来说,其最根本、最核心的部分是中断,或者说其最核心的部分是可以捕获中断。

6.6.1 常见的3种断点方法

在前面介绍OD的时候提到过,产生中断的方法是设置断点。常见的产生中断的断点方法有3种,分别是中断断点、内存断点和硬件断点。下面介绍这3种断点的不同。

中断断点,这里通常指的是汇编语言中的int 3 指令,CPU 执行该指令时会产生一个断点,因此也常称之为INT3 断点。现在演示如何使用int 3 来产生一个断点,代码如下:

int main(int argc, char* argv[])

{

__asm int 3

return 0;

}

代码中使用了__asm,在__asm后面可以使用汇编指令。如果想添加一段汇编指令,方法是__asm{}。通过__asm可以在C 语言中进行内嵌汇编语言。在__asm后面直接使用的是int 3指令,这样会产生一个异常,称为断点中断异常。对这段简单的代码进行编译连接,并且运行。运行后出现错误对话框,如图6-62所示。

注:图6-62所示的异常对话框中通过链接“请单击此处”可以打开详细的异常报告。如果读者电脑与此处显示的对话框不同,请依次进行如下设置:在“我的电脑”上单击右键,在弹出的菜单中选择“属性”,打开“属性”对话框,选择“高级”选项卡,选择“错误报告”按钮,打开“错误汇报”界面,在该界面上选择“启用错误汇报”单选按钮,然后单击确定。通过这样的设置,就可以启动“异常对话框”了。对于分析程序的BUG、挖掘软件的漏洞,弹出异常对话框界面是非常有用的。

这个对话框可能常常见到,而且见到以后多半会很让人郁闷,通常情况是直接单击“不发送”按钮,然后关闭这个对话框。在这里,这个异常是通过int 3 导致的,不要忙着关掉它。通常在写自己的软件时如果出现这样的错误,应该去寻找更多的帮助信息来修正错误。单击“请单击此处”链接,出现如图6-63所示的对话框。

弹出“异常基本信息”对话框,因为这个对话框给出的信息实在太少了,继续单击“要查看关于错误报告的技术信息”后面的“请单击此处”链接,打开如图6-64所示的对话框。

通常情况下,在这个报告中只关心两个内容,一是Code,二是Address。在图6-64中, Code 后面的值为0x80000003, Address 后面的值为0x0000000000401028。Code 的值为产生异常的异常代码,Address是产生异常的地址。在Winnt.h中定义了关于Code的值,在这里0x80000003的定义为STATUS_BREAKPOINT,也就是断点中断。在Winnt.h中的定义为:

#define STATUS_BREAKPOINT   ((DWORD)0x80000003L)

可以看出,这里给的Address是一个VA(虚拟地址),用OD打开这个程序,直接按F9键运行,如图6-65和图6-66所示。

从图6-65中可以看到,程序执行停在了00401029位置处。从图6-66看到,INT3命令位于00401028位置处。再看一下图6-64中Address后面的值,为00401028。这也就证明了在系统的错误报告中可以给出正确的出错地址(或产生异常的地址)。这样在以后写程序的过程中可以很容易地定位到自己程序中有错误的位置。

注:在OD 中运行自己的int 3 程序时,可能OD 不会停在00401029 地址处,也不会给出类似图6-65的提示。在实验这个例子的时候需要对OD进行设置,在菜单中选择“选项”->“调试设置”,打开“调试选项”对话框,选择“异常”选项卡,取消“INT3 中断”复选框的选中状态,这样就可以按照该例子进行测试了。

回到中断断点的话题上,中断断点是由int 3 产生的,那么要如何通过调试器(调试进程)在被调试进程中设置中断断点呢?看图6-65中00401028地址处,在地址值的后面、反汇编代码的前面,中间那一列的内容是汇编指令对应的机器码。可以看出,INT3对应的机器码是0xCC。如果想通过调试器在被调试进程中设置INT3断点的话,那么只需要把要中断的位置的机器码改为0xCC即可。当调试器捕获到该断点异常时,修改为原来的值即可。

内存断点的方法同样是通过异常产生的。在Win32平台下,内存是按页进行划分的,每页的大小为4KB。每一页内存都有其各自的内存属性,常见的内存属性有只读、可读写、可执行、可共享等。内存断点的原理就是通过对内存属性的修改,本该允许进行的操作无法进行,这样便会引发异常。

在OD中关于内存断点有两种,一种是内存访问,另一种是内存写入。用OD随便打开一个应用程序,在其“转存窗口”(或者叫“数据窗口”)中随便选中一些数据点后单击右键,在弹出的菜单中选择“断点”命令,在“断点”子命令下会看到“内存访问”和“内存写入”两种断点,如图6-67所示。

下面通过简单例子来看如何产生一个内存访问异常,代码如下:

#include <Windows.h>

#define MEMLEN 0x100

int main(int argc, char* argv[])

{

PBYTE pByte = NULL;

pByte = (PBYTE)malloc(MEMLEN);

if ( pByte == NULL )

{

return -1;

}

DWORD dwProtect = 0;

VirtualProtect(pByte, MEMLEN, PAGE_READONLY, &dwProtect);

BYTE bByte = '\xCC';

memcpy(pByte, (const char *)&bByte, MEMLEN);

free(pByte);

return 0;

}

这个程序中使用了VirtualProtect()函数,该函数与第3章中介绍的VirtualProtectEx()函数类似,不过 VirtualProtect()是用来修改当前进程的内存属性的。读者如果不记得,可以参考MSDN。

对这个程序编译连接,并运行起来。熟悉的出错界面又出现在眼前,如图6-68所示。

按照前面介绍的步骤打开“错误报告内容”对话框,如图6-69所示。

按照上面的分析方法来看一下Code和Address这两个值。Code后面的值为0xc0000005,这个值在Winnt.h中的定义如下:

#define STATUS_ACCESS_VIOLATION  ((DWORD)0xC0000005L)

这个值的意义表示访问违例。Address后面的值为0x0000000000403093,这个值是地址,但是这里的地址根据程序来考虑,值是用 malloc()函数申请的,用于保存数据的堆地址,而不是用来保存代码的地址。这个地址就不进行测试了,因为是动态申请,很可能每次不同,因此读者了解就可以了。

硬件断点是由硬件进行支持的,它是硬件提供的调试寄存器组。通过这些硬件寄存器设置相应的值,然后让硬件断在需要下断点的地址。在CPU上有一组特殊的寄存器,被称作调试寄存器。该调试寄存器有8个,分别是DR0—DR7,用于设置和管理硬件断点。调试寄存器DR0—DR3用于存储所设置硬件断点的内存地址,由于只有4个调试寄存器可以用来存放地址,因此最多只能设置4个硬件断点。寄存器DR4和DR5是系统保留的,并没有公开其用处。调试寄存器DR6被称为调试状态寄存器,记录了上一次断点触发所产生的调试事件类型信息。调试寄存器DR7用于设置触发硬件断点的条件,比如硬件读断点、硬件访问断点或硬件执行断点。由于调试寄存器原理内容较多,这里就不具体进行介绍。

6.6.2 调试API函数及相关结构体介绍

通过前面的内容已经知道,调试器的根本是依靠中断,其核心也是中断。前面也演示了两个产生中断异常的例子。本小节的内容是介绍调试API函数及其相关的调试结构体。调试API 函数的数量非常少,但是其结构体是非常少有的较为复杂的。虽然说是复杂,其实只是嵌套的层级比较多,只要了解了较为常见的,剩下的可以自己对照MSDN进行学习。在介绍完调试API函数及其结构体后,再来简单演示如何通过调试API捕获INT3断点和内存断点。

1.创建调试关系

既然是调试,那么必然存在调试和被调试。调试和被调试的这种调试关系是如何建立起来的,这是读者首先要了解的内容。要使调试和被调试创建调试关系,就会用到两个函数中的一个,分别是CreateProcess()和DebugActiveProcess()。其中CreateProcess()函数已经介绍过了,那么如何使用 CreateProcess()函数来建立一个需要被调试的进程呢?回顾一下CreateProcess()函数,其定义如下:

BOOL CreateProcess(

LPCTSTR lpApplicationName,     // name of executable module

LPTSTR lpCommandLine,      // command line string

LPSECURITY_ATTRIBUTES lpProcessAttributes, // SD

LPSECURITY_ATTRIBUTES lpThreadAttributes, // SD

BOOL bInheritHandles,      // handle inheritance option

DWORD dwCreationFlags,      // creation flags

LPVOID lpEnvironment,      // new environment block

LPCTSTR lpCurrentDirectory,     // current directory name

LPSTARTUPINFO lpStartupInfo,     // startup information

LPPROCESS_INFORMATION lpProcessInformation // process information

);

现在要做的是创建一个被调试进程。CreateProcess()函数有一个 dwCreationFlags 参数,其取值中有两个重要的常量,分别为DEBUG_PROCESS和DEBUG_ONLY_THIS_PROCESS。DEBUG_PROCESS的作用是被创建的进程处于调试状态。如果一同指定了DEBUG_ONLY_THIS_PROCESS的话,那么就只能调试被创建的进程,而不能调试被调试进程创建出来的进程。只要在使用CreateProcess()函数时指定这两个常量即可。

除了CreateProcess()函数以外,还有一种创建调试关系的方法,该方法用的函数如下:

BOOL DebugActiveProcess(

DWORD dwProcessId // process to be debugged

);

这个函数的功能是将调试进程附加到被调试的进程上。该函数的参数只有一个,该参数指定了被调试进程的进程 ID 号。从函数名与函数参数可以看出,这个函数是和一个已经被创建的进程来建立调试关系的,跟 CreateProcess()的方法不一样。在 OD 中也同样有这个功能,打开 OD,选择菜单中的“文件”->“挂接”(或者是“附加”)命令,就出现“选择要附加的进程”窗口,如图6-70所示。

OD的这个功能是通过DebugActiveProcess()函数来完成的。

调试器与被调试的目标进程可以通过前两个函数建立调试关系,但是如何使调试器与被调试的目标进程断开调试关系呢?有一个很简单的方法:关闭调试器进程,这样调试器进程与被调试的目标进程会同时结束。也可以关闭被调试的目标进程,这样也可以断开调试关系。那如何让调试器与被调试的目标进程断开调试关系,又保持被调试目标进程的运行呢?这里介绍一个函数,函数名为DebugActiveProcessStop(),其定义如下:

WINBASEAPI

BOOL

WINAPI

DebugActiveProcessStop(

__in DWORD dwProcessId

);

该函数只有一个参数,就是被调试进程的进程 ID 号。使用该函数可以在不影响调试器进程和被调试进程的正常运行的情况下,将两者的关系解除。但是有一个前提,被调试进程需要处于运行状态,而不是中断状态。如果被调试进程处于中断状态时和调试进程解除调试关系,由于被调试进程无法运行而导致退出。

2.判断进程是否处于被调试状态

很多程序都要检测自己是否处于被调试状态,比如游戏、病毒,或者加壳后的程序。游戏为了防止被做出外挂而进行反调试,病毒为了给反病毒工程师增加分析难度而反调试。加壳程序是专门用来保护软件的,当然也会有反调试的功能(该功能仅限于加密壳,压缩壳一般没有反调试功能)。

本小节不是要介绍反调试,而是介绍一个简单的函数,这个函数是判断自身是否处于被调试状态,函数名为IsDebuggerPresent(),其定义如下:

BOOL IsDebuggerPresent(VOID);

该函数没有参数,根据返回值来判断是否处于被调试状态。这个函数也可以用来进行反调试。不过由于这个函数的实现过于简单,很容易就能够被分析者突破,因此现在也没有软件再使用该函数来进行反调试了。

下面通过一个简单的例子来演示IsDebuggerPresent()函数的使用,代码如下:

#include <Windows.h>

#include <stdio.h>

extern "C" BOOL WINAPI IsDebuggerPresent(VOID);

DWORD WINAPI ThreadProc(LPVOID lpParam)

{

while ( TRUE )

{

//检测用ActiveDebugProcess()来创建调试关系

if ( IsDebuggerPresent() == TRUE )

{

printf("thread func checked the debuggee \r\n");

break;

}

Sleep(1000);

}

return 0;

}

int main(int argc, char* argv[])

{

BOOL bRet = FALSE;

//检测CreateProcess()创建调试关系

bRet = IsDebuggerPresent();

if ( bRet == TRUE )

{

printf("main func checked the debuggee \r\n");

getchar();

return 1;

}

HANDLE hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

if ( hThread == NULL )

{

return -1;

}

WaitForSingleObject(hThread, INFINITE);

CloseHandle(hThread);

getchar();

return 0;

}

这个例子用来检测自身是否处于被调试状态。在进入主函数后,直接调用 IsDebugger Present()函数,判断是否被调试器创建。在自定义线程函数中,一直循环检测是否被附加。只要发现自身处于被调试状态,那么就在控制台中进行输出提示。

现在用OD对这个程序进行测试。首先用OD直接打开这个程序,并按F9键运行,如图6-71所示。

按下F9 键启动以后,控制台中输出“main func checked the debuggee”,也就是发现了调试器。再测试一下检测OD附加的效果。先运行这个程序,然后用OD去挂接它,看其提示,如图6-72所示。

控制台中输出“thread func checked the debuggee”。可以看出,用OD 进行附加也能够检测到自身处于被调试状态。

注意:进行该测试时请选用原版OD。由于检测是否处于被调试的这种方法过于简单,因此任何其他修改版的OD都可以将其突破,从而使得测试失败。

3.断点异常函数

有时为了调试方便可能会在自己的代码中插入__asm int 3,这样当程序运行到这里时会产生一个断点,就可以用调试器进行调试了。其实微软提供了一个函数,使用该函数可以直接让程序运行到某处的时候产生INT3断点,该函数的定义如下:

VOID DebugBreak(VOID);

修改一下前面的程序,把__asm int 3 替换为DebugBreak(),编译连接并运行。同样会因产生异常而出现“异常基本信息”对话框,查看它的“错误报告内容”,如图6-73所示。

Code 后面的值为 0x80000003 ,看到它就应该知道是 EXCEPTION_BREAKPOINT。Address后面的值为0x000000007c92120e,可以看出该值在系统的DLL文件中,因为调用的是系统提供的函数。

4.调试事件

调试器在调试程序的过程中是通过用户不断地下断点、单步等来完成的,而断点的产生在前面的内容中提到过一部分。通过前面介绍的INT3断点、内存断点和硬件断点可以得知,调试器是在捕获目标进程产生的断点或异常从而做出响应。当然,对于所介绍的断点来说是这样的。不过对于调试器来说,除了对断点和异常做出响应以外,还会对其他的一些事件做出响应,断点和异常只是所有调试能进行响应事件的一部分。

调试器的工作方式主要是依赖在调试过程中不断产生的调试事件。调试事件在系统中被定义为一个结构体,也是到目前为止要接触的最为复杂的一个结构体,因为这个结构体的嵌套关系很多。这个结构体的定义如下:

typedef struct _DEBUG_EVENT {

DWORD dwDebugEventCode;

DWORD dwProcessId;

DWORD dwThreadId;

union {

EXCEPTION_DEBUG_INFO Exception;

CREATE_THREAD_DEBUG_INFO CreateThread;

CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;

EXIT_THREAD_DEBUG_INFO ExitThread;

EXIT_PROCESS_DEBUG_INFO ExitProcess;

LOAD_DLL_DEBUG_INFO LoadDll;

UNLOAD_DLL_DEBUG_INFO UnloadDll;

OUTPUT_DEBUG_STRING_INFO DebugString;

RIP_INFO RipInfo;

} u;

} DEBUG_EVENT, *LPDEBUG_EVENT;

这个结构体非常重要,这里有必要详细地介绍。

dwDebugEventCode:该字段指定了调试事件的类型编码。在调试过程中可能产生的调试事件非常多,因此要根据不同的类型码进行不同的响应处理。常见的调试事件如图6-74所示。

dwProcessId:该字段指明了引发调试事件的进程ID号。

dwThreadId:该字段指明了引发调试事件的线程ID号。

u:该字段是一个联合体,其取值由dwDebugEventCode指定。该联合体包含很多个结构体,包括EXCEPTION_DEBUG_INFO、CREATE_THREAD_ DEBUG_INFO、CREATE_PRO CESS_DEBUG_INFO、EXIT_THREAD_DEBUG_INFO、EXIT_PROCESS_DEBUG_INFO、LOAD_DLL_DEBUG_INFO、UNLOAD_DLL_DEBUG_INFO和OUTPUT_DEBUG_STRING _INFO。

在以上众多的结构体中,特别要介绍一下 EXCEPTION_DEBUG_INFO,因为这个结构体包含关于异常相关的信息;而其他几个结构体的使用比较简单,读者可以参考MSDN。

EXCEPTION_DEBUG_INFO的定义如下:

typedef struct _EXCEPTION_DEBUG_INFO {

EXCEPTION_RECORD ExceptionRecord;

DWORD dwFirstChance;

} EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;

EXCEPTION_DEBUG_INFO包含的EXCEPTION_RECORD结构体中保存着真正的异常信息, dwFirstChance 中保存着 ExceptionRecord 的个数。EXCEPTION_RECORD 结构体的定义如下:

typedef struct _EXCEPTION_RECORD {

DWORD ExceptionCode;

DWORD ExceptionFlags;

struct _EXCEPTION_RECORD *ExceptionRecord;

PVOID ExceptionAddress;

DWORD NumberParameters;

ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];

} EXCEPTION_RECORD, *PEXCEPTION_RECORD;

ExceptionCode:异常码。该值在MSDN中的定义非常多,不过这里需要使用的值只有3个,分别是EXCEPTION_ACCESS_VIOLATION(访问违例)、EXCEPTION_ BREAKPOINT (断点异常)和 EXCEPTION_SINGLE_STEP(单步异常)。这 3 个值中的前两个值对于读者来说应该是非常熟悉的,因为在前面已经介绍过了;最后一个单步异常想必读者也非常熟悉。使用 OD 快捷键的 F7 键、F8 键时就是在使用单步功能,而单步异常就是由EXCEPTION_SINGLE _STEP 来表示的。

ExceptionRecord:指向一个 EXCEPTION_RECORD 的指针,异常记录是一个链表,其中可能保存着很多异常信息。

ExceptionAddress:异常产生的地址。

调试事件这个结构体 DEBUG_EVENT 看似非常复杂,其实也只是嵌套得比较深而已。只要读者仔细体会每个结构体、每层嵌套的含义,自然就觉得它没有多么复杂。

5.调试循环

调试器不断地对被调试目标进程进行捕获调试信息,有点类似于Win32应用程序的消息循环,但是又有所不同。调试器在捕获到调试信息后进行相应的处理,然后恢复线程,使之继续运行。

用来等待捕获被调试进程调试事件的函数是WaitForDebugEvent(),其定义如下:

BOOL WaitForDebugEvent(

LPDEBUG_EVENT lpDebugEvent,  // debug event information

DWORD dwMilliseconds   // time-out value

);

lpDebugEvent:该参数用于接收保存调试事件;

dwMilliseconds:该参数用于指定超时的时间,无限制等待使用INFINITE。

调试器捕获到调试事件后,会对被调试的目标进程中产生调试事件的线程进行挂起。调试器对被调试目标进程进行相应的处理后,需要使用 ContinueDebugEvent()对先前被挂起的线程进行恢复。ContinueDebugEvent()函数的定义如下:

BOOL ContinueDebugEvent(

DWORD dwProcessId,   // process to continue

DWORD dwThreadId,   // thread to continue

DWORD dwContinueStatus  // continuation status

);

dwProcessId:该参数表示被调试进程的进程标识符。

dwThreadId:该参数表示准备恢复挂起线程的线程标识符。

dwContinueStatus:该参数指定了该线程以何种方式继续执行,其取值为DBG_EXCEPTI ON_NOT_HANDLED 和 DBG_CONTINUE。对于这两个值来说,通常情况下并没有什么差别。但是当遇到调试事件中的调试码为 EXCEPTION_DEBUG_EVENT 时,这两个常量就会有不同的动作。如果使用DBG_EXCEPTION_NOT_HANDLED,调试器进程将会忽略该异常, Windows会使用被调试进程的异常处理函数对异常进行处理;如果使用DBG_CONTINUE的话,那么需要调试器进程对异常进行处理,然后继续运行。

由上面两个函数配合调试事件结构体,就可以构成一个完整的调试循环。以下这段调试循环的代码摘自MSDN:

DEBUG_EVENT DebugEv;      // debugging event information

DWORD dwContinueStatus = DBG_CONTINUE;  // exception continuation

for(;;)

{

//Wait for a debugging event to occur. The second parameter indicates

//that the function does not return until a debugging event occurs.

WaitForDebugEvent(&DebugEv, INFINITE);

//Process the debugging event code.

switch (DebugEv.dwDebugEventCode)

{

case EXCEPTION_DEBUG_EVENT:

//Process the exception code. When handling

//exceptions, remember to set the continuation

//status parameter (dwContinueStatus). This value

//is used by the ContinueDebugEvent function.

switch (DebugEv.u.Exception.ExceptionRecord.ExceptionCode)

{

case EXCEPTION_ACCESS_VIOLATION:

//First chance: Pass this on to the system.

//Last chance: Display an appropriate error.

case EXCEPTION_BREAKPOINT:

//First chance: Display the current

//instruction and register values.

case EXCEPTION_DATATYPE_MISALIGNMENT:

//First chance: Pass this on to the system.

//Last chance: Display an appropriate error.

case EXCEPTION_SINGLE_STEP:

//First chance: Update the display of the

//current instruction and register values.

case DBG_CONTROL_C:

//First chance: Pass this on to the system.

//Last chance: Display an appropriate error.

//Handle other exceptions.

}

case CREATE_THREAD_DEBUG_EVENT:

//As needed, examine or change the thread's registers

//with the GetThreadContext and SetThreadContext functions;

//and suspend and resume thread execution with the

//SuspendThread and ResumeThread functions.

case CREATE_PROCESS_DEBUG_EVENT:

//As needed, examine or change the registers of the

//process's initial thread with the GetThreadContext and

//SetThreadContext functions; read from and write to the

//process's virtual memory with the ReadProcessMemory and

//WriteProcessMemory functions; and suspend and resume

//thread execution with the SuspendThread and ResumeThread

//functions.

case EXIT_THREAD_DEBUG_EVENT:

//Display the thread's exit code.

case EXIT_PROCESS_DEBUG_EVENT:

//Display the process's exit code.

case LOAD_DLL_DEBUG_EVENT:

//Read the debugging information included in the newly

//loaded DLL.

case UNLOAD_DLL_DEBUG_EVENT:

//Display a message that the DLL has been unloaded.

case OUTPUT_DEBUG_STRING_EVENT:

//Display the output debugging string.

}

//Resume executing the thread that reported the debugging event.

ContinueDebugEvent(DebugEv.dwProcessId,

DebugEv.dwThreadId, dwContinueStatus);

}

以上就是一个完整的调试循环。不过有些调试事件对于读者来说可能是用不到的,那么就把不需要的调试事件所对应的case语句删除就可以了。

6.内存的操作

调试器进程通常要对被调试的目标进程进行内存的读取或写入。跨进程的内存读取和写入的函数其实在前面的章节已介绍过,就是ReadProcessMemory()和WriteProcessMemory()。

要对被调试的目标进程设置INT3断点,就需要使用WriteProcessMemory()函数对指定的位置写入0xCC。当INT3被执行后,要在原来的位置上把原来的机器码写回去,原来的机器码需要使用ReadProcessMemory()函数来进行读取。

内存操作除了以上两个函数以外,还有一个就是修改内存的页面属性的函数,即VirtualProtectEx()。这个函数在前面也介绍过了。

7.线程环境相关API及结构体

在前面的章节中介绍过,进程是用来向系统申请各种资源的,而真正被分配到CPU并执行代码的是线程。进程中的每个线程都共享进程的资源,但是每个线程都有不同的线程上下文或线程环境。Windows是一个多任务的操作系统,在Windows中为每一个线程分配一个时间片,当某个线程执行完其所属的时间片后,Windows会切换到另外的线程去执行。在进行线程切换以前有一步保存线程环境的工作,那就是保证在切换时线程的寄存器值、栈信息及描述符等相关的所有信息在切换回来后不变。只有把线程的上下文保存起来,下次该线程被CPU再次调度时才能正确地接着上次的工作继续进行。

在Windows系统下,将线程环境定义为CONTEXT结构体。该结构体需要在Winnt.h头文件中找到,在MSDN中并没有给出定义。CONTEXT结构体的定义如下:

//

//Context Frame

//

// This frame has a several purposes: 1) it is used as an argument to

// NtContinue, 2) is is used to constuct a call frame for APC delivery,

// and 3) it is used in the user level thread creation routines.

//

// The layout of the record conforms to a standard call frame.

//

typedef struct _CONTEXT {

//

//The flags values within this flag control the contents of

//a CONTEXT record.

//

//If the context record is used as an input parameter, then

//for each portion of the context record controlled by a flag

//whose value is set, it is assumed that that portion of the

//context record contains valid context. If the context record

//is being used to modify a threads context, then only that

//portion of the threads context will be modified.

//

//If the context record is used as an IN OUT parameter to capture

//the context of a thread, then only those portions of the thread's

//context corresponding to set flags will be returned.

//

//The context record is never used as an OUT only parameter.

//

DWORD ContextFlags;

//

//This section is specified/returned if CONTEXT_DEBUG_REGISTERS is

//set in ContextFlags. Note that CONTEXT_DEBUG_REGISTERS is NOT

//included in CONTEXT_FULL.

//

DWORD Dr0;

DWORD Dr1;

DWORD Dr2;

DWORD Dr3;

DWORD Dr6;

DWORD Dr7;

//

//This section is specified/returned if the

//ContextFlags word contians the flag CONTEXT_FLOATING_POINT.

//

FLOATING_SAVE_AREA FloatSave;

//

//This section is specified/returned if the

//ContextFlags word contians the flag CONTEXT_SEGMENTS.

//

DWORD SegGs;

DWORD SegFs;

DWORD SegEs;

DWORD SegDs;

//

//This section is specified/returned if the

//ContextFlags word contians the flag CONTEXT_INTEGER.

//

DWORD Edi;

DWORD Esi;

DWORD Ebx;

DWORD Edx;

DWORD Ecx;

DWORD Eax;

//

//This section is specified/returned if the

//ContextFlags word contians the flag CONTEXT_CONTROL.

//

DWORD Ebp;

DWORD Eip;

DWORD SegCs;   // MUST BE SANITIZED

DWORD EFlags;   // MUST BE SANITIZED

DWORD Esp;

DWORD SegSs;

//

//This section is specified/returned if the ContextFlags word

//contains the flag CONTEXT_EXTENDED_REGISTERS.

//The format and contexts are processor specific

//

BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];

} CONTEXT;

这个结构体看似很大,只要了解汇编语言其实也并不大。前面章节中介绍了关于汇编语言的知识,对于结构体中的各个字段,读者应该非常熟悉。关于各个寄存器的介绍,这里就不重复了,这需要读者翻看前面的内容。这里只介绍ContextFlags字段的功能,该字段用于控制GetThreadContext()和SetThreadContext()能够获取或写入的环境信息。ContextFlags的取值也只能在Winnt.h头文件中找到,其取值如下:

#define CONTEXT_CONTROL (CONTEXT_i386 | 0x00000001L) // SS:SP, CS:IP, FLAGS, BP

#define CONTEXT_INTEGER (CONTEXT_i386 | 0x00000002L) // AX, BX, CX, DX, SI, DI

#define CONTEXT_SEGMENTS (CONTEXT_i386 | 0x00000004L) // DS, ES, FS, GS

#define CONTEXT_FLOATING_POINT (CONTEXT_i386 | 0x00000008L) // 387 state

#define CONTEXT_DEBUG_REGISTERS (CONTEXT_i386 | 0x00000010L) // DB 0-3,6,7

#define CONTEXT_EXTENDED_REGISTERS (CONTEXT_i386|0x00000020L) //cpu specific exten-sions

#define CONTEXT_FULL (CONTEXT_CONTROL | CONTEXT_INTEGER |\

CONTEXT_SEGMENTS)

#define CONTEXT_ALL (CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS | CONTEXT_FLOATING_POINT | CONTEXT_DEBUG_REGISTERS | CONTEXT_EXTENDED_REGISTERS)

从这些宏定义的注释能很清楚地知道这些宏可以控制 GetThreadContext()和 SetThread Context()进行何种操作。用户在真正使用时进行相应的赋值就可以了。

注意:关于CONTEXT结构体可能会在Winnt.h头文件中找到多个定义,因为该结构体是与平台相关的。因此,在各种不同平台上,此结构体有所不同。

线程环境在Windows中定义了一个CONTEXT的结构体。要获取或设置线程环境的话,需要使用GetThreadContext()和SetThreadContext()。这两个函数的定义如下:

BOOL GetThreadContext(

HANDLE hThread,    // handle to thread with context

LPCONTEXT lpContext   // context structure

);

BOOL SetThreadContext(

HANDLE hThread,    // handle to thread

CONST CONTEXT *lpContext // context structure

);

这两个函数的参数基本一样,hThread表示线程句柄,而lpContext表示指向CONTEXT的指针。所不同的是,GetThreadContext()是用来获取线程环境的,SetThreadContext()是用来设置线程环境的。需要注意的是,在获取或设置线程的上下文时,请将线程暂停后进行,以免发生“不明现象”。

6.7 打造一个密码显示器

关于系统提供的调试API函数已经学习了不少,而且基本上常用到的函数都已学过。下面用调试API编写一个能够显示密码的程序。读者别以为这里写的程序什么密码都能显示,这是不可能的。下面针对前面的CrackMe来编写一个显示密码的程序。

在编写关于CrackMe的密码显示程序以前需要准备两项工作,第一项工作是知道要在什么地方合理地下断点,第二项工作是从哪里能读取到密码。带着这两个问题重新来思考一下。在这里的程序中,要对两个字符串进行比较,而比较的函数是strcmp(),该函数有两个参数,分别是输入的密码和真正的密码。也就是说,在调用strcmp()函数的位置下断点,通过查看它的参数是可以获取到正确的密码的。在调用strcmp()函数的位置设置INT3断点,也就是将 0xCC 机器码写入这个地址。用 OD 看一下调用 strcmp()函数的地址,如图 6-75所示。

从图6-75中可以看出,调用strcmp()函数的地址为00401E9E。有了这个地址,只要找到该函数的两个参数,就可以找到输入的错误的密码及正确的密码。从图6-75中可以看出,正确的密码的起始地址保存在EDX中,错误的密码的起始地址保存在ECX中。只要在00401E9E地址处下断点,并通过线程环境读取EDX和ECX寄存器值就可以得到两个密码的起始地址。

进行准备的工作已经做好了,下面来写一个控制台的程序。先定义两个常量,一个是用来设置断点的地址,另一个是INT3指令的机器码。定义如下:

//需要设置INT3 断点的位置

#define BP_VA 0x00401E9E

//INT3 的机器码

const BYTE bInt3 = '\xCC';

把CrackMe的文件路径及文件名当参数传递给显示密码的程序。显示的程序首先要以调试的方式创建CrackMe,代码如下:

//启动信息

STARTUPINFO si = { 0 };

si.cb = sizeof(STARTUPINFO);

GetStartupInfo(&si);

//进程信息

PROCESS_INFORMATION pi = { 0 };

//创建被调试进程

BOOL bRet = CreateProcess(pszFileName,

NULL,

NULL,

NULL,

FALSE,

DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS,

NULL,

NULL,

&si,

&pi);

if ( bRet == FALSE )

{

printf("CreateProcess Error \r\n");

return -1;

}

然后进入调试循环,要处理两个调试事件,一个是CREATE_PROCESS_DEBUG_EVENT,另一个是EXCEPTION_DEBUG_EVENT下的EXCEPTION_BREAKPOINT。处理CREATE_PROCESS_DEBUG_EVENT的代码如下:

//创建进程时的调试事件

case CREATE_PROCESS_DEBUG_EVENT:

{

//读取欲设置INT3断点处的机器码

//方便后面恢复

ReadProcessMemory(pi.hProcess,

(LPVOID)BP_VA,

(LPVOID)&bOldByte,

sizeof(BYTE),

&dwReadWriteNum);

//将INT3的机器码0xCC写入断点处

WriteProcessMemory(pi.hProcess,

(LPVOID)BP_VA,

(LPVOID)&bInt3,

sizeof(BYTE),

&dwReadWriteNum);

break;

}

在 CREATE_PROCESS_DEBUG_EVENT 中对调用 strcmp()函数的地址处设置 INT3 断点,再将 0xCC 写入这里时要把原来的机器码读取出来。读取原机器码使用 ReadProcess Memory(),写入INT3的机器码使用WriteProcessMemory()。读取原机器码的作用是当写入的0xCC产生中断以后,需要将原机器码写回,以便程序可以正确继续运行。

再来看一下EXCEPTION_DEBUG_EVENT下的EXCEPTION_BREAKPOINT是如何进行处理的,代码如下:

//产生异常时的调试事件

case EXCEPTION_DEBUG_EVENT:

{

//判断异常类型

switch ( de.u.Exception.ExceptionRecord.ExceptionCode )

{

//INT3类型的异常

case EXCEPTION_BREAKPOINT:

{

//获取线程环境

context.ContextFlags = CONTEXT_FULL;

GetThreadContext(pi.hThread, &context);

//判断是否断在设置的断点位置处

if ( (BP_VA + 1) == context.Eip )

{

//读取正确的密码

ReadProcessMemory(pi.hProcess,

(LPVOID)context.Edx,

(LPVOID)pszPassword,

MAXBYTE,

&dwReadWriteNum);

//读取错误密码

ReadProcessMemory(pi.hProcess,

(LPVOID)context.Ecx,

(LPVOID)pszErrorPass,

MAXBYTE,

&dwReadWriteNum);

printf("你输入的密码是: %s \r\n", pszErrorPass);

printf("正确的密码是: %s \r\n", pszPassword);

//指令执行了INT3而被中断

//INT3的机器指令长度为1字节

//因此需要将EIP减一来修正EIP

//EIP是指令指针寄存器

//其中保存着下条要执行指令的地址

context.Eip --;

//修正原来该地址的机器码

WriteProcessMemory(pi.hProcess,

(LPVOID)BP_VA,

(LPVOID)&bOldByte,

sizeof(BYTE),

&dwReadWriteNum);

//设置当前的线程环境

SetThreadContext(pi.hThread, &context);

}

break;

}

}

}

对于调试事件的处理,应该放到调试循环中。上面的代码给出的是对调试事件的处理,再来看一下调试循环的大体代码:

while ( TRUE )

{

//获取调试事件

WaitForDebugEvent(&de, INFINITE);

//判断事件类型

switch ( de.dwDebugEventCode )

{

//创建进程时的调试事件

case CREATE_PROCESS_DEBUG_EVENT:

{

break;

}

//产生异常时的调试事件

case EXCEPTION_DEBUG_EVENT:

{

//判断异常类型

switch ( de.u.Exception.ExceptionRecord.ExceptionCode )

{

//INT3类型的异常

case EXCEPTION_BREAKPOINT:

{

}

break;

}

}

}

ContinueDebugEvent(de.dwProcessId,de.dwThreadId,DBG_CONTINUE);

}

只要把调试事件的处理方法放入调试循环中,程序就完整了。接下来编译连接一下,然后把CrackMe直接拖放到这个密码显示程序上。程序会启动CrackMe进程,并等待用户的输入。输入账号及密码后,单击“确定”按钮,程序会显示出正确的密码和用户输入的密码,如图6-76所示。

根据图6-76显示的结果进行验证,可见获取的密码是正确的。程序到此结束,读者可以把该程序改成通过附加调试进程来显示密码,以巩固所学的知识。

6.8 KeyMake工具的使用

本章介绍了 PE 结构和调试原理,此外还介绍了文件补丁和内存补丁方面的知识。在此顺便介绍一款制作注册机的工具——KeyMake(《黑客帝国》第二部中的“关键人物”就是KeyMake,用来配钥匙的那个老头)。KeyMake的界面如图6-77所示。

KeyMake的功能非常多,这里主要介绍“其他”菜单下的功能,如图6-78所示。

KeyMake菜单有3个主要功能,分别是“内存注册机”、“制作文件补丁”和“制作内存补丁”。分别以前面的程序例子来制作3个补丁程序。

首先来制作“内存注册机”。在KeyMake的“其他”菜单下选择“内存注册机”,出现“设置注册机信息”界面,如图6-79所示。

在图 6-79 中的“程序名称”处选择前面写的 CrackMe 程序,然后单击“添加”按钮,出现“添加数据”界面,添加相应的数据,如图6-80所示。

在图6-80中,首先要添加中断地址,在“中断地址”处输入“00401E9E”,在“中断次数”处输入“1”,在“第一字节”处输入“E8”,在“指令长度”处输入“5”。为什么这么填写呢?对于“中断地址”、“第一字节”和“指令长度”的填写方法,参考图6-75就能够明白。“中断次数”是指在中断地址被断下第几次后去读取数据。由于正确“密码”在内存中,因此在“保存下列信息为注册码”窗口中选择“内存方式”,选择“寄存器”为“EDX”。这里也对照图6-75就可以明白。填写完上面的内容后,单击“添加”按钮则返回“设置注册机信息”界面,然后单击“生成”按钮,将“内存注册机”放在与CrackMe相同的目录下即可。然后运行生成的注册机,会出现 CrackMe 程序界面,随便输入一个“账号”和“密码”,单击“确定”按钮即可出现正确的注册码,如图6-81所示。

制作“文件补丁”相对于“内存注册机”要简单很多。制作“文件补丁”的 KeyMake界面如图6-82所示。

在图6-82中,在“原始的文件”处选择破解前的文件,在“已破解文件”处选择已经破解后的文件,然后单击“制作”按钮即可生成一个文件补丁程序。这里需要说明的是,之所以选择“原始的文件”,是因为生成的文件补丁在对没有破解的文件进行打补丁前需要对文件的CRC校验和进行计算,以防止由于文件版本的不同而导致文件破坏。

最后介绍一下“内存补丁”。“内存补丁”的制作也是比较容易的,打开“内存补丁”的制作界面,然后依照图6-83所示进行设置。

“制作内存补丁”界面中的“添加数据”窗口中的相应设置,请参考图6-57进行设置。

本章的程序中给出了KeyMake中的“文件补丁”、“内存补丁”和“内存注册机”的编写方式,请读者参照KeyMake软件再次体会前面的例子。

6.9 总结

本章介绍了PE结构的基础部分、OD的使用及调试API函数等。相信读者对PE结构解析、OD 调试工具使用及调试原理有了一定的了解。本章还介绍了一些基础的也是非常必要的加解密知识。读者在以后学习更多相关知识后会发现,这些基础知识对学习加解密知识是非常重要的。

本章最后的部分介绍了KeyMake工具的使用。通过KeyMake的具体实例,读者可以深刻领会本章前面所学知识的精要之处。KeyMake工具十分强大,如果在接触前面的知识前直接接触KeyMake工具,会觉得它很神奇,但是通过自己编写关于文件补丁、内存补丁和内存注册机的实例代码后,就会觉得KeyMake工具的基础与原理其实并不复杂,甚至读者会自己设计出一个更强大的KeyMake工具。

本章的知识除了可以应用到加密与解密方面,还可以应用在免杀、加壳脱壳、反调试、反病毒等方面的。希望读者在掌握原理后多动手实践。

相关图书

CMake构建实战:项目开发卷
CMake构建实战:项目开发卷
代码审计——C/C++实践
代码审计——C/C++实践
C++ Templates(第2版)中文版
C++ Templates(第2版)中文版
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
计算机图形学编程(使用OpenGL和C++)(第2版)
Qt 6 C++开发指南
Qt 6 C++开发指南

相关文章

相关课程