基于机器学习与异常检测的APT攻击识别实践
2026/9/19 11:32:42 网站建设 项目流程

简介:在网络安全领域,高级持续性威胁(APT)因其隐蔽性强、攻击链长而难以被传统特征匹配方案发现。基于机器学习的异常检测技术,通过构建正常行为基线,识别偏离基线的可疑活动,为APT检测提供了新思路。其核心原理是利用无监督模型(如孤立森林)从海量网络日志中召回离群点,再结合有监督模型进行精排,从而在可接受的误报率下提升攻击发现能力。这一技术路径在安全数据分析、态势感知等场景中具有重要应用价值,尤其适合中小型企业基于会话日志的检测场景。本文围绕基于Python与scikit-learn的工程实现,重点介绍了特征工程、模型组合及调优经验,展示了如何将机器学习落地到APT检测的实际业务中。 如果你在安全领域待过一段时间,一定听说过 APT(高级持续性威胁)这个名字。它不像普通木马那样"打一枪换一个地方",而是有组织、有耐心地潜伏在企业内网里,可能几个月甚至一两年都不被发现。传统基于特征库的检测规则在面对 APT 时往往力不从心——不是不够勤奋,而是攻击者太会变通:换一个 C2 域名、改一下载荷特征,签名就失效了。所以这两年业内越来越多地把目光转向机器学习,用"行为画像"替代"特征匹配",用"异常发现"替代"已知匹配"。

这篇文章就围绕一个基于机器学习的 APT 检测项目来写,我会把整套代码的核心思路、特征怎么设计、模型怎么选、踩过哪些坑都展开讲一遍。内容面向两类人:一类是刚接触安全数据分析、想用机器学习试水 APT 检测的初学者;另一类是已经在做日志分析或态势感知、想提升检测能力的乙方或甲方安全工程师。文章里的代码用 Python + scikit-learn 实现,不需要 GPU,普通服务器就能跑。

1. 为什么用机器学习做 APT 检测:从特征匹配到异常发现

1.1 传统检测方案在 APT 面前的短板

先要弄清楚,传统检测为什么对 APT 不灵光。市面上大多数 IDS/IPS,包括很多 EDR 产品,底层依赖的还是规则和特征库。规则引擎的工作模式是:把流量或主机行为拆成一个个"证据",然后和规则库里预先写好的签名做比对,命中就告警。这套逻辑对付脚本小子和已知攻击确实有效,但 APT 的核心手法是"定制化攻击"——攻击者会针对目标环境专门改写工具、更换通信协议、甚至利用 0day 漏洞。只要特征没被收录,流量在检测引擎眼里就是"正常"的。

再补一个更尴尬的点:APT 攻击链很长,从最初的钓鱼邮件到最终的数据外传,中间要经历横向移动、权限维持、凭据窃取等多个阶段。如果规则引擎只盯着单一阶段的特征,很多中间行为单独看和正常运维操作没有区别。比如一条凌晨 3 点的远程桌面登录记录,从规则上很难判定它是不是攻击;但如果把它放进"用户行为历史 + 全网横向对比"的上下文里,异常程度立刻就会凸显出来。

机器学习做的事,本质上是把"检测"从"查已知"变成"找不同"。它不看某个特征是否命中签名,而是看这批数据整体上是否符合正常基线。偏离基线越多,越有可能是异常。这个思路天然适合 APT 检测:因为你不需要知道攻击者长什么样,只需要知道他干了和平时不一样的事。

1.2 机器学习检测的核心逻辑

用机器学习做 APT 检测,管线一般是这样的:流量/日志采集 -> 特征提取 -> 模型推断 -> 告警。其中特征提取是承上启下的关键一环,模型能不能学到东西,取决于你喂给它的特征矩阵里有没有"区分度"。

这里要区分两类任务:有监督分类和无监督异常检测。有监督的方案,比如随机森林、XGBoost,需要大量打好标签的攻击样本和正常样本。问题在于 APT 攻击样本稀缺,很多企业内部根本找不到足够的正样本去训练。无监督的方案,比如孤立森林、One-Class SVM、自编码器,只使用正常数据训练,然后找出偏离正常分布的离群点。在某些场景下还会用半监督方案:用少量已知攻击样本来辅助校准正常模型。

