Windows调试艺术——从真实病毒学习消息机制

 

要阅读本文章的小伙伴建议先看看《windows调试艺术》的这两篇文章来了解一下前置知识

Windows调试艺术——从0开始的异常处理(下)

Windows调试艺术——从0开始的异常处理(上)

之前的时候偶然在某网站拿到一款很简单的病毒程序,虽然分析的难度不高,但是它巧妙的利用了Windows的消息机制实现了恶意功能,正好可以用它做个例子来学习一下Windows的消息机制。

 

Windows 消息结构

每一个程序猿都应该知道Windows是一个消息驱动的系统,可是真正提到什么是消息,消息又是如何组织的就一头雾水了。实际上Windows的应用内部的各个线程、各个应用、应用与操作系统之间都会通过消息来传递。消息就是一个信号,应用会根据收到的信号做出不同的反应,比如我们点击了窗口的关闭按钮,那么就会传递给应用一个”关闭”的消息,然后窗体关闭。

Windows以窗口作为基础实现了可视化的交互,窗口是基于线程实现的,一个线程又维护着一个消息队列,每一个传递给这个窗口的消息都要依次进入队列进行”先进先出”的操作,不分轻重缓急,再紧急的情况也只能老老实实排队。

消息

一个消息说白了就是一段数据,消息在Windows的定义如下

typedef struct tagMsg
{
HWND hwnd;    //目标的窗口句柄
UINT message; //消息的标识符
WPARAM wParam;//附加信息,与消息标识符有关
LPARAM lParam;//附加信息,与消息标识符有关
DWORD time;   //消息产生的时间
POINT pt;     //消息发生产生时的按屏幕坐标表示的鼠标光标的位置
}MSG,*PMSG;

消息按照用途可以分为:

  • 窗口消息,比如WM_PAINT窗口绘制、WM_CREATE窗口创建等等
  • 命令消息,一般是指WM_COMMAND,表示用户执行了一个命令,产生的对象一般是菜单或是控件
  • 通知消息,一般是指WM_NOTIFY,由公用控件发出
  • 反射消息,处理需要经过”反射”机制的消息,之后会详细说明

消息按照区段可分为:

  • 标识符由0x0000到0x03ff的系统消息
  •  0x0001-0x0087    窗口消息。
     0x00A0-0x00A9    非客户区消息 
     0x0100-0x0108    键盘消息
     0x0111-0x0126    菜单消息
     0x0132-0x0138    颜色控制消息
     0x0200-0x020A    鼠标消息
     0x0211-0x0213    菜单循环消息
     0x0220-0x0230    多文档消息
     0x03E0-0x03E8    DDE消息
    
  • 标识符由0x0400到0x7FFF的用户自定义消息,以VM_USER(0x0400)为基址,自定义偏移所对应的消息
  • 标识符由0x8000到0xBFFF的用户自定语消息,一般是基于某一个窗口类。用作应用之间的通信
  • 标识符由0xC000到0xFFFF的来自于RegisterWindowMessage函数,它会将传入的字符串注册成一个信息

消息队列

Windows维护了两种类型的队列,一种是系统消息队列,它是唯一的,用户的输入通过驱动程序转化为消息后会进入该队列,然后再将消息放入对应线程(窗口)的消息队列;另外一种是线程消息队列,在调用User或者GDI的函数时创建,队列中的消息会经过消息泵传递给窗口回调函数。

消息也不都是这么”听话”,比如一下的几种

  • WM_PAINT、WM_TIMER等,它们只有在队列中没有其他消息的时候才会处理,而VM_PAINT甚至还会进行合并来提高效率,这其实是因为它们消息的优先级较低
  • WM_ACTIVATE、WM_SETFOCUS等,它们会绕过消息队列直接被目标窗口处理
  • 来自其他线程的消息,处理上还是一样,但是它们的优先级较高一些,在下边消息处理中会有所体现

 

消息的处理过程

消息首先由系统或应用产生,由于应用的消息可定制化程度太高,所以我们这里选择系统的消息来作为例子。

消息的传递对应大体有两种方式,一种是POST,一种是SEND,涉及到了各种各样的发送形式

