2011年4月12日星期二

U盘病毒查杀程序开发历程

U盘病毒查杀程序开发历程

这是一个同学给我的小项目,客户要求做一个动态链接库(DLL),具体功能是在U盘插入电脑时调用两种杀毒软件依次对其查杀。另外可以从U盘中复制文件到电脑中其他位置。刚拿上手时觉得很简单,没有什么复杂的东西,而事实也确实如此。但是最后这一项目还是流产了,原因是客户在杀毒软件的选择上一定要用瑞星。也许这个世界上的杀毒软件质量参差不齐,但无疑瑞星是排名第一的烂,兼容性和查杀率的低劣大家都有目共睹,竟然连命令行参数都没有。抛开这些不谈,我还是想把这个DLL的开发记录下来,给以后留个纪念。

项目分解

这个项目就两个主要功能,查杀U盘和复制文件。分解开来看,有4个待解决的问题:
1.         检测U盘插入;
2.         调用杀毒软件;
3.         复制文件;
4.         编译DLL
这样分开来看,每一个问题都不是问题了,其中除了检测U盘插入以前没接触过,其他的都有现成的或半成品的代码。
鉴于MFC编写DLL在非MFC程序中兼容性打折扣的问题,我决定采用纯Windows API的方式完成此次开发。
因为DLL的调试比较繁琐,我决定先用普通Win32应用程序的思路处理前3个问题,待调试成功后再改写成DLL。最终的事实也证明这样的方法非常省时高效,前面的调试进行的很顺利,最后改写DLL的过程也只花了数十分钟就完成了。

检测U盘插入

当一个即插即用设备接入或移除电脑时,操作系统都会向所有顶级窗口发送WM_DEVICECHANGE消息,表示设备变动。(不知道什么是顶级窗口?就看做是全部窗口好了)WPARAM参数包含变动的类型,例如:DBT_DEVICEARRIVAL表示设备已经接入系统,DBT_DEVICEREMOVECOMPLETE表示设备已完全移除。而WM_DEVICECHANGE消息中的LPARAM参数会包含变动设备的一些基本信息,如设备类型,容量等。具体内容可以参见MSDN对这些消息的解释,这里不再赘述。我的目的是检测U盘接入,因此我只关心WM_DEVICECHANGE消息的WPARAM等于DBT_DEVICEARRIVAL的情况。并且要通过对LPARAM内容的判断,排除插入光盘等其他设备的情况。
接下来具体组织代码。先用CreateWindow建立一个对话框作为主窗口,写好基本的消息循环。将
                    Device_Changed(hwnd, wParam, lParam);
                    break;
加入到消息循环中去。这样在设备接入时程序就会捕获到此消息,并且调用处理程序。我这里给处理程序起名Device_Changed。在处理程序内部详细处理所捕获到的消息,通过WPARAM对它的动作进行分析,我只关心插入动作,也就只处理当WPARAM等于DBT_DEVICEARRIVAL的消息。另外,也要对LPARAM进行判断,排除其他设备类型。
switch(wParam)
{
case DBT_DEVICEARRIVAL:
                    if((PDEV_BROADCAST_HDR) lParam -> dbch_devicetype == DBT_DEVTYP_VOLUME)
                    {
                         if(((PDEV_BROADCAST_VOLUME) lParam ->dbcv_flags & (DBTF_MEDIA|DBTF_NET)) == 0)              {
                                Call_Anti_Virus(lParam);
                         }
                    }
……
上面的代码中DBT_DEVTYP_VOLUME表示设备类型为存储卷,区别于其他即插即用设备。DBTF_MEDIA表示光驱,DBTF_NET表示网络驱动器,除过这两种剩下的就是U盘了。至此检测U盘插入的工作就算是告成了。

调用杀毒程序

这一步里面我需要调用两个杀毒程序,依次对U盘进行查杀。这里涉及的问题有,如何调用杀毒软件,如何得知第一个杀毒程序已结束,如何让杀毒软件查杀指定U盘。
首先要明确一个概念:程序只能从外部调用杀毒软件。杀毒软件为了自身安全性,往往不会把内部接口开放给用户,因此,我们只能用命令行调用的形式从外部启动杀毒软件。这种方式受限于杀毒软件提供的命令行参数,如果杀毒软件没有提供足够的命令行参数,对其的控制就很难完善。例如瑞星,干脆没有提供命令行参数,程序就只能启动而无法控制它。其他一些杀软,可能没有提供杀毒后自动关闭的参数,那么程序也无法让它在杀毒后关闭,而因杀软的高权限,强行终止杀毒软件的进程往往很难成功。
明确了上面的概念,程序的行为就很简单了。(1)获取U盘路径;(2)调用杀软在命令行执行。而U盘的路径我们可以在WM_DEVICECHANGE消息的LPARAM参数中获得。将LPARAM参数看做PDEV_BROADCAST_VOLUME类型,其中的成员变量dbcv_unitmask就保存了设备盘符。dbcv_unitmask是一个DWORD类型值,共32位,它是用bit位来表示信息。它从第一位开始,每1 bit表示一个盘符。例如,A盘就是第一位置1D盘就是第4位置1。如果插入设备盘符是F,则dbcv_unitmask就等于十六进制的0x20,也就是第6位被置一。我们通过一个简单的程序来取出盘符:
char FirstDriveFromMask( ULONG unitmask )
{
                    char i;
                    for (i = 0; i < 26; ++i)
                    {
                         if (unitmask & 0x1)
                                break;
                         unitmask = unitmask >> 1;
                    }
                    return( i + 'A' );
}
将得到的盘符和冒号“:”、斜杠“\”等组合起来形成带扫描路径,然后根据所要使用杀毒软件的命令行参数规范,构成完整的命令行字符串。例如我是用的小红伞Avira的命令行就是“D:\\Program Files\\Avira\\AntiVir Desktop\\avscan.exe PATH=I:\\”。这里我没有添加运行完自动关闭的参数,但在应用中是一定要加上的,要不然第一个杀软杀完就停那儿了,第二个杀软就不知道自己什么时候开始了。那么第二个杀软是怎么知道轮到自己了呢?这就需要我们在调用杀软的时候,选择可以掌握运行状态的API,要能知道它们什么时候运行结束了。
调用杀毒软件其实就跟调用其他程序是一样的。Windows API中调用其他应用程序的函数有:
(1)       WinExec:原型是UINT WINAPI WinExec(LPCSTR lpCmdLine, UINT uCmdShow); 第一个参数是就是上面所说的命令行,第二个参数是控制显示方式,如最大化,最小化等。这个函数非常简单易用,但是缺点也很明显,控制力不足。
(2)       ShellExecute:相对于WinExec较为复杂,当然也提供了相对较多的控制,我的程序最就用了这个函数的扩展形式:ShellExecuteEx,关于这两个APIMSDN上都有讲。我们之所以选择ShellExecuteEx,是因为,它在执行后可以有一个变量存放被执行进程的信息。而通过这个变量就可以确认所执行进程是否终止了。
(3)       CreateProcess:功能最强大的函数,可以控制进程的几乎所有属性,但是随之而来的是复杂的调用。为了简单起见我并没有用它。从另一个角度讲,如果这个程序是我自己用的,我可能就会用它了,而这个本是给别人做的项目。
具体的调用代码:
SHELLEXECUTEINFO ShExecInfo = {0};
ShExecInfo.cbSize = sizeof(SHELLEXECUTEINFO);
ShExecInfo.fMask = SEE_MASK_NOCLOSEPROCESS;
ShExecInfo.hwnd = NULL;
ShExecInfo.lpVerb = NULL;
ShExecInfo.lpFile = command_path;
ShExecInfo.lpParameters = parameters;
ShExecInfo.lpDirectory = NULL;
ShExecInfo.nShow = SW_SHOW;
ShExecInfo.hInstApp = NULL;
ShellExecuteEx(&ShExecInfo);
WaitForSingleObject(ShExecInfo.hProcess,INFINITE);
代码倒数第二行调用了ShellExecuteEx,而前面的若干行都是在创建一个保存运行信息的SHELLEXECUTEINFO结构,这里面包含了命令行、参数、显示方式等,具体的还是MSDN说的清楚。而在最后面有个WaitForSingleObject函数,它用被调用进程的句柄作为参数,是干什么用的呢?其实无论ShellExecuteEx还是WinExec在执行完后都会立即返回然后就会紧接着执行下面的命令了。而我要做的是让第一个杀软杀毒完成后才开启第二个杀软。因此,要让程序在开启第一个杀软后进入等待状态,一直等到第一个杀软退出后才往下执行。WaitForSingleObject恰恰就有这样的作用。此函数第一个参数是句柄hInstance,第二个参数是毫秒数dwMilliseconds,它本来是用来阻塞程序直到第一个参数所代表的事件发生,或者超过所设定毫秒数才返回相应结果。我们这里用被调用杀软的进程句柄作为第一个参数,第二个参数设为无限,则程序在此处会一直等到被调用进程销毁,然后才返回。后面只需要紧跟第二个杀软的调用代码,这样就可以让程序在第一个杀软查杀时一直处于等待状态,一旦第一个查杀完成退出后第二个马上就开始了。
至此,U盘查杀的工作已经完全做完了,程序已经能够检测到U盘插入并依次调用两个杀软查杀了。

复制文件夹

最后一部分是关于复制文件的。客户之前的要求是,要能复制U盘文件夹到硬盘的某个文件夹,而不考虑单个文件的问题,因此我很快想到了SHBrowseForFolder函数。别会错意,这个函数可没有复制的能力,它是一个文件夹选择对话框。不同于文件选择对话框类CFileDialog,它只能选目录,不能选文件,而且是一个地地道道的Windows API,不是MFC的类,没有兼容性问题。
SHBrowseForFolder的使用非常简单
char szPathName[1024]
BROWSEINFO browse_info;
memset(&browse_info, 0, sizeof(BROWSEINFO));
browse_info.lpszTitle = "选择文件夹";
browse_info.ulFlags = BIF_RETURNONLYFSDIRS|BIF_DONTGOBELOWDOMAIN|BIF_RETURNFSANCESTORS;
LPCITEMIDLIST pidl;
pidl = SHBrowseForFolder(&browse_info);
if(pidl == NULL)
                    return -1;
SHGetPathFromIDList(pidl, szPathName);
最后一句中SHGetPathFromIDList函数取得所选路径字符串,存入szPathName中。这样我们就得到了要操作的文件夹路径,只需要代入复制函数中去就完成了。
至于复制函数,我试用过WinExec调用xcopy命令,完成复制文件夹没有任何问题。但是此法没有复制进度的提示,复制大文件时,时间一长人就受不了了,还是决定要弄个进度条出来。后来突然发现了功能强大的SHFileOperation函数。它可以对文件系统中的对象执行复制、删除、移动、重命名操作,而且看起来就跟系统自己操作时的界面一模一样。(是不是Windows系统的文件操作就用这个API?)由于这个函数很强大,我看了很久也只看了九牛一毛,只学了用它来复制文件和文件夹。
SHFILEOPSTRUCT file_operation;
file_operation.hwnd = NULL;
file_operation.wFunc = FO_COPY;
file_operation.pFrom = from;
file_operation.pTo = to;
file_operation.fFlags = FOF_NOCONFIRMATION;
SHFileOperation(&file_operation);
上面代码中显示出,SHFileOperation只需要一个SHFILEOPSTRUCT结构体作为参数,这个结构体中wFunc表示要执行的操作,我们这里选FO_COPY,就是复制的意思,pFrompTo这两个字符串分别是复制文件的源路径和目的路径。执行时结果如下图,是不是很顺眼。

改写成DLL

前面的步骤做完之后,直接调试程序,看看它在各方面表现是否正常,全都弄好之后就该把他改写成DLL了。说起来这最后一步也最简单,只需要修改WinMain函数就可以了。把WinMain函数换个名字,然后再加上DLL的声明。
原先的代码:
int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
……
改为:
extern "C"
{
                    __declspec(dllexport) int CreateDlg(HINSTANCE hInstance)
                    {
……
还有别忘了把消息循环里面对话框不应该有的处理方式都变一变,这样就能够编译成一个DLL了。

总结

虽然这个工程很顺利的完成了,但是最后还是没能交给用户,因为这样的程序调用杀毒软件完全受限制,对于瑞星这样的杀软就一点办法没有。可那个该死的客户还说另一个怎么都行,就得要一个瑞星。也怪自己能力不够,没有能把瑞星的程序破解了。但是最后,还是有一种可行的想法:就是利用SendMessage模拟用户的键鼠动作来操作瑞星,但是这样一是太繁琐耗时间,二个对于瑞星这么热衷的人,我很不待见,也就不想再做了。我自己能力有限,写了这么个只为留个纪念,如果希望跟我交流非常欢迎,意见建议也可以。转帖一定要标出处。
最后再补充一下关于头文件的问题。这里面用了那么多Windows APIwindows.h自然是一定要了。DBT_DEVICEARRIVAL等消息都定义在dbt.h中,而SHBrowseForFolderSHFileOperationShell函数则是定义在shlobj.h中。

阅读全文...