在实际工程里,我见过最多的落地组合是"无监督做召回、有监督做精排":先用孤立森林在所有流量里筛出一批可疑会话,再由一个训练好的二分类模型对这批准告警做二次判定,降低误报率。这个思路在后文代码里也会体现。很多人一上来就想上深度网络,其实对于安全告警场景,可解释性比模型复杂度更重要。为此,这条技术路线里应该优先选择能输出"哪个特征贡献最大"的模型,而不要一上来就堆 LSTM 或 Transformer。

2. 整体设计思路与方案选型

2.1 数据来源与场景假设

在开始写代码之前,得先确定数据从哪来。APT 检测的数据来源主要有四个层面:

  • 网络流量数据:NetFlow、全包抓取、DNS 日志、HTTP 会话日志
  • 终端行为数据:进程创建、注册表写入、文件操作、计划任务变更
  • 登录认证日志:成功/失败登录、远程访问、账号异常使用
  • 邮件与办公数据:附件哈希、钓鱼链接、宏行为

我这里选择的场景是"基于网络会话日志的异常检测",因为网络日志最容易获取,也最贴近多数中小企业的实际能力。输入是一批 CSV 格式的会话记录,每条记录是一个网络连接的聚合信息,比如源 IP、目标 IP、目标端口、协议、时长、上下行流量包数、字节数等。

这里要强调一个实际操作中的观点:不要一开始就追求全流量抓包分析,那是大厂才玩得起的基础设施。对大多数团队而言,把 NetFlow 或防火墙日志里的会话记录拿来做分析,已经能覆盖相当比例的横向移动和数据外传行为。先把这条线跑通,再考虑要不要扩容。

2.2 特征工程:决定模型天花板

很多入门者容易犯一个错误:把原始字段直接丢进模型,期望它自己学出规律。但流量数据里很多字段是 ID 类、枚举类或高度偏态的,不加工直接用,模型很难学到有意义的信息。特征工程要做的,是把一条会话记录转化为一组能反映"行为意图"的数字特征。

我在这套项目里用的特征分三类:

第一类是基础统计特征。包括会话时长、上行包数、下行包数、上行字节数、下行字节数、平均包长、包长标准差等。这些特征刻画了单条会话的基本"体格"。

第二类是分布与熵特征。比如目标端口的熵值、DNS 查询的域名长度、域名熵、访问的独立目标 IP 数量等。这里尤其推荐"目标端口熵":正常业务访问的端口集中在少数几个,而扫描行为会均匀访问大量端口,熵值显著偏高。

第三类是时序与上下文特征。包括同一源 IP 在过去 1 小时内的连接频次、失败连接占比、访问的外部目标域名数量等。这些特征能捕捉到 APT 典型的"低频慢速"动作模式——不是突然爆发一次,而是每天稳定地多那么几条可疑外联。

特征工程需要结合具体场景反复迭代,先做出一个基线版本,再根据漏报和误报反推特征是否需要增强。完整代码里我封装了 fetch_features 函数,输入原始 DataFrame,输出可以直接进模型的特征矩阵。

2.3 模型选型:为什么选择"无监督 + 监督"组合

整个项目里我并没用单一模型,而是采用了"孤立森林 + 随机森林"的组合方案。理由如下:孤立森林非常适合高维异常检测,它速度快、对线性不可分的数据也不敏感,而且不依赖样本分布假设。但它有一个弱点:对"局部异常"不敏感,也就是那些在特定群体内部偏离、但整体看来并不突出的点。

随机森林作为二次判定模型可以弥补这个短板。先用孤立森林在全量数据上做大范围召回,把得分最高的前 5% 或前 10% 会话捞出来,然后由随机森林模型做细粒度判定。随机森林训练时有标签,它可以学习到"什么样的特征组合下更像恶意行为",而非只靠偏离度。