postMessage //消息进入消息队列中后立即返回,消息可能不被处理。
PostThreadMessage //消息放入指定线程的消息队列中后立即返回,消息可能不被处理。
SendMessage //消息进入消息队列中,处理后才返回,如果消息不被处理,发送消息的线程将一直处于阻塞状态,等待消息返回。
SendNotifyMessage//如果消息进入本线程,则为SendMessage(),不是则采取postMessage(),当目标线程仍然依send处理
SendMessageTimeout //消息进入消息队列,处理或超时则返回,实际上SendMessage()就是建立在该函数上的
SendMessageCallback //在本线程再指定一个回调函数,当处理完后再次处理
BroadcastSystemMessage //发送目标为系统组件,比如驱动程序

消息发送处理时会先判定消息的目标是不是在同一线程而产生不同的结果

  • 是,SendMessage()发送的消息不进入消息队列直接处理,而postMessage()进入消息队列
  • 否,SendMessage()发送消息至目标线程的队列,然后监视直至处理,PostThreadMessage()进入队列后返回

其实真正处理消息的就是一个窗口过程函数,它的参数实际上就是一个简化的MSG结构,包括了:对应窗口的句柄、消息的ID、消息的参数

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)

当我们创建一个窗口的时候有一个注册窗口的过程,代码如下:

ATOM MyRegisterClass(HINSTANCE hInstance)  
{  
   WNDCLASSEX wcex;  

   wcex.cbSize = sizeof(WNDCLASSEX);  

   wcex.style   = CS_HREDRAW | CS_VREDRAW;  
   wcex.lpfnWndProc = WndProc; 
   wcex.cbClsExtra  = 0;  
   wcex.cbWndExtra  = 0;  
   wcex.hInstance  = hInstance;  
   wcex.hIcon   = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_WINDOWSP));  
   wcex.hCursor  = LoadCursor(NULL, IDC_ARROW);  
   wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW+1);  
   wcex.lpszMenuName = MAKEINTRESOURCE(IDC_WINDOWSP);  
   wcex.lpszClassName = szWindowClass;  
   wcex.hIconSm  = LoadIcon(wcex.hInstance, MAKEINTRESOURCE(IDI_SMALL));  

   return RegisterClassEx(&wcex);  
}

很显然在注册时就绑定了上面的窗口过程函数,进而对各式各样的消息进行处理

紧接着就到了从队列中接受消息的过程,消息队列中对消息的处理主要有以下三个函数

BOOL PeekMessage(LPMSG lpMsg, HWND hWnd, UINT wMsgFilterMin, UINT wMsgFilterMax, UINT wRemoveMsg);
BOOL GetMessage(LPMSG lpMsg, HWND hWnd, UINT wMsgFilterMin, UINT wMsgFilterMax);
BOOL WaitMessage();
  • PeekMessage用来判断队列中有没有消息,可通过设置wRemoveMsg来决定是否删除进行判断的消息
  • GetMessage会取出线程的消息到一个MSG结构中,如果调用了该函数且队列为空就会出现线程挂起,进入休眠状态,CPU会分配给其他线程。这里涉及到线程、进程方面的知识,以后再作详细说明
  • WaitMessage,当没有消息时使用,使线程挂起处于等待状态

当然有的消息中的内容并不能被直接识别,还需要一个翻译过程,也就是需要调用TranslateMessage、TranslateAccelerator两个函数进行处理,这里主要是键盘等外部设备用户的输入(后者是用来处理快捷键的),普通消息可以跳过

接着就是重头戏了,DispatchMessage函数,看这个名字有没有想到之前DisPatchException?它们同样是用来分发的函数,则不过之前分发的是异常,现在分发的消息罢了

  • 检查目标窗口是否存在,不存在直接将消息丢弃
  • 是否为不必须处理的事件,举个栗子,比如窗口边框没左键的功能,你还疯狂点它。如果是的话进入DefWindowProc进行下一步处理,处理很简单,再生成一个新的消息传出去,重复过程
  • 调用相应的回调函数

可以看到正儿八经的消息到这就告一段落了,反而是那些”不需要”的消息耽误事还要再走一遍……

死锁

死锁,即Message Deadlocks,这个词很好理解,生动点说就是暗恋的俩人都在等待对方给发消息,结果都不好意思发就一直等着。比如下面的例子:

  • a线程发消息1给b线程
  • b线程处理消息1,回调函数中发了消息2给a
  • a接到消息2,但因为b对消息1的处理结果还没回来而等待
  • b因为消息2的处理结果还没回来而等待

