恶意样本分析手册–特殊方法篇

windows服务是由三个组件构成的:服务应用,服务控制程序SCP,以及服务控制管理器SCM,当SCM启动一个服务进程时,该进程必须立即调用StartServiceCtrlDispatcher函数。StartServiceCtrlDispatcher函数接受一个入口点列表,每个入口点对应于该进程中的一个服务。

调试服务程序

服务简介

windows服务是由三个组件构成的:服务应用,服务控制程序SCP,以及服务控制管理器SCM,当SCM启动一个服务进程时,该进程必须立即调用StartServiceCtrlDispatcher函数。StartServiceCtrlDispatcher函数接受一个入口点列表,每个入口点对应于该进程中的一个服务。每个入口点是由它所对应的服务的名称来标识的。StartServiceCtrlDispatcher创建了一个命名管道来跟SCM进行通信,在建立了该通信管道以后,它等待SCM通过该管道发送过来的命令。每次SCM启动一个属于该进程的服务时,它发送一个“服务启动”命令。StartServiceCtrlDispatcher函数对于所接收到的每个启动命令,创建一个线程(服务线程),由该线程调用所启动服务的入口点函数,并实现该服务的命令循环。StartServiceCtrlDispatcher一直在等待来自SCM的命令,只有当该进程的所有服务都停止时,它才会将控制返回至该进程的main函数,以便服务进程在退出以前做一些资源清理工作。

当SCM要启动一个服务时,它就调用ScStartService,当ScStartService启动一个windows服务时,它首先读取该服务的注册表键中ImagePath值,以确定该服务进程的映像文件名。然后,它检查该服务的Type值,如果此值是SERVICE_WINDOWS_SHARE_PROCESS(0x20)那么,SCM保证该服务运行所在的进程(如果已经启动了的话),其登录的账户一定与服务的指定启动账户相同。

SCM在一个称为映像数据库的内部数据库中,检查是否有针对该服务的ImagePath值的条目,以便验证该服务的进程尚未在其他账户下被启动起来。如果在映像数据库中没有找到该ImagePath值的条目,则SCM创建一个这样的条目。当SCM创建一个新的条目时,它还将该账户的登录账户名,以及该服务的ImagePath值中的数据也存储起来。如果SCM在映像数据库中找到了一个与该服务的ImagePath数据相匹配的条目,那么,必须保证当前正在启动的服务的用户账户信息与数据库条目中存储的信息是相同的。一个进程只能以一个账户的身份来登录,所以,当一个服务指定的账户名与同一个进程中已经启动起来的其他服务的账户名不相同时,SCM会报告一个错误。

SCM调用ScLogonAndStartImage来登录一个服务,并启动该服务的进程。SCM通过调用lsass函数LogonUserEx来登录那些并非运行在系统账户下的服务。

当SCM配置一个服务的登录信息时,SCM利用LsaStorePrivateData函数来指示lsass将一个登录口令保存到Secrets子键下。在登陆成功后,LogonUserEx给调用者返回一个句柄,指向一个访问令牌。Windows使用 访问令牌来代表一个用户的安全环境,以后SCM将该访问令牌与实现此服务的进程关联起来。

下一个步骤是,如果该服务的进程尚未被启动(例如,为了另一个服务),则SclogonAndStartImage为该服务激发一个进程。SCM通过windows函数CreateProdcessAsUser来启动此进程,并且将该进程的状态设置为挂起状态。接下来SCM创建一个命名管道,以后通过该管道与服务进程进行通信,分配给管道的名称为\Pipe\Net\NtControlPipeX,其中X是一个数字,每次SCM创建一个管道,该数字就会递增。然后,SCM通过ResumeThread函数来恢复服务进程的执行,并且等待该服务连接到它的SCM管道上。如果注册表值HKLM\SYSTEM\CurrentControlSet\Control\ServicesPipeTimeout存在的话,则它决定了SCM等待又给服务调用StartServiceCtrlDispatcher并连接过来的时间长度,如果在这么长时间里没有等到,则SCM就会放弃,终止该进程,并得出结论:该服务未能启动。如果ServicesPipeTimeout不存在,则SCM使用默认的30秒作为超时间隔值。SCM对于它所有的服务通信都是用同样的超时间隔值。

