简介:这是一套完整的基于客户端蜜罐与机器学习技术的风险网站检测系统项目工程,面向毕业设计、课程设计、工程实训及学科竞赛等场景,指导学生从零搭建并跑通全流程。系统前端为Vue打包成品(包含js、css、map等静态资源文件),后端采用Python实现(py文件与pyc编译文件为主),并附带配置说明、依赖锁定文件与运行脚本。资源共155个文件,压缩包63.8MB,目录结构清晰,便于快速定位源码、配置与说明文档;pickle数据文件用于模型持久化,日志文件与cvd文件则保留运行痕迹,辅助结果验证。已有45人学习下载,项目经严格测试运行通过,可实现复现复刻,设计报告亦可借鉴,适合希望快速上手完整风险网站检测方案的中高年级学生或开发者扩展更多功能。
1. 客户端蜜罐与机器学习:给风险网站做一次“入户检查”
一个陌生链接摆在面前,网址看不出毛病、页面也像模像样,你敢直接点吗?这就是我一直想解决的问题。这套“基于客户端蜜罐和机器学习的风险网站检测系统”给了一个反直觉的思路:别等攻击者来找你,你主动派一个“假访客”去访问它,把它干过的事全部记下来,再用机器学习给这个网站打分。它直接落在工程层面,前端有检测界面,后端有分析接口,模型训练脚本一应俱全。打比方说,这套系统像给风险网站做一次入户检查,适合安全方向的毕业设计、课程设计,也适合想在自己项目里快速接一个“网址体检”模块的开发者。
2. 为什么是客户端蜜罐:把“等攻击”换成“主动取证”
2.1 服务端蜜罐与客户端蜜罐的分工
做安全方向的人一听到蜜罐,第一反应是搭建一个公网服务,等扫描器或攻击者来碰撞,然后把攻击行为记录下来。这是服务端蜜罐,逻辑是“守株待兔”。它的问题也很明显:服务暴露在公网里,一旦被识别甚至反打,风险不小,对毕业设计来说维护成本也高。
客户端蜜罐的做法正好反过来。它在受控环境(虚拟机、容器或者一个独立探针进程)里扮演真实访客,主动去访问那些可疑 URL,观察网站的“反应动作”:是不是跳了好几次、有没有偷偷加载脚本、有没有尝试读浏览器的指纹、返回的页面里是不是嵌套了 iframe。整个过程是在可控环境里完成的,访问结束直接把容器销毁,恶意代码跑在笼子里,不会飞到你的真实主机上。
这两者最大的差别在于“取证方向”和“上手成本”。服务端蜜罐拿到的是攻击者的扫描轨迹,适合研究攻击手法;客户端蜜罐拿到的是恶意网站对访客的投毒路径,正好用来回答“这个网站能不能访问”这个问题。所以做风险网站检测,用客户端蜜罐是更直接也更安全的选择。这套项目的核心就是把这类探针数据沉淀下来,再用模型做判断。
2.2 特征工程:采集哪些信号、哪些是强特征
蜜罐访问一个目标 URL 时,绝对不只是抓一段 HTML 回来。我在接这种项目时,习惯把采集项分成五类:URL 结构、域名画像、访问行为、页面行为和下载行为。下面这张表基本就是复现时的采集维度:
| 类别 | 特征项 | 数据来源 | 风险指向 |
|---|---|---|---|
| URL 结构 | 长度、路径层级数、特殊字符密度、数字占比 | 解析目标 URL | 伪装长链接、混淆参数 |
| 域名画像 | 注册时长、Whois 变更次数、证书有效期、疑似动态 DNS | WHOIS 查询 / 证书信息 | 短期域名、仿冒域名 |
| 访问行为 | 重定向次数、最终落地页与初始域名一致性、响应耗时 | 无头浏览器日志 | 跳板链、JS 二次跳转 |
| 页面行为 | 外链脚本数、iframe 嵌套层数、是否检测浏览器指纹、是否出现加密流量 | 渲染后 DOM | 挖矿脚本、流量劫持 |
| 下载行为 | 文件哈希、文件类型、是否命中恶意签名 | 下载缓存 | 恶意载荷投递 |
你会发现,单看一个特征很难下结论。比如“域名注册时间短”只能说明网站比较新,不代表它是恶意的;“页面有脚本”更是到处都是。真正靠谱的是组合特征:一个域名注册不足 30 天,证书还是三个月短期证书,页面里又嵌着一个动态加载的 iframe,再配合三次 HTTP 跳转——这一串组合下来,正常网站很难解释。
特征提取得靠代码落下来。我一般会用一个独立的特征提取模块,把蜜罐原始日志转成模型能吃的向量,核心代码如下:
# feature_extractor.py import re from urllib.parse import urlparse def extract_url_features(url, page_state): features = {} parsed = urlparse(url) host = parsed.hostname or "" # URL 结构特征 features["url_len"] = len(url) features["url_path_depth"] = len([s for s in parsed.path.split("/") if s]) features["num_special_chars"] = len(re.findall(r"[@_\-=&\?\.]", url)) features["digit_ratio"] = sum(c.isdigit() for c in url) / max(len(url), 1) # 域名画像特征 features["domain_len"] = len(host) features["has_https"] = 1 if parsed.scheme == "https" else 0 # 访问行为特征 features["redirect_count"] = page_state.get("redirect_count", 0) features["final_same_domain"] = 1 if host in page_state.get("final_url", "") else 0 # 页面行为特征 features["script_count"] = page_state.get("script_count", 0) features["iframe_level"] = page_state.get("iframe_max_depth", 0) features["detect_fingerprint"] = page_state.get("detect_fingerprint", 0) return features这段代码的逻辑是把原始访问信息转成结构化特征。url_path_depth统计路径层级,钓鱼站喜欢把真正的页面藏在三四层路径之后;digit_ratio抓的是纯数字域名,这类域名在动态 DNS 和短链接服务里很常见;redirect_count和final_same_domain是一对配合特征,用来识别“跳了几次跳到一个完全不相关的域名”的典型跳板行为。
实际跑的时候,page_state不是凭空构造的,它来自无头浏览器的监听事件。采集重定向次数时要注意,很多风险网站用的是 JS 跳转,不只是 HTTP 302,所以必须在浏览器事件层监听will-navigate和framenavigated,否则统计会偏少。
2.3 模型选型:为什么随机森林在这个场景比深度学习更稳
这个项目的样本量级通常是几千到几万个 URL,特征维度在几十维左右,属于典型的结构化小样本分类问题。在这个场景里,随机森林和 LightGBM 这类集成树模型比 CNN、LSTM 更合适,原因很实际。
集成树模型对特征尺度不敏感,不需要做标准化,蜜罐特征里既有 0/1 标记,又有 URL 长度这种几百到几千的大数值,放一起训练完全没问题。深度学习模型则容易在小样本上加倍过拟合,而且需要反复调参,答辩现场出现“训练损失降了但测试指标很差”会很尴尬。
随机森林还自带特征重要性排序,这是它最大的一个“讲PPT”优势。你可以直接输出“哪些特征对判断风险贡献最大”,导师问起来也答得明白。前面表格里的redirect_count、final_same_domain、detect_fingerprint在项目里基本都是高重要度特征。
模型训练时还需要关注样本不平衡。恶意样本在网络里永远是少数,如果正常:恶意接近 10:1,直接训练出来的模型会倾向预测“正常”。我一般会给模型加class_weight,让少数类在损失计算里获得更高权重:
# train_rf.py from sklearn.ensemble import RandomForestClassifier import joblib X_train = ... y_train = ... model = RandomForestClassifier( n_estimators=300, max_depth=12, min_samples_split=5, min_samples_leaf=2, class_weight="balanced_subsample", n_jobs=-1, random_state=42, ) model.fit(X_train, y_train) joblib.dump(model, "risk_model.pkl")参数里n_estimators=300在几千样本量下足够,再往上增加对精度的提升很小,但模型文件会明显变大,后面接口加载会变慢;max_depth=12是控制单棵树深度的常用区间,太深容易过拟合训练集;class_weight="balanced_subsample"会对每个自助采样子集做类别均衡,比全局scale_pos_weight更细。这套配置在多数风险网站检测数据集上表现都比较稳。
3. 从源码到能跑通:前端构建、后端接口与模型训练
3.1 前端工程:Vue 构建产物与检测页面
资源包里能看到一批带 hash 的 CSS 和 JS 文件,比如chunk-vendors开头的第三方依赖产物和app开头的主应用产物,这是标准的 Vue CLI 构建输出。chunk-vendors是把 Vue、Vue Router、Element UI 这类不常变的第三方库单独打包,长缓存下浏览器可以少请求一次;app包才是业务代码。文件名里的 hash 是内容指纹,内容一变 hash 就会变,部署时不用担心浏览器缓存旧页面。
复现时前端有两种跑法。如果包里有完整的源码目录,就重新构建;如果只有dist目录,直接部署就可以。我一般推荐重新构建,因为你自己改检测规则后,前端提示文案也要跟着变。
cd frontend # 安装依赖,建议用固定版本,防止与构建时的依赖大版本不一致 npm install # 构建生产包 npm run build执行npm run build后,产物会输出到dist目录。这里有一个关键配置:检查项目根目录的vue.config.js,确认publicPath是'./'还是'/'。
// vue.config.js module.exports = { publicPath: "./", outputDir: "dist", productionSourceMap: false, };publicPath决定静态资源引用前缀。如果部署在域名根目录,'/'没问题;一旦放到子路径(比如http://某个地址/web/),'/'开头的绝对路径就会全部 404。改成'./'后,资源路径变成相对路径,部署到哪里都能用。这是很多初学者最容易忽略的一行配置。
3.2 后端 API 与蜜罐取证服务
后端部分我建议拆成三个接口:提交检测任务、查询任务状态、获取检测结果。这样好处是接口职责单一,蜜罐访问可能需要几秒到几十秒,让前端轮询状态比同步等待要合理得多。
# app.py from flask import Flask, request, jsonify app = Flask(__name__) @app.post("/api/risk/submit") def submit_task(): data = request.get_json() url = data.get("url", "").strip() if not url.startswith(("http://", "https://")): return jsonify({"code": 400, "msg": "仅支持 http/https 协议"}), 400 task_id = enqueue_detect(url) return jsonify({"code": 0, "task_id": task_id}) @app.get("/api/risk/result/<task_id>") def get_result(task_id): result = query_result(task_id) if result is None: return jsonify({"code": 1, "msg": "任务未完成"}), 202 return jsonify({"code": 0, "data": result})入参校验这里做了一层协议白名单,只允许 HTTP 和 HTTPS,避免传入file://或javascript://这样的非网络协议。enqueue_detect是任务队列入口,生产环境我一般用 Celery + Redis,毕设场景可以简化为一个线程池。任务落库时要给每条 URL 一个唯一task_id,前端轮询就是拿这个 ID 查状态。
查询接口返回的数据结构里,至少包含三块:风险概率、命中规则列表、蜜罐证据。证据是判断“为什么给这个分数”的材料,通常以 JSON 数组形式保存当时的重定向链和脚本事件,答辩时很好用。
3.3 模型训练一跑:数据切分、标签与回看逻辑
训练模型之前,先聊一个在安全领域特别重要的点:数据切分方式。很多同学拿到公开样本集后直接train_test_split,随机打乱再切,导致训练集和验证集里出现了属于同一时间段甚至同源攻击的样本。看起来验证 AUC 有 0.98,一上线就会发现完全不是那么回事。
我惯用的做法是按时间切分。蜜罐是持续采集数据的,取前 7 天的样本做训练集,后 3 天的样本做验证集。这样模拟的是“用历史识别未来”,不会出现时间穿越。落成代码如下:
# train_script.py import pandas as pd from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report import joblib df = pd.read_csv("honeypot_samples.csv", parse_dates=["visit_time"]) df = df.sort_values("visit_time") split_day = df["visit_time"].max() - pd.Timedelta(days=3) train_df = df[df["visit_time"] <= split_day] valid_df = df[df["visit_time"] > split_day] feature_cols = [ "url_len", "url_path_depth", "num_special_chars", "digit_ratio", "domain_len", "has_https", "redirect_count", "final_same_domain", "script_count", "iframe_level", "detect_fingerprint", ] model = RandomForestClassifier( n_estimators=300, max_depth=12, min_samples_split=5, class_weight="balanced_subsample", n_jobs=-1, random_state=42, ) model.fit(train_df[feature_cols], train_df["label"]) print(classification_report(valid_df["label"], model.predict(valid_df[feature_cols]))) joblib.dump(model, "risk_model.pkl")这段代码的逻辑是“先排序,再切分,后训练”。排序必须按采样时间字段做,visit_time在真实项目里对应蜜罐完成访问的时刻。如果数据集没有时间列,那就说明数据准备阶段少了关键元数据,需要回到采集环节补上。classification_report打印的是精确率、召回率和 F1,重点看风险类的召回率,漏放风险网站比误杀正常网站更危险。
训练完模型,还要在推理时加一层规则兜底。机器学习模型的风险概率是连续的,比如 0.73,但有些场景只凭概率不够。我一般会保留一组硬规则:域名注册时间小于 15 天且证书有效期小于 90 天,直接标记高风险;页面检测到浏览器指纹且重定向次数大于 3,直接标记高风险。这些规则不需要模型参与,逻辑写在推理入口的前面即可。
3.4 兜底查毒:用 daily.cvd 做本地病毒库比对
模型负责看不熟悉的“新手法”,但有的风险网站不玩页面脚本,它只是安静地给你一个文件下载。这种情况下蜜罐会记录下载行为,把文件拖进隔离区,然后走一遍本地病毒库查毒。
资源包里的daily.cvd是 ClamAV 的增量签名库文件,这类文件记录了最近一段时间新增的恶意软件签名。项目把它直接放到检测服务目录里,推理模块每次做一次增量更新,再查下载文件。更新和扫描的常用命令如下:
# 更新病毒库到指定目录 freshclam --datadir=./clamav # 扫描蜜罐下载的文件 clamscan --database=./clamav/daily.cvd /tmp/honeypot_downloads/sample.binfreshclam是 ClamAV 的更新工具,--datadir指定签名库存放目录;clamscan是扫描命令,--database可以单独指定用哪个签名库文件。日常维护中,我只对蜜罐下载目录做实时扫描,不对整个磁盘扫描,因为蜜罐下载目录的流量完全可控,又不会拖慢宿主机。
4. 避坑记录:让项目翻车的五个高频问题
4.1 动态页面抓不到真身
现象:用 requests 库抓回来的页面,一段脚本都没看到,模型分数全是低风险,但同一个 URL 用浏览器打开后明显有恶意跳转。
原因:很多风险网站在初期返回一个空白骨架,真正的内容和跳转全部由 JavaScript 在浏览器加载后执行。requests 只拿到 HTML 初稿,根本不会执行 JS,等于入户检查只敲了一下门就走了。
解决:把访问模块从 requests 换成 Playwright 或 Puppeteer,用无头浏览器渲染页面。等待策略设成networkidle,确保页面网络请求基本结束再取 DOM。采集脚本事件时不要只看最终 HTML,要监听console、request、framenavigated三类事件,拼出完整访问链。
4.2 时间泄露导致训练指标虚高
现象:训练集和验证集随机切分,模型验证 AUC 0.98,上线后面对新域名立刻打回原形。
原因:随机切分把同一时间段的攻击样本同时分进了训练集和验证集,模型相当于“记住了”这批样本的分布。攻击手法随时间快速变化,你的模型并没有真正学会泛化。
解决:强制按时间切分数据。前 2/3 时间做训练,后 1/3 时间做验证。同时检查数据里是否存在完全重复的 URL,两条相同记录很可能一条在训练集一条在验证集,造成无意义的指标虚高。
4.3 蜜罐并发太高被源站封 IP
现象:同时提交 50 个待检测 URL,蜜罐模块密集访问同域名,结果大量任务返回 403 Forbidden,采集到的特征全是错误页,模型只能给低风险分。
原因:源站有反爬策略,单位时间内同 IP 请求次数超过阈值就会触发拦截。蜜罐如果做成并发无节制的“轰炸机”,反而拿不到真实页面。
解决:在蜜罐模块里加全局限流。同一域名下同时最多一个访问任务,不同域名之间并发数控制在 5 以下。每次访问前随机生成 User-Agent,并打乱访问时间,模拟真实用户浏览节奏。更保险的做法是接一个 HTTP 代理池,每个访问任务换出口 IP,但毕设场景里限流已经能解决大部分问题。
4.4 模型文件过大拖垮接口
现象:模型训练完,启动后端服务加载risk_model.pkl要七八秒,第一次请求响应时间 10 秒起步,前端直接超时。
原因:随机森林默认n_estimators=1000,训练完每棵树上百层,模型文件几百 MB。接口每次启动都要把整个模型反序列化进内存,慢是必然的。
解决:控制树的数量和深度。n_estimators降到 300 以内,max_depth限制在 12~16 层,模型文件能缩小到几十 MB,精度几乎不掉。还可以把推理接口做成常驻进程,启动时只加载一次模型,后续请求直接走内存推理,不要每次请求都重新加载。
4.5 前端构建后资源路径全部 404
现象:npm run build成功,把dist里文件部署到服务器的/webdetect/目录,打开页面后 CSS 加载不出来,控制台全是404。
原因:vue.config.js里的publicPath配置成了'/',构建后所有资源引用指向域名根路径,而项目实际部署在子目录里。
解决:把publicPath改为'./'重新构建。改完之后引用路径变成相对路径,无论部署在根目录还是子目录,都能正常加载。
5. 进阶验证与模型迭代:让系统越用越准
复现并跑通只是第一步,真正让这个系统在答辩或实际使用中有说服力的,是你能否展示一套“持续验证”的方法。我建议在项目里留一个定期回测脚本,把每周蜜罐采集的新样本累积起来,对上一版模型做一次滚动验证。
# eval_latest.py import joblib import pandas as pd from sklearn.metrics import roc_auc_score, recall_score model_name = "risk_model.pkl" latest_df = pd.read_csv("latest_week_samples.csv") model = joblib.load(model_name) probs = model.predict_proba(latest_df[feature_cols])[:, 1] preds = (probs >= 0.5).astype(int) print("AUC:", roc_auc_score(latest_df["label"], probs)) print("Risk Recall:", recall_score(latest_df["label"], preds))把上一周蜜罐新采集的样本当成“未来数据”,用旧的模型去预测,得出真实的泛化指标。如果风险召回率掉到 0.7 以下,说明攻击手法又变了,需要把新样本并入训练集重训模型。我会把这个脚本挂成定时任务,每周自动跑一次,指标下降就触发重训。
做对抗实验时,我喜欢手动构造几类绕过样本:把正常页面加长 URL、把恶意脚本做混淆、把短域名换成长期域名。目的是看模型有没有过拟合“某个道具”,而不是真的理解行为模式。这类对抗样本还能作为后续训练集的增量。
最后一步是把证据存进结果回调里,不单单输出风险分。这样前端展示检测结果时,可以显示“命中规则:重定向次数过高、页面指纹检测”,让使用的人知道这个结论是怎么来的。我从那以后每次换数据集或者调模型参数,都会强制走一遍“时间切分 + 召回率检查 + 对抗样本回放”这三步,少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取