简介:基于Python机器学习朴素贝叶斯算法实现的文本级WebShell检测工具,面向希望掌握安全检测与文本分类实战的初中级学习者,可用于毕设、课程设计或工程实训。资源共14个文件、压缩包约32KB,包含4个Python脚本、7个文本数据/说明文件,以及Markdown文档与项目Logo图片,结构紧凑,Data目录按正常样本与WebShell样本区分,覆盖php、asp、jsp三种常见脚本类型。工具以词袋模型加TF-IDF完成特征提取与预处理,配合朴素贝叶斯算法训练分类器,能体现文本特征表示与检测流程的完整搭建,适合对照源码理解贝叶斯分类在安全场景中的应用。目前已有234人学习浏览,作为参考项目可帮助读者快速搭建WebShell检测实验环境,梳理数据组织方式与训练检测思路。
1. 基于文本的WebShell检测:NB算法为什么值得先做一版
应急响应时翻到一台被人上传过文件的服务器,PHP 目录里躺着十几个可疑脚本。按文件名、按时间、按特征正则一层层筛,总能漏掉几个混淆过的变种——它们把 eval 拆成字符串拼接,把 $_POST 换成 array 索引,正则规则要一次次跟着改。这时候我一般会做一件更省事的事:把脚本当纯文本,交给机器学习里的 NB(朴素贝叶斯)算法去判。基于文本,意味着不依赖请求日志和流量行为,只看文件内容;NB 算法,意味着几百个样本就能训练、单文件毫秒级检测、判定结果还能说清是哪些词把文件带偏成恶意。这个方案适合两类人:刚开始做安全工具开发的 Python 工程师,想找一个能落地的机器学习入门项目;蓝队或应急响应人员,需要一个可解释、可调阈值的 WebShell 检测工具,而不是又一条追不完的正则。
2. 先把NB算法讲透:朴素贝叶斯为什么能识别WebShell文本
2.1 贝叶斯定理与“朴素”假设:从P(恶意|文本)说起
NB 的核心是贝叶斯定理,我们要算的是“给定这段文本,它是 WebShell 的概率”。公式写作:P(恶意|文本) = P(文本|恶意) × P(恶意) / P(文本)。实际比较恶意和正常两个类别时,分母 P(文本) 对两边都一样,可以忽略,只需要比较分子。P(恶意) 是先验概率,从训练样本里恶意样本占比估计出来,比如训练集里恶意占 1/3,那先验就是 0.33。
“朴素”两个字来自一个明显不成立的假设:特征之间条件独立。文本转成词袋之后,就是假设“eval 出现”和“$_POST 出现”这两件事互不影响。现实里它们强相关,恶意代码几乎总是同时用它们,但 NB 照样能出活。原因在于分类看的是后验概率的相对大小,不是精准概率——即使概率估计有偏,只要排序不翻,决策边界依然可用。对 WebShell 检测来说,危险词强相关反而让模型更稳,它不会因为某几个词共现就过度自信,也不会因为少了一个词就立刻翻转结论。
举个具体例子。把<?php @eval($_POST['x']); ?>拆成 php、eval、_POST、x 这几个 token,训练时统计出“eval”在恶意类里出现频率远高于正常类,“_POST”也一样。贝叶斯把这些独立的 token 概率乘起来,恶意类的后验概率自然被推高。你不需要理解代码逻辑,只需要统计规律就够了,这就是“基于文本”的含义。
2.2 NB家族选型:伯努利、多项式、补集NB在文本检测上的取舍
sklearn 里 NB 有三个常见变体,差别在输入特征和概率计算方式,选错会直接影响检出效果。
| 模型 | 输入特征 | 特点 | WebShell场景建议 |
|---|---|---|---|
| 伯努利NB | 词是否出现(0/1) | 适合短文本,忽略词频 | 一句话木马这种超短样本可用 |
| 多项式NB | 词频或TF-IDF | 文本分类标准选择 | 默认推荐,适配变长脚本 |
| 补集NB | 词频 | 用互补类别估计权重,专治不平衡 | 恶意样本占比偏低时推荐 |
我的默认组合是多项式NB配 CountVectorizer 原始词频。伯努利NB 对几百行的混淆脚本会浪费词频信息——恶意代码里危险函数反复出现,出现次数和只出现一次在判别强度上完全不同。补集NB 是多项式NB 的特化版本,它用“补集”类别的统计量来估计权重,样本不均衡时更稳。如果训练集里恶意样本不到总量的 20%,直接换ComplementNB,特征处理不用改,只换类名。
2.3 为什么先上NB而不是深度学习:小样本、可解释、易落地
WebShell 检测这个场景,深度学习不是不能做,是不划算。第一,样本量不够。WebShell 变种多但公开标注样本少,一场应急响应能收集几百个恶意样本就算不错,这点数据训文本 CNN 基本是玄学,NB 小样本就能收敛。第二,可解释性。NB 训练完可以直接反查词表,看哪些 token 权重最高,给客户交代“为什么判定这个文件是恶意”时,能列出一串具体特征词,而不是指着一个黑匣子说“模型算出来的”。第三,部署成本。训练好的 NB 模型加向量器打包成一个 joblib 文件,几十 MB,纯 CPU 推理毫秒级。不用 GPU、不用服务化框架,命令行工具就能跑,放在应急响应 U 盘里都不嫌重。
NB 当然不能替代 AST 解析和动态沙箱,但在“快速覆盖未知变种”这件事上,它是性价比最高的一档。后面接一层人工复核或沙箱,NB 做初筛,生产环境完全跑得动。
3. 训练数据与特征工程:从原始脚本到NB能吃的数值向量
3.1 数据从哪来:公开样本、历史木马与正常业务代码的配比
NB 是统计模型,训练数据决定了能力上限。常见做法是三个来源混在一起:公开的 WebShell 样本库,GitHub 上很多人整理过,搜 webshell sample 能找到一堆;历史应急响应收集的样本,这个最值钱,因为都是真实变种;现网业务代码里抽样出的正常脚本,注意要用真实项目,不要用刻意写得很干净的 Demo。
拿到样本后第一件事是去重。公开样本库有个大坑:不同作者互相搬运,同一个文件换了几次名出现几十次。直接用会让模型记住重复样本而不是抽取规律,训练集和测试集里同时出现同一份内容,评估分数虚高到没法看。去重按文件内容 MD5,不要按文件名,改名太容易了。
负样本的选择也有讲究。只放干净的框架代码远远不够,最好混入一些“长得像恶意但其实是正常”的代码:用 eval 做模板渲染、用 base64_decode 做编码转换、用 serialize 做缓存的业务脚本。这些正是 NB 最容易误报的类型,提前放进训练集,能显著压低上线后的误报率。恶意和正常的比例建议在 1:1 到 1:3 之间,恶意样本太少先验会偏,正常样本太杂决策边界会糊。实在收集不够,从 1:2 起步,后面再调。
3.2 文本清洗:去掉注释和字符串字面量,降低误报
清洗是特征工程里最容易被跳过的一步,但效果差距巨大。直接拿原始脚本去训练,模型会把注释内容、字符串内容、代码结构全混在一起学,词表里塞满噪声。我一般会先做这一步:
import re def clean_script(text: str) -> str: # 去掉多行注释和单行注释,避免注释里的危险词干扰分类 text = re.sub(r'/\*.*?\*/', ' ', text, flags=re.S) text = re.sub(r'//.*?$', ' ', text, flags=re.M) text = re.sub(r'#.*?$', ' ', text, flags=re.M) # 统一小写,PHP函数名和关键字大小写不敏感 text = text.lower() # 把字符串字面量替换成占位符,避免把业务文案当作特征 text = re.sub(r'(["\']).*?\1', ' str_literal ', text) return text逻辑说明:先去多行注释和单行注释。恶意样本的注释里经常写着“bypass 检测”或大段 payload 说明,不去掉模型会学到注释特征而不是代码特征。字符串字面量替换成占位符是关键一步——正常代码里会有base64_decode('xxxx'),恶意代码里也会有,真正有区分度的是“调用方式”而不是“字符串内容”,统一成str_literal后模型被迫去学结构特征。
参数说明:re.S让.能匹配换行,多行注释才能一次删干净;re.M让^和$匹配每行边界,单行注释才删得准;替换字符串时用非贪婪.*?,避免一个文件里多个字符串被合并替换成一个大占位符。注意这套正则只对 PHP 有效,JSP 的注释是<%-- --%>,ASP 是',如果样本混了多种语言,按语言分派清洗函数,不要一个函数通吃。
3.3 用CountVectorizer把脚本变成词频向量:token规则与n-gram参数
这里用 CountVectorizer 而不是 TfidfVectorizer。多项式NB 配原始词频一般就够用,TF-IDF 会额外惩罚高频词,但对 WebShell 场景来说,危险函数名本身就是高频词,TF-IDF 反而会把它们的权重压低。先用词频,效果不满意再换 TF-IDF 做对比实验。
from sklearn.feature_extraction.text import CountVectorizer vectorizer = CountVectorizer( analyzer='word', token_pattern=r'[a-zA-Z_$][a-zA-Z0-9_$]{1,}', ngram_range=(1, 2), max_features=20000, min_df=2, ) X = vectorizer.fit_transform(cleaned_samples)逻辑说明:token_pattern要能匹配$var、_func、eval这类 PHP 常见标识符。PHP 变量以$开头,函数名以下划线或字母开头,所以字符集写成[a-zA-Z_$]开头、后续加数字和下划线。不匹配中文词——脚本里的中文通常只在注释和字符串里,前面清洗已经处理掉大部分,剩下的中文 token 对代码分类没有区分度,不匹配反而能减小词表。
参数说明:ngram_range=(1, 2)表示单个词和相邻两个词都作为特征,能捕获eval(和$_POST这种二元搭配。试过(1, 3)对混淆程度高的脚本更敏感,但词表膨胀快、小样本容易过拟合,一般从 2 开始调。max_features=20000是词表上限,防止处理几万个文件时内存爆炸;min_df=2表示至少在两个样本里出现过的词才保留,过滤掉只出现在单个样本里的拼写噪声。拿到X后先看X.shape,如果特征数顶到 20000 上限,说明数据太杂,把min_df调到 3 或 5 再试。
注意:清洗函数在训练和预测时必须完全一致。上线后改了清洗逻辑,模型看到的特征分布就变了,概率输出会整体偏移。
4. 训练与封装:用Scikit-learn训练NB模型并落地成命令行工具
4.1 训练脚本:切分、训练、评估一个都不能少
from sklearn.model_selection import train_test_split from sklearn.naive_bayes import MultinomialNB, ComplementNB from sklearn.metrics import classification_report X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) model = MultinomialNB(alpha=1.0, fit_prior=True) model.fit(X_train, y_train) print(classification_report(y_test, model.predict(X_test)))逻辑说明:stratify=y做分层切分,保证训练集和测试集里恶意/正常比例一致。如果不分层,随机切分后测试集里恶意样本太少,评估结果会虚高得离谱。random_state=42固定下来,后续每次调参才能在同一份数据上对比效果。
参数说明:alpha=1.0是拉普拉斯平滑系数。某个词在训练集的恶意类里从未出现时,概率会算成 0,平滑给它一个小概率兜底。如果模型频繁输出极端概率(0.99 或 0.01),怀疑过拟合,把 alpha 调到 2 或 5 再训练对比。fit_prior=True表示使用训练集的先验概率,当样本比例是手工控制的时候,先验本身就是设计的一部分,保留即可。
评估时别看 accuracy,看classification_report里恶意类的两个指标:recall 是漏报率,precision 是误报率。recall 低于 0.8 说明恶意样本漏掉太多,precision 低于 0.8 说明误报太多,先判断业务更在意哪一边,再决定调阈值还是调数据。
4.2 概率阈值才是真正的检测开关:默认0.5不一定适合生产
prob = model.predict_proba(X_test)[:, 1] # 第二列对应恶意类,前提是恶意编码为1 for threshold in [0.3, 0.5, 0.7]: pred = (prob >= threshold).astype(int) report = classification_report(y_test, pred, output_dict=True) print(f"threshold={threshold}, recall={report['1']['recall']:.3f}, precision={report['1']['precision']:.3f}")逻辑说明:predict_proba返回每个样本属于各个类的概率,取第二列就是判为恶意的置信度。阈值调低,更多文件被判恶意,recall 上升 precision 下降;调高则相反。这段代码一次性跑三个阈值下的指标,直接看业务更在意漏报还是误报。
默认 0.5 是一个错觉,它只是数学上的中立点,不一定是业务上的合理点。应急响应全量扫描时阈值用 0.3 到 0.4,宁可多报几个回头人工看,也不能漏掉一个后门;接入生产环境的自动处置链路时阈值放到 0.7 以上,误报删除文件的事故比漏报更可怕。阈值由使用场景定,不是由模型定。
4.3 封装CLI工具:输入文件或目录,输出判定结果
import argparse import os import joblib def detect_file(path, model, vectorizer, threshold): with open(path, 'r', encoding='utf-8', errors='ignore') as f: text = f.read() cleaned = clean_script(text) x = vectorizer.transform([cleaned]) prob = model.predict_proba(x)[0][1] return prob, prob >= threshold def main(): parser = argparse.ArgumentParser(description='基于文本的WebShell检测工具') parser.add_argument('path', help='待检测的文件或目录') parser.add_argument('--threshold', type=float, default=0.5) parser.add_argument('--model', default='webshell_nb.joblib') args = parser.parse_args() artifacts = joblib.load(args.model) model = artifacts['model'] vectorizer = artifacts['vectorizer'] if os.path.isfile(args.path): targets = [args.path] else: extensions = {'.php', '.jsp', '.asp', '.aspx', '.java'} targets = [ os.path.join(root, name) for root, _, files in os.walk(args.path) for name in files if os.path.splitext(name)[1].lower() in extensions ] for path in targets: prob, is_malicious = detect_file(path, model, vectorizer, args.threshold) flag = '[MALICIOUS]' if is_malicious else '[CLEAN]' print(f'{flag} {path} (prob={prob:.3f})') if __name__ == '__main__': main()逻辑说明:detect_file里复用训练时的clean_script和vectorizer.transform。注意这里是transform不是fit_transform——词表已经在训练时定死,任何新文本都映射到同一套特征空间。目录遍历限定常见 Web 脚本扩展名,避免把图片、压缩包也读进来。读取文件用errors='ignore',很多一句话木马混着二进制内容或非 UTF-8 编码,直接报编码错误会让扫描中断。
参数说明:--threshold和--model做成命令行开关,是为了不同场景复用同一个模型文件。应急响应跑一版 0.3 扫描,生产巡检跑一版 0.7,模型不用换,只换阈值。输出里带概率值,后续人工复核时按置信度排优先级,先看概率最高的。
4.4 模型持久化:joblib保存和加载,避免每次重新训练
import joblib artifacts = { 'model': model, 'vectorizer': vectorizer, 'classes': model.classes_.tolist(), } joblib.dump(artifacts, 'webshell_nb.joblib', compress=3)逻辑说明:把模型和向量器打包成一个 dict 再存,加载时一次拿回全部依赖。classes存下来是为了加载后确认模型是二分类且 label 1 对应恶意,防止误用别的模型文件。compress=3压缩级别适中,模型文件比不压缩小一半以上,加载稍慢但可接受。
加载之后第一件事,跑一条已知恶意样本验证模型没坏:
# 加载后自检:用一个已知样本确认模型没坏 test_sample = "<?php @eval($_POST['x']); ?>" prob = model.predict_proba(vectorizer.transform([clean_script(test_sample)]))[0][1] assert prob > 0.8, '模型自检失败,请检查模型文件'这段自检逻辑我一般会写进工具启动流程。模型文件被误替换或损毁时,第一时间报错,而不是带着坏模型扫描整个目录——那是最难排查的坑,扫完了你都不知道结果是从什么时候开始错的。
5. WebShell检测避坑指南:特征穿越、误报集中点与样本偏斜
5.1 特征穿越:为什么交叉验证分数虚高,一上线就翻车
现象:训练时测试集 F1 有 0.95,把模型部署到现网后随便扫一个正常业务目录就报几十个恶意,误报率高到没法用。
原因:最常见的是向量化在切分之前就 fit 了。CountVectorizer 在全部数据上fit_transform,词表已经把测试集甚至全量数据的词频统计信息吸收了;接着再train_test_split,测试集样本在向量化阶段“见过”整个语料,评估分数自然虚高。这是文本分类最容易踩的坑。
解决:先切分,再在训练集上fit_transform,测试集和预测集只用transform。
# 错误示范:向量化在切分前,测试集信息已泄漏进词表 X_all = vectorizer.fit_transform(all_scripts) X_train, X_test, y_train, y_test = train_test_split(X_all, y) # 模型分数虚高,一上线就原形毕露 # 正确做法:先切分,再分别处理 X_train_raw, X_test_raw, y_train, y_test = train_test_split( all_scripts, y, test_size=0.2, random_state=42, stratify=y ) X_train = vectorizer.fit_transform(X_train_raw) X_test = vectorizer.transform(X_test_raw)更省心的做法是直接用 sklearn 的 Pipeline,把向量器和模型绑在一起,fit 时内部自动只在训练数据上做特征提取。新项目建议直接写 Pipeline,少一个坑。
5.2 误报集中在哪:加密函数、序列化数据与正常的一句话风格调用
现象:正常项目里一个用 eval 拼模板的 PHP 文件、一个用 base64_decode 解配置串的文件,被模型标成恶意。
原因:NB 看到的是词频,不是语义。eval、base64_decode、str_rot13 这些函数在恶意样本里频次高,模型给它们挂了很高的权重;正常业务代码一旦用到同样的函数,词频分布接近,就被拉向恶意一侧。
解决:分三层处理。第一层,清洗时把字符串字面量替换成占位符,前面clean_script里的str_literal就是干这个的。第二层,把这些“易误报但业务里合法存在”的代码样本补进负样本,让模型知道 eval 加模板也是正常形态。第三层,实在压不住就把阈值调高,让这类文件落进“可疑但需人工复核”的区间,而不是直接判恶意。我一般会在工具里加一个灰度输出,概率在 0.4 到 0.7 之间的文件单独列一栏叫 suspicious,不当恶意处理,也不直接放行。
5.3 样本不均衡:恶意样本太少,模型学成“全判正常”
现象:训练集里恶意样本只有几十个,正常样本上千个,准确率 99%,但把模型拿到恶意样本上一测,recall 只有 0.3,几乎全漏。
原因:多项式NB 的先验来自训练集类别比例,恶意类先验太低,后验概率被整体压低。再加上词表很大时,恶意类的统计噪声也大,决策边界整体偏向“正常”。
解决:先调数据再调模型。把正负样本比例控制在 1:3 以内,不够就收集更多恶意样本,或对正常样本做欠采样——随机抽一部分,而不是全用。模型层面可以换 ComplementNB,它用“补集”估计权重,天然对少数类更友好,代码就是一行替换:
from sklearn.naive_bayes import ComplementNB model = ComplementNB(alpha=1.0, norm=True)大多数情况下,补集NB 在不均衡数据上的表现会明显好于多项式NB。还有一个办法是给预测概率加一个偏移量,阈值不变但把概率整体上移,不过这是治标,数据比例和模型选型才是治本。
5.4 Python环境坑:sklearn版本差异与文件编码
现象:模型在开发机训练好,拷到服务器上用同一个脚本加载,报ValueError: The truth value of an array is ambiguous或者 pipeline 里某个类加载失败;扫到中文注释较多的文件时,输出乱码或直接抛UnicodeDecodeError。
原因:joblib 的模型文件对 sklearn 版本敏感,小版本升级也可能导致 pickle 对象无法加载。文件编码问题则是 Web 脚本里常见 GBK/UTF-8 混合,open 时指定单一编码必然翻车。
解决:模型交付时把 sklearn 版本号写进 README,更稳的做法是在加载时做版本检查:
import sklearn assert sklearn.__version__.startswith('1.3'), f'sklearn版本不匹配: {sklearn.__version__}'文件读取做双编码回退:
try: text = open(path, encoding='utf-8').read() except UnicodeDecodeError: text = open(path, encoding='gbk', errors='ignore').read()这个双编码回退在 Windows 服务器上尤其常见,php 文件被记事本改过编码后,utf-8 读取必炸。加上errors='ignore'兜底,保证扫描流程不中断。
5.5 模型要迭代:新变种来了,不是只加一条正则
现象:隔了三个月再扫一批新采集的 WebShell,检出率明显下降,新出现的混淆手法模型完全没反应。
原因:NB 是统计模型,训练时没见过的新特征组合,它没有能力凭空认识。如果每次发现新变种就只往特征里加东西,和正则规则一个套路,模型会越训越臃肿。
解决:做增量迭代而不是全量重训。每批新样本先做去重,剔除和旧样本 MD5 重复的,只把真正的新变种混入训练集,然后全量重训一次,用新样本做留出验证,确认 recall 回升。我会在项目里维护一个 samples 目录,按日期存放新样本,训练脚本扫描整个目录自动去重重训。同时保留旧模型文件,新模型上线前跑一遍历史恶意样本集,防止“新变种能检出,旧变种全忘了”。
6. 进阶:三招验证模型真本事,把NB检测用到生产环境
6.1 用独立样本集做泛化验证,别只看测试集分数
训练时的测试集是从同一批数据里切的,样本同源,分数天然乐观。真正的验证要用训练期间没接触过的新样本——最近一个月应急响应新收集的 WebShell、现网新上线的业务代码。两三个月攒一批,单独放一个 validation 目录,训练时不碰,验证时跑一遍。这一步能真实反映模型在时间维度上的泛化能力,也能暴露出新变种失忆的问题。
6.2 结合信息熵与危险函数密度做加权投票
NB 只看了 token 分布,对“整段脚本的随机性”没有感知。很多混淆 WebShell 会把变量名改成无意义短名、大量使用十六进制字符串,信息熵显著高于正常代码。可以加一路简单特征做加权:
from collections import Counter import math def shannon_entropy(text: str) -> float: freq = Counter(text) length = len(text) return -sum((c / length) * math.log2(c / length) for c in freq.values())把信息熵和危险函数密度各自标准化后,跟 NB 概率做加权平均,权重按实际误报情况调。这个方案的好处是逻辑透明,出问题时能逐项排查是哪个信号把结果带偏,比直接上黑匣子模型好维护。
6.3 阈值怎么定:一张表说清不同场景的推荐值
| 使用场景 | 推荐阈值 | 理由 |
|---|---|---|
| 应急响应全量扫描 | 0.3 - 0.4 | 优先保 recall,宁可多报,再人工复核 |
| 生产环境定时巡检 | 0.6 - 0.7 | 平衡误报与漏报,输出可疑清单 |
| 自动隔离/删除链路 | 0.85 以上 | 误操作代价高,必须超高置信才动文件 |
阈值不是模型参数,是业务参数,上线前和运维确认好再定。我自己的习惯是框架里把阈值做成可配置项,默认按场景分档,运营人员可以直接改配置而不需要碰代码。
说句实在话,NB 这套方案定位不是“终极检测器”,它是在样本少、算力紧、还要给客户解释清楚判定依据的时候,性价比最高的一档。真正严格的现场,我会在 NB 后面再接一层人工复核或动态沙箱,用 NB 做初筛,把可疑样本范围缩到十分之一,再投入更多资源。这也是我做了几年应急响应的血泪经验:工具帮人缩小范围,人不该把判断权完全交给工具。希望这个基于文本加 NB 算法的小工具,能帮你在下一次遇到翻车现场时,少熬一个通宵。
本文还有配套的精品资源,点击获取