当一个服务通过它的管道连接到SCM时,SCM向该服务发送一个启动命令。如果该服务未能在有效时间内响应启动命令,SCM就会放弃,转移到启动下一个服务。当一个服务没有对启动请求做出响应时,SCM不会像一个服务在指定时间内没有调用StartServiceCtrlDispatcher的情形一样终止进程,相反会在系统的时间日志中记录一个错误,指明该服务未能及时启动起来。

如果SCM调用ScStartService启动的服务有一个Type注册表值为SERVICE_KERNEL_DRIVER或SERVICE_FILE_SYSTEM_DRIVER,那么该服务是一个设备驱动程序,所以ScStartService调用ScLoadDeviceDriver来加载该驱动程序。ScLoadDeviceDriver首先使SCM进程具有加载驱动程序的安全特权,然后调用内核服务NtLoadDriver,将该驱动程序的注册表键中的ImagePath值的数据传递过去。与windows服务不同的是,驱动程序不需要指定ImagePath值;如果该值不存在的话,SCM通过将驱动程序的名称附加在字符串%SystemRoot%\System32\Drivers\的后面就可以构造一个映像文件路径。

ScAutostartServices继续循环处理同一个组的服务,直到所有这些服务要么被启动起来,要么产生相依性错误。这种循环处理方式是SCM根据一个组中的服务的DependOnService相依性来自动对他们进行顺序处理的。SCM在较早的循环中启动那些被其他服务依赖的服务,跳过那些依赖于其他服务的服务,而在后续的循环中再启动这些服务。

SCM在处理了自动启动的服务以后,调用ScInitDelayStart,该函数将一个延迟的工作项目加入队列中,该工作项目与一个专门的辅助线程相关联,它负责处理所有由于被标记为“延迟的自动启动”而被ScAutoStartServices忽略掉的服务。此辅助线程将在一段时间的延迟之后执行,默认的延迟是120秒,但那时可以通过在HTML\SYSTEM\CurrentControlSet\Control中创建一个AutoStartDelay值,可以覆盖这一默认值。

当SCM完成了启动所有这些自动启动的服务和驱动程序,以及设置了延迟的自动启动的工作项目时,发信号通过\BaseNameObjects\SC_AutostartComplete事件完成。这一事件被windows setup程序用于在安装过程中衡量启动过程。

有些恶意样本会通过创建系统服务来执行自身的恶意功能,通过这种方法可以实现开机启动,也可以达到隐藏自身的目的,对于安全人员来说,调试服务程序也比较麻烦,这里介绍两种调试的方法。

服务启动后附加

通过任务管理器找到要调试的服务程序,查看目标进程的pid,然后再启动windbg,选择file->Attach to a Process

在显示的进程列表中找到目标程序,然后点击OK即可开始调试。(关于此工具的详细使用方法,请参考工具篇介绍)。

服务启动时附加

在服务启动的时候就使用windbg进行附加,使用这种方法需要进行简单的配置。

  1. 使用管理员启动windbg目录下的exe,在“Image File”标签页中填写Image名字(不需要写全路径)。如下图所示,服务名称是010editorAS,可执行路径为C:\Users\xxxxx\AppData\Roaming\010editorAS.exe。

按照上图的例子,在Image框中填写010editorAS.exe,在下面的Debugger框中填写windbg的全路径。

  1. 设置服务的属性,允许服务以交互形式启动。

3. 设置结束后,就可以启动服务了,系统会显示交互式服务检测,点击查看消息后,便会中断在windbg中。

但是这种方法有个问题,就是只能断下来30秒,过了这个时间就无法进行调试了。上文中提到了这个问题,查找网上的资料说,在注册表HKLM\SYSTEM\CurrentControlSet\Control下新建一个值ServicesPipeTimeout,这个值表示超时时间,但是经过实践发现并没有什么效果。

另外一种方法是在服务的入口加int3断点,这样就能在超时间隔内使程序执行完StartServiceCtrlDispatcher函数,不至于使服务管理器因为长时间收不到来自服务的消息而终止服务的运行。

进程间通信

随着人们对应用程序的要求越来越高,单进程应用在许多场合已不能满足人们的要求,编写多进程/多线程程序成为现代程序设计的一个重要特点,恶意软件也跟随时代的脚步,通过多进程来相互保护。而多进程之间想“交流”,那么肯定少不了进程间通信的技术,如果我们熟悉进程间通信技术,就可以轻松自如的调试多进程的恶意样本。

文件映射