好了,这哥俩现在就处于死锁状态了,俩人都干愣着。这是我们刻意的构造的一种情况,更多的时候死锁的产生还是由于发送的消息被处理时被”丢弃”了,而发送与接收的线程是同一队列,这就会导致该线程”死”了

为了防止死锁现象的产生,我们可以使用上面提到的SendMessageTimeout来设置最大等待时间

反射

从操作系统的角度讲,在Windows的世界里,一个按钮的改变应该发消息给父窗口,由父窗口操作;从编程语言的角度讲,C++的世界里,一个按钮就是一个类的具体对象,它应该自己处理自己的变化,这就有矛盾了,那这样是处理呢?

这样的矛盾就让反射机制诞生了,对于控件自己应该处理的内容,当父窗口收到了相关消息时,重新发回给控件。

 

MFC消息映射

MFC的消息处理其实本质上并没有什么不同,但是MFC做了一定的封装,掩盖了一部分消息的处理,使用起来比Windows消息处理更加简洁,这个封装起来的过程也就是消息映射。我们在vs上试着编辑一个mfc程序,当我们手动添加一个控件并指定了它的OnLButtonDown时,会自动为我们添加三处代码

class CDrawView : public CView
{
    afx_msg void OnLButtonDown(UINT nFlags, CPoint point);//afx_msg指的是消息响应函数,此处也就是函数的声明
};
ON_WM_LBUTTONDOWN()//此处定义了消息的映射宏
void CDrawView::OnLButtonDown(UINT nFlags, CPoint point)
{
    // TODO: 在此添加消息处理程序代码和/或调用默认值
    CView::OnLButtonDown(nFlags, point);//此处为消息响应函数的定义
}

实际上mfc为每一个要处理消息的类都维护了一个静态的消息映射表,一种消息对应了一种消息处理函数指针,不同的类因为要处理的消息不同,所以维护的表的大小也有差异,当该类的实例需要处理消息时,只需要搜索该表寻找相应的函数即可。上面为我们添加的消息的映射宏就是实现了高效的维护消息映射表的功能,实际上它展开后就是一个具体的消息结构

struct AFX_MSGMAP_ENTRY
{
    UINT nMessage;   // windows message
    UINT nCode;      // control code or WM_NOTIFY code
    UINT nID;        // control ID (or 0 for windows messages)
    UINT nLastID;    // used for entries specifying a range of control id's
    UINT_PTR nSig;       // signature type (action) or pointer to message #
    AFX_PMSG pfn;    // routine to call (or special value)
};

消息的处理过程在一下函数中完成

LRESULT CWnd::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    // OnWndMsg does most of the work, except for DefWindowProc call
    LRESULT lResult = 0;
    if (!OnWndMsg(message, wParam, lParam, &lResult))
        lResult = DefWindowProc(message, wParam, lParam);
    return lResult;
}

其中的关键函数也就是OnWndMsg

