恶意脚本检测有一个很实际的问题:真正有问题的代码可能只占一小段。脚本整体很长,但危险行为往往集中在某几行,例如解码、下载、执行或跳转。如果直接把长脚本截断成固定长度输入,就有可能把关键负载切掉,模型最后是在一段看起来正常的文本上学习“恶意”标签。

我之前的方案是字符级 1D CNN。这个结构轻,适合端侧部署,但它的感受野有限。输入长度设得太短会丢上下文,设得太长又不代表模型真的能理解远距离依赖。实际测试里,即使用比较长的 input sequence length,长脚本截断仍然会引入明显噪声。

一个更自然的处理方式是分块再聚合。把脚本看成一条长序列,按滑动窗口切成多个有重叠的 chunk,模型分别预测每个 chunk 的恶意概率,最后再把这些结果聚合成脚本级判断。比如模型得到 [0.1, 0.05, 0.98, 0.95, 0.2],只要有一个 chunk 的概率超过阈值,就可以把整段脚本判为恶意。

这个推理策略又会反过来影响训练。一个恶意脚本切成 10 个 chunk,可能只有 1 个 chunk 真的包含恶意代码,另外 9 个只是函数定义、注释或普通逻辑。如果把 10 个 chunk 全都当作正样本训练,模型会被迫把良性片段也拟合成恶意,误报风险会被放大。

这里可以用 Multi-Instance Learning 来描述。脚本是一个 bag,chunk 是 instance。正 bag 只表示“至少有一个 instance 是正的”,不要求每个 instance 都是正的;负 bag 则表示里面的每个 instance 都应该是负的。这样训练目标就和脚本检测的实际标注粒度一致了。

大 bag 是另一个工程问题。有些脚本很长,切出来的 chunk 数量会非常大,一次性送进 GPU 容易 OOM。比较稳的办法是实例累积:把一个 bag 拆成多个小批次前向传播,只保存每个 chunk 的 logit,最后再在 logit 层面做聚合。logit 很小,显存压力会低很多。

这里有一个细节:如果先用 torch.no_grad() 跑完所有 chunk,再做 max pooling,反向传播会断掉。更可行的实现是先记录每个 chunk 的 logit 和来源,找到贡献最大值的 chunk 后,再对这个 chunk 做一次带梯度的前向传播。这样不会丢掉全部信息,也能把显存控制在可接受范围内。

推理阶段不能简单照搬训练时的完整滑窗。对一个 1 MB 的脚本,如果 chunk 是 1 KB、步长是 512 B,模型可能要跑接近 2000 次,这在端侧开销太高。可以考虑几种折中:

  • 提前退出:只要某个 chunk 的概率超过高置信阈值,例如 0.95,就立即停止扫描。
  • 前置分流:先用轻量规则筛一遍,例如是否存在高风险 API 或明显的动态执行模式,再决定是否走模型。
  • 抽样扫描:只看头部、中部、尾部或随机抽取若干 chunk,但要接受漏掉隐藏负载的风险。

分块和 MIL 先把恶意行为的局部性放进建模假设里,再去对齐标注粒度、训练方式和推理成本。CNN 仍然只看局部窗口,聚合策略负责把局部判断还原成脚本级结论。