文件映射(Memory-Mapped Files)能使进程把文件内容当作进程地址区间一块内存那样来对待。因此,进程不必使用文件I/O操作,只需简单的指针操作就可读取和修改文件的内容。

Win32 API允许多个进程访问同一文件映射对象,各个进程在它自己的地址空间里接收内存的指针。通过使用这些指针,不同进程就可以读或修改文件的内容,实现了对文件中数据的共享。

应用程序有三种方法来使多个进程共享一个文件映射对象。

(1)继承:第一个进程建立文件映射对象,其子进程继承该对象的句柄。

(2)命名文件映射:第一个进程在建立文件映射对象时可以给该对象指定一个名字(可与文件名不同)。第二个进程可通过这个名字打开此文件映射对象。另外,第一个进程也可以通过一些其它IPC机制(有名管道、邮件槽等)把名字传给第二个进程。

(3)句柄复制:第一个进程建立文件映射对象,然后通过其它IPC机制(有名管道、邮件槽等)把对象句柄传递给第二个进程。第二个进程复制该句柄就取得对该文件映射对象的访问权限。

文件映射是在多个进程间共享数据的非常有效方法,有较好的安全性。但文件映射只能用于本地机器的进程之间,不能用于网络中,而开发者还必须控制进程间的同步。

下面的示例代码展示了如何通过文件来进行通信:

发送方:

接收方:

首先发送方创建一个文件并进行映射,然后向映射的内存中写入数据,接着再从映射的内存中读取数据,接下来运行接收方,接收方先是读取数据,然后在往里面写入数据,所以,运行之后会看到发送方读取的数据变化了。

使用这种方法进行通信用到的函数主要有CreateFile,CreateFileMapping,MapViewOfFile,sprintf。也有一种情况是使用函数WriteFile向文件种写入数据。

通过以上代码可以知道这种机制的通信方式,然后在上述的函数上设置断点,截获程序交互的数据即可。

共享内存

共享内存主要是通过映射机制实现的。

Windows下进程的地址空间在逻辑上是相互隔离的,但是在物理上却是重叠的。所谓的重叠是指同一块内存区域可能被多个进程同时使用。当调用CreateFileMapping 创建命名的内存映射文件对象时,Windows 即在物理内存申请一块指定大小的内存区域,返回文件映射对象的句柄 hMap。为了能够访问这块内存区域必须调用 MapViewOfFile 函数,促使 Windows 将此内存空间映射到进程的地址空间中。当在其他进程访问这块内存区域时,则必须使用OpenFileMapping 函数取得对象句柄 hMap,并调用 MapViewOfFile 函数得到此内存空间的一个映射。这样一来,系统就把同一块内存区域映射到了不同进程的地址空间中,从而达到共享内存的目的。

以下面的代码为例,第一次运行时,创建一个共享内存,写入数据“HelloWorld”,只要创建共享内存的进程没有关闭句柄hMap,以后运行的程序就会读出共享内存里面的数据,并打印出来。

知道了共享内存的手法,就可以在关键函数处下断点进行调试了。


连续运行两次程序,结果如下图所示:

这种方式和上述介绍的第一种方式类似,用到的函数有OpenFileMapping,CreateFileMapping,MapViewOfFile。在调用MapViewOfFile后,会返回一个地址,进程交互的数据就是通过这个地址,所以在MapViewOfFile函数处设置断点,然后在内存种跟踪返回地址中的数据即可。

匿名管道

匿名管道是一种未命名的、单向管道,通常用来在一个父进程和一个子进程之间传输数据。匿名的管道只能实现本地机器上两个进程间的通信,而不能实现跨网络的通信。

匿名管道是在父进程和子进程之间,或同一父进程的两个子进程之间传输数据的无名字的单向管道。通常由父进程创建管道,然后由要通信的子进程继承通道的读端点句柄或写端点句柄,然后实现通信。父进程还可以建立两个或更多个继承匿名管道读和写句柄的子进程。这些子进程可以使用管道直接通信,不需要通过父进程。

匿名管道是单机上实现子进程标准I/O重定向的有效方法,它不能在网上使用,也不能用于两个不相关的进程之间。

创建匿名管道的函数为CreatePipe,对管道进行读写的操作是ReadFile和WriteFile函数。

参考代码如下:


运行结果如下:

在父进程创建一个匿名管道,将管道的读句柄传给子进程,并且向管道中写入“HelloWorld”,子进程启动后,通过管道的读句柄从管道中读取父进程写入的数据,这就是简单的匿名管道通信机制,调试的时候,如果看到类似的机制,可以在WriteFile或者ReadFile上设置断点来读取程序写入管道的值。

在调试的时候,遇到函数CreatePipe,需要重点关注它的前两个参数,这两个参数分别为数据读取句柄和数据写入句柄,记下这两个值之后,在遇到WriteFile或者ReadFile函数时,如果句柄值为CreatePipe函数返回的值,那么就需要截获写入或读取的数据。

命名管道

命名管道(Named Pipe)是服务器进程和一个或多个客户进程之间通信的单向或双向管道。不同于匿名管道的是命名管道可以在不相关的进程之间和不同计算机之间使用,服务器建立命名管道时给它指定一个名字,任何进程都可以通过该名字打开管道的另一端,根据给定的权限和服务器进程通信。

命名管道提供了相对简单的编程接口,使通过网络传输数据并不比同一计算机上两进程之间通信更困难,不过如果要同时和多个进程通信就力不从心了。

创建匿名管道的函数为CreateNamedPipe,通过WriteFile或ReadFile向这个管道内写入数据或读取数据,此时的客户端用CreateFile先打开此命名管道,通过ReadFile读取管道内的数据或用WriteFile向管道内写入数据。

参考代码(包含服务端和客户端):

服务端:

客户端:

命名管道的创建函数为CreateNamedPipe,这个函数会返回一个句柄,进行数据传递时,就调用WriteFile或者ReadFile向这个句柄中写入或读取数据。原理基本和匿名管道一致,不再进行赘述。

邮槽Mailslot

邮件槽(Mailslots)提供进程间单向通信能力,任何进程都能建立邮件槽成为邮件槽服务器。其它进程,称为邮件槽客户,可以通过邮件槽的名字给邮件槽服务器进程发送消息。进来的消息一直放在邮件槽中,直到服务器进程读取它为止。一个进程既可以是邮件槽服务器也可以是邮件槽客户,因此可建立多个邮件槽实现进程间的双向通信。

邮件槽与命名管道相似,不过它传输数据是通过不可靠的数据报(如TCP/IP协议中的UDP包)完成的,一旦网络发生错误则无法保证消息正确地接收,而命名管道传输数据则是建立在可靠连接基础上的。不过邮件槽有简化的编程接口和给指定网络区域内的所有计算机广播消息的能力,所以邮件槽不失为应用程序发送和接收消息的另一种选择。

创建邮槽的函数为CreateMailslot,使用这个函数可以创建一个命名的邮槽,名字格式为\\\\.\\mailslot\\Hello。

下面通过程序示例代码进行原理说明:

服务端:

客户端:

运行结果如下:

代码说明:服务端使用函数CreateMailslotA创建一个邮槽,然后使用函数GetMailslotInfo获取mailslot消息,如果有消息的话使用ReadFile函数读取邮槽中的数据。客户端通过邮槽的名字使用函数CreateFileA来打开邮槽,直接调用WriteFile函数向邮槽中写入数据,邮槽的命名格式为\\\\.\\mailslot\\sample_mailslot。

调试的时候,如果看到CreateMailslotA,需要注意它的返回值—邮槽的句柄,后期程序会通过这个句柄来向邮槽中写入或读取数据,所以服务端需要注意的函数有CreateMailslotA,GetMailslotInfo和ReadFile,客户端需要注意的函数有CreateFileA,WriteFile。

剪切板

剪贴板(Clipped Board)实质是Win32 API中一组用来传输数据的函数和消息,为Windows应用程序之间进行数据共享提供了一个中介,Windows已建立的剪切(复制)-粘贴的机制为不同应用程序之间共享不同格式数据提供了一条捷径。当用户在应用程序中执行剪切或复制操作时,应用程序把选取的数据用一种或多种格式放在剪贴板上。然后任何其它应用程序都可以从剪贴板上拾取数据,从给定格式中选择适合自己的格式。

使用剪切板进行通信的过程大致如下:

发送端:

首先OpenClipboard打开剪切板,打开剪切板后其他应用程序不能修改剪切板,直到调用了CloseClipboard。然后调用函数EmptyClipboard清空剪切板并释放剪切板中的句柄,将使用权给当前打开剪切板的窗口。接着使用SetclipboardData向剪切板中放置数据。

