news 2026/10/3 7:51:40

基于朴素贝叶斯与TF-IDF的文本WebShell检测工具实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于朴素贝叶斯与TF-IDF的文本WebShell检测工具实践

简介:基于Python语言、使用机器学习朴素贝叶斯(NB)算法实现的WebShell检测工具,适合想将机器学习用于安全领域的小白或进阶学习者,可支撑毕业设计、课程设计、大作业或初期工程实训。压缩包一共14个文件,主要有4个Python脚本、7个文本说明、1个Markdown文档、1张示意图及少量辅助文件,整体体积只有32KB,结构清晰、便于直接阅读与调试。已有234人学习/下载。工具采用词袋模型配合TF-IDF特征提取方法,能够对文本内容进行预处理和特征化,有效识别PHP、ASP、JSP三种常见的WebShell;项目目录将正常样本与WebShell样本分开存放,用户可自行放好黑白样本后运行训练和检测脚本,快速体验完整的机器学习应用流程。源码作为参考资料而非定制代码,建议有一定基础的用户阅读、调试并二次修改,以真正掌握朴素贝叶斯算法在文本安全检测中的实现思路。

1. 基于文本的WebShell检测,为什么选朴素贝叶斯这条路

做应急响应时,最磨人的不是服务器被种了马,而是明明报了告警,人工打开文件一看,几百行PHP代码里混着一句eval($_POST['x'])。这类基于文本的WebShell,恰好是python机器学习NB算法最容易发挥的场景:没有必要上深度学习,也不需要搭建复杂图谱,把文件当作文本,抽取特征,交给朴素贝叶斯分类器,就能在秒级给出置信度。这就是“基于python机器学习NB算法实现基于文本的WebShell检测工具”这条技术路线的核心价值。

对于安全工程师、蓝队值守人员和搞日志分析的新手来说,这个工具的价值在于:它不依赖外部沙箱,不要求样本库持续更新,只要能拿到一批已标注的webshell样本和正常Web脚本,就能训练出一个可解释性很强的检测模型。相比正则匹配的黑名单方式,NB算法对“没见过但长得像木马”的代码更敏感;相比深度学习,它对机器配置要求低,训练速度快,调参空间小,适合作为应急响应webshell查杀流程里的第一道过滤。

2. 文本型WebShell检测任务拆解:特征怎么提,NB算法凭什么能扛

2.1 把WebShell检测转换成文本二分类问题

在动手写代码之前,需要先明确一个前提:基于文本的WebShell检测,本质上是“给定一段脚本源码,判断它是恶意的WebShell还是正常的Web业务代码”。这个任务天然适合朴素贝叶斯,因为NB算法假设特征之间相互独立,而文本分词后的词项分布恰好可以按这个假设来近似。

一个PHP一句话木马,常见的形态是:

<?php @eval($_POST['c']);?>

正常业务代码可能是:

<?php $action = $_GET['action']; switch($action) { case 'login': $user = trim($_POST['username']); break; } ?>

从人类视角看,eval、assert、$_POST、base64_decode这些词是高危信号;从算法视角看,这些词在不同类别文档中的词频差异,就是分类依据。NB算法要做的事,就是统计“恶意样本中出现eval的概率”和“正常样本中出现eval的概率”,再用贝叶斯公式计算后验概率。

2.2 文本特征化的三个层次:原始词频、TF-IDF、自定义风险词表

特征工程是这类工具最影响效果的部分,比算法选型本身更关键。常见做法是以下三层特征配合使用。

第一层是原始词频,直接用CountVectorizer把脚本源码按字符流切分成词项,统计每个词出现的次数。这个做法实现最简单,但容易被“大量无关注释”和“高频正常函数名”干扰。

第二层是TF-IDF,用TfidfVectorizer把词频转化为权重。TF-IDF能压制那些在正常和恶意样本中都频繁出现的通用词,比如function、return、$this,突出eval、base64_decode这类有区分度的词。在实验里,TF-IDF加多项式朴素贝叶斯的组合,在PHP样本集上的F1值通常比纯词频高出三到五个百分点。

第三层是自定义风险词表,把WebShell常用函数、编码函数、混淆手法关键词提取出来,拼到特征向量里。比如eval、assert、call_user_func、preg_replace、/e修饰符、gzuncompress、str_rot13这些,可以在向量化之后追加一个“是否命中高危词”的二元特征维度。

我一般会这样组织特征提取代码:

