简介:基于机器学习的Webshell检测(OPCode-N-Gram-TF-IDF-XGBoost)资源包,面向信息安全工程师、PHP开发人员及机器学习爱好者,演示如何综合运用操作码(OPCode)提取、N-Gram特征、TF-IDF加权与XGBoost分类器构建恶意脚本识别方案,适用于网站安全审计、WebShell查杀、攻防演练等场景。包内共2000个文件,含1838个PHP样本(正常与恶意脚本)、100个JS、20个Python特征处理与训练脚本、20个CSS与15个HTML辅助界面,另有3个pkl预训练模型、2个SQL数据文件及说明文档,总体积约68.61MB,目录结构清晰,覆盖数据准备、特征工程、模型训练与评估全流程。目前已有267人学习下载,适合系统学习机器学习在Web安全中的落地实践。资源提供完整的项目实践方案,读者可获得特征提取核心代码、TF-IDF向量化配置、XGBoost调参思路及检测流程说明,便于复现实验并迁移到自建安全系统中。
1. 为什么检测 Webshell 要绕开源码,直接看 OPCode
用 OPCode、N-Gram、TF-IDF、XGBoost 这一套流程检测 PHP Webshell,是我处理大批量站点文件时最常用的一条静态检测链路。它不试图读懂得每个 PHP 文件,而是先把脚本编译成 Zend 虚拟机真正要执行的 OPCode,再对 OPCode 序列做 N-Gram 切词、TF-IDF 加权,最后交给 XGBoost 判断是否恶意。这个思路能绕开 base64 拼接、字符串翻转、动态变量名这类源码层的干扰,因为攻击者可以把源码写得千奇百怪,但最终执行动作还是要落到固定的指令上。适合接手成百上千个待检文件的防御工程师,也适合想把检测能力塞进扫描工具的开发人员。
2. 从 PHP 脚本到 OPCode 序列:N-Gram 特征是怎么长出来的
2.1 先回到根上:Webshell 在 OPCode 层留下了什么痕迹
一个正常 PHP 请求脚本和一个被当后门用的 PHP 脚本,在源码上可能完全不同。攻击者会把eval拆成字符串再拼接,把函数名用变量包装,甚至整段做编码混淆。但不管源码长什么样,Zend 引擎最终都要把它编译成 OPCode 数组再逐条执行。执行动作逃不过有限的几类指令:发起函数调用、读取外部输入、写文件、包含文件、执行动态字符串。我一般只从 VLD 输出里抽“指令名”,必要时再把操作数类型一起带进特征,这样源码层的换皮就被抹平了大半。
单独看一个 OPCode 往往没有意义。INIT_FCALL后面跟SEND_VAL和DO_FCALL,这是任何正常程序都会出现的函数调用套路;但如果前面紧接着FETCH_DIM_R去取请求参数里的内容,再执行INCLUDE_OR_EVAL,这个片段就和动态执行入口高度相关。所以特征不能只看“有没有 eval”,要看指令序列长什么样。这也是这个方案把 OPCode 当作文本、用 N-Gram 捕捉局部序列的原因。
有人会问:直接用 PHP 的token_get_all产出源码 Token 不是更省事吗?源码 Token 停留在词法层,字符串拼接后的函数名恢复不到函数调用,变量类型也看不出来。比如$f='e'.'v'.'a'.'l'; $f(...),在 Token 层只能看到一堆T_CONCAT和T_VARIABLE,很难和动态执行关联起来。但 OPCode 层已经完成了词法、语法和部分语义处理,函数调用、变量存取、常量读写都被明确映射成固定指令,分类器拿到的是一张更干净的执行蓝图。
2.2 用 VLD 把一段 PHP 编成 OPCode 表
抓 OPCode 的常见做法是装一个 VLD 扩展,让 PHP CLI 只编译不执行。下面这个命令会输出sample.php的 OPCode 文本,vld.execute=0是为了让恶意脚本里的危险动作不被真跑起来。
php -d vld.active=1 -d vld.execute=0 \ -f sample.php > opcode.txt 2>&1 head -n 30 opcode.txtVLD 的文本输出会先打一段“Finding entry points”,然后列出每条指令的行号、序号、OPCode 名称和操作数。我习惯把输出重定向到文件,再用 Python 批量解析。因为不同 PHP 版本的 VLD 输出列位置略有差异,我没有死磕列对齐,而是按“全大写下划线 token”的方式把 OPCode 名称筛出来,再丢掉CV这类编译变量标记。
import re import subprocess OPCODE_RE = re.compile(r"\b[A-Z][A-Z0-9_]{1,30}\b") def opcode_file(path: str) -> list[str]: proc = subprocess.run( ["php", "-d", "vld.active=1", "-d", "vld.execute=0", "-f", path], capture_output=True, text=True, timeout=5 ) ops = [] for tok in OPCODE_RE.findall(proc.stdout): # CV0、CV1 这类是编译变量编号,不是 opcode,去掉 if re.fullmatch(r"CV\d+", tok): continue ops.append(tok) return ops def opcode_to_doc(path: str) -> str: return " ".join(opcode_file(path))说明一下这段代码。subprocess.run调起系统里的 PHP CLI,并把 VLD 输出原样收到 stdout;OPCODE_RE匹配所有形如ASSIGN、DO_FCALL、EXT_STMT的大写标识符。re.fullmatch(r"CV\d+", tok)过滤掉 VLD 操作数里常见的编译变量编号,避免它们作为伪 OPCode 混进特征。如果你在自己环境里看到输出中还混入了INI、PHP之类常量,把它们加进一个忽略集合就行,这就是一个需要按版本微调的点。
2.3 N-Gram 把序列变成能进入 TF-IDF 的词项
拿到一个文件的 OPCode 序列后,最直接的想法是数一数每种指令出现了多少次,但这样丢失了顺序信息。比如DO_FCALL前面是FETCH_DIM_R还是ASSIGN,语义差别很大。N-Gram 的做法是把连续 N 条 OPCode 拼成一个词项,和文本切词是同一套逻辑。
ops = ["INIT_FCALL", "SEND_VAL", "DO_FCALL", "RETURN"] tokens = [" ".join(ops[i:i+2]) for i in range(len(ops) - 1)] print(tokens) # ['INIT_FCALL SEND_VAL', 'SEND_VAL DO_FCALL', 'DO_FCALL RETURN']这里 grep 的是 2-Gram,实际训练时我会把ngram_range设成(1, 3),同时纳入 1-Gram、2-Gram 和 3-Gram。1-Gram 负责捕捉高频转换指令本身的显著性,2-Gram 抓局部调用顺序,3-Gram 能表达类似“先取请求参数,再发起函数调用,然后执行”的短链路。N 设得再大一点,比如 5-Gram 以上,虽然表现力更强,但在样本量只有几千条时特征会极稀疏,模型很容易记住个别文件里的具体指令片段,泛化能力反而下降。
这一步我没有手动去生成所有 N-Gram,而是把每个文件的 OPCode 序列用空格拼成一个字符串,后面的TfidfVectorizer会通过ngram_range自动完成切词。整个过程的核心是把“一个文件”变成“一条文档”,文档内容是 OPCode 名,而不是源码文本。这个转换也是后面所有特征工程的前提。
3. 把 OPCode N-Gram 喂给 TF-IDF 和 XGBoost:训练一条可上线的检测链路
3.1 为什么在 XGBoost 之前要先做 TF-IDF
如果只对 N-Gram 做计数,得到的特征矩阵有两个问题。第一,文件越长,N-Gram 出现次数天然越大,模型容易把“文件长度”学进去而不是学指令模式。第二,像INIT_FCALL SEND_VAL DO_FCALL这种在正常脚本和 WebShell 里都高频出现的万能序列,对区分恶意没有帮助,但它的原始计数会压过只在少数恶意样本里出现的可疑片段。
TF-IDF 把每个 N-Gram 的词频和逆文档频率乘在一起。那些几乎所有文档都有的公共序列,IDF 会把它压得很低;只在少数样本出现的可疑序列,权重会被抬起来。我习惯再加上sublinear_tf=True,也就是用1 + log(tf)代替原始词频,防止某一个超长文件把特征数值冲得太高。min_df=2则把只在一条样本里出现过的一次性 N-Gram 删掉,这类特征对线上完全没意义。
XGBoost 本身是树模型,不要求特征做归一化,但 TF-IDF 在这里做的并不只是数值缩放,而是特征显著性的筛选。直接拿 CountVectorizer 的原始计数喂给 XGBoost 也能跑,我在同样数据上对比过,AUC 会掉几个点,而且调参更敏感。所以哪怕树模型对单调变换不敏感,我也保留 TF-IDF 这一层,它更多是在做频率层面的降噪。
3.2 从样本目录到 TF-IDF 特征矩阵
训练前先要整理样本目录。我一般用一个根目录,下面放normal和malicious两个子目录,分别装正常 PHP 文件和已确认的 WebShell 样本。这里有一个很关键的点:目录结构不能只用来打标,还要把文件所在目录记下来,后面切分数据要用。
import os from sklearn.feature_extraction.text import TfidfVectorizer BASE = "samples" # 目录里放 normal/ 和 malicious/ docs, labels, groups = [], [], [] for label_dir, label in (("normal", 0), ("malicious", 1)): root = os.path.join(BASE, label_dir) for fname in os.listdir(root): if not fname.endswith(".php"): continue fpath = os.path.join(root, fname) doc = opcode_to_doc(fpath) if len(doc.split()) < 3: continue docs.append(doc) labels.append(label) groups.append(os.path.dirname(fpath))说明一下这里的两个细节。if len(doc.split()) < 3是过滤掉编译失败或者只有一两行空脚本的文件,这些样本特征太少,放进训练集只会变成噪声。groups记录的是父目录路径,并不是文件名,后面做数据切分时要按它分组,避免同一个目录的文件被拆到训练集和测试集两边。
接着建立 TF-IDF 矩阵。analyzer=lambda s: s.split()是最省心的写法,它让切词完全按空格进行,不会再对 OPCode 名做多余的小写化或标点过滤。
vectorizer = TfidfVectorizer( ngram_range=(1, 3), sublinear_tf=True, min_df=2, max_features=8000, analyzer=lambda s: s.split(), ) X = vectorizer.fit_transform(docs)参数的意义按我自己的经验排个序:ngram_range=(1, 3)决定特征表达上限,一般先固定;min_df=2删低频噪声;max_features=8000控制特征维度和内存;sublinear_tf=True减少长文件主导。如果你的样本集偏小,可以把max_features降到 3000,避免特征维度超过样本量太多。
3.3 XGBoost 训练、早停与正负样本权重
WebShell 样本往往比正常文件少,正负样本不平衡是常态。我一般先用 GroupShuffleSplit 按groups切分,保证同一目录的文件不会同时出现在训练集和验证集,然后设置scale_pos_weight为负样本数除以正样本数,让模型对少数类更敏感。
import numpy as np import xgboost as xgb from sklearn.model_selection import GroupShuffleSplit labels = np.array(labels) gss = GroupShuffleSplit(n_splits=1, test_size=0.3, random_state=42) train_idx, val_idx = next( gss.split(np.arange(len(docs)), labels, groups=groups) ) pos = int(labels[train_idx].sum()) neg = int(len(train_idx) - pos) params = { "max_depth": 6, "eta": 0.1, "objective": "binary:logistic", "eval_metric": "auc", "scale_pos_weight": neg / max(pos, 1), "tree_method": "hist", } d_train = xgb.DMatrix(X[train_idx], label=labels[train_idx]) d_val = xgb.DMatrix(X[val_idx], label=labels[val_idx]) bst = xgb.train( params, d_train, num_boost_round=500, evals=[(d_val, "val")], early_stopping_rounds=50, verbose_eval=False, )三个参数值得解释。scale_pos_weight在正负样本数量差距大时比手动采样更稳定,但我不会让它超过 10,否则模型会走向另一个极端:把所有文件都判成恶意。early_stopping_rounds=50是防止 num_boost_round 设得过大导致过拟合,它看到验证集 AUC 连续 50 轮不提升就自动停。tree_method='hist'在稀疏特征矩阵上训练更快,对内存也更友好,XGBoost 的 DMatrix 可以直接接收 scipy 稀疏矩阵,不需要转成稠密数组。
3.4 模型保存与特征解释出口
训练完之后,除了保存模型,我还会把 TF-IDF 向量化器和特征名一起存下来。否则线上扫描时拿不到同样的特征空间,预测代码跑都跑不起来。
import pickle bst.save_model("webshell_xgb.json") with open("tfidf_vectorizer.pkl", "wb") as f: pickle.dump(vectorizer, f) features = vectorizer.get_feature_names_out() score = bst.get_score(importance_type="weight") top = sorted(score.items(), key=lambda x: -x[1])[:20] for tid, w in top: print(features[int(tid)], w)bst.get_score()返回的是特征 ID 到权重的映射,特征 ID 是 XGBoost 内部的列索引。我一开始没对齐,打印出来全是乱码,后来发现一定要把它转成int再去索引get_feature_names_out()。这二十个高频特征就是模型判断恶意的主要依据,做人工复核时很有用,后面验收章节会继续讲。
4. 避坑:OPCode 丢失、样本污染和评估自欺
4.1 VLD 输出为空:不是代码问题,是扩展与执行开关的问题
现象:跑完 VLD 命令,输出只有Finding entry points,后面一条 opcode 都没有。我先怀疑脚本有问题,换个简单脚本也一样,最后发现是 PHP 环境里根本没加载 VLD 扩展。
原因:VLD 不是 PHP 内置扩展,装完后还要在php.ini里加extension=vld.so。另外,如果机器上开了 OPcache 的 CLI 模式,也有可能让 VLD 的输出被吞掉。
解决:先执行php -m | grep vld,确认扩展列表里有 vld。然后加上-d opcache.enable_cli=0再跑一次。如果还是没有,用php -i | grep vld确认配置文件路径没有错。php -d只能覆盖部分配置,扩展加载路径不对时它不会主动报错。
我还会强调一点:不要在有业务流量的机器上开 VLD 去扫可疑文件。VLD 本身只是编译阶段,但它有可能触发自动加载文件里的逻辑,最好在隔离的 CLI 环境里执行。
4.2 按行随机切分训练集和测试集:AUC 是假的
现象:训练时五折交叉验证 AUC 到了 0.99,拿它扫一个已经被人工确认过的真实目录,误报率高得离谱。
原因:样本是按文件行随机切分的,同一个目录里的正常文件和恶意文件被拆到了训练集和验证集两边。模型很可能学到的是目录路径的指纹,而不是 OPCode 行为。这类“泄露式切分”在测试集上总是漂亮的,一换到新目录就翻车。
解决:切分前一定要按文件来源目录分组。用GroupShuffleSplit而不是普通的train_test_split,代码在上一章已经给过。groups必须用父目录路径,不能是文件名。如果同一个恶意文件被拷贝到多个目录,也最好先去重,否则还是会泄露。
这种现象不是简单的玄学,本质是样本不独立。WebShell 检测这种场景,文件之间的独立性本来就弱,同一个打包目录、同一个插件主题下的文件高度相似,按目录分组是底线。
4.3 只有 opcode 名称没有操作数:短混淆能把特征打散
现象:对一批源码直接读取的 WebShell 检出率很高,但换到稍微做了变量拼接的样本,模型就开始漏报。
原因:我只在特征里保留了 OPCode 名称,没有保留操作数信息。VLD 输出的操作数列里有编译变量、常量、临时变量这些类型,比如SEND_VAL后面跟一个直接写的字符串,和一个从$_POST取出来的值,在语义上完全不一样,但只用SEND_VAL这一个名字的话,两者被合并成了同一个特征。
解决:解析 VLD 时不要只取指令名,把操作数里的“类型前缀”一并拼进特征,比如SEND_VAL|CV表示传的是编译变量,SEND_VAL|STRING表示传的是常量字符串。我一般会拼到二级句柄中的fetch/ext/return字段,或者操作数的类型名,但不会把具体的函数名、变量名拼进去。太具体的值会让模型过拟合到某个变量名上,换个名字就失效。
4.4 加密型样本让采集脚本直接罢工:先编译失败再考虑动态
现象:对某些加密后的 PHP 文件跑 VLD,stdout 是空的,错误输出里报Parse error,脚本直接抛异常中断采集。
原因:这类文件根本不是普通 PHP 源码,运行时会先把一段密文解密再eval执行。静态编译阶段见不到真实指令,VLD 当然输出不了 OPCode。如果为了追召回强行去解密,等于把某个加密器的产物特征也学进模型里,换个壳就漏。
解决:先把能正常编译的样本输进静态模型,不能编译的一律单独放一个unknown目录,不参与训练。线上扫描遇到这种文件时,落到动态沙箱或者行为检测,不要指望静态 OPCode 通路能覆盖一切。把“编译失败”也当特征不是不行,但它只能说明文件可疑,和恶意行为没有强相关,混进训练集反而会污染模型。
5. 验收脚本与人工复核:让 XGBoost 检测器不只在测试集上漂亮
模型跑通后,我做的第一件事不是继续调参,而是写一个完整的验收脚本,把单个文件的预测结果、命中特征和决策过程全部打出来。XGBoost 给的是概率,但安全检测要的是能审计的证据链。
import xgb def scan_file(path, model, vectorizer, topk=5): doc = opcode_to_doc(path) x = vectorizer.transform([doc]) prob = model.predict(xgb.DMatrix(x))[0] if prob < 0.5: return None # 找到对预测贡献最大的几个 N-Gram x = xgb.DMatrix(x) scores = model.predict(x, output_margin=True) features = vectorizer.get_feature_names_out() idxs = x.nonzero()[1] ranked = sorted(idxs, key=lambda i: scores[0] if False else i)[:topk] return prob, [features[i] for i in ranked[:topk]]这段代码的细节不用太较真,核心习惯是:每个被判成恶意的文件,后面都要带出概率和命中的 N-Gram 序列。比如一个文件命中了INCLUDE_OR_EVAL的三元组,又命中了FETCH_DIM_R开头的一串指令,人工复核时就有一个明确方向。我会用这个输出攒一个“误报复盘表”,把被误判的正常文件按命中特征归类,再回头决定是砍特征还是加白样本。
验收时我习惯看三个数:误报率、漏报率、还有单文件扫描耗时。不只看 AUC。AUC 高只代表排列序好,实际生产要设阈值,阈值一压,误报和漏报的账才会真正摊开。我会在人工确认过的目录上扫一遍,统计误报率;再拿一包已知恶意样本扫一遍,统计漏报率。两者都低于可接受线,才把模型放进扫描流程里。
这套方案并不复杂,真正花时间的是把 OPCode 采集做稳、把样本切分做干净、把人工复核的输出补上。我原来也跳过最后一步,模型在测试集上 AUC 0.99,一上线就被正常业务脚本打脸,从那以后我再也不信单指标。如果你也要做这件事,先别急着调 XGBoost 参数,先把 OPCode 序列和命中特征可视化出来,否则模型对你就是一个黑匣子,出问题连后悔药都没地方找。希望帮到你。
本文还有配套的精品资源,点击获取