参考代码:

接收端:

打开剪切板,这时不再需要清空剪切板内容了,调用函数GetClipboardData获取剪切板的数据。参考代码:

从示例代码中可以知道,需要重点关注的函数有:OpenClipboard,CloseClipboard,EmptyClipboard,SetclipboardData,GetClipboardData。其中SetclipboardData用来往剪切板里设置数据,GetClipboardData用来获取剪切板中的数据,所以在这两个函数上设置断点即可截获进程间传输的数据。

socket

关于socket通信大家应该都不陌生,我们只需要在关键函数上设置断点即可截获程序间传输的数据,创建套接字的函数为socket,可以在这个函数上设置断点,然后逐步跟踪,服务端方面后期会调用bind绑定特定端口,listen等待客户端连接,accept来接收客户端的连接,返回客户端套接字,客户端方面生成套接字后直接调用connect与服务端进行连接,连接成功后就进行数据传输,发送数据的函数为recv、recvfrom,接收数据的函数为send、sendto。当断在这些关键函数上时,就可以获取他们传输的数据了。

远程线程调试

远程线程是攻击者常用的手法,通过这种方式,可以达到隐藏自身的目的,也可以注入代码或者动态库。

攻击者如果使用远程线程进行注入,肯定会在目标进程中写代码,使用的函数是WriteProcessMemory,这个函数的第二个参数给出了写入的目标地址,记下这个地址。后面攻击者会调用函数RtlCreateUserThread,ResumeThread恢复线程运行。在它调用ResumeThread前,附加到目标进程中,点击菜单栏的“M”选项可以看到此进程的内存信息。在主进程WriteProcessMemory写入的内存地址处设置内存访问断点,然后F9让其运行,接着在回到主进程中让其调用ResumeThread,这样,就可以在目标进程中断下来。

定位DllEntry

调试动态库文件的时候,最简单的方式是使用OD载入,OD中的Loaddll.exe会自动加载动态库文件,并定位到入口处,虽然有时候不太准确。这里讲解的是如何手动定位到DllEntry。

先借助LordPE获取输出表的方法获取RVA地址

通过OD获取动态库文件的加载基地址

通过基址和RVA计算出来VA,定位到入口函数进行分析

TLS调试

TLS的全称是Thread Local Storage,是Windows为解决一个进程中多个线程同时访问全局变量而提供的机制。TLS可以简单的由操作系统代为完成整个互斥过程,也可以由用户自己编写控制信号量的函数。当进程中的线程访问预先指定的内存空间时,操作系统会调用系统默认的或用户自定义的信号量函数,保证数据的完整性和正确性。

当用户选择使用自己编写的信号量函数时,在应用程序初始化阶段,系统将要调用一个由用户编写的初始化函数以完成信号量的初始化以及其他的一些初始化工作。此调用必须在侧滑盖内需真正开始执行到入口点之前就完成,以保证程序执行的正确性。

TLS回调函数的函数原型如下:

void NTAPI TlsCallBackFunction(PVOID Handle,DWORD Reason,PVOID Reserve);

Windows的可执行文件为PE格式,在PE格式中,专门为TLS数据开辟了一段空间,具体位置为IMAGE_NT_HEADERS.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS]。其中Data Directory的元素具有如下结构:

对于TLS的DataDirectory元素,VirtualAddress成员指向一个结构体,结构体中定义了访问需要互斥的内存地址,TLS回调函数地址以及其他一些信息。

由于TLS回调函数是在main函数之前执行的,所以有些恶意软件将恶意代码写入道TLS回调函数中,我们调试的时候,在我们找到main函数时,攻击者已经执行完了它的恶意功能,所以我们要尽快下手,在回调函数执行前就断下来。

要在执行前设置断点,首要的任务就是找到回调函数的地址。

首先我们写一个带有回调函数的程序,参考代码如下:

运行结果如下:

可以看到,在main函数前,两个回调函数已经执行过了。下面介绍定位回调函数的方法。

使用CFF,Load PE或者其他工具,查看PE文件的数据目录表可以看到是否存在TLS回调函数

然后打开OD或者IDA定位到这个地址,就可以看到回调函数了。

使用IDA找回调函数的一个简单的方法是,使用快捷键Ctrl+E,就可以在弹出的窗口中看到所有的回调函数。

定位到回调函数后就可以像普通函数一样进行调试了。

