十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零实现500行Python杀毒软件:特征码扫描原理与工程实践

从零实现500行Python杀毒软件:特征码扫描原理与工程实践 简介一份基于VC/MFC的“简单杀毒软件”源代码工程以“木马辅助分析系统”为实例面向安全入门开发者与逆向爱好者展示了基础木马扫描器的整体设计思路涵盖特征码匹配、文件定位、注册表防护、进程检测、扫描日志与更新机制等核心模块。压缩包共70个文件以C源文件cpp/h和MFC界面资源rc/ico/bmp为主并包含可编译的Visual Studio工程文件dsw/dsp、生成后的exe、pdb、obj等中间文件以及数据库与文本说明文档整体约1.1MB可直接打开工程编译运行便于边读代码边验证效果。代码中不仅实现了简单的恶意代码签名比对、文件隔离与清理还加入了右键菜单、开机自启检测、辅助分析界面等实用功能结构清晰适合初学者对照学习杀毒软件的基础工作流程。目前已有1031人学习浏览对于希望进入安全攻防领域的开发者来说是一份既能学习原理又能动手实践的高价值入门源代码。 简单杀毒软件源代码这名字听起来像是课程设计里凑数的题目但我确实把它当成一个值得认真对待的项目来做了。原因很简单我用杀毒软件十几年却一直说不清楚它弹出的那个警告窗口到底是凭什么做出判断的。与其继续隔着图形界面猜不如自己动手写一个最小可用版本把原理彻底跑通。这个项目最终的产出是一个不到 500 行的 Python 命令行工具能扫描指定目录、匹配已知恶意软件特征、隔离可疑文件并提供基础的白名单机制。对想接触安全产品底层原理的开发者、正在做课程设计的同学或者单纯好奇“杀毒软件到底在干什么”的人来说都是一个适合拿源码逐行读的小项目。本文会从设计思路、核心原理、完整实现到踩坑排查把这条链路讲透。1. 项目定位与整体设计思路1.1 这个项目解决什么问题一个完整的商业杀毒软件包含的东西多得吓人内核驱动、云查杀集群、行为沙箱、威胁情报联动、更新分发系统……如果照着那个标准去做代码还没写完人先崩溃了。所以这个项目的定位非常明确只做“基于特征码的静态扫描引擎”而且是单机版、命令行版、可理解版。它解决的核心问题只有一个给定一个文件怎么在没有人工参与的情况下尽可能快地判断它是不是已知的恶意程序。商业杀毒软件当然还有启发式、机器学习这些手段但那是在特征码基础之上叠的 Buff基础逻辑还是“先匹配再判断”。对学习者来说这个项目的价值在于它打通了三条知识链路文件格式的读取方式、特征提取的工程实现、安全扫描的完整流程。做完之后你会发现杀毒软件没有想象中神秘它本质上就是一个“读文件 → 提取特征 → 查库比对”的管道。把这条管道跑通后续无论往哪个方向深挖都有底子。1.2 技术选型与功能边界我最终选了 Python 作为实现语言而不是 C 或 Rust。理由很直白这个项目的目标是搞清楚原理不是追求极致性能。Python 标准库自带hashlib、os、argparse能覆盖 90% 的需求代码可读性也高适合逐行讲解。如果选 C光处理跨平台的目录遍历和文件权限就会耗掉大半篇幅得不偿失。功能边界上我也做了刻意收敛。第一版只支持全文件哈希匹配之后再扩展字符串特征匹配。实时监控、网络拦截这些需要驻留内核态或系统钩子的功能一律不做只在最后给出扩展思路。这样设计的好处是每个迭代周期的代码量都能控制在 200 行以内调试起来不费劲。这里想提醒一句写安全工具最容易犯的错误是一上来就想“覆盖所有场景”。场景铺得越广边界条件越多程序越容易因为各种权限、格式、并发问题崩溃。不如先把一条最基本的链路做稳定再像搭积木一样加功能。这个思路做个人项目尤其重要。2. 核心原理与关键技术点2.1 杀毒引擎的工作流程不管你用的是什么杀毒软件扫描一个文件时背后的逻辑都是这四步读取文件内容、计算或提取特征、拿特征去匹配样本库、根据匹配结果决定处置方式。对于静态扫描引擎来说整个流程是线性的没有太多分支。细拆一下就是遍历目标目录拿到所有文件的路径列表。逐个打开文件按固定大小的块读取内容避免大文件一次性载入内存。基于读取的内容计算特征值比如 MD5、SHA256或者提取片段字节。将特征值与病毒库中的记录比对。命中则进入隔离区否则判定为干净文件并继续下一个。这套流程在商业引擎里也一样只不过它们的“特征”维度更丰富文件哈希、模糊哈希、字符串指纹、PE 结构特征、行为日志序列等等。但骨架没有变。理解这个线性管道比记住任何具体的数据结构都重要。2.2 特征码的两种形态哈希与字符串特征特征码这个词听起来高大上本质就是一个“可识别的指纹”。工程上常用的有两种形态。第一种是整文件哈希也就是对文件全部字节做 MD5 或 SHA256 运算得到一个固定长度的十六进制串。这种方式实现起来最简单文件变化一个字节哈希就完全变了所以它只能精确匹配“未经修改的原版样本”。第二种是字符串特征也就是在文件中搜索特定的字节序列。恶意软件通常包含一些独有的字符串或指令片段比如某个远控工具的通信协议标识、加壳器的固定头信息。字符串特征不需要匹配整个文件只要文件中包含某段特征就能判定为恶意。它的优点是能识别同一家族的变种缺点是实现复杂——你得处理偏移定位、编码转换、子串匹配性能等问题。我们第一版用哈希就够了。字符串特征更像“升级装备”可以在核心跑通之后再加。2.3 为什么先做哈希版很多初学者会问哈希匹配这么容易被绕过为什么还要先做我的答案是它能让整个系统的框架先立起来。哈希版的优势有三点匹配逻辑最简单误报率最低测试样本容易构造。我习惯用 EICAR 标准测试文件来验证这是一段纯文本的测试样本内容是完全公开的几乎所有的杀毒软件都会报毒但它没有任何破坏性可以放心使用。把 EICAR 的 MD5 值加入特征库再用扫描器扫它所在的目录能直接验证整条链路通没通。先做哈希版还有一个隐性好处它逼你把“特征库的格式”提前设计好。特征库结构如果一开始不做规划后面扩展字符串特征时加载逻辑、匹配逻辑都要返工。哈希版让整个数据流先跑起来后加特征类型只是“多一种 case”的问题。3. 从零实现命令行扫描器3.1 项目文件结构与特征库加载我在本地建的项目目录结构是这样的simple-antivirus/ ├── scanner.py # 命令行入口 ├── av_core.py # 核心扫描逻辑 ├── features.db # 特征库文件 └── quarantine/ # 隔离目录av_core.py里放核心扫描类scanner.py只负责解析命令行参数和调用核心函数。features.db是纯文本文件一行一条记录格式是类型, 值, 描述。特征库加载代码很简单def load_feature_db(db_path): feature_db { md5: set(), strings: [] } with open(db_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line or line.startswith(#): continue parts [item.strip() for item in line.split(,, 2)] if len(parts) 2: continue if parts[0] MD5: feature_db[md5].add(parts[1].lower()) elif parts[0] STRING: feature_db[strings].append(parts[1].encode(utf-8)) return feature_db这里有几个细节值得说。第一特征值统一转小写避免后续比对时的大小写陷阱。第二字符串特征先转成字节流再存比扫描时反复编码要高效。第三用split(,, 2)而不是split(,)是为了防止特征描述里本身包含逗号导致解析错位。特征库文件里可以加入注释行方便记录样本来源。比如每一行特征后面写一句“从哪个样本提取的、提取时间、报告链接”这样特征库也具备基本的审计能力。3.2 核心扫描逻辑核心扫描逻辑放在av_core.py的Scanner类里。第一版只实现哈希匹配代码长这样import hashlib import os from pathlib import Path class Scanner: def __init__(self, feature_db): self.md5_set feature_db[md5] def scan_file(self, file_path, max_size50 * 1024 * 1024): try: file_size os.path.getsize(file_path) if file_size max_size: return {file: str(file_path), status: SKIPPED, reason: too_large} md5 self._calc_md5(file_path) if md5 in self.md5_set: return {file: str(file_path), status: MALWARE, md5: md5} return {file: str(file_path), status: CLEAN, md5: md5} except (PermissionError, OSError) as e: return {file: str(file_path), status: SKIPPED, reason: str(e)} staticmethod def _calc_md5(file_path, chunk_size65536): h hashlib.md5() with open(file_path, rb) as f: while True: chunk f.read(chunk_size) if not chunk: break h.update(chunk) return h.hexdigest()_calc_md5里按 64KB 分块读取是关键不要一次性read()整个文件。10GB 的视频文件如果被整体读进内存程序会直接内存溢出。分块读取虽然增速没差多少但内存占用是恒定值这个习惯做安全工具时一定要养成。max_size参数也是为自己兜底。默认 50MB 以上的文件不扫描这是合理的工程取舍——恶意样本几乎都是小体积而一个视频文件计算整文件哈希的时间成本远大于收益。如果后续要扫描压缩包或固件再单独调整这个阈值。来看字符串特征的匹配逻辑它只需要在现有代码上加一个分支。扫描时把文件内容分块读入一个字节序列然后逐个查找特征子串# 在 Scanner 类中新增方法 def _contains_string_features(self, data, string_features): for feature in string_features: if feature in data: return True, feature return False, None匹配时要注意一个问题特征可能跨分块边界。如果某个恶意特征串恰好被 64KB 的分块切断就会漏报。工程上有两种处理办法一是重叠读取每次保留上一块的最后若干字节二是对压缩检测的目标文件干脆把整个文件读入再做子串搜索。第二版里我采用后者牺牲内存换取正确性因为能命中字符串特征的文件通常都不大。3.3 命令行入口与输出设计scanner.py只做三件事读参数、初始化扫描器、遍历目录输出结果。import argparse from pathlib import Path from av_core import Scanner, load_feature_db def main(): parser argparse.ArgumentParser(descriptionSimple Antivirus Scanner) parser.add_argument(path, help扫描路径) parser.add_argument(--db, defaultfeatures.db, help特征库路径) parser.add_argument(--quarantine, defaultquarantine, help隔离目录) args parser.parse_args() feature_db load_feature_db(args.db) scanner Scanner(feature_db) target Path(args.path) results [] if target.is_file(): results.append(scanner.scan_file(target)) elif target.is_dir(): for file_path in target.rglob(*): if file_path.is_file(): results.append(scanner.scan_file(file_path)) # 结果输出……结果输出的格式我选择了最直白的控制台文本而不是 JSON。虽然 JSON 更适合程序对接但命令行用户一眼就能看懂[FOUND] /tmp/demo/eicar.txt MD5: 44d88612fea8a8f36de82e1278abb02f [CLEAN] /tmp/demo/readme.md [SKIP] /tmp/demo/large_video.iso reason: too_large那个44d88612fea8a8f36de82e1278abb02f就是 EICAR 测试文件的标准 MD5 值公开资料里到处都能搜到。你本地测试时如果发现哈希对不上多半是文件里有换行符差异导致重新计算一次以本地结果为准。被检测为恶意的文件处理策略是复制到隔离目录而不是直接删除。隔离有两个好处一是给误判留了回滚余地二是便于后续人工取证分析。真正的删除操作要在人工复核之后手动执行。4. 进阶路径与工程化扩展4.1 扫描性能优化从串行到多线程第一版扫描器是单线程串行处理目录里文件一多速度肉眼可见地慢。优化性能最直接的手段是改用线程池Python 的concurrent.futures.ThreadPoolExecutor几行代码就能搞定。from concurrent.futures import ThreadPoolExecutor def scan_directory(scanner, root, max_workers8): files [p for p in Path(root).rglob(*) if p.is_file()] with ThreadPoolExecutor(max_workersmax_workers) as executor: return list(executor.map(scanner.scan_file, files))注意两点。第一线程数不是越多越好实际跑下来 8 到 12 个线程附近能达到吞吐峰值再多反而因为文件系统锁竞争和上下文切换导致性能下降。第二Python 有 GIL计算哈希这种 CPU 密集型任务用多线程提升有限。想要真正吃满多核得用multiprocessing开多进程但那是另一个复杂度层级个人项目里先不碰。另外可以加一层“已扫描缓存”。用一个字典记录文件路径和最后修改时间扫描过的文件如果mtime没变就直接跳过。这个优化对重复扫描场景特别有效实测能把二次扫描耗时降低 90% 以上代价是内存占用增加一些。4.2 从静态扫描到实时监控如果希望扫描器能自动发现新下载的恶意文件需要给它加实时监控能力。实现思路是监听文件系统事件当有文件创建或修改时立即触发一次扫描。Linux 上有inotifyWindows 上有ReadDirectoryChangesWmacOS 上有 FSEvents。但直接用原生 API 很繁琐Python 的watchdog库把这一切封装成了统一接口from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class QuarantineHandler(FileSystemEventHandler): def __init__(self, scanner): self.scanner scanner def on_created(self, event): if not event.is_directory: result self.scanner.scan_file(event.src_path) if result[status] MALWARE: self._quarantine(event.src_path)这个扩展做好之后你的“简单杀毒软件”就具备了最基础的主动防御能力。但不要小看实时监控的工程复杂度事件风暴处理、文件占用重试、递归目录监听、操作系统层面的事件丢失每一个都是坑。作为学习项目能把“新文件创建后 1 秒内自动扫描”跑通就已经很棒了。4.3 启发式检测的简单实践哈希匹配的局限性非常明显没见过的新变种完全无能为力。要提升检测能力就要引入启发式规则。我做过一个很粗糙但能说明原理的版本——对每个被扫描的文件提取三个特征维度文件熵值。恶意样本为了对抗 AV 查杀经常加壳或加密加密数据的信息熵通常很高接近 8.0正常程序编译出来的代码熵值一般在 6.0 到 7.0 之间。我把熵值超过 7.5 作为一条可疑线索。敏感 API 调用序列。对 PE 文件解析出导入表如果发现某些特定 API 名称组合出现就打分。可疑字符串密度。比如文件中包含大量“cmd.exe /c”的变种写法或者高密度的 PowerShell 命令行痕迹。给每个维度设一个分数总分超过阈值才判定为可疑。启发式检测的核心是“评分”不是“精确匹配”所以一定能解释为什么某个文件被标为可疑。我做了一个很简化的打分逻辑命中熵阈值 20 分命中敏感 API 字符串 40 分可疑字符串密度超过 5% 40 分总分大于等于 60 就隔离。这个逻辑的误报率在真实环境里相当高但它展示了一个关键思路杀软不是只靠“是/否”匹配而是在做概率评估和风险打分。理解这个思维转变比记住任何一个规则都重要。4.4 误报管理与白名单机制做杀毒软件绕不开误报。你辛辛苦苦写的工具官网下载地址被自家杀毒软件拦了这种事每天都在发生。给扫描器加白名单机制是走向工程化的必经一步。我的实现方式非常简单维护一个whitelist.db里面存的是经过人工确认的“用户自信任文件”的哈希和路径。扫描时先查白名单命中直接输出TRUSTED不再走后续检测逻辑。白名单需要注意两点。第一必须同时记录文件路径和文件哈希因为用户可能会移动文件路径是给人看的哈希是给程序用的。第二白名单要支持用户手动导入比如--allow /path/to/file这种参数。真实场景里开发者编译产物经常被误报有一个一键放行的机制能极大提升使用体验。5. 常见问题与踩坑记录5.1 扫描结果不稳定文件占用与权限我在测试时遇到过扫描结果时好时坏的情况同一个目录两次扫描报毒数量居然不一样。排查了一圈最典型的两个原因是文件被系统或其他进程占用读取时抛权限异常被标记成 SKIP文件在扫描过程中正在被写入读到一半内容不完整导致哈希计算错。解决办法有三个方向。一是对读取失败的文件做重试间隔 200 毫秒重试三次三次都失败再标记 SKIP。二是扫描前记录文件大小和最后修改时间扫描完成后再对比一次如果这两者发生变化说明文件仍在变化标记为“不稳定”并延后处理。三是把权限异常单独归类输出不要和“干净文件”混在一起方便人工排查。5.2 扫描大目录卡死递归遍历的坑用Path.rglob(*)递归遍历目录时如果不小心处理符号链接或挂载点可能会陷入无限循环。Linux 下的/proc、/sys这类虚拟文件系统直接遍历会看到大量动态生成的条目扫描过程会持续很久且没有产出。我的解决办法是维护一个“跳过目录名单”把系统目录、虚拟目录、跨平台缓存目录比如.git、node_modules都加进去。另有--skip参数允许用户自定义忽略路径。不要小看这个细节做安全工具时“不扫哪些目录”和“扫哪些目录”同样重要。5.3 特征提取与源码加密的常见误区很多朋友把“杀毒软件检测”和“源代码保护”混在一起讨论这是两个层面的问题。杀毒软件针对的是编译后的二进制文件它提取的是机器码层面的特征比如编译器生成的指令片段、导入表结构、字符串常量源码本身通常不参与检测除非你中了那种专门匹配源码文本的特别规则。网上经常看到“Go 反编译能看到源代码吗”这类提问。从杀软引擎开发者的视角看反编译恢复的是程序的逻辑结构和字符串常量而不是原本的源码文本这两者的差别类似于“看到一座建筑的平面图”和“看到建筑师的原始设计稿”。同样“源代码加密”防的是源码泄露跟杀软怎么识别病毒完全不是一回事。理解这些概念差异有助于你判断该在哪个环节做防护。另外加壳或混淆后的文件哈希值会改变所以如果特征库只存 MD5加壳变种就一定漏报。解决之道是加一层“壳特征”匹配识别 UPX、MPRESS 等常见壳的特征字节命中后先“脱壳”再提取内部文件哈希。这个玩起来很深入作为学习项目可以先从识别“是否加壳”开始。最后再分享一个小细节写这个项目最大的收获不是那几百行能跑的代码而是我终于理解了杀毒软件在面对海量文件时的那种“恐惧感”。它必须每秒判断成千上万个文件是否可信每一个误判都可能带来不可逆的后果而它唯一的依据是那些不断更新的特征规则。工程上每加一层优化和兜底背后都是对风险的敬畏。如果你也想做一个类似的项目我的建议是先用 EICAR 测试文件把整条链路跑通再拿一个你本地常见的正常文件测试误报最后才考虑加实时监控或者启发式。把地基打稳后边的功能都是锦上添花。希望这份源码和思路能给你一些参考。本文还有配套的精品资源点击获取
返回列表