BOOL CWnd::OnWndMsg(UINT message, WPARAM wParam, LPARAM lParam, LRESULT* pResult)
{
    LRESULT lResult = 0;
    union MessageMapFunctions mmf;
    mmf.pfn = 0;
    CInternalGlobalLock winMsgLock;
    // special case for commands
    if (message == WM_COMMAND)
    {
        if (OnCommand(wParam, lParam))
        {
            lResult = 1;
            goto LReturnTrue;
        }
        return FALSE;
    }

    // special case for notifies
    if (message == WM_NOTIFY)
    {
        NMHDR* pNMHDR = (NMHDR*)lParam;
        if (pNMHDR->hwndFrom != NULL && OnNotify(wParam, lParam, &lResult))
            goto LReturnTrue;
        return FALSE;
    }

...
LDispatch:
    ASSERT(message < 0xC000);

    mmf.pfn = lpEntry->pfn;

    switch (lpEntry->nSig)
    {
    default:
        ASSERT(FALSE);
        break;
    case AfxSig_l_p:
        {
            CPoint point(lParam);        
            lResult = (this->*mmf.pfn_l_p)(point);
            break;
        }
  • 检查消息是否有对应的处理函数声明和消息映射宏
  • 检查相应的消息响应函数,存在则执行
  • 检查基类的消息响应函数,存在则执行

当然这里只是简单的聊了一聊,关于MFC的消息映射实际上还有很多很多的门道,由于篇幅问题就不再说了,以后再做总结。最后还有个问题,同样是维护一张表,为什么不干脆就用c++的虚函数实现呢?其实答案很简单,上面也提到了,大家可以自己想一想。

 

实战病毒程序

由于实际分析一个病毒过程很繁琐,所以我们这里只说重点,其余的详细病毒行为不再赘述

病毒行为捕获

利用云沙箱、虚拟机等对病毒的行为进行测试

  • 在C:UsersxxxAppDataRoaminghao123释放hao123.exe,并创建桌面的快捷方式
  • 修改注册表、篡改首页
  • 自删除,但是云沙箱并没有检测到

逆向分析

image-20190407145345811

通过od查询到了大量的可疑字符串,包括系统应用名、注册表项、网址等

image-20190407145301954

看ida的反汇编结果,可以看到似乎病毒没有做什么恶意操作,虽然有几个call比较可疑,但点进去分析都没有发现和我们之前发现的恶意行为相关的代码,连之前发现的可以字符串都没有了踪迹,仿佛就是单单调用了几个常见的函数而已

紧接着看一看od的载入情况,可疑字符串的调用似乎仍然和程序没关系,仅仅是有代码而已,但我们多次实验后,可以在某个call找到了程序唯一的行为,并且这里我们总算是发现了可疑之处

image-20190407145558511

程序调用了CreateWindowEx函数,但却将窗口的样式被设置成为WS_EX_TOOLWINDOW,查阅资料我们可以发现带有这个属性的窗口有以下特点:

  1. 不在任务栏显示。
  2. 不显示在Alt+Tab的切换列表中。
  3. 在任务管理器的窗口管理Tab中不显示。

换句话说,用户就基本上是发现不了病毒创建窗口这一操作的,那这又有什么用呢?

我们用od在此处下断点,仔细观察,发现当od执行到该命令时会产生特别奇怪的现象,之前发现的那个hao123的exe竟然出现了,并且也成功在桌面创建了快捷方式,这就很让人疑惑了,明明前面的函数调用完全没有涉及这方面的操作,这是怎么实现的呢?

实际上这就是通过消息机制调用回调函数实现的,因为回调函数是不需要我们去指定调用的时机,只要有相应的消息就会触发,病毒正是钻了这个空子,让我们第一时间无法发现函数的调用。

程序调用的CreateWindows会发送一个名为WM_CREATE的消息,而既然有这个消息了,那我们的程序就要对这个消息有所反馈,在CreateWindows这个消息发出后,我们的恶意程序就接受了这个消息,紧接着按照程序设定的原始方案执行恶意代码。

我们去找RegisterClass这个函数,这个函数就帮程序设置好了对应不同的消息要进行哪一些的处理。

image-20190407151046671

利用od找到函数的参数中包含的消息的结构体,结构体的第三个成员处下断,我们就可以截获到处理各个消息的switch语句了。进入即可找到恶意代码的位置了

image-20190407151059400

首先就是在此处修改了注册表,将目标网址添加了进去,从而实现了篡改主页的功能。

image-20190407151114516

同样利用注册表修改了我们的收藏夹

image-20190407151127892

这里调用了SHGetFolderPathW这个函数,这个函数在病毒中很常见,是用来获取系统的特殊路径的,也就是上面提到的C:UsersxxxAppDataRoaming,紧接着又将hao123和上面的路径连接起来,这样就组成了之前释放的恶意文件的存储路径

image-20190407151228670

释放了另外一个恶意文件

image-20190407151255735

创建快捷方式

到这里该消息的响应操作就执行完了,但程序不应该结束啊,因为我们还是没有找到自删除相关的操作啊。这里大胆发挥想象力,会不会和开始一样,也是通过某个消息机制实现的呢?

联系带程序非常诡异的自删除时间和之前那个用户“感受”不到的窗口,我们好像稍微有了一点点思路:既然开窗口有操作,那关闭窗口是不是也可以有操作呢?我们立刻着手寻找VM_DESTROY

image-20190407151620815

果然,它释放了一个bat批处理文件,内容很简单,删除恶意程序,然后把自己也删除。

(完)