windbg调试.NET

对于.net程序的调试,首选的工具还是建议使用Reflector或者dnspy,这两款工具结合了静态反编译和动态调试两项功能。如果样本没有进行混淆的话,可以使用这两款工具直接看到程序源码,对于动态调试具有极大的帮助。但是对于一些特殊的情况,必须恶意样本在内存中释放的一段.net程序,这并不是一个完整的.net文件,所有就无法用上述两款工具进行查看,这样的话,就可以使用windbg进行调试。

这里使用自己写的一段简单的.net代码进行演示说明:

首先使用windbg打开可执行文件,然后加载动态库sos.dll和clr.dll。需要注意的是,版本一定要对应,不然会出现The call to LoadLibrary(C:\Windows\Microsoft.NET\Framework\v4.0.30319\sos.dll) failed, Win32 error 0n193这样的错误。这两个动态库的路径一般为C:\Windows\Microsoft.NET\Framework\v4.0.30319,如果是64位程序,使用64的windbg打开,就需要加载64位下的动态库。加载命令为:

.load C:\Windows\Microsoft.NET\Framework\v4.0.30319\sos.dll

.load C:\Windows\Microsoft.NET\Framework\v4.0.30319\clr.dll

接着输入g运行程序,在程序断下来时加载调试扩展

.loadby sos clr

然后使用命令!CLRStack仅为托管代码提供真实的调用栈信息

其中的@ 17,@ 18表示源码中的行号。

这种情况下,就可以直接使用bp Address在目标地址出设置断点了。

sos扩展命令如下所示:

如果要查看某个命令的详细信息,可以使用!help <functionname>进行查看。

消息处理调试

Windows程序和MFC程序是靠消息驱动的,他们对于消息的处理本质上是相同的。只是Windows程序对于消息处理的过程十分清晰明了,MFC程序则掩盖了消息处理的过程,以消息映射的方式呈现在开发者面前,使得开发消息的处理十分简单。当然,在分析消息类程序之前首先对Windows程序的消息机制进行一个简单的描述。

消息机制说明

1.什么是消息

消息对于Win32程序来说十分重要,它是一个程序运行的动力源泉。一个消息,是系统定义的一个32位的值,他唯一的定义了一个事件,向 Windows发出一个通知,告诉应用程序某个事情发生了。例如,单击鼠标、改变窗口尺寸、按下键盘上的一个键都会使Windows发送一个消息给应用程序的消息队列中,然后应用程序再从消息队列中取出消息并进行相应的响应。在这个处理的过程中,操作系统也会给应用程序“发送消息”,而所谓的发送消息–实际上就是操作系统调用程序中的一个专门负责处理消息的函数,这个函数称为窗口过程。

消息本身是作为一个记录传递给应用程序的,这个记录中包含了消息的类型以及其他信息。例如,对于单击鼠标所产生的消息来说,这个记录中包含了单击鼠标时的坐标。这个记录类型叫做MSG,MSG含有来自windows应用程序消息队列的消息信息,在Windows中MSG结构体定义如下:

hwnd:窗口句柄,它表示的是消息所属的窗口。我们通常开发的程序都是窗口应用程序,一般一个消息都是和某个窗口相关联的。比如我们在某个活动窗口按下鼠标右键,此时产生的消息就是发送给该活动窗口的。窗口可以是任何类型的屏幕对象,因为Win32能够维护大多数可视对象的句柄(窗口、对话框、按钮、编辑框等)。

message:一个消息的标识符,用于区别其他消息的常量值,这些常量可以是Windows单元中预定义的常量,也可以是自定义的常量。在Windows中消息是由一个数值表示的,不同的消息对应不同的数值。但由于当这些消息种类多到足以挑战我们的IQ,所以聪明的程序开发者便想到将这些数值定义为WM_XXX宏的形式。例如,鼠标左键按下的消息–WM_LBUTTONDOWN,键盘按下消息–WM_KEYDOWN,字符消息–WM_CHAR,等等..消息标识符以常量命名的方式指出消息的含义。当窗口过程接收到消息之后,他就会使用消息标识符来决定如何处理消息。例如、WM_PAINT告诉窗口过程窗体客户区被改变了需要重绘。符号常量指定系统消息属于的类别,其前缀指明了处理解释消息的窗体的类型。