深度学习方案我也试过,比如用 AutoEncoder 重建误差做异常检测。效果并不比孤立森林好多少,反而训练和推理成本高了一大截。在一个需要快速交付的检测项目里,简单模型带来的可维护性优势是很明显的。

3. 核心代码实现:从数据集到检测模型

3.1 环境与依赖

我用的环境是 Python 3.9,依赖如下:

pandas>=1.5.0 numpy>=1.23.0 scikit-learn>=1.2.0 matplotlib>=3.6.0 joblib>=1.2.0

安装直接pip install pandas numpy scikit-learn matplotlib joblib就行。没有特殊版本要求,保持默认安装即可。Windows / Linux 都能跑,但生产环境建议部署在 Linux 上。

3.2 数据加载与特征预处理

预处理这一步要注意几个细节。一是缺失值处理,网络日志经常有字段缺失,如果某条记录的目标端口为空,可以直接丢弃,不需要强行填充。二是时间字段要转成 datetime 类型,才能做按小时的窗口统计。三是对枚举字段(协议类型、目标端口)做编码时,最好保留原始数值型字段,不要做 one-hot 展开,否则特征维度爆炸、模型训练变慢。

预处理代码大概长这样:

import pandas as pd import numpy as np from sklearn.preprocessing import StandardScaler def load_and_preprocess(file_path): df = pd.read_csv(file_path) df['timestamp'] = pd.to_datetime(df['timestamp']) df = df.dropna(subset=['dst_port', 'src_ip', 'dst_ip']) df['hour'] = df['timestamp'].dt.hour df['day_of_week'] = df['timestamp'].dt.dayofweek return df

实际项目里建议再加一步:基于 src_ip 做分组聚合,生成每个源 IP 的窗口统计特征。这一步要用 groupby 做,注意窗口大小的选择。太短,比如 5 分钟,容易得到大量全零的稀疏特征;太长,比如 24 小时,又会把瞬时爆发的异常淹没。我常用的是 1 小时滑动窗口,既不漏掉低频慢速外联,也能看到短时间段内的突发行为。

3.3 模型训练与评估

训练阶段分为两步,先训练孤立森林模型,再做有监督精排模型的训练。孤立森林的训练不需要标签,直接丢进特征矩阵就可以。它有一个核心参数是 n_estimators,默认值是 100,数据量不大时够用;如果特征维度很高,可以适当调到 200。contamination 参数表示预期异常比例,AP T 场景下我一般设 0.05 到 0.1 之间,不要设得太高,否则会引入大量误报。

随机森林精排模型的训练需要构造标签。方案是这样的:把孤立森林得分最高的 10% 数据标记为"可疑正样本",再从剩余数据里随机抽样等量的"正常样本",交给专家做人工复核后生成标签。这一步在代码里叫 semi_labeling,本质上是一种伪标签策略。它不需要标注全部数据,只需要标注模型捞出来的少量可疑样本,标注成本大大降低。

训练完成后,用 precision、recall、F1 三个指标做评估。这里要提醒一下:安全场景里 F1 不是唯一标准,业务上通常更关注"在可接受的误报率下能达到多高召回率"。比如每天误报 100 条但能抓到 80% 的横向移动行为,可能比每天误报 10 条但只能抓到 30% 更有价值。这个权衡需要在部署前和安全团队对齐。

3.4 异常检测完整流程代码

下面我把核心检测流程完整写出来。这个代码可以在真实环境里跑通,但数据格式需要调整成你自己的字段名。

