
简介基于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, flagsre.S) text re.sub(r//.*?$, , text, flagsre.M) text re.sub(r#.*?$, , text, flagsre.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( analyzerword, token_patternr[a-zA-Z_$][a-zA-Z0-9_$]{1,}, ngram_range(1, 2), max_features20000, min_df2, ) 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_features20000是词表上限防止处理几万个文件时内存爆炸min_df2表示至少在两个样本里出现过的词才保留过滤掉只出现在单个样本里的拼写噪声。拿到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_size0.2, random_state42, stratifyy ) model MultinomialNB(alpha1.0, fit_priorTrue) model.fit(X_train, y_train) print(classification_report(y_test, model.predict(X_test)))逻辑说明stratifyy做分层切分保证训练集和测试集里恶意/正常比例一致。如果不分层随机切分后测试集里恶意样本太少评估结果会虚高得离谱。random_state42固定下来后续每次调参才能在同一份数据上对比效果。参数说明alpha1.0是拉普拉斯平滑系数。某个词在训练集的恶意类里从未出现时概率会算成 0平滑给它一个小概率兜底。如果模型频繁输出极端概率0.99 或 0.01怀疑过拟合把 alpha 调到 2 或 5 再训练对比。fit_priorTrue表示使用训练集的先验概率当样本比例是手工控制的时候先验本身就是设计的一部分保留即可。评估时别看 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_dictTrue) print(fthreshold{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, encodingutf-8, errorsignore) 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, typefloat, default0.5) parser.add_argument(--model, defaultwebshell_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 脚本扩展名避免把图片、压缩包也读进来。读取文件用errorsignore很多一句话木马混着二进制内容或非 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, compress3)逻辑说明把模型和向量器打包成一个 dict 再存加载时一次拿回全部依赖。classes存下来是为了加载后确认模型是二分类且 label 1 对应恶意防止误用别的模型文件。compress3压缩级别适中模型文件比不压缩小一半以上加载稍慢但可接受。加载之后第一件事跑一条已知恶意样本验证模型没坏# 加载后自检用一个已知样本确认模型没坏 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_size0.2, random_state42, stratifyy ) 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(alpha1.0, normTrue)大多数情况下补集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), fsklearn版本不匹配: {sklearn.__version__}文件读取做双编码回退try: text open(path, encodingutf-8).read() except UnicodeDecodeError: text open(path, encodinggbk, errorsignore).read()这个双编码回退在 Windows 服务器上尤其常见php 文件被记事本改过编码后utf-8 读取必炸。加上errorsignore兜底保证扫描流程不中断。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 算法的小工具能帮你在下一次遇到翻车现场时少熬一个通宵。本文还有配套的精品资源点击获取