from sklearn.feature_extraction.text import TfidfVectorizer risk_words = ['eval', 'assert', 'base64_decode', 'call_user_func', 'preg_replace', 'gzuncompress', 'str_rot13', 'pack', 'chr'] class FeatureBuilder: def __init__(self): self.tfidf = TfidfVectorizer( analyzer='word', token_pattern=r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', max_features=5000, ngram_range=(1, 2), sublinear_tf=True ) self.risk_words = risk_words def build_features(self, texts): tfidf_matrix = self.tfidf.fit_transform(texts) risk_matrix = self._build_risk_features(texts) from scipy.sparse import hstack return hstack([tfidf_matrix, risk_matrix]) def _build_risk_features(self, texts): import numpy as np rows = [] for text in texts: row = [1 if w in text.lower() else 0 for w in self.risk_words] rows.append(row) return np.array(rows)

这里有两个参数值得说明。token_pattern用\b[a-zA-Z_][a-zA-Z0-9_]*\b,是为了把PHP变量名$_POST、函数名base64_decode都切成单词,同时又忽略掉<?php这类标记符。ngram_range=(1, 2)让特征同时包含单个词和相邻词组合,比如eval和$_POST连在一起出现时,二元词组eval $_POST会比单独两个词更有判别力。sublinear_tf=True对词频做1+log(tf)变换,能削弱超高频词对模型的影响。

2.3 朴素贝叶斯在文本分类里的数学直觉与代码实现

朴素贝叶斯的核心公式是后验概率等于先验概率乘以似然概率再归一化。在文本分类场景里,类别c下出现文档d的概率,由文档中每个词w_i在类别c下的条件概率连乘得到。

用scikit-learn实现时,代码量很小:

from sklearn.naive_bayes import MultinomialNB from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report X = feature_matrix y = labels # 1表示WebShell,0表示正常脚本 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) model = MultinomialNB(alpha=0.01) model.fit(X_train, y_train) y_pred = model.predict(X_test) print(classification_report(y_test, y_pred, target_names=['normal', 'webshell']))

MultinomialNB适合这种离散词频计数输入,alpha=0.01是拉普拉斯平滑系数,防止某个词在训练集中从未出现导致概率为零。注意train_test_split里的stratify=y,这个参数保证了划分后正负样本比例和原始数据集一致,对WebShell这种正样本占比通常较低的场景尤其重要。

注意:不要直接用GaussianNB处理TF-IDF矩阵,高斯朴素贝叶斯假设特征服从正态分布,对稀疏非负的词频数据拟合效果很差,训练出来的模型会偏向把大多数样本判为同一类。

3. 从样本到模型:训练一个能用的WebShell检测工具

3.1 训练数据怎么组织:目录结构、标注策略与样本来源

常见做法是准备两个目录,一个放已确认的WebShell样本,一个放正常Web项目源码。目录结构可以这样组织:

dataset/ ├── webshell/ │ ├── shell_001.php │ ├── shell_002.php │ ├── shell_003.jsp │ └── ... └── normal/ ├── index.php ├── config.php ├── usercenter.jsp └── ...

样本来源方面,可以用GitHub上开源的WebShell样本集、自己平时应急响应收集的真实样本,以及从开源CMS源码里抽取的正常PHP/JSP文件。需要注意标注策略:只要文件里出现了eval($_POST)、assert($_REQUEST)这类明确执行外部输入的行为,就标为1;普通的文件上传、数据库操作代码即使写法不规范,也标为0。

读取样本的代码:

import os from pathlib import Path def load_samples(base_dir): texts, labels = [], [] for label, subdir in [(1, 'webshell'), (0, 'normal')]: target_dir = Path(base_dir) / subdir for fp in target_dir.rglob('*'): if fp.suffix.lower() not in ['.php', '.jsp', '.asp', '.aspx', '.py']: continue try: content = fp.read_text(encoding='utf-8', errors='ignore') except (UnicodeDecodeError, IsADirectoryError): continue if len(content.strip()) < 10: continue texts.append(content) labels.append(label) return texts, labels

errors='ignore'是用来兜底的,因为WebShell样本经常混用GBK、UTF-8甚至UTF-16编码,直接把解码错误忽略掉,能保证大多数文件进入训练集。过滤掉小于10字符的文件,是为了去掉空文件和纯二进制内容。

3.2 训练脚本完整拆分:向量化、模型训练与持久化

把训练流程写成一个可复用脚本,是让这个工具从“自己实验”走向“团队可用”的关键一步。脚本需要做的事情包括:加载样本、构建特征、训练模型、评估效果、保存模型与向量器。

import joblib from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import Pipeline from sklearn.model_selection import cross_val_score texts, labels = load_samples('dataset') print(f'样本总数: {len(texts)}, WebShell占比: {sum(labels) / len(labels):.2%}') pipeline = Pipeline([ ('tfidf', TfidfVectorizer( analyzer='word', token_pattern=r'\b[a-zA-Z_][a-zA-Z0-9_]*\b', max_features=8000, ngram_range=(1, 2), sublinear_tf=True )), ('clf', MultinomialNB(alpha=0.01)) ]) scores = cross_val_score(pipeline, texts, labels, cv=5, scoring='f1') print(f'5折交叉验证F1: {scores.mean():.4f} (+/- {scores.std():.4f})') pipeline.fit(texts, labels) joblib.dump(pipeline, 'webshell_nb_model.joblib')

这里用Pipeline把向量化和分类器串成一条链,好处是预测时不需要手动对输入文本做相同的特征转换,直接调用pipeline.predict_proba(texts)就行。交叉验证用f1作为评分指标,而不是准确率,因为WebShell检测中正样本远少于负样本时,简单准确率会被“大部分判负”的策略拉高,不能反映真实检测能力。

保存模型时用joblib而不是pickle,因为joblib对大数组的序列化效率更高,模型文件里包含的稀疏矩阵和词表数据用joblib保存能小三分之一以上。

3.3 模型评估:不只看准确率,要看误报率和召回率的平衡

训练完成后,一定要做细粒度评估。用一个独立的测试集或者至少用保留的测试划分,输出混淆矩阵和分类报告:

from sklearn.metrics import confusion_matrix y_proba = pipeline.predict_proba(test_texts)[:, 1] y_pred_binary = (y_proba >= 0.6).astype(int) cm = confusion_matrix(test_labels, y_pred_binary) tn, fp, fn, tp = cm.ravel() print(f'TP={tp} FP={fp} FN={fn} TN={tn}') print(f'误报率: {fp / (fp + tn):.2%}') print(f'召回率: {tp / (tp + fn):.2%}')

阈值0.6是一个经验起点。NB算法输出的概率往往偏极端,要么很接近0,要么很接近1,中间地带样本不多。把阈值从默认的0.5提高到0.6到0.7,能显著压低误报率,代价是漏掉一部分被混淆过的WebShell。实际部署时,建议输出概率而不是硬分类结果,让使用者自己决定阈值。

4. 做成可用的检测工具:命令行扫描器与接口封装

4.1 命令行工具的目录结构与核心入口

训练完模型只是第一步,要让人愿意用,还得把它包装成一个能处理真实应急场景的命令行工具。我一般这样组织项目结构:

webshell_detector/ ├── train.py # 训练脚本 ├── detect.py # 检测入口 ├── models/ │ └── webshell_nb_model.joblib ├── dataset/ │ ├── webshell/ │ └── normal/ └── rules/ └── high_risk_words.txt

detect.py需要支持两种调用方式:单文件检测和目录扫描。目录扫描是应急响应里最常用的场景,因为攻击者通常不会只丢一个文件,而是会在多个目录下分散上传。

import argparse import joblib from pathlib import Path def main(): parser = argparse.ArgumentParser(description='基于朴素贝叶斯的文本WebShell检测工具') parser.add_argument('-f', '--file', help='待检测的单个文件路径') parser.add_argument('-d', '--dir', help='待检测的目录路径') parser.add_argument('-t', '--threshold', type=float, default=0.6, help='判定阈值,默认0.6') args = parser.parse_args() model = joblib.load('models/webshell_nb_model.joblib') files = [] if args.file: files = [Path(args.file)] elif args.dir: files = list(Path(args.dir).rglob('*')) else: parser.print_help() return for fp in files: if fp.suffix.lower() not in ['.php', '.jsp', '.asp', '.aspx']: continue try: content = fp.read_text(encoding='utf-8', errors='ignore') except Exception: continue proba = model.predict_proba([content])[0][1] if proba >= args.threshold: flag = '[高危]' elif proba >= args.threshold * 0.6: flag = '[可疑]' else: flag = '[正常]' print(f'{flag} {proba:.2%} {fp}')

这段代码里有一个容易被忽略的细节:proba >= args.threshold * 0.6这行用一个可疑区间替代非黑即白的输出。实际应急场景中,宁可信可疑度高但概率没到阈值的文件,也不要漏掉。可疑清单可以交给人工二次确认,高危清单直接进入处置流程。

4.2 与hash查杀、正则规则互补的检测策略

NB模型有一个明显短板:对“混淆程度极高、词项被拆散”的WebShell,比如把eval写成ev"al",或者用变量拼接函数名的样本,特征词被破坏后得分会下降。常见做法是让工具同时叠加两个辅助策略。

第一个辅助策略是文件hash比对,调用hashlib计算文件的MD5或SHA256,和已经收录的已知WebShell hash库匹配。这个策略查杀速度最快,但只能查到已入库的样本。

第二个辅助策略是正则规则引擎,在NB给出低分时,检查文件内容是否命中高置信度规则,比如/eval\s*\(\s*base64_decode/i、/assert\s*\(\s*\$\_(POST|GET|REQUEST)/i。命中任何一条规则,无论NB分数多低,至少标为可疑。

import re HIGH_RISK_RULES = [ re.compile(r'/\*.*\*/\s*@eval', re.S), re.compile(r'assert\s*\(\s*\$_(POST|GET|REQUEST)', re.I), re.compile(r'eval\s*\(\s*base64_decode', re.I), re.compile(r'\$_\w+\s*=\s*chr\(', re.I), ] def rule_engine_hit(content): return [r.pattern for r in HIGH_RISK_RULES if r.search(content)]

这组正则规则需要按自己收集的样本持续维护,规则越具体越好。注意不要把preg_replace直接加入高危清单,因为正常代码里也会用preg_replace做字符串替换,只有当它的正则模式串带/e修饰符时才危险。

4.3 把检测结果输出成可读报告

应急响应结束后需要留痕,工具的输出不能只是一行行告警。常见做法是把检测结果汇总成JSON或者CSV,方便后续导入日志平台或做时间线分析。

import json import datetime report = { 'scan_time': datetime.datetime.now().isoformat(), 'threshold': args.threshold, 'file_count': 0, 'risk_files': [] } # 在文件扫描循环内追加记录 item = { 'file': str(fp), 'size': fp.stat().st_size, 'proba': round(float(proba), 4), 'level': ''.join(flag.strip('[]')), 'rule_hits': rule_engine_hit(content) } report['risk_files'].append(item) report['file_count'] += 1

输出报告时把正常文件也记录进去,但只统计数量和路径,不打印完整内容,避免敏感信息泄出。报告文件建议保存在独立的reports/目录下,文件名带时间戳,比如report_20250615_143022.json。

5. 避坑指南:WebShell检测工具开发中的五个血泪教训

5.1 训练数据类别不平衡导致模型整体偏向“正常”

现象:训练出来的模型在测试集上准确率高达98%,但把所有WebShell文件丢进去扫描时,只查出不到一半。

原因:数据集里正常文件可能是WebShell样本的三到五倍,MultinomialNB计算先验概率时,正常类别占了明显优势。模型学到的决策边界偏向预测为正常,把很多低置信度的恶意样本放过了。

解决:训练前统计类别分布,如果正负样本比例超过1:2,要做下采样或类别加权。朴素贝叶斯没有直接的class_weight参数,常见做法是复制WebShell样本到接近1:1的比例,或者对正常样本随机下采样。也可以用BalancedBaggingClassifier包装一下,但那样会失去NB算法的简洁性。

5.2 编码问题让大量样本变成乱码,特征完全失效

现象:Windows下从被害主机拷出来的asp木马,用read_text(encoding='utf-8')读取后全是锟斤拷,模型给所有乱码文件打了超低分。

原因:WebShell为了隐藏特征,经常用GBK、GB2312或者UTF-16编码保存,UTF-8解码失败后产生替换字符,原本的eval、assert这些词全部丢失。

解决:检测时尝试按编码优先级依次解码,成功率高的编码作为最终内容。具体做法是先用utf-8解码,失败后用gbk,再失败用latin-1兜底,因为latin-1对任何字节序列都不会报错。对文件做二进制熵值分析还可以辅助判断内容是否被加密压缩过。

5.3 正常业务代码里的高危函数触发大量误报

现象:模型对一个使用了call_user_func动态调用回调函数的框架文件给出了95%的WebShell置信度,实际它是日志系统注册消息回调的正常代码。

原因:call_user_func、preg_replace、array_map这类函数在正常框架代码中很常见,单独把它们作为风险词会让模型对正常代码产生强烈误判。

解决:只保留那些“几乎只出现在恶意样本里”的词作为风险特征,比如eval配合$_POST的二元组合、base64_decode配合gzuncompress的组合。把单字风险词表砍掉一半,F1值不降反升。同时对命中风险词的文件做上下文检查,如果高危函数前后行是被注释掉的示例代码,应降低权重。

5.4 混淆过的WebShell在分词后丢失关键特征

现象:一段代码把eval拆成e . "va" . "l",或者用$_GET['a']作为函数名,模型打分只有0.3。

原因:token_pattern按字母数字下划线分词,e、va、l被拆成独立的短词,它们在正常代码中也高频出现;$_GET作为变量名被保留,但和eval的组合关系断裂了。

解决:在特征提取之前增加一个轻量级的去混淆预处理,把明显的字符串拼接和变量拼接还原成可识别词项。比如把"e" . "va" . "l"先拼成eval再进入分词器。对$_GET['a']这种动态函数名,直接提取出$_GET和$特征即可,不必纠结它最终调用了谁。不要试图彻底还原所有混淆,NB模型本身对不完全特征也具备一定鲁棒性,重点是保住eval、assert这些锚点词。

5.5 模型在新样本上衰减得比预期快

现象:上个月训练的模型用于当月的新攻击样本,召回率从92%掉到70%左右。

原因:攻击者会针对公开的检测规则实时变形,大量使用变量名随机化、函数名大小写交替、注释混淆等手段,而训练集里的样本分布没有跟上这些变化。

解决:把检测工具接入WebShell文件上传漏洞分析溯源流程,每次应急响应后把确认的新样本追加进训练集,重新训练并做交叉验证。我一般会保留最近三个月内的样本,更早的旧样本按比例衰减权重,避免过期样本干扰模型对新变种的判别。对持续暴露在公网的Web服务器,建议每周自动跑一次增量训练任务。

6. 进阶技巧:用置信度输出做二次研判,把NB模型变成真正的检测引擎

NB模型输出的概率值非常适合用于二次研判,这也是它比正则黑名单更适合做检测工具内核的原因。拿到predict_proba的结果后,不要只用一个阈值做决断,而是把输出分成三个区间:概率低于0.3视为正常,0.3到0.7之间视为可疑,高于0.7视为高危。可疑区间里的文件,交给规则引擎补查,或者用第二分类器复核,比如再训练一个随机森林做投票,能有效降低误报。

我自己在长期使用中养成的习惯是:把每次检测命中的文件路径、概率值、来源URL、命中规则都记录下来,形成一份本地的WebShell样本演化时间线。每隔一段时间回看这份时间线,就能发现攻击手法的变化趋势。比如某段时间集中出现$_COOKIE传参的一句话,过段时间全部变成$_SERVER['HTTP_USER_AGENT']取参,NB模型对这些新参数名并不敏感,但时间线能提醒我及时补充特征词表。

如果想进一步压榨检测效果,可以尝试把单个文件拆成“头部注释区”“业务代码区”“字符串赋值区”三段分别提取特征,再拼接到同一个特征向量里。WebShell为了迷惑检测,经常会在文件头部堆大量正常业务代码,中部夹带恶意载荷,全局词频很容易被稀释。分区块提取后,恶意载荷所在区块的词频权重会更突出。

这个方向的最终形态,是把NB检测器作为管线中的第一层过滤器,通过低阈值召回,把可疑结果送进后续的沙箱动态分析或者代码审计工具。NB模型参数量小、推理速度快,完全能扛住正常业务下的实时检测流量。

回看这个项目的落地过程,最深的体会是:朴素贝叶斯不是这个领域里最花哨的算法,但它让整个工具保持住了可解释性和低维护成本。当一行疑似WebShell被标记出来时,你能说出是哪个词、哪个词组、哪个风险特征主导了判定,而不是面对一个黑匣子无从下手。安全检测工具要想真正在应急响应现场被使用,可解释性和误报率控制永远比算法新意更重要。希望我的这些踩坑和调试经验,能帮你把这个工具做得更顺手。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 7:51:11

Ollama+open-webui+AnythingLLM 本地大模型私有化部署实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:50:24

操作系统结构、进程与线程:从内核态到PCB的状态转换全解析

很多人在系统学习操作系统这门课时&#xff0c;都倒在了第一座大山的半山腰上——学完“启动”和“基本概念”之后&#xff0c;到了“操作系统结构”和“进程与线程”这一段&#xff0c;突然觉得概念满天飞&#xff1a;内核态、用户态、中断、异常、系统调用、PCB、状态转换、线…

作者头像 李华
网站建设 2026/10/3 7:50:06

DRV8818+TM4C129工业步进驱动硬核实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:49:52

ArcMap流域分析实操:从DEM到流域图全流程详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:49:25

DRV8818+PIC18LF45K80工业级步进控制实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 7:48:45

数学建模国赛实战指南:选题、建模、编程到论文全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华