简介:这是一份面向开发者和安全初学者的简单杀毒软件源代码工程,基于Windows下的Visual C++工程实现,核心目标是演示木马与病毒的基本检测与清除流程。资源共70个文件,以14个头文件、11个C++源文件为代码主体,另附图标、位图、数据库、可执行程序及工程配置文件,压缩包仅1.1MB,结构紧凑,适合本地剖析。目前已有1034人学习下载。代码中可以看到恶意代码签名库、扫描引擎、文件隔离与清除、实时保护、日志报告、更新机制和简洁用户界面等关键模块,还涉及进程监控、启动项免疫等辅助设计,能够帮助读者理解杀毒软件如何通过特征匹配与行为监控发现威胁,并掌握从界面触发扫描到后台处理的完整实现思路。对于想进入安全领域或为现有软件增加防护能力的程序员,这是一份务实、可继续扩展的参考源码。
1. 简单杀毒软件源代码:不靠商业引擎,也能把查杀闭环跑通
“简单杀毒软件源代码”这个标题,看着是一个教学向的小项目,但把它当成一个正经的落地方向去看,它其实回答了一个很实际的问题:在没有商业引擎授权、没有云查杀接口的前提下,一个人能不能用纯脚本实现可用的病毒扫描、隔离和恢复流程?答案是能。这个量级的目标不是对抗 APT,而是在授权环境下做批量文件体检、U 盘交接检查、内网共享目录巡检这类明确场景。适合两类人:一类是刚接触安全开发的初学者,想搞清楚扫描器内部到底怎么运转;另一类是运维或研发,需要快速产出一个能挂进现有脚本体系的检测工具,而不是去买一套重引擎。
我接下来说的这套方案,核心思路是用特征码匹配加启发式规则,把扫描主循环、签名库、隔离区、日志四件事拆开做,全程只依赖标准库,不引入重型框架。这样代码拿到任何一台机器都能跑,后续要包成命令行工具或者加一个简单界面也容易。说句实在话,这个项目的难点从来不是写扫描逻辑,而是特征库怎么维护、误报怎么收敛、隔离怎么不出事故,这三件事才是真正值得花时间的地方。
2. 先想清楚再动手:特征码 vs 启发式,这个量级该选哪条路
2.1 三种检测机制的区别,以及为什么选“特征码为主”
杀毒软件的核心是“判定一个文件是否可疑”,判定机制常见有三种:特征码匹配、启发式规则、行为监控。特征码匹配是把已知恶意文件的哈希值或字节序列存进签名库,扫描时逐文件比对,命中即告警。启发式规则则不依赖已知样本,而是通过文件结构、扩展名组合、代码特征来推断风险。行为监控需要在程序运行过程中拦系统调用,工程量大,不适合这个量级。
我一般会把行为监控排除在“简单杀毒软件源代码”这个项目之外,原因很直接:行为监控需要挂钩文件系统、进程创建、网络连接,这些操作在 Windows 上要碰驱动层,在 Linux 上要碰 audit 或 eBPF,写出来就不是“简单”二字能覆盖的了。特征码匹配加启发式规则组合起来,已经能覆盖大多数静态查杀场景——U 盘里的可疑脚本、下载目录里来历不明的可执行文件、被批量篡改的文档,这些目标靠静态特征就足够发现。
还有一点容易被忽略:特征码匹配的查杀能力上限取决于签名库,而签名库的维护成本是可控的。商业引擎每天推送几十万条新签名,那是靠全球样本网络撑起来的;自己做教学级或内部工具级扫描器,维护几百到几千条精确哈希,配合规则过滤,性价比反而最高。误报率低于漏报率,这是内部工具的第一原则。
2.2 最小可行方案需要哪四个构件
一个能号称“杀毒软件”的扫描器,再简单也得有四个构件:扫描器本体、签名库、隔离区、日志。扫描器本体负责遍历目录、计算文件摘要、和签名库比对;签名库是一个独立的签名文件,决定检测能力的边界;隔离区存放被拦截的文件,不能直接删除,否则误报时没有后悔药;日志记录每次扫描的时间、路径、文件哈希、命中规则和处置结果,方便事后复盘。
这四个构件里,最容易被人低估的是签名库的文件格式。签名库用 JSON 承载即可,原因有三个:可读性好,文本编辑器能直接改;程序解析简单,标准库 json 模块几行代码就能读进来;后续要合并另一份签名列表,写一个小的合并脚本也很容易。SQLite 虽然更“正式”,但对于几百条签名来说属于过度设计,而且会让整个项目的初始门槛变高。
目录结构我建议这样规划:sengine.py 放扫描主逻辑,signatures.json 放特征库,quarantine 目录放隔离文件,scan.log 写日志,run_scan.py 作为入口。这样拆分的好处是,后续要加启发式规则、加白名单机制,都是往对应模块里补逻辑,不需要把主循环推倒重来。初学者最容易犯的错误是把所有逻辑塞进一个文件,结果改一处崩三处。
2.3 环境准备与代码骨架:Python 标准库就够
这个项目我强烈建议用 Python 实现,不要一上来就上 C/C++。Python 的跨平台行为一致,在 Windows 和 Linux 上遍历目录、计算哈希的接口完全一致,代码拿到哪都能跑。标准库里的 hashlib、pathlib、json、argparse、concurrent.futures 正好覆盖全部需求,零第三方依赖意味着部署时不需要 pip install 任何东西。性能方面,Python 计算文件哈希的速度大约在每秒 80 到 150 MB,单机扫描几 GB 文件完全可以接受,瓶颈往往在磁盘 IO,不在语言。
# run_scan.py —— 项目入口,解析命令行参数并调用引擎 import argparse from pathlib import Path from concurrent.futures import ThreadPoolExecutor import hashlib, json, shutil, datetime SCAN_ROOT = Path(__file__).parent SIGNATURE_FILE = SCAN_ROOT / "signatures.json" QUARANTINE_DIR = SCAN_ROOT / "quarantine" LOG_FILE = SCAN_ROOT / "scan.log" def load_signatures(): with open(SIGNATURE_FILE, "r", encoding="utf-8") as f: return json.load(f)load_signatures 把签名文件一次性读入内存,避免扫描每个文件都去读磁盘。签名文件用 UTF-8 编码打开是因为内部可能包含中文注释或说明字段,Windows 下默认编码可能报错,这一步不能省。quarantine 目录和日志文件放在项目根目录下,方便出问题时直接翻看,不要把隔离区放到系统临时目录,临时目录会被清理,误报时文件就真丢了。
3. 把扫描引擎写出来:主循环、哈希比对与隔离恢复
3.1 扫描主循环:递归目录遍历与系统路径过滤
主循环是整个引擎的骨架,它要做的事情是给定一个根目录,递归地找出所有文件,逐个交给检测函数。这里有一个常见的翻车点:不假思索地把系统目录、内存映射文件、临时文件全部纳入扫描范围。系统目录权限受限,扫描会报 PermissionError;Windows 下页面文件(pagefile.sys)体积巨大,哈希计算白白消耗 CPU;临时文件高频变化,也算不出稳定结果。
所以要有一个“跳过规则”:遇到系统目录和明显无风险的文件类型直接跳过,不进入哈希计算流程。这一步不是偷懒,而是工程上的必要取舍。内部工具的目标是批量检测可疑样本,不是全盘杀毒,把资源花在高价值文件上才是正道。
def iter_files(root: Path): skip_dirs = {"$RECYCLE.BIN", "System Volume Information", "Windows", "Program Files"} skip_exts = {".tmp", ".log", ".dmp", ".iso", ".vhd"} for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in skip_dirs] for fname in filenames: fpath = Path(dirpath) / fname if fpath.suffix.lower() in skip_exts: continue yield fpathiter_files 是一个生成器,用 os.walk 从上往下遍历,每一层把跳过目录从 dirnames 里剔除,这样 walk 就不会进入这些目录,省掉无意义的递归。skip_exts 过滤掉明显不可能承载恶意载荷的临时文件和镜像文件,扩展名判断用 lower() 统一成小写再比对,避免 .TMP 和 .tmp 被当成两种类型。需要注意:跳过目录的集合是按 Windows 环境写的,如果在 Linux 上跑,要换成 /proc、/sys、/dev 这些虚拟文件系统目录,否则扫描结果会被大量伪文件污染。
3.2 文件哈希计算与特征库比对:用 SHA-256 还是 MD5
计算文件摘要时,我推荐优先用 SHA-256,而不是 MD5。MD5 的碰撞成本已经低到普通人也能伪造,虽然作为样本标识还算够用,但既然标准库里同时支持两种算法,没必要在 2025 年还守着一个已经被攻破的哈希。SHA-256 的计算速度比 MD5 慢 20% 左右,在扫描场景下这点差距可以忽略。文件哈希计算有几种常见方式:一次性读入内存、分块读取、开启内存映射。大文件一次性读入会导致内存峰值很高,分块读取是最稳妥的做法,chunk_size 设置在 64 KB 到 1 MB 之间,兼顾 IO 效率和内存占用。
def file_sha256(fpath: Path, chunk_size: int = 65536) -> str: h = hashlib.sha256() try: with open(fpath, "rb") as f: while chunk := f.read(chunk_size): h.update(chunk) except PermissionError: return None return h.hexdigest()file_sha256 用 while 循环加 walrus 运算符读取文件块,直到文件末尾。chunk 取 64 KB 是因为这个大小在机械硬盘和固态硬盘上都能发挥比较好的读写性能,太小的块会增加系统调用次数,太大的块浪费内存且对性能提升有限。PermissionError 被捕获后返回 None,由上层决定是跳过该文件还是继续扫描,不会因为一个受保护文件让整个扫描任务中断。文件被占用、文件被删除等异常也需要一并捕获,实际跑扫描时,这两种情况在 Windows 上非常常见。
3.3 命中判定与隔离操作:把文件“请进”隔离区
隔离操作是整个扫描器的安全阀。命中的文件不能直接删除,而是移动到 quarantine 目录下,并在文件名后面附加哈希值作为标识。这样即使签名库误报,也可以通过扫描日志找到原路径,把文件从隔离区恢复回去。移动比复制快,因为跨目录移动只改文件系统记录。这里有一个坑:如果隔离目录和原文件在同一个分区下,Path.rename 一次就能完成;如果跨分区,rename 会抛 OSError,必须改成 shutil.move 并用 try 兜底。
def quarantine(fpath: Path, sig_id: str): ts = datetime.now().strftime("%Y%m%d_%H%M%S") target = QUARANTINE_DIR / f"{fpath.name}.{sig_id}.{ts}" try: shutil.move(str(fpath), str(target)) except OSError: # 跨分区回退:先复制再删除,保留原始文件的元数据 shutil.copy2(str(fpath), str(target)) fpath.unlink() log_entry("quarantine", fpath, target, sig_id) return targetquarantine 函数把文件移动到 QUARANTINE_DIR 下,文件名拼接了签名 ID 和时间戳,目的是保证即使有两个同名文件先后命中,也不会互相覆盖。shutil.move 内部如果发现目标分区不同,会自动回退到跨分区复制删除流程,但会丢失部分元数据,所以这里先尝试 move,捕获 OSError 后再用 copy2 加 unlink 的组合兜底。日志里记录了原路径、隔离后路径和签名 ID,为后续恢复提供完整信息。隔离目录的权限要特别注意——如果运行扫描器的账户没有写入隔离目录的权限,整个隔离操作会失败,建议把隔离目录放在项目根目录而不是系统受保护目录。
3.4 主扫描流程代码:串起整个逻辑
把上面几个函数组合起来,就是一个最小可用的扫描器主程序。主程序接收一个命令行参数作为扫描根目录,遍历文件、算哈希、比签名库、执行隔离,最后打印统计结果。这一步要处理扫描进度和退出状态,不要等到全部扫完才输出信息,否则长时间运行时使用者完全不知道任务是否卡死。
def main(root_arg: str): root = Path(root_arg) signatures = load_signatures() hit_count = 0 for fpath in iter_files(root): digest = file_sha256(fpath) if digest is None: continue if digest in signatures: sig_info = signatures[digest] quarantine(fpath, sig_info.get("id", "unknown")) hit_count += 1 if hit_count == 0: continue print(f"扫描完成,隔离 {hit_count} 个文件,详见 scan.log")main 函数的判断逻辑直接拿哈希值查签名库字典。字典查找的时间复杂度是 O(1),几千条签名完全不影响扫描速度。sig_info 里可选的 id 字段用于标识签名来源,当签名库是从多个渠道合并过来时,这个字段能帮你判断命中的是哪一批规则。当前版本没有做“每扫描到一个文件就写日志”,而是只在命中时写日志,这样日志文件不会因为扫描大量正常文件而急剧膨胀,需要全量审计时再开启详细模式。
4. 让它更像“杀毒软件”:启发式规则与增量特征库
4.1 启发式规则:三行代码提高检出率
特征码匹配只能查出已知样本,面对同一恶意家族的微小变种容易失效。启发式规则的意义在于,不依赖具体样本哈希,而是通过文件结构特征推断风险。这个项目里我加了三类规则:可疑扩展名组合、文件头魔法字节、最近修改时间窗口。
可疑扩展名组合指的是检测“双重扩展名”手法,比如xxx.pdf.exe,这种命名方式在正常软件中几乎不会出现,是攻击者常用的伪装手段。文件头魔法字节规则是检查文件开头几个字节是否对应某种格式——比如一个文件声称是 PNG 图片,但文件头却是 MZ 可执行文件的标志,这个矛盾本身就是强烈的告警信号。修改时间窗口规则是看文件是否在最近几天内被创建或修改,适合发现批量投递的附件。
SUSPECT_DOUBLE_EXT = {".exe", ".scr", ".bat", ".cmd", ".vbs"} MZ_HEADER = b"MZ" IMAGE_SIGS = {b"\x89PNG", b"\xff\xd8\xff", b"GIF8"} def heuristic_check(fpath: Path, data_head: bytes): reasons = [] if fpath.suffix.lower() in SUSPECT_DOUBLE_EXT: stem = fpath.stem.lower() if any(stem.endswith(ext) for ext in SUSPECT_DOUBLE_EXT): reasons.append("double_ext") if data_head.startswith(MZ_HEADER): if not fpath.suffix.lower() in {".exe", ".dll", ".sys"}: reasons.append("mz_header_mismatch") return reasonsheuristic_check 接收文件路径和文件头 64 字节作为参数,返回一个风险原因列表。双重扩展名判断先确认文件后缀本身是高风险类型,再检查文件主名部分是否也以高风险扩展名结尾。文件头比对则是看可执行文件标志是否与扩展名匹配,正常情况下一个 .jpg 文件不太可能以 MZ 开头。启发式规则不要直接触发隔离,而是作为“疑似”标记写入日志,交由人工复核,这是收敛误报的关键策略。
4.2 特征库增量更新:让扫描器可持续使用
固定的签名库无法应对新样本,增量更新机制是这个项目能否长期使用的前提。签名库文件用 JSON 存储,更新脚本从外部数据源拉取新签名并合并到本地文件。外部数据源可以是爬取的公开样本哈希列表,也可以是内部恶意文件分析流水线产出的结果。更新脚本要做的核心是“合并非破坏”——保留本地已有签名,只追加新的,避免重复条目导致签名库膨胀。
# update_signatures.py —— 增量合并外部签名 def merge_signatures(external_path: str): with open(SIGNATURE_FILE, "r", encoding="utf-8") as f: local = json.load(f) with open(external_path, "r", encoding="utf-8") as f: external = json.load(f) before = len(local) for digest, info in external.items(): if digest not in local: local[digest] = info with open(SIGNATURE_FILE, "w", encoding="utf-8") as f: json.dump(local, f, ensure_ascii=False, indent=2) print(f"签名库从 {before} 条增加到 {len(local)} 条")merge_signatures 的逻辑非常朴素:遍历外部签名,键不存在的才加入,存在则跳过。这样本地对某条签名的覆盖决策永远掌握在自己手里,不会被外部源意外改掉。合并完成后用 indent=2 重新写回 JSON,保持文件人类可读。键的判重用not in local,这个操作的复杂度是 O(n),几千条签名完全够用。更新操作的执行频率建议一天一次,放在低峰时段跑,更新完成后立即对内部共享目录做一次增量扫描。
4.3 白名单机制:把误报防在源头
启发式规则必然带来误报,白名单机制是对付误报最直接的工具。白名单可以按路径、按哈希、按签名规则三张表来做。按路径适合放行某个固定的开发目录,按哈希适合放行某个明确的正常文件,按规则 ID 适合放行某条启发式规则命中的所有文件。三张表都维护在 whitelist.json 里,扫描时先查白名单再跑检测逻辑。
def is_whitelisted(fpath: Path, digest: str, reasons: list): with open(WHITELIST_FILE, "r", encoding="utf-8") as f: wl = json.load(f) if str(fpath) in wl.get("paths", []): return True if digest in wl.get("hashes", []): return True if any(r in wl.get("rule_ids", []) for r in reasons): return True return Falseis_whitelisted 每扫描一个文件就打开一次白名单文件,性能上有优化空间。更优的做法是在主程序启动时把白名单一次性加载到内存,这里写成每次打开是为了保持函数独立性,实际项目中建议改造为一次性加载。白名单的粒度要控制好,不要用大目录路径作为白名单入口,否则等于关掉了该目录下的所有检测。
5. 踩坑记录:误报、卡顿、权限不足,五个典型翻车现场
5.1 扫描系统目录导致 CPU 瞬时满载
现象:第一次把扫描根目录设置为“我的电脑”,程序开始后 CPU 瞬间拉满,界面假死,半个多小时没有输出。原因:没有做系统目录过滤,大量系统 DLL、缓存文件和高频变化的临时文件全部进入哈希计算流程。解决:在 iter_files 里增加系统目录跳过集合,针对 Windows 至少跳过$RECYCLE.BIN、System Volume Information、Windows目录,这几个目录既没有检测价值,又存在大量锁定的文件,会拉慢整体扫描进度。加完过滤后,同一个扫描目标耗时降到了原来的三分之一。
5.2 同一个文件换个后缀就检不出来
现象:一个命中签名的样本,把 .exe 改成 .bin 后再次扫描,程序完全没有反应。原因:签名库只存了文件哈希,没有存文件头信息;哈希匹配与文件内容一一对应,改扩展名不会改变哈希值,理论上应该仍能命中,排查后发现问题出在迭代器把 .bin 当成了“可疑扩展名之外的安全类型”跳过了。解决:iter_files 里的 skip_exts 不能盲目包含未知扩展名,要改成只跳过明确安全的大文件类型,其余全部进入哈希计算,让签名库去做最终判定。
5.3 隔离文件时 PermissionError 导致扫描中断
现象:扫描到某个文件时程序报 PermissionError,整个扫描流程被中断,之前的扫描结果全部丢失。原因:open 文件或移动文件时遇到 NTFS 权限限制,文件被其他进程占用,或者文件属性是只读。解决:file_sha256 和 quarantine 两个函数都要用 try 包裹异常,单文件失败只记录日志并跳过,不影响整个扫描任务的继续执行。只读属性问题要在隔离前先调用 os.chmod 把只读位去掉,否则移动到隔离目录后,后面的恢复流程也会被同样的属性拦一次。
5.4 启发式规则误杀率过高,用户直接不信任
现象:启用 mz_header_mismatch 规则后,大量正常文件被标记为“疑似”,尤其是那些没有扩展名的脚本文件和某些安装包的自解压模块。原因:规则太宽泛,把一个“可能可疑”的特征直接放大成了“高度可疑”,没有区分风险等级。解决:修改规则判定逻辑:文件头是 MZ 且扩展名未知,标记为“低风险”写入日志但不隔离;只有文件头是 MZ 且扩展名是图片或文档类型,才标记为“高风险”。经验法则是:启发式规则的输出永远不能直接触发隔离,只能触发人工复审,命中率不足时先调阈值再调规则本身。
5.5 打包成独立 EXE 后被其他杀软当成样本
现象:用 PyInstaller 打包后的扫毒工具,发到另一台机器上直接被 Windows Defender 拦截,提示是 HackTool。原因:打包产物包含 Python 解释器特征和文件系统操作逻辑,部分杀毒引擎会把自动化文件操作工具归类为“潜在不受欢迎的程序”,这算是静态引擎的通病。解决:给打包产物增加数字签名,签名能显著降低误报率;同时向杀毒软件厂商提交误报申诉,把内部工具加入白名单。另一个备选方案是不要打包成单文件 EXE,而是直接分发源码加一个启动脚本,减少被引擎扫描的特征面。
6. 还能再进一步:从扫描脚本到批量检测工具的调优习惯
扫描器跑通之后,下一步不是加界面,而是把验证方法和运维习惯补上。我会先手工构造一个测试样本集:三个命中签名库的真实样本副本、两个双层扩展名文件、一个文件头与扩展名冲突的图片文件,再放四五个完全正常的文件当对照组。每次修改规则后都跑一遍这个固定样本集,记录检出数和误报数,这套回归流程能防止改动 A 规则时破坏 B 规则。
第二个要调的是日志内容。scan.log 不要只写“命中、隔离”这类状态,要写全六个字段:扫描时间、文件路径、SHA-256、命中规则、处理动作、恢复所需信息。这样过了一个月要复盘某次误报时,不需要重新扫描原文件就能定位问题。日志文件本身要注意轮转,单文件超过 10 MB 就重命名归档,否则日志文件本身会变成磁盘占用大户。
第三件事是做一个恢复工具。隔离区里存了文件,就必须有恢复入口。恢复脚本按日志里的条目把文件从隔离区移回原目录,并恢复原文件名,这个操作看起来简单,但没有独立工具的话,误报后只能手动翻隔离目录找文件。恢复功能建议和扫描主程序放在同一个代码文件里,通过--restore参数触发,不单独建模块。分发源码时,把签名格式说明写进 README,让接手的人知道每条签名是什么格式、从哪来、如何验证有效性。
我自己的习惯是:每次在这个脚本上做改动后,第一件事就是用它扫描自己的下载目录和 U 盘,看有没有意外行为。有一回我加了一个新启发式规则,用测试样本集跑通过,然后随手扫了一下我的临时文件目录,结果把自己写的一个自动化脚本误报了。当时的第一反应是“规则有问题”,而不是“文件有问题”,改规则还是加白名单,取决于这条规则本身的价值——白名单防的是个案,规则修正防的是整类误报,别搞反。希望帮到你。
本文还有配套的精品资源,点击获取