import pandas as pd import numpy as np from sklearn.ensemble import IsolationForest, RandomForestClassifier from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, roc_auc_score import joblib class APTDetector: def __init__(self, contamination=0.1): self.contamination = contamination self.scaler = StandardScaler() self.iso_forest = IsolationForest( n_estimators=200, contamination=contamination, n_jobs=-1, random_state=42 ) self.rf_clf = RandomForestClassifier( n_estimators=200, max_depth=10, min_samples_leaf=5, n_jobs=-1, random_state=42 ) self.feature_columns = None def fetch_features(self, df): df = df.copy() df['total_bytes'] = df['up_bytes'] + df['down_bytes'] df['total_packets'] = df['up_packets'] + df['down_packets'] df['avg_packet_len'] = df['total_bytes'] / (df['total_packets'] + 1e-9) df['packet_len_std'] = df[['avg_packet_len']].rolling( window=10, min_periods=1 ).std().fillna(0) # 目标端口熵特征 port_freq = df.groupby('src_ip')['dst_port'].transform('nunique') df['dst_port_entropy'] = - (port_freq / (port_freq.sum() + 1e-9)) * \ np.log((port_freq / (port_freq.sum() + 1e-9)) + 1e-9) # 1小时窗口源IP连接频次 df['ip_window_count'] = df.groupby('src_ip')['timestamp'].transform('count') # 失败连接占比 df['fail_ratio'] = (df['resp_code'] == -1).astype(int) feature_cols = [ 'duration', 'up_bytes', 'down_bytes', 'up_packets', 'down_packets', 'total_bytes', 'total_packets', 'avg_packet_len', 'packet_len_std', 'dst_port_entropy', 'ip_window_count', 'fail_ratio' ] self.feature_columns = feature_cols return df[feature_cols] def fit(self, df): X = self.fetch_features(df) X_scaled = self.scaler.fit_transform(X) # 无监督阶段:孤立森林 self.iso_forest.fit(X_scaled) scores = self.iso_forest.decision_function(X_scaled) # 伪标签生成 threshold = np.percentile(scores, 10) pseudo_label = (scores < threshold).astype(int) # 以伪标签训练随机森林精排模型 X_train, X_test, y_train, y_test = train_test_split( X_scaled, pseudo_label, test_size=0.3, random_state=42, stratify=pseudo_label ) self.rf_clf.fit(X_train, y_train) y_pred = self.rf_clf.predict(X_test) print(classification_report(y_test, y_pred)) print('AUC:', roc_auc_score(y_test, self.rf_clf.predict_proba(X_test)[:, 1])) def predict(self, df): X = self.fetch_features(df) X_scaled = self.scaler.transform(X) iso_score = self.iso_forest.decision_function(X_scaled) rf_score = self.rf_clf.predict_proba(X_scaled)[:, 1] # 综合得分:无监督+监督组合 final_score = 0.4 * (1 - (iso_score - iso_score.min()) / (iso_score.max() - iso_score.min() + 1e-9)) \ + 0.6 * rf_score return final_score def save_model(self, path='apt_model.joblib'): joblib.dump({ 'scaler': self.scaler, 'iso_forest': self.iso_forest, 'rf_clf': self.rf_clf, 'feature_columns': self.feature_columns }, path) def load_model(self, path='apt_model.joblib'): data = joblib.load(path) self.scaler = data['scaler'] self.iso_forest = data['iso_forest'] self.rf_clf = data['rf_clf'] self.feature_columns = data['feature_columns']

这段代码里最值得说明的是 predict 方法里的综合得分逻辑。孤立森林的 decision_function 输出的是"正常程度"得分,值越大越正常。我在组合前先把它反转映射到 0-1 区间,让它的含义变成"异常程度"。随机森林的输出本身就是异常概率,两者加权平均得到最终分数。

这里有个细节:加权系数 0.4 和 0.6 是怎么来的?我是通过网格搜索在验证集上试出来的,scope 是让 F1 最高的组合。你也可以改成 0.5 对 0.5,差别不会太大。但不要给随机森林权重太高,否则在正样本极少时,模型会倾向于输出低概率,什么都报不成异常。

3.5 端到端检测脚本

模型训练完成后,需要把它接入一个"定时扫描"流程。我习惯写一个脚本,每天凌晨跑一次,读取过去 24 小时的日志数据,调用模型预测,把得分超过阈值的会话写入告警表。

import pandas as pd from apt_detector import APTDetector detector = APTDetector() detector.load_model('apt_model.joblib') # 加载当天新增日志 df = pd.read_csv('/data/logs/2024-01-01_sessions.csv') scores = detector.predict(df) # 阈值可以用分位数动态调整,也可以用固定值 threshold = 0.8 alerts = df.loc[scores >= threshold].copy() alerts['score'] = scores[scores >= threshold] # 按分数排序,输出 TOP 200 alerts = alerts.sort_values('score', ascending=False).head(200) alerts.to_csv('/data/alerts/alerts_2024-01-01.csv', index=False)

实际部署时不要用固定阈值,因为数据分布会随时间漂移。我常用的做法是用近 7 天得分的 95 分位数作为动态阈值。这样在业务高峰期,阈值会自动抬升;业务低谷期,阈值自动下降,比较不容易产生大批量误报。

4. 模型调优与效果评估

4.1 关键指标解读

搞安全的人不一定都熟悉机器学习指标。这里做个通俗解释:精确率(Precision)表示模型报出来的可疑事件里,有多少是真攻击;召回率(Recall)表示所有真实攻击里,模型抓到了多少。安全场景里两者永远在打架,追求高召回必然带来高误报,追求高精确又会漏报。

我建议在 APT 检测这个场景里优先看 Recall@TopK。也就是说,在模型输出的得分最高的前 100 条或前 500 条里,有多少条是人工核实过的真攻击。这个指标比全局 F1 更贴近实际使用体验,因为它模拟了分析师"每天只看前 N 条告警"的真实工作方式。完整代码里,我用 classification_report 输出所有指标,但真正决策时看的是 TopK 命中率。

另一个容易踩的坑是数据不平衡。APT 检测场景里正样本可能只占万分之几,甚至更少。如果你直接把原始分布丢给随机森林,它会把几乎所有样本都预测为正常,因为这样做准确率也有 99.9%。所以代码里用了伪标签和 stratify 分层采样,保证训练集里正负样本比例不至于太离谱。

4.2 阈值选择与误报控制

误报率是 APT 检测系统能不能长期跑下去的关键。一个每天生成几百条告警、但 99% 都是误报的系统,用不了两周就会被安全团队弃用。所以在调优阶段,阈值选择要格外谨慎。

我常用的方法:先让模型对近 30 天的日志做一次全量回放,输出每天得分阈值为 0.9、0.8、0.7 时的告警量,画一条趋势线。正常业务下,每天的告警数量应当相对平稳。如果某一天告警量突然暴涨,要么那天发生了异常事件,要么数据源出现故障——这两种情况都值得关注。

还有一个工程小技巧:给每个告警加一个"上下文维度",比如源 IP 是否关联到域控服务器、目标 IP 是否为外部地址、是否属于首次出现的外部通信。这些规则和机器学习打分做"与"逻辑组合,可以把误报率再压低一个量级。这里要注意主次:规则是辅助,不是主体,否则你又走回了传统检测的老路。

4.3 特征组合的迭代思路

模型上线后并不是一劳永逸的。APT 攻击者在不断变化,你的特征也需要持续迭代。迭代方向有两个维度:一是增加新特征源,比如把威胁情报里的恶意 IP 列表作为特征合并进来;二是改造特征计算方式,比如从"固定窗口统计"升级为"动态滑动窗口统计"。

判断特征是否有效,最简单的方法是看特征重要性排序。随机森林训练完成后,可以直接打印 feature_importances_。我发现很多场景里排在前三的特征往往是"同一源 IP 的连接数"、"目标端口的熵值"和"平均包长",这符合 APT 横向移动和数据外传的行为特征:它会突然访问很多不同端口的主机,同时外传时的流量包往往比正常业务更整齐。

一个我反复踩过的坑:不要在特征里加入任何"未来信息"。比如用整天的数据统计窗口特征,然后用这个特征预测当天下午的会话,这属于特征泄露。正确做法是统计窗口截止于当前时间之前,比如预测 t 时刻的会话,特征统计应该只使用 t-1 小时之前的数据。

5. 常见问题与排错实录

5.1 问题速查表

下面是这个项目跑下来最常遇到的几个问题,直接整理成表格方便对照排查:

现象可能原因解决办法
训练时损失不收敛、AUC 接近 0.5特征泄露:统计特征混入了未来信息检查特征计算窗口,确保只使用历史数据
告警数每天基本都是 0阈值设置过高降低固定阈值,或改用近 7 天得分的 95 分位数
告警数每天几千条,全是误报数据源异常,常见于 NetFlow 日志缺失导致端口字段为 0检查数据清洗环节,剔除 dst_port=0 的会话
模型在测试集效果好,上线后变差数据分布漂移加入周期性重训练流程,建议每周跑一次
特征重要性里时间字段排第一正常业务有明显的昼夜规律,时间特征与业务强相关区分业务系统特征,不同系统分组建模
训练速度过慢特征维度太高或 n_estimators 过大先跑一次特征重要性,删掉尾部不重要的特征

5.2 踩坑记录

这里分享几个实际项目中让我印象深刻的踩坑瞬间,都是代码层面和经验层面的教训。

第一个坑是"重复样本导致模型过拟合"。网络日志里同一条会话可能被多个采集器重复上报,直接进模型会让模型记住这些重复样本,误以为它们是重要特征。解决办法是在预处理阶段对 session_id 或(src_ip, dst_ip, dst_port, timestamp)做去重。这个动作看起来不起眼,但能显著提升模型的泛化能力。

第二个坑是"孤立森林对离群点过于敏感"。孤立森林本来就擅长找离群点,但日志数据里有大量"天然离群"的运维行为,比如管理员手动执行数据库全表扫描、备份系统发起大流量传输。这些都会被模型标记为高分异常。我的应对措施是维护一个"白名单源 IP"列表,在特征里加一个 is_whitelisted 的布尔特征,让模型学会忽略这些正常运维行为。

第三个坑是"模型文件名和版本管理混乱"。这个项目迭代了十几个版本,如果每个版本都叫 apt_model.joblib,回滚时会非常痛苦。我后来改成了按日期命名:apt_model_20240101.joblib,并且保留最近 30 天版本。模型回滚在安全系统里很重要,一旦某个新版误报率激增,必须能快速切回旧的稳定版本。

6. 部署落地与日常运营经验

6.1 模型上线前要做的事

先把一个安全检测系统部署上线,不只是把模型文件放到服务器上完事。我强烈建议先做两周的"影子模式"运行:模型在线跑,但告警只写入日志库,不推送给安全团队。这样做的目的是积累足够多的预测结果,用于估算真实误报率、验证阈值是否合理,同时也能发现数据源和特征计算环节的潜在问题。

影子模式的另一个好处是可以积累一批高质量的标注样本。这些样本来自模型召回、人工不介入的纯自然分布,标注出来的数据比上线后的人工复核样本更干净,可以用于后续重新训练精排模型。

6.2 告警运营闭环

模型只是检测环节,完整的闭环还包括告警处置和反馈回传。我建议每次安全分析师处理一条告警时,都记录一个"最终判定"字段:true_positive(真攻击)、false_positive(误报)、duplicate(重复告警)。这些反馈数据定期回流到训练集,重新训练随机森林精排模型,形成一个非常朴素但有效的反馈闭环。

没有反馈闭环的检测系统,用半年后效果会肉眼可见地变差。原因很简单:业务在变、IT 环境在变,静态模型永远追不上动态环境。有了反馈数据,模型才能持续学习"哪些新出现的行为其实是正常的"。

6.3 这套方案的适用边界

最后说点掏心窝子的话。这套基于机器学习的 APT 检测方案,更适合中小型企业和单一业务线的场景。它成本低、见效快、有可解释性,能覆盖大部分横向移动和数据外传场景。但如果你面对的是国家级攻击者,或者需要在一线城市的大型企业网络里做全流量检测,这套方案就不够用了——那种场景需要更复杂的图神经网络、深度行为建模和规模化实时计算平台,不是一两篇文章能讲完的。

从我个人经验看,机器学习在安全检测里的定位一直是"辅助人"而不是"替代人"。模型帮分析师生成候选、排序优先级、压缩攻击面,最后由人来做最终判定。这个定位放清楚,你设计系统的时候就不会纠结要不要上大模型、要不要做全自动化,而是会把精力放在怎么把特征做扎实、把反馈闭环跑通上。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询