简介:这是一份基于VC++实现的360杀毒高仿项目源码,面向具备C++基础、希望深入Windows桌面开发与安全软件架构的学习者。项目以MFC与SkinUI界面库搭建皮肤化窗口,通过XML布局文件实现界面与业务逻辑解耦,并模拟病毒扫描、隔离清除、多线程后台查杀、错误日志记录与系统资源管理等核心环节,是理解安全防护类应用整体结构的实践素材。压缩包共85个文件,约936KB,包含28个png界面素材、22个h头文件、16个xml布局、3个cpp源文件及sln、vcproj工程文件,另有dll、lib、exe等运行依赖,目录按include、src、bin、build等模块划分,结构清晰便于对照阅读。目前已有468人学习下载,适合作为课程设计、毕业设计或自学Windows编程的参考案例,帮助读者掌握界面设计、多线程调度与文件I/O操作等实用技能。
1. 从“高仿”二字说起:这套代码到底在仿什么
很多人第一次看到“vc++ 360杀毒代码(高仿)”这个标题,脑子里冒出来的画面是:一个用 MFC 或 Qt 写出来的绿色界面,左边一排功能按钮,右边一个扫描进度条,点一下“快速扫描”就开始滚文件路径。这个理解对了一半,但真正值得动手做的部分不在界面,而在界面背后那套“扫描引擎 + 特征库 + 隔离区”的最小闭环。所谓“高仿”,仿的是交互流程和模块划分,不是去逆向谁的文件格式,更不是去复制谁的病毒库——那既不现实也没必要。
这套东西能解决什么问题?如果你正在学 VC++,写腻了计算器和通讯录,想找一个能把文件 IO、多线程、PE 结构解析、注册表操作、界面刷新串起来的综合练手项目,杀毒软件的最小模型几乎是性价比最高的选择。它逼着你处理“扫描时界面不能卡死”“大文件不能一次性读进内存”“误报和漏报怎么权衡”这些真实工程问题。适合谁?适合已经能独立写出几百行 C++、知道CreateThread和std::thread区别、但还没完整做过一个带后台任务和状态反馈的桌面程序的人。下面我按自己搭这套东西的顺序,把能复现的路径拆开讲。
2. 扫描引擎的最小闭环:从文件遍历到特征匹配
2.1 为什么先做引擎再做界面
新手最容易翻车的地方,是一上来就拖控件、画皮肤,结果界面像模像样,点“扫描”按钮却不知道后面该调什么。我一般会先把引擎写成独立的一层,它不依赖任何窗口句柄,输入是一个目录路径,输出是一组“可疑文件记录”。这样做的另一个好处是:引擎可以用控制台程序单独测,不用每次改代码都重新编译整个 MFC 工程。引擎的核心职责只有三件事——遍历、读取、匹配。遍历负责把目录树走一遍,读取负责把文件内容按块拿进来,匹配负责拿内容去和特征库比对。三者解耦,后面换特征库格式或者加多线程都不会牵一发动全身。
2.2 用递归遍历目录并过滤可执行文件
Windows 下遍历目录,常见做法是FindFirstFile/FindNextFile这一对 API。不要用std::filesystem的递归迭代器直接上,因为它在遇到权限不足的目录时会抛异常,而杀毒扫描恰恰要频繁穿越System Volume Information这类没权限的目录,异常处理写起来很啰嗦。用原生 API 配合一个显式的栈或队列,遇到错误就跳过,逻辑更可控。
#include <windows.h> #include <string> #include <vector> // 判断是否是我们关心的可执行类文件 bool IsTargetFile(const std::wstring& path) { const wchar_t* exts[] = { L".exe", L".dll", L".sys", L".scr" }; size_t dot = path.find_last_of(L'.'); if (dot == std::wstring::npos) return false; std::wstring ext = path.substr(dot); for (auto e : exts) { if (_wcsicmp(ext.c_str(), e) == 0) return true; } return false; } // 递归遍历,结果塞进 out void WalkDir(const std::wstring& root, std::vector<std::wstring>& out) { std::wstring pattern = root + L"\\*"; WIN32_FIND_DATAW fd; HANDLE h = FindFirstFileW(pattern.c_str(), &fd); if (h == INVALID_HANDLE_VALUE) return; // 权限不足直接跳过 do { if (wcscmp(fd.cFileName, L".") == 0 || wcscmp(fd.cFileName, L"..") == 0) continue; std::wstring full = root + L"\\" + fd.cFileName; if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { WalkDir(full, out); // 目录就往下走 } else if (IsTargetFile(full)) { out.push_back(full); // 文件就收进来 } } while (FindNextFileW(h, &fd)); FindClose(h); }这段代码里IsTargetFile的扩展名列表就是你的“扫描范围”参数。想扫全类型就把判断去掉,但那样扫描时间会成倍上涨。WalkDir用递归实现,深度一般不会超过系统限制,真遇到超深目录可以改成显式栈。注意FindClose必须配对,否则句柄泄漏,扫几次之后FindFirstFile就会开始失败,这是血泪经验。
2.3 特征匹配:哈希比对和字节串匹配怎么选
特征库有两种常见形态。一种是哈希库,存已知恶意样本的 MD5 或 SHA256,扫描时算文件哈希去查表。优点是实现简单、误报极低;缺点是只能抓“已知的完整文件”,改一个字节就失效。另一种是字节串特征,比如某段 PE 节区里固定出现的字节序列,匹配时在文件内容里做子串查找。优点是能抓变种;缺点是容易误报,特征选不好会把正常程序也报出来。
我一般会两个都留:哈希库做主判据,字节串库做辅助。哈希计算用 Windows 自带的CryptCreateHash系列,不用自己实现 MD5。下面是一个算文件 SHA256 的封装:
#include <wincrypt.h> #pragma comment(lib, "crypt32.lib") bool FileSha256(const std::wstring& path, std::string& outHex) { HANDLE f = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); if (f == INVALID_HANDLE_VALUE) return false; HCRYPTPROV prov = 0; HCRYPTHASH hash = 0; CryptAcquireContext(&prov, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT); CryptCreateHash(prov, CALG_SHA_256, 0, 0, &hash); BYTE buf[8192]; DWORD read = 0; while (ReadFile(f, buf, sizeof(buf), &read, nullptr) && read > 0) { CryptHashData(hash, buf, read, 0); // 分块喂进去,避免大文件爆内存 } BYTE digest[32]; DWORD len = 32; CryptGetHashParam(hash, HP_HASHVAL, digest, &len, 0); char hex[65] = {0}; for (int i = 0; i < 32; ++i) sprintf_s(hex + i * 2, 3, "%02x", digest[i]); outHex = hex; CryptDestroyHash(hash); CryptReleaseContext(prov, 0); CloseHandle(f); return true; }关键参数是buf的大小。设成 8KB 是折中,太小系统调用次数多,太大对内存不友好。FILE_SHARE_READ这个标志很重要,它允许别的进程同时读这个文件,否则扫到正在运行的程序会直接失败。哈希算完转十六进制字符串,方便和特征库里的文本行比对。
2.4 特征库的加载与内存索引
特征库不要硬编码在代码里,放一个文本文件,一行一条记录,格式用类型:值:名称。加载时读进内存,哈希类特征塞进std::unordered_map<std::string, std::string>,字节串类特征塞进std::vector。用 map 查哈希是 O(1),几万条特征也不会有明显延迟。加载函数要处理空行和注释行,否则特征库维护起来会很难受。
#include <fstream> #include <unordered_map> struct SigDB { std::unordered_map<std::string, std::string> hashes; // sha256 -> 名称 std::vector<std::pair<std::string, std::string>> bytes; // 字节串 -> 名称 }; bool LoadSigDB(const std::string& file, SigDB& db) { std::ifstream in(file); if (!in) return false; std::string line; while (std::getline(in, line)) { if (line.empty() || line[0] == '#') continue; // 跳过注释 size_t p1 = line.find(':'); size_t p2 = line.find(':', p1 + 1); if (p1 == std::string::npos || p2 == std::string::npos) continue; std::string type = line.substr(0, p1); std::string val = line.substr(p1 + 1, p2 - p1 - 1); std::string name = line.substr(p2 + 1); if (type == "sha256") db.hashes[val] = name; else if (type == "bytes") db.bytes.emplace_back(val, name); } return true; }type字段留了扩展余地,以后想加“文件大小 + 哈希”的组合特征,直接加一个分支就行。字节串特征在内存里存的是原始字符串,匹配时用std::search在文件缓冲区里找。注意字节串里可能包含\0,所以不能用strstr,必须用带长度的查找。
3. 把引擎塞进 MFC 界面:线程、进度与隔离区
3.1 扫描线程与界面线程的分工
MFC 的界面线程负责响应消息,扫描必须放到工作线程里,否则进度条不会动,窗口拖动会留下白影。工作线程不能直接操作控件,正确做法是PostMessage把进度和结果发回主线程,主线程收到自定义消息再更新界面。自定义消息用WM_USER + 100起步,避免和系统消息冲突。
// 主线程侧:定义消息和处理函数 #define WM_SCAN_PROGRESS (WM_USER + 100) #define WM_SCAN_DONE (WM_USER + 101) // 工作线程函数 UINT ScanThread(LPVOID param) { CWnd* pWnd = (CWnd*)param; std::vector<std::wstring> files; WalkDir(L"C:\\TestDir", files); // 先收集文件列表 for (size_t i = 0; i < files.size(); ++i) { std::string sha; if (FileSha256(files[i], sha)) { // 查特征库,命中就记录 } int percent = (int)((i + 1) * 100 / files.size()); pWnd->PostMessage(WM_SCAN_PROGRESS, percent, 0); // 只传数字,不传指针 } pWnd->PostMessage(WM_SCAN_DONE, 0, 0); return 0; }这里有个坑:PostMessage的wParam和lParam只能传整数或指针,传指针的话接收方必须保证指针生命周期覆盖消息处理时间。我一般只传进度百分比和结果索引,具体数据放在线程安全的队列里,主线程按索引取。这样不会出现“消息到了,数据已经被释放”的玄学崩溃。
3.2 进度反馈与取消机制
进度条更新频率不要太高,每扫一个文件就发一次消息,文件多的时候消息队列会堆积,界面反而更卡。常见做法是每 50 个文件或每 200 毫秒发一次。取消机制用一个volatile bool标志,工作线程每轮循环检查一次,主线程点“停止”时置位。注意这个标志要用volatile修饰,或者用std::atomic<bool>,否则编译器优化后可能永远读到旧值。
std::atomic<bool> g_cancel{false}; // 工作线程循环里 for (size_t i = 0; i < files.size(); ++i) { if (g_cancel.load()) break; // 收到取消就退出 // ... 扫描逻辑 }std::atomic比volatile更稳妥,它保证了内存序。取消后要把已经扫到的结果保留下来,不能直接清空,否则用户会觉得“点了停止就白扫了”。
3.3 隔离区:把可疑文件挪走而不是删掉
扫描出可疑文件后,直接删除是危险操作,误报会把用户正常文件干掉。正确做法是“隔离”:把文件移动到程序目录下的Quarantine文件夹,同时记录原始路径到一个索引文件。恢复时按索引挪回去。移动用MoveFileEx,跨盘符时它会自动退化成复制加删除。
bool QuarantineFile(const std::wstring& src, const std::wstring& qdir, const std::wstring& name) { std::wstring dst = qdir + L"\\" + name + L".qtn"; if (!MoveFileExW(src.c_str(), dst.c_str(), MOVEFILE_COPY_ALLOWED)) return false; // 追加记录到 index.txt:原始路径|隔离文件名 std::ofstream idx("Quarantine\\index.txt", std::ios::app); idx << std::string(src.begin(), src.end()) << "|" << std::string(name.begin(), name.end()) << "\n"; return true; }隔离文件名不要用原始文件名,用哈希或时间戳重命名,避免同名冲突。索引文件里存原始路径,恢复时读出来反向操作。这个索引文件本身要放在隔离区外面,否则恢复逻辑会递归找自己。
4. 避坑与排查:那些让程序当场翻车的细节
4.1 扫描到系统目录直接卡死
现象:进度条走到某个百分比就不动了,窗口无响应。原因:扫到了C:\Windows\System32下的某些被系统占用的文件,CreateFile打开时阻塞,或者FindFirstFile在权限不足的目录上反复重试。解决:在CreateFileW里加FILE_FLAG_BACKUP_SEMANTICS和超时控制不现实,更简单的做法是维护一个跳过列表,把System Volume Information、$Recycle.Bin这类目录直接排除。另外CreateFileW打开失败就立刻返回,不要重试。
4.2 大文件把内存吃满
现象:扫一个几百 MB 的安装包时程序内存暴涨,甚至崩溃。原因:有人图省事用std::ifstream一次性读到std::string里再匹配。解决:哈希计算必须分块,字节串匹配也要分块,块与块之间保留特征长度减一的 overlap,防止特征刚好跨在块边界上被漏掉。这个 overlap 的长度等于最长特征的长度减一,特征库加载时顺便算出来。
4.3 特征库里的字节串匹配误报
现象:扫出一堆正常程序,用户一看全是误报。原因:字节串特征选得太短,比如只用了 4 个字节的序列,正常文件里随机出现概率很高。解决:字节串特征长度至少 16 字节,并且优先选 PE 文件的代码节区里的序列,不要选数据节区。另外可以加一个“白名单哈希”机制,已知正常的文件哈希直接跳过匹配。
4.4 多线程扫描时结果顺序错乱
现象:开了 4 个线程扫描,结果列表里同一个文件出现两次,或者进度条来回跳。原因:多个线程同时往同一个vector里push_back,没有加锁。解决:每个线程维护自己的局部结果列表,扫描结束后由主线程合并。进度用一个原子计数器累加,不要用共享的int直接++。
4.5 隔离后恢复时文件被占用
现象:恢复隔离文件时提示“另一个程序正在使用此文件”。原因:隔离时移动的是原文件,但原文件可能还被某个进程打开着。解决:隔离前先尝试用FILE_SHARE_READ | FILE_SHARE_WRITE打开一次,能打开说明没被独占,再移动。如果打不开,记录到“待隔离”列表,提示用户重启后再处理。这个逻辑虽然麻烦,但比强行移动导致数据损坏要好。
5. 进阶技巧:让特征库和扫描策略更接近实用
5.1 用 PE 节区信息缩小匹配范围
全文件字节匹配效率低,因为大部分内容是不相关的资源数据。更聪明的做法是先解析 PE 头,定位到.text节区的偏移和大小,只在这个范围内做字节串匹配。PE 解析不需要第三方库,IMAGE_DOS_HEADER和IMAGE_NT_HEADERS结构体在winnt.h里都有定义。读文件前 4KB 就能拿到节区表,然后按节区偏移去读对应块。这样匹配范围能缩小到原文件的十分之一左右,速度提升明显。
// 读取 PE 节区信息(简化版,只取第一个可执行节区) bool GetTextSection(const std::wstring& path, DWORD& offset, DWORD& size) { HANDLE f = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); if (f == INVALID_HANDLE_VALUE) return false; IMAGE_DOS_HEADER dos; DWORD read; ReadFile(f, &dos, sizeof(dos), &read, nullptr); if (dos.e_magic != IMAGE_DOS_SIGNATURE) { CloseHandle(f); return false; } SetFilePointer(f, dos.e_lfanew, nullptr, FILE_BEGIN); IMAGE_NT_HEADERS nt; ReadFile(f, &nt, sizeof(nt), &read, nullptr); if (nt.Signature != IMAGE_NT_SIGNATURE) { CloseHandle(f); return false; } auto* sec = IMAGE_FIRST_SECTION(&nt); for (int i = 0; i < nt.FileHeader.NumberOfSections; ++i) { if (strncmp((char*)sec[i].Name, ".text", 5) == 0) { offset = sec[i].PointerToRawData; size = sec[i].SizeOfRawData; CloseHandle(f); return true; } } CloseHandle(f); return false; }拿到offset和size后,SetFilePointer跳过去,只读这一段做匹配。注意SizeOfRawData可能大于实际内容,读的时候按实际读到的字节数算。
5.2 特征库的版本管理和增量更新
特征库文件不要每次全量替换,用“主库 + 增量库”的方式。主库放基础特征,增量库放最近新增的,加载时两个都读,内存里合并。增量库可以按日期命名,比如sig_20250101.db,程序启动时扫描目录下所有增量库按文件名排序加载。这样更新时只需要下发一个小文件,不用动主库。删除特征时在主库里标记#deleted前缀,加载时跳过,而不是真的去改主库文件。
5.3 扫描结果的置信度分级
不要只输出“安全/危险”两个状态,加一个置信度字段。哈希命中给 100 分,字节串命中给 60 分,PE 节区异常给 40 分。界面上按分数排序,高分标红,低分标黄。这样用户看到一堆黄色记录时不会那么恐慌,也方便你调试特征库——如果正常文件大量出现在 60 分档,说明字节串特征选得太宽了。
| 判据 | 分值 | 说明 |
|---|---|---|
| SHA256 命中 | 100 | 完整文件匹配,几乎无误报 |
| 字节串命中 | 60 | 需结合节区位置判断 |
| PE 节区异常 | 40 | 节区名乱码或大小异常 |
| 文件大小异常 | 20 | 极小或极大,仅作参考 |
这张表可以直接做成配置,分值改起来不用重新编译。我自己的习惯是每次调整特征库后,拿一个装满正常程序的目录跑一遍,看有多少文件落在 60 分以上,超过 5% 就说明特征需要收紧。这个“误报率自测”步骤比任何理论都管用,希望帮到你。
本文还有配套的精品资源,点击获取