简介:这是一份基于VC++开发的360杀毒软件高仿项目源码,面向C++初学者及Windows桌面应用进阶学习者,旨在通过实战掌握MFC/WinAPI界面开发、SkinUI皮肤化布局、多线程病毒扫描模拟等核心技能。资源共85个文件,包含22个头文件(h)用于模块接口定义、16个XML布局文件实现界面与逻辑解耦、3个CPP源文件承载主业务逻辑,辅以PNG图标资源、EXE可执行程序及DLL动态库,完整呈现从UI构建、事件响应到后台任务调度的典型安全软件架构,压缩包仅936KB,轻量易读。目前已有468人学习下载,适合希望深入理解Windows原生应用分层设计、实践界面-逻辑分离模式、并复现基础杀毒功能(如文件遍历、特征匹配模拟、托盘交互)的开发者。
1. 这不是杀毒软件源码,而是一份被严重误标的技术教学素材:VC++ 实现基础 PE 文件解析与内存扫描逻辑的实操笔记
去年在整理一批老项目资料时,我翻出一个标着“vc++ 360杀毒代码(高仿)”的压缩包,解压后发现根本不是什么商业产品逆向工程成果,而是某高校课程设计作业的完整工程——用 VC++ 6.0 + MFC 搭建的轻量级本地文件扫描器原型。它不调用任何第三方引擎,不联网、不签名验证、不处理驱动层行为监控,核心就三块:PE 头解析器、字符串特征匹配模块、简单内存映射加载器。但恰恰是这种“简陋”,让它成了我带新人过手写安全工具链的第一课:没有黑匣子,所有判断逻辑裸露在 C++ 类里;没有玄学优化,每行ReadProcessMemory调用都配了错误码检查;连“查杀”结果弹窗都是手动AfxMessageBox拼出来的。如果你正卡在“想学终端安全开发却找不到可编译、可调试、可改写的起点”,这份代码就是那个能让你从CreateFile开始,亲手把“扫描”两个字变成for (int i = 0; i < sigList.size(); i++)的实物。它解决不了真实攻防场景里的免杀对抗,但它能帮你把“病毒扫描”从概念拉回 Win32 API 层面的内存读写操作。
2. 从工程结构到核心类:看清这个“高仿”到底仿了哪几层皮
这个项目不是拿来即用的成品,而是一个典型的教学型分层架构。它没用现代 C++ 模式,但每一层职责清晰得像教科书插图。我一般会先带新人用 Visual Studio 2019(兼容 VC++ 6.0 工程)打开.dsp文件,然后按以下顺序逐层拆解:
2.1 工程目录与编译配置:为什么必须用 VC++ 6.0 兼容模式?
整个工程包含 4 个关键目录:
Source/:存放全部.cpp和.h文件,无子目录;Resource/:图标、对话框资源.rc文件,MFC 对话框控件全靠手动拖拽定义;SigDB/:纯文本特征库,每行格式为MD5:xxxx|NAME:xxx|OFFSET:0x123|PATTERN:4D5A9000;Bin/:预编译的ScanEngine.dll(仅含导出函数ScanFile()),但项目实际未调用,属于冗余残留。
提示:VS2019 打开时需手动设置平台工具集为
Visual Studio 6.0 (v60),否则#include <afxwin.h>会报错。这不是怀旧,而是因为CWinApp的消息循环初始化方式在后续版本中已重构,强行升级会导致InitInstance()中m_pMainWnd->ShowWindow()失效。
2.2 主框架类CScanApp:MFC 应用生命周期的硬编码实践
CScanApp继承自CWinApp,但重写了全部关键虚函数:
InitInstance()中强制调用AfxEnableControlContainer()启用 ActiveX 容器(为后续可能集成 COM 组件预留);ExitInstance()里显式调用FreeLibrary(hScanDll)(尽管ScanEngine.dll未被加载,但此逻辑体现资源释放意识);- 最关键的是
Run()函数被重写:它绕过 MFC 默认消息泵,在while (GetMessage())循环外插入了OnIdle()回调,用于在 UI 空闲时执行后台扫描任务——这是教学重点:如何避免界面假死。
// ScanApp.cpp 片段:非阻塞扫描调度逻辑 BOOL CScanApp::OnIdle(LONG lCount) { if (m_bScanning && !m_bScanPaused) { // 每次空闲只处理 10 个文件,防止 UI 响应延迟 for (int i = 0; i < 10 && m_nCurrentFile < m_FileList.GetSize(); i++) { ScanSingleFile(m_FileList[m_nCurrentFile++]); } return TRUE; // 继续调用 OnIdle } return CWinApp::OnIdle(lCount); }这段代码暴露了原始设计意图:它不追求吞吐量,而强调“可控性”。新手常误以为杀毒必须多线程,但这里用单线程+消息泵调度,反而更易调试内存泄漏和句柄泄露。
2.3 扫描引擎核心CScanEngine:三个函数撑起全部逻辑
CScanEngine是纯 C++ 类(非 MFC),无继承关系,仅提供三个静态接口:
static BOOL LoadSignatureDB(LPCTSTR lpszPath):逐行读取SigDB/下文本文件,用strtok解析字段,存入std::vector<SCAN_SIG>;static DWORD ScanFile(LPCTSTR lpszFilePath, CString& strResult):主扫描函数,流程为:①CreateFile打开文件 → ②CreateFileMapping映射 → ③MapViewOfFile获取内存指针 → ④ 遍历特征库做memcmp匹配;static BOOL IsPEFile(BYTE* pBase):最简 PE 判断:检查pBase[0]==0x4D && pBase[1]==0x5A && *(WORD*)(pBase+0x3C)==0x0000(即 DOS 头魔数 + PE 签名偏移)。
注意:它不校验 PE 结构完整性,不解析节表,不处理重定位。IsPEFile函数只是快速过滤,真正扫描时直接对整个映射内存块暴力匹配。这正是教学价值所在——它把“扫描”降维到最原始的内存比对,剥离所有干扰项。
2.4 特征库SigDB:文本规则的朴素实现与致命缺陷
SigDB/目录下只有一个default.sig,内容示例:
MD5:8d7b5f1a2c3e4b5a6d7c8e9f0a1b2c3d|NAME:FakePEHeader|OFFSET:0x0|PATTERN:4D5A900000000000 MD5:1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d|NAME:ShellcodeJMP|OFFSET:0x100|PATTERN:E9FFFFFFFF规则字段含义:
MD5:仅作标识,代码中未使用;NAME:匹配成功后显示的名称;OFFSET:从文件开头起算的偏移(支持0x十六进制);PATTERN:十六进制字符串,转为字节数组后用于memcmp。
致命缺陷在于:OFFSET是绝对偏移,但 PE 文件加载后虚拟地址与文件偏移不一致。代码中ScanFile函数直接将pBase + offset作为比对起点,这意味着它只能检测文件头固定位置的特征,无法处理节内代码段特征。这是刻意为之的教学留白——让学习者自己发现“为什么扫不出加壳样本”,进而引出 RVA 转 FOA 的转换逻辑。
3. 关键技术点深挖:PE 头解析、内存映射扫描、特征匹配的底层实现
这个项目的价值不在功能完整,而在每个技术点都暴露了 Win32 编程的原始肌理。我带新人时,会强制他们关掉 IntelliSense,一行行跟踪ScanFile的每一步系统调用,直到看懂MapViewOfFile返回的指针为何能直接当内存地址用。
3.1IsPEFile的三重校验:为什么只检查 DOS 头和 PE 签名偏移?
标准 PE 判断需验证:① DOS 头魔数MZ;②e_lfanew偏移处是否为PE\0\0;③OptionalHeader中Magic是否为0x010B(32位)或0x020B(64位)。但本项目只做前两步:
// ScanEngine.cpp 片段 BOOL CScanEngine::IsPEFile(BYTE* pBase) { // Step 1: Check DOS header magic if (pBase[0] != 0x4D || pBase[1] != 0x5A) return FALSE; // Step 2: Read e_lfanew (offset 0x3C, 4 bytes) DWORD dwPEHeaderOffset = *(DWORD*)(pBase + 0x3C); // Step 3: Check "PE\0\0" at that offset if (dwPEHeaderOffset + 4 > 0x1000) // Prevent overflow return FALSE; BYTE* pPEHeader = pBase + dwPEHeaderOffset; if (pPEHeader[0] != 0x50 || pPEHeader[1] != 0x45 || pPEHeader[2] != 0x00 || pPEHeader[3] != 0x00) return FALSE; return TRUE; }这段代码的精妙在于第三步的溢出防护:dwPEHeaderOffset + 4 > 0x1000。它假设 PE 头不会出现在文件末尾 4KB 后(合理经验阈值),避免pPEHeader指针越界读取。这是典型的老派防御思维——不用复杂算法,用经验边界卡住绝大多数异常。
3.2 内存映射扫描:CreateFileMapping为何比ReadFile更适合大文件?
ScanFile函数中,对大于 1MB 的文件强制启用内存映射:
// ScanFile 函数内片段 HANDLE hFile = CreateFile(lpszFilePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); DWORD dwFileSize = GetFileSize(hFile, NULL); HANDLE hMap = NULL; BYTE* pBase = NULL; if (dwFileSize > 0x100000) { // >1MB hMap = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL); pBase = (BYTE*)MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0); } else { // 小文件用 ReadFile 分配堆内存 pBase = new BYTE[dwFileSize]; DWORD dwRead; ReadFile(hFile, pBase, dwFileSize, &dwRead, NULL); }关键差异:
ReadFile:需申请堆内存,new BYTE[size]可能失败(大文件触发std::bad_alloc);CreateFileMapping:由系统管理虚拟内存,即使文件 2GB,MapViewOfFile也只提交实际访问页的物理内存,且pBase是合法用户态指针,可直接memcmp。
但隐患在于:MapViewOfFile返回的pBase指向的是文件视图,其布局与磁盘文件完全一致(即 FOA 地址),而非加载后的内存布局(RVA)。所以OFFSET字段必须是 FOA,否则匹配必然失败。这是新手最容易踩坑的底层认知断层。
3.3 特征匹配引擎:memcmp的朴素威力与性能陷阱
匹配逻辑极简:
// ScanFile 函数内循环体 for (int i = 0; i < m_SigList.size(); i++) { SCAN_SIG& sig = m_SigList[i]; BYTE* pPattern = sig.pPattern; // 已从 hex string 转为 byte array int nPatternLen = sig.nLength; // 计算匹配起点:文件开头 + OFFSET BYTE* pStart = pBase + sig.dwOffset; // 边界检查:确保不越界 if ((DWORD)(pStart - pBase) + nPatternLen > dwFileSize) continue; if (memcmp(pStart, pPattern, nPatternLen) == 0) { strResult.Format(_T("Found: %s"), sig.strName); break; } }这里有两个硬编码约束:
sig.dwOffset必须 ≤dwFileSize - nPatternLen,否则跳过;memcmp不支持通配符(如??),所有特征必须是精确字节序列。
性能陷阱在于:对 100MB 文件,若特征库有 100 条规则,最坏情况需做100 × 100MB次内存比较(即 10GB 数据搬运)。真实杀软会用 Aho-Corasick 多模匹配算法,但本项目用memcmp强迫学习者直面 O(n×m) 复杂度——当你亲手把扫描时间从 2 秒拖到 47 秒时,才会真正理解为什么需要状态机。
4. 避坑指南:五个血泪教训,全是我在调试时重启过三次 VS 的真实记录
这个项目表面简单,但因年代久远、API 使用粗放,埋了大量运行时地雷。以下是我在 Windows 10 x64 + VS2019 环境下实测踩出的 5 个高频问题,按“现象→原因→解决”结构整理,每一条都对应一次真实的蓝屏或崩溃。
4.1 现象:程序启动后立即弹出“应用程序无法正常启动 (0xc000007b)”
原因:VC++ 6.0 编译的.exe默认依赖msvcrt.dll(VC6 运行时),而 Windows 10 自带的是msvcr140.dll。系统找不到旧版 CRT,且错误码0xc000007b正是“STATUS_INVALID_IMAGE_FORMAT”的别名(此处实为 DLL 加载失败的误报)。
解决:在项目属性 → 配置属性 → 常规 → “使用 MFC” 选“在共享 DLL 中使用 MFC”;再进入 C/C++ → 代码生成 → “运行时库” 改为/MD(多线程 DLL)。重新编译后,程序会动态链接系统自带的msvcrt.dll(Windows 兼容层提供)。
4.2 现象:扫描.sys驱动文件时,CreateFile返回INVALID_HANDLE_VALUE,GetLastError()为5(拒绝访问)
原因:.sys文件默认受系统保护,普通进程无权以GENERIC_READ打开。这不是权限问题,而是 Windows 对驱动文件的硬性限制。
解决:在CreateFile前添加SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_HIGHEST)提升线程优先级(无效);正确做法是改用\\?\前缀绕过路径检查:CreateFile(_T("\\\\?\\C:\\test.sys"), ...)。但注意:此方法仅对管理员权限进程有效,需右键“以管理员身份运行”。
4.3 现象:扫描大文件(>2GB)时,GetFileSize返回0xFFFFFFFF,导致dwFileSize被截断为 4GB-1
原因:GetFileSize的返回值是DWORD(32位),无法表示 >4GB 文件大小。虽然文档说“高32位存于lpFileSizeHigh”,但本项目未传入该参数。
解决:改用GetFileSizeEx,它接受LARGE_INTEGER*参数:
LARGE_INTEGER liSize; if (GetFileSizeEx(hFile, &liSize)) { dwFileSize = (DWORD)liSize.LowPart; // 仍只取低32位,但至少不溢出 // 实际应改用 liSize.QuadPart 全局处理 }4.4 现象:特征匹配成功后,strResult显示乱码(如“Found: Ί»Ί»Ί»Ί»”)
原因:CString在 Unicode 工程中默认宽字符,但sig.strName是char*(ANSI),strResult.Format(_T("Found: %s"), sig.strName)中%s期望wchar_t*,导致字节被错误解释。
解决:统一字符集。在项目属性 → 常规 → “字符集” 改为“使用多字节字符集(MBCS)”,并删除所有_T("")宏,改用""字符串字面量。或者,保留 Unicode,将sig.strName转为宽字符:CA2W(sig.strName).m_psz。
4.5 现象:多次扫描同一文件后,程序内存占用持续增长,最终new BYTE[]报std::bad_alloc
原因:小文件分支中pBase = new BYTE[dwFileSize]分配的内存,在函数退出前未delete[] pBase。ScanFile是静态函数,无析构逻辑,每次调用都泄漏。
解决:在ScanFile函数末尾添加:
if (pBase && !hMap) { delete[] pBase; pBase = NULL; } if (hMap) { UnmapViewOfFile(pBase); CloseHandle(hMap); } CloseHandle(hFile);注意:UnmapViewOfFile必须在CloseHandle(hMap)之前调用,否则视图无法释放。
5. 功能增强实战:给原始代码加上“节区扫描”和“字符串提取”,让它真正可用
原始代码的OFFSET机制只能扫文件头,对真实恶意软件(如加壳后代码段偏移变化)完全失效。我带新人做的第一个增强,就是加入 PE 节表遍历,让扫描器能自动定位.text、.data等节的内存区域,并在这些区域内进行特征匹配。这不是炫技,而是补上从“文件扫描”到“内存扫描”的关键一跳。
5.1 解析节表:从e_lfanew到IMAGE_SECTION_HEADER
在IsPEFile验证通过后,我们已有pBase指针和dwPEHeaderOffset。接下来提取节表:
// 新增函数:GetSectionInfo BOOL GetSectionInfo(BYTE* pBase, DWORD dwPEHeaderOffset, std::vector<SECTION_INFO>& vecSections) { // 1. 定位 IMAGE_NT_HEADERS PIMAGE_NT_HEADERS pNtHdr = (PIMAGE_NT_HEADERS)(pBase + dwPEHeaderOffset); // 2. 获取节表起始地址:NT Headers 后紧跟节表 PIMAGE_SECTION_HEADER pSecHdr = IMAGE_FIRST_SECTION(pNtHdr); // 3. 遍历节表(NumberOfSections 指定数量) WORD nSections = pNtHdr->FileHeader.NumberOfSections; for (int i = 0; i < nSections; i++) { SECTION_INFO sec; sec.dwVirtualAddress = pSecHdr[i].VirtualAddress; // RVA sec.dwVirtualSize = pSecHdr[i].Misc.VirtualSize; sec.dwPointerToRawData = pSecHdr[i].PointerToRawData; // FOA sec.dwSizeOfRawData = pSecHdr[i].SizeOfRawData; strncpy_s(sec.szName, pSecHdr[i].Name, 8); vecSections.push_back(sec); } return TRUE; }关键点:IMAGE_FIRST_SECTION是 Windows SDK 定义的宏,计算公式为(PIMAGE_SECTION_HEADER)((BYTE*)pNtHdr + sizeof(IMAGE_NT_HEADERS))。pSecHdr[i].VirtualAddress是 RVA(相对虚拟地址),需转换为 FOA(文件偏移)才能在文件映射内存中定位:
// RVA to FOA 转换函数(简化版,忽略重定位) DWORD RvaToFoa(BYTE* pBase, DWORD dwPEHeaderOffset, DWORD dwRva) { PIMAGE_NT_HEADERS pNtHdr = (PIMAGE_NT_HEADERS)(pBase + dwPEHeaderOffset); PIMAGE_SECTION_HEADER pSecHdr = IMAGE_FIRST_SECTION(pNtHdr); WORD nSections = pNtHdr->FileHeader.NumberOfSections; for (int i = 0; i < nSections; i++) { DWORD dwSecRva = pSecHdr[i].VirtualAddress; DWORD dwSecSize = pSecHdr[i].Misc.VirtualSize; if (dwRva >= dwSecRva && dwRva < dwSecRva + dwSecSize) { // RVA 落在该节内,计算 FOA 偏移 DWORD dwFoaInSec = dwRva - dwSecRva; return pSecHdr[i].PointerToRawData + dwFoaInSec; } } return 0; // 未找到 }5.2 节区扫描:在.text节内暴力匹配特征
修改ScanFile主逻辑,在IsPEFile成功后插入节表扫描:
// ScanFile 函数新增分支 if (CScanEngine::IsPEFile(pBase)) { std::vector<SECTION_INFO> vecSections; GetSectionInfo(pBase, dwPEHeaderOffset, vecSections); // 仅扫描 .text 和 .data 节 for (auto& sec : vecSections) { if (strcmp(sec.szName, ".text") == 0 || strcmp(sec.szName, ".data") == 0) { DWORD dwFoaStart = sec.dwPointerToRawData; DWORD dwFoaEnd = dwFoaStart + min(sec.dwSizeOfRawData, sec.dwVirtualSize); // 在该节 FOA 范围内匹配所有特征 for (int i = 0; i < m_SigList.size(); i++) { SCAN_SIG& sig = m_SigList[i]; // 注意:此时 sig.dwOffset 应为 0,表示在整个节内搜索 BYTE* pStart = pBase + dwFoaStart; BYTE* pEnd = pBase + dwFoaEnd; // 在 [pStart, pEnd) 区间内滑动窗口匹配 for (BYTE* pCur = pStart; pCur <= pEnd - sig.nLength; pCur++) { if (memcmp(pCur, sig.pPattern, sig.nLength) == 0) { strResult.Format(_T("Found in %s: %s"), sec.szName, sig.strName); goto FOUND; } } } } } }此增强使扫描器能检出 UPX 壳内的原始代码特征(如push ebp指令序列),不再受限于文件头固定偏移。
5.3 字符串提取:从内存中捞出可疑 ASCII/Unicode 字符串
恶意软件常硬编码 URL、IP、命令等字符串。我们增加一个ExtractStrings函数,在扫描前预处理内存块:
// 新增函数:ExtractStrings void ExtractStrings(BYTE* pBase, DWORD dwSize, std::vector<CString>& vecStrings) { const int MIN_STR_LEN = 4; // 至少 4 字节才认为是字符串 for (DWORD i = 0; i < dwSize - MIN_STR_LEN; i++) { // 检查 ASCII 字符串:连续可打印字符 + 结束符 if (pBase[i] >= 0x20 && pBase[i] <= 0x7E) { int len = 0; while (i + len < dwSize && pBase[i + len] >= 0x20 && pBase[i + len] <= 0x7E && pBase[i + len] != 0x00) { len++; } if (len >= MIN_STR_LEN) { char szBuf[256] = {0}; memcpy(szBuf, pBase + i, min(len, 255)); vecStrings.push_back(CString(szBuf)); i += len; // 跳过已扫描部分 continue; } } // 检查 Unicode 字符串(偶数地址,双字节 0x00 结尾) if (i % 2 == 0 && i + 1 < dwSize && pBase[i + 1] == 0x00) { // 简化:只检查偶数位为可打印 ASCII,奇数位为 0x00 if (pBase[i] >= 0x20 && pBase[i] <= 0x7E) { int len = 0; while (i + len * 2 < dwSize && pBase[i + len * 2] >= 0x20 && pBase[i + len * 2] <= 0x7E && pBase[i + len * 2 + 1] == 0x00) { len++; } if (len >= MIN_STR_LEN) { WCHAR wszBuf[256] = {0}; for (int j = 0; j < len && j < 255; j++) { wszBuf[j] = pBase[i + j * 2]; } vecStrings.push_back(CString(wszBuf)); i += len * 2; } } } } }调用位置:在ScanFile开头,pBase获取后立即执行。提取的字符串存入vecStrings,后续可做关键词匹配(如"http://","cmd.exe")。
6. 从“高仿”到真用:我的三个落地习惯,让这份老代码成为日常武器
这份代码从来不是为了替代商业杀软,而是作为一个可塑性强的“安全开发沙盒”。我至今仍在用它做三件事:快速验证新发现的恶意样本特征、教学演示 PE 结构解析原理、以及作为自动化分析流水线的轻量级预处理器。它的价值不在功能多强,而在于每一个CreateFileMapping调用背后,都藏着 Win32 编程最原始的呼吸节奏。
6.1 我的日常工作流:用它做“特征有效性验证”的第一道筛子
当我在分析一个新样本时,第一反应不是扔进 IDA,而是把它拖进这个扫描器的 GUI。为什么?因为 IDA 的反汇编结果可能被混淆器误导,但内存映射后的原始字节不会说谎。我会临时编辑SigDB/default.sig,加入刚在样本中提取的 shellcode 片段(比如"\x6a\x01\x58\xcd\x2e"),然后点击“扫描”。如果它能命中,说明这个特征在文件层面稳定存在;如果扫不出来,那大概率是加壳或加密导致特征被破坏——这时我才启动调试器,去研究解密逻辑。这个习惯帮我避开了 70% 的无效反编译时间。从那以后,我每次拿到新样本,都强制走一遍“拖入扫描器→加特征→跑一次”的流程,哪怕只花 12 秒。
6.2 教学中的不可替代性:让学生亲手看到“PE 头”在内存里长什么样
我给新人讲 PE 结构时,绝不用 PPT 画图。我会让他们打开这个项目的调试器,在IsPEFile函数第一行下断点,然后观察pBase指针指向的内存:pBase[0]是0x4D,pBase[1]是0x5A,pBase[0x3C]开始是0x00000080(e_lfanew 值),再跳到pBase+0x80,就能看到0x50 0x45 0x00 0x00。这种“指针所指即真相”的体验,比背一百遍结构体定义都管用。学生常问:“为什么e_lfanew是 4 字节?”——我就让他们手动改pBase[0x3C]的值,再看IsPEFile返回FALSE。代码即教材,内存即黑板。
6.3 安全开发者的底层肌肉记忆:从memcmp到memcmp的进化
这个项目最珍贵的遗产,是它强迫你直面memcmp的每一次调用。当我第一次把扫描时间从 47 秒优化到 1.3 秒时,用的不是 fancy 算法,而是把memcmp换成汇编内联的rep cmpsb(在 VS6 中支持),再加一层缓存:对同一文件,只在首次扫描时做全量比对,后续只检查最后 100KB。这种“从 API 到指令”的穿透式思考,后来让我在写 YARA 规则引擎时,一眼看出wide修饰符为何比ascii慢 3 倍——因为后者只需单字节比较,前者要拆解 UTF-16。这份代码没教我怎么写高并发服务,但它教会我:所有上层抽象,最终都要落回mov eax, [ebx]这样的指令。希望帮到你。
本文还有配套的精品资源,点击获取