wParam和lParam- – – 用于指定消息的附加信息。例如,当我们收到一个键盘按下消息的时候,message成员变量的值就是WM_KEYDOWN,但是用户到底按下的是哪一个按键,我们就得拜托这二位,由他们来告知我们具体的信息。

time和pt- – -这俩兄弟分别被用来表示消息投递到消息队列中的时间和鼠标当前的位置,一般情况下不怎么使用(但不代表没用)。

2.消息队列

在Windows编程中,每一个Windows应用程序开始执行后,系统都会为该程序创建一个消息队列,这个消息队列用来存放该应用程序所创建的窗口的信息。例如,当我们按下鼠标右键的时候,这时会产生一个WM_RBUTTONDOWN消息,系统会自动将这个消息放进当前窗口所属的应用程序的消息队列中,等待应用程序的结束。Windows将产生的消息以此放进消息队列中,应用程序则通过一个消息循环不断的从该消息队列中读取消息,并做出响应。

3.消息标识符

系统保留消息标识符的值在0x0000在0x03ff(WM_USER-1)范围。这些值被系统定义消息使用。应用程序不能使用这些值给自己的消息。应用程序消息从WM_USER(0X0400)到0X7FFF,或0XC000到0XFFFF;WM_USER到 0X7FFF范围的消息由应用程序自己使用;0XC000到0XFFFF范围的消息用来和其他应用程序通信,在此只是罗列一些具有标志性的消息值:

4.消息的分类

Windows消息大体上可以分为3类:窗口消息,命令消息和空间通知消息。

窗口消息是系统中最常见的消息,它是指由操作系统和控制其他窗口的窗口所使用的消息。例如CreateWindow、DestroyWindow和MoveWindow等都会激发窗口消息,还有我们在上面谈到的单击鼠标所产生的消息也是一种窗口消息。

命令消息是一种特殊的窗口消息,他用来处理从一个窗口发送到另一个窗口的用户请求,例如按下一个按钮,他就会向主窗口发送一个命令消息。

控件消息:其实它是这样的,当一个窗口内的子控件发生了一些事情,而这些是需要通知父窗口的,此刻它就上场啦。通知消息只适用于标准的窗口控件如按钮、列表框、组合框、编辑框,以及Windows公共控件如树状视图、列表视图等。

例如,单击或双击一个控件、在控件中选择部分文本、操作控件的滚动条都会产生通知消息–她类似于命令消息,那么控件通知消息就会从控件窗口发送到它的主窗口。但是这种消息的存在并不是为了处理用户命令,而是为了让主窗口能够改变控件,例如加载、显示数据。

5.队列消息和非队列消息

从消息的发送途径来看,Windows程序中的消息可以分成2种:队列消息和非队列消息,也有叫“进队消息”和“不进队消息”。

消息队列可以分成系统消息队列和线程消息队列。系统消息队列由Windows维护,线程消息队列则由每个GUI线程自己进行维护,为避免给non-GUI现成创建消息队列,所有线程产生时并没有消息队列,仅当线程第一次调用GDI函数时系统才给线程创建一个消息队列。

(1)队列消息送到系统消息队列,然后到线程消息队列;

对于队列消息,最常见的是鼠标和键盘触发的消息,例如WM_MOUSERMOVE,WM_CHAR等消息,还有一些其它的消息,例如:WM_PAINT、 WM_TIMER和WM_QUIT。当鼠标、键盘事件被触发后,相应的鼠标或键盘驱动程序就会把这些事件转换成相应的消息,然后输送到系统消息队列,由 Windows系统去进行处理。Windows系统则在适当的时机,从系统消息队列中取出一个消息,根据前面我们所说的MSG消息结构确定消息是要被送往那个窗口,然后把取出的消息送往创建窗口的线程的相应队列,下面的事情就该由线程消息队列操心了,Windows开始忙自己的事情去了。线程看到自己的消息队列中有消息,就从队列中取出来,通过操作系统发送到合适的窗口过程去处理。

一般来讲,系统总是将消息Post在消息队列的末尾。这样保证窗口以先进先出的顺序接受消息。然而,WM_PAINT是一个例外,同一个窗口的多个 WM_PAINT被合并成一个 WM_PAINT 消息, 合并所有的无效区域到一个无效区域。合并WM_PAIN的目的是为了减少刷新窗口的次数。

(2)非队列消息直接送给目的窗口过程。

非队列消息将会绕过系统队列和消息队列,直接将消息发送到窗口过程。系统发送非队列消息通知窗口,系统发送消息通知窗口。例如,当用户激活一个窗口系统发送WM_ACTIVATE,WM_SETFOCUS, and WM_SETCURSOR。这些消息通知窗口它被激活了。非队列消息也可以由当应用程序调用系统函数产生。例如,当程序调用SetWindowPos系统发送WM_WINDOWPOSCHANGED消息。一些函数也发送非队列消息。

调试示例

以一个简单的win32程序进行说明(直接使用VS生成win32项目即可)。由于代码量比较大,这里只展现关键代码。

注册窗口类:

注册完之后创建窗口:

消息循环:

消息处理函数:

这个win32程序,我们没有写任何代码,运行之后显示一个窗口,关闭窗口程序结束运行。借助这个程序说一下与消息相关的函数。

把一个消息发送到窗口有三种方式:发送,寄送和广播。

发送消息的函数有SendMessage、SendMessageCallback、SendNotifyMessage、 SendMessageTimeout。

寄送消息的函数有PostMessage、PostThreadMessage、 PostQuitMessage。

广播消息的函数有:BroadcastSystemMessage、 BroadcastSystemMessageEx。

消息的接收主要由3个函数:GetMessage、PeekMessage、WaitMessage。

关于函数的信息可以参考API函数篇或自行网上查阅。

消息循环:

在消息循环中,GetMessage从进程的主线程的消息队列中获取一个消息并将它复制到MSG结构,如果队列中没有消息,则GetMessage函数将等待一个消息的到来以后才返回。如果你将一个窗口句柄作为第二个参数传入GetMessage,那么只有指定窗口的的消息可以从队列中获得。GetMessage也可以从消息队列中过滤消息只接受消息队列中落在范围内的消息。这时候就要利用GetMessage/PeekMessage指定一个消息过滤器。这个过滤器是一个消息标识符的范围或者是一个窗体句柄,或者两者同时指定。当应用程序要查找一个后入消息队列的消息是很有用。WM_KEYFIRST 和 WM_KEYLAST 常量用于接受所有的键盘消息。 WM_MOUSEFIRST 和 WM_MOUSELAST 常量用于接受所有的鼠标消息。

然后TranslateAccelerator判断该消息是不是一个按键消息并且是一个加速键消息,如果是,则该函数将把几个按键消息转换成一个加速键消息传递给窗口的回调函数。处理了加速键之后,函数TranslateMessage将把两个按键消息WM_KEYDOWN和WM_KEYUP转换成一个 WM_CHAR,不过需要注意的是,消息WM_KEYDOWN,WM_KEYUP仍然将传递给窗口的回调函数。

处理完之后,DispatchMessage函数将把此消息发送给该消息指定的窗口中已设定的回调函数。如果消息是WM_QUIT,则 GetMessage返回0,从而退出循环体。应用程序可以使用PostQuitMessage来结束自己的消息循环。通常在主窗口的 WM_DESTROY消息中调用。

消息处理函数:

窗口过程是一个用于处理所有发送到这个窗口的消息的函数。任何一个窗口类都有一个窗口过程。同一个类的窗口使用同样的窗口过程来响应消息。系统发送消息给窗口过程将消息作为参数传递给他,消息到来之后,按照消息类型排序进行处理,其中的参数则用来区分不同的消息,窗口过程使用参数产生对应行为。

一个窗口过程不经常忽略消息,如果它不处理,他会将消息传回执行到默认的处理。窗口过程通过调用DefWindowProc来做这个处理。窗口过程必须return一个值作为它的消息处理结果。大多数窗口只处理小部分消息和将其他的通过DefWindowProc传递给系统做默认的处理。窗口过程被素有属于同一个类的窗口共享,能为不同点窗口处理消息。

知道了消息机制的原理,下面简单说明一下如何使用OD调试。调试Win32程序,重点关注的是消息的处理。而消息过程函数是在注册类的时候初始化的,所以我们需要在RegisterClassEx函数设置断点,它的参数即为窗口类,结构体声明如下:

第三个参数就是我们需要关注的重点函数。

程序断在RegisterClassEx后,左边为pWndClassEx参数的数据部分,可以看到第三个参数0xEB1352就是消息过程函数,再在这个函数设置断点,运行程序,在有消息处理的时候就会停留在过程处理函数中。然后调试方法就和普通程序的调试方法一样了。

发表评论