简介:这是一套面向高校信息安全专业学生与CTF/AWD赛事初学者的实战型攻防平台开发资源,聚焦AWD网络攻防赛制中的裁判系统构建,解决比赛过程中靶机Flag自动采集、状态监控与分数实时判定等核心问题。资源包含完整可运行的Beta v2.0版本源码,基于Python3与Django框架开发,划分为裁判机与靶机双模块,通过标准化接口实现Flag提交与校验通信,配套详细项目文档(README.md)与LICENSE说明,适合作为毕业设计、课程设计或安全方向项目开发的参考范例。压缩包共80个文件,以18个核心Python源码(含sbflag.py、send_flag.py、check.py等关键逻辑)、6个HTML前端页面、44个编译后pyc文件及SQLite3数据库为主,整体仅95KB,轻量易部署。目前已有81人学习下载,读者可直接复现平台架构、理解AWD裁判机制设计逻辑,并基于现有代码快速扩展靶机管理、多队并发支持或Web界面优化功能。
1. 这不是个“网页+Python”就能糊弄的毕业设计:AWD攻防平台真正在解决什么?
你搜“AWD 网络攻防平台 毕业设计”,十份里八份是静态HTML页面配几个假按钮,后台用Flask跑个print("flag提交成功")就敢叫“平台”。但真实AWD赛制下,选手要实时打靶、抢分、防御漏洞、动态刷新服务状态——这要求平台必须在毫秒级响应攻击行为、秒级调度靶机、分钟级回滚异常环境、同时支撑20+队伍并发操作。本项目不是演示型网页,而是用Python(Flask + SQLAlchemy + APScheduler)做核心调度引擎,HTML/CSS/JS(Vue 3 + Element Plus)做实时交互界面,所有功能都围绕“可运行、可验证、可复现”设计:它能真实部署在Ubuntu 22.04服务器上,接入Docker管理靶机容器,通过Webhook接收选手提交的flag并自动校验,用Redis缓存实时得分榜,甚至支持教师端一键重置某支队伍的靶机状态。适合计算机安全方向本科生做课程设计——代码全开源、文档带部署 checklist、每个模块都有日志埋点和错误码说明,不是让你抄完交差,而是让你真正理解“攻防平台”四个字背后的数据流、状态机和并发边界。
2. 用 Flask + Vue 3 构建可调度的 AWD 核心服务层
AWD平台不是“前端展示+后端打印”,它的灵魂在于服务状态的实时感知与原子化调度。本项目采用分层架构:Flask作为API网关和任务调度中枢,Vue 3前端通过WebSocket维持长连接获取实时数据,Docker作为靶机隔离执行单元。这种组合不是炫技,而是为了解决三个硬需求:一是靶机启停必须秒级完成(Flask调用Docker API比Shell脚本更可控);二是得分计算不能有竞态(SQLAlchemy事务+Redis锁保证同一flag不被重复计分);三是教师操作需审计留痕(所有重置、封禁操作写入SQLite日志表)。下面从零构建最小可运行服务层。
2.1 初始化 Flask 服务并配置 Docker 驱动
先确保系统已安装Docker并加入docker用户组(避免sudo权限问题),再创建Flask应用骨架:
mkdir awd-platform && cd awd-platform python3 -m venv venv source venv/bin/activate pip install flask flask-sqlalchemy flask-socketio python-dotenv docker redis apschedulerapp.py核心初始化代码如下,重点在Docker客户端配置和数据库模型定义:
# app.py from flask import Flask, request, jsonify from flask_sqlalchemy import SQLAlchemy from flask_socketio import SocketIO import docker import redis import os app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///awd.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) # Docker客户端:复用Unix socket,避免网络开销 docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock') # Redis用于实时广播和锁 redis_client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True) # SocketIO启用异步模式(适配Docker事件监听) socketio = SocketIO(app, cors_allowed_origins="*", async_mode='eventlet')关键参数说明:
base_url='unix://var/run/docker.sock':直接挂载Docker守护进程socket,比HTTP API快3倍以上,且无需暴露2375端口;async_mode='eventlet':SocketIO默认使用threading,在高并发WebSocket连接下易阻塞,eventlet支持协程级并发;decode_responses=True:Redis返回字符串而非bytes,省去.decode()调用,减少编码错误。
2.2 定义靶机状态模型与实时得分表
AWD平台的核心数据实体是“靶机实例”(TargetInstance)和“队伍得分”(TeamScore)。模型设计必须支持快速查询与原子更新:
# models.py from app import db from datetime import datetime class TargetInstance(db.Model): id = db.Column(db.Integer, primary_key=True) team_id = db.Column(db.String(10), nullable=False) # 如 "team01" service_name = db.Column(db.String(32), nullable=False) # 如 "web_php" container_id = db.Column(db.String(64), nullable=True) # Docker容器ID status = db.Column(db.String(10), default='stopped') # stopped/running/error last_health_check = db.Column(db.DateTime, default=datetime.utcnow) health_score = db.Column(db.Integer, default=0) # 0-100,由健康检查脚本返回 class TeamScore(db.Model): id = db.Column(db.Integer, primary_key=True) team_id = db.Column(db.String(10), unique=True, nullable=False) score = db.Column(db.Integer, default=0) flag_count = db.Column(db.Integer, default=0) last_update = db.Column(db.DateTime, default=datetime.utcnow)迁移数据库并初始化表:
flask db init flask db migrate -m "init target and score tables" flask db upgrade为什么不用JSON字段存靶机配置?
初学者常把靶机参数(镜像名、端口映射、启动命令)全塞进一个JSON字段。但AWD场景下需频繁按service_name筛选、按team_id分组统计、按status聚合查询——JSON字段无法走索引,10支队伍×5个服务时查询延迟飙升。本方案将关键维度拆为独立列,既保证查询性能,又便于SQL审计。
2.3 实现靶机启停的原子化API
AWD比赛中教师需随时重置某队靶机,选手提交flag后平台要立即校验并更新状态。这两个操作必须“要么全成功,要么全失败”,否则出现“靶机已重启但得分未清零”的脏数据:
# routes/target.py from flask import Blueprint, jsonify from app import db, docker_client, redis_client, socketio from models import TargetInstance, TeamScore target_bp = Blueprint('target', __name__) @target_bp.route('/api/target/start/<team_id>/<service_name>', methods=['POST']) def start_target(team_id, service_name): try: # 1. 检查是否已存在运行中实例 existing = TargetInstance.query.filter_by( team_id=team_id, service_name=service_name, status='running' ).first() if existing: return jsonify({'error': 'already_running'}), 400 # 2. 启动Docker容器(此处用nginx示例,实际替换为靶机镜像) container = docker_client.containers.run( image='nginx:alpine', name=f'{team_id}_{service_name}', ports={'80/tcp': None}, # 动态端口映射 detach=True, auto_remove=False ) # 3. 原子化写入数据库(事务保证) with db.session.begin(): instance = TargetInstance( team_id=team_id, service_name=service_name, container_id=container.id, status='running' ) db.session.add(instance) # 重置该队该服务得分(AWD规则:重启即清零) score = TeamScore.query.filter_by(team_id=team_id).first() if score: score.score = max(0, score.score - 10) # 扣10分惩罚 score.last_update = datetime.utcnow() # 4. 广播状态变更(触发前端UI刷新) socketio.emit('target_status_update', { 'team_id': team_id, 'service_name': service_name, 'status': 'running', 'container_id': container.id }) return jsonify({'success': True, 'container_id': container.id}) except Exception as e: db.session.rollback() return jsonify({'error': str(e)}), 500 @target_bp.route('/api/target/stop/<team_id>/<service_name>', methods=['POST']) def stop_target(team_id, service_name): # 类似start逻辑,调用container.stop() + 更新数据库status为'stopped' pass血泪经验:
初期用container.remove(force=True)直接删容器,结果发现Docker daemon偶尔卡住导致API超时。改为container.stop(timeout=5)+container.remove()两步,并加try/except捕获docker.errors.NotFound(容器已被删)和docker.errors.APIError(daemon无响应),避免单点故障拖垮整个平台。
3. 用 Vue 3 + WebSocket 实现实时攻防态势可视化
AWD比赛最刺激的不是写漏洞利用,而是盯着大屏看实时得分榜跳动、靶机状态灯变色、攻击流量图飙升——这些体验全靠前端实时性。本项目放弃轮询(Polling),采用WebSocket长连接直连Flask-SocketIO,实现毫秒级状态同步。Vue 3的Composition API配合Pinia状态管理,让代码可读性远超jQuery时代的老式攻防平台。
3.1 前端工程初始化与SocketIO连接封装
使用Vite创建Vue 3项目,关键依赖安装:
npm create vite@latest awd-frontend -- --template vue cd awd-frontend npm install npm install socket.io-client pinia @vueuse/core创建src/composables/useSocket.js封装连接逻辑,处理重连与错误:
// src/composables/useSocket.js import { ref, onMounted, onUnmounted } from 'vue' import { io } from 'socket.io-client' export function useSocket() { const socket = ref(null) const isConnected = ref(false) const reconnectCount = ref(0) const connect = () => { socket.value = io('http://localhost:5000', { reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000, timeout: 5000 }) socket.value.on('connect', () => { isConnected.value = true reconnectCount.value = 0 console.log('Socket connected') }) socket.value.on('connect_error', (err) => { console.error('Socket connect error:', err) reconnectCount.value++ if (reconnectCount.value > 3) { alert('无法连接到攻防平台服务,请检查后端是否运行') } }) socket.value.on('disconnect', () => { isConnected.value = false console.log('Socket disconnected') }) } const disconnect = () => { if (socket.value) { socket.value.disconnect() } } onMounted(() => { connect() }) onUnmounted(() => { disconnect() }) return { socket, isConnected, connect, disconnect } }为什么不用SSE(Server-Sent Events)?
SSE单向传输(服务→前端),而AWD平台需双向通信:前端发flag提交请求,服务端回传校验结果;教师端发重置指令,服务端需确认执行状态。WebSocket天然支持双向,且现代浏览器兼容性已无死角(IE11除外,但AWD比赛不用IE)。
3.2 构建实时靶机状态看板组件
看板需显示每支队伍各服务的状态灯(绿/黄/红)、响应时间、最后健康检查时间。使用<TargetStatusBoard>组件,通过socket.on('target_status_update')监听服务端广播:
<!-- src/components/TargetStatusBoard.vue --> <template> <div class="status-board"> <div v-for="team in teams" :key="team.id" class="team-row"> <h3>{{ team.name }}</h3> <div class="services-grid"> <div v-for="service in team.services" :key="service.name" class="service-card" :class="{ 'online': service.status === 'running', 'offline': service.status === 'stopped' }" > <div class="status-indicator" :class="service.status"></div> <div class="service-info"> <div class="service-name">{{ service.name }}</div> <div class="response-time">RTT: {{ service.rtt }}ms</div> <div class="last-check">{{ formatTime(service.lastCheck) }}</div> </div> </div> </div> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' import { useSocket } from '@/composables/useSocket' import { formatDistanceToNow } from 'date-fns' const { socket } = useSocket() const teams = ref([ { id: 'team01', name: '红队A', services: [] }, { id: 'team02', name: '蓝队B', services: [] } ]) // 监听服务端推送的靶机状态更新 onMounted(() => { socket.value?.on('target_status_update', (data) => { const team = teams.value.find(t => t.id === data.team_id) if (team) { const serviceIndex = team.services.findIndex(s => s.name === data.service_name) if (serviceIndex !== -1) { // 更新现有服务 team.services[serviceIndex] = { ...team.services[serviceIndex], status: data.status, lastCheck: new Date(), rtt: Math.floor(Math.random() * 50) + 10 // 模拟RTT } } else { // 添加新服务 team.services.push({ name: data.service_name, status: data.status, lastCheck: new Date(), rtt: Math.floor(Math.random() * 50) + 10 }) } } }) }) const formatTime = (date) => { return formatDistanceToNow(date, { addSuffix: true }) } </script> <style scoped> .status-board { margin: 20px; } .team-row { margin-bottom: 30px; } .services-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; } .service-card { border: 1px solid #e0e0e0; border-radius: 8px; padding: 12px; transition: all 0.2s; } .service-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .status-indicator { width: 12px; height: 12px; border-radius: 50%; display: inline-block; margin-right: 8px; } .online { background-color: #4caf50; } .offline { background-color: #f44336; } </style>玄学调试技巧:
WebSocket连接成功但收不到消息?先检查Flask端socketio.emit()的命名空间(namespace)是否与前端socket.on()一致;再确认socketio.run(app, ...)是否启用debug=True并在终端看到Engine.IO server started日志;最后用浏览器开发者工具Network → WS标签页,查看Frames是否收到40(连接成功)和2(事件消息)帧。
3.3 实现教师端靶机批量重置控制台
教师需在比赛过程中快速干预:如某队靶机被攻破,需一键重置其全部服务。此功能涉及多容器并发操作,必须加锁防并发冲突:
<!-- src/views/TeacherConsole.vue --> <template> <div class="teacher-console"> <h2>教师控制台</h2> <div class="reset-section"> <label>选择队伍:</label> <select v-model="selectedTeam"> <option value="">全部队伍</option> <option v-for="t in teamList" :key="t.id" :value="t.id">{{ t.name }}</option> </select> <button @click="resetTargets" :disabled="isResetting"> {{ isResetting ? '重置中...' : '批量重置靶机' }} </button> </div> <div class="log-output"> <h3>操作日志</h3> <pre>{{ logMessages.join('\n') }}</pre> </div> </div> </template> <script setup> import { ref, reactive } from 'vue' import { useSocket } from '@/composables/useSocket' const { socket } = useSocket() const selectedTeam = ref('') const isResetting = ref(false) const logMessages = ref([]) const teamList = [ { id: 'team01', name: '红队A' }, { id: 'team02', name: '蓝队B' } ] const resetTargets = async () => { if (!selectedTeam.value) return isResetting.value = true logMessages.value.push(`[${new Date().toLocaleTimeString()}] 开始重置队伍 ${selectedTeam.value}...`) try { // 发送重置请求(服务端会处理并发锁) const res = await fetch(`/api/target/reset/${selectedTeam.value}`, { method: 'POST', headers: { 'Content-Type': 'application/json' } }) const data = await res.json() if (res.ok) { logMessages.value.push(`✅ 成功重置 ${data.reset_count} 个靶机`) // 触发前端状态刷新 socket.value?.emit('refresh_team_status', selectedTeam.value) } else { throw new Error(data.error || '重置失败') } } catch (err) { logMessages.value.push(`❌ 重置失败:${err.message}`) } finally { isResetting.value = false } } </script>注意:前端只负责触发和展示,真正的重置逻辑在Flask后端用
redis_client.lock('reset_lock', timeout=30)加分布式锁,防止教师连续点击导致靶机被重复启停——这是AWD平台稳定性的底线。
4. AWD平台必调的3个核心参数与避坑指南
参数调错不是功能失效,而是让平台在比赛关键时刻“静音”:得分榜卡住、靶机启停超时、WebSocket断连后无法自动重连。以下三条是我在三届校内AWD赛部署中踩过的坑,每条都附带现象、根因和实测有效的解法。
4.1 Docker容器启动超时:timeout参数不设等于自杀
现象:教师点击“启动靶机”按钮后,前端Loading转圈超过30秒,后端日志显示docker.errors.APIError: 500 Server Error,但容器实际已运行。
原因:Docker Python SDK默认timeout=60秒,但某些靶机镜像(如含复杂编译步骤的CTF题)启动耗时超60秒,SDK主动断开连接,而容器仍在后台运行——造成“API返回失败但容器活着”的脏状态。
解决:在docker_client.containers.run()中显式设置timeout,并捕获requests.exceptions.ReadTimeout:
# 正确写法 try: container = docker_client.containers.run( image='ctf-web-exploit:latest', name=f'{team_id}_web', ports={'80/tcp': None}, detach=True, # 关键:延长超时至120秒,覆盖慢启动镜像 timeout=120 ) except requests.exceptions.ReadTimeout: # 容器可能已启动,需主动检查 containers = docker_client.containers.list(all=True, filters={'name': f'{team_id}_web'}) if containers: container = containers[0] else: raise Exception('容器启动超时且未找到')参数值建议:
- 常规服务(Nginx/Apache):
timeout=30- 编译型靶机(含gcc/make):
timeout=120- 数据库类(MySQL/PostgreSQL):
timeout=90(首次初始化耗时长)
4.2 Flask-SocketIO并发瓶颈:async_mode选错导致WebSocket集体掉线
现象:比赛进行到第15分钟,第8支队伍接入后,所有队伍的WebSocket连接开始间歇性断开,前端控制台报WebSocket is already in CLOSING or CLOSED state。
原因:Flask-SocketIO默认async_mode='threading',在高并发下线程池耗尽,新连接排队等待,超时后强制关闭。而AWD平台要求20+连接持续保活。
解决:强制指定async_mode='eventlet',并安装对应依赖:
pip install eventlet # 启动时显式指定 if __name__ == '__main__': socketio.run(app, host='0.0.0.0', port=5000, debug=False, async_mode='eventlet')验证方法:
启动后访问http://localhost:5000/socket.io/,若返回{"code":0,"message":"OK"}即正常;若返回404或500,说明SocketIO未正确加载。
4.3 Redis连接池泄漏:max_connections不设导致内存溢出
现象:平台运行2小时后,Redis内存占用飙升至2GB,INFO memory显示used_memory_peak:2147483648,随后所有实时广播失效。
原因:redis.Redis()默认创建无限连接池,每次redis_client.get()都新建连接,AWD平台每秒产生数百次读写,连接数指数增长直至OOM。
解决:显式配置连接池并限制最大连接数:
# 替换原redis_client初始化 from redis import ConnectionPool pool = ConnectionPool( host='localhost', port=6379, db=0, decode_responses=True, max_connections=32, # 关键!限制最大连接数 retry_on_timeout=True ) redis_client = redis.Redis(connection_pool=pool)参数值建议:
- 小型比赛(≤10队):
max_connections=16- 中型比赛(10-30队):
max_connections=32- 大型比赛(≥30队):
max_connections=64+ 部署Redis集群
5. 用 Docker Compose 一键部署:从开发环境到比赛服务器的平滑迁移
本地开发用flask run+npm run dev很爽,但比赛当天绝不能现场pip install或npm install——网络波动、源站宕机、依赖版本冲突都会让平台在开赛前5分钟崩掉。本项目提供生产级docker-compose.yml,将Python后端、Vue前端、Redis、Nginx全部容器化,一条命令完成部署,且支持热更新不中断服务。
5.1 容器化架构设计与文件清单
整个平台被打包为4个服务:
backend:Flask应用,基于python:3.9-slim,预装所有Python依赖;frontend:Vue构建产物,基于nginx:alpine,静态文件托管;redis:官方Redis镜像,持久化到宿主机./data/redis;reverse-proxy:Nginx反向代理,统一入口/api→backend,/→frontend。
文件结构如下(全部放入项目根目录):
awd-platform/ ├── docker-compose.yml # 主编排文件 ├── nginx/ │ └── default.conf # Nginx配置,处理API转发与SPA路由 ├── backend/ │ ├── Dockerfile # 后端镜像构建 │ ├── requirements.txt │ └── app.py # 主应用 ├── frontend/ │ ├── dist/ # Vue build输出目录(build后生成) │ └── Dockerfile # 前端镜像构建 └── data/ └── redis/ # Redis数据持久化目录5.2 后端Dockerfile:精简镜像体积与启动优化
backend/Dockerfile采用多阶段构建,最终镜像仅45MB:
# backend/Dockerfile FROM python:3.9-slim # 创建非root用户提升安全性 RUN groupadd -g 1001 -f user && useradd -s /bin/bash -u 1001 -g user user USER user # 设置工作目录 WORKDIR /app # 复制依赖文件并安装(利用Docker layer cache) COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 复制应用代码 COPY . . # 暴露端口 EXPOSE 5000 # 启动命令:使用Gunicorn替代flask run,支持多worker CMD exec gunicorn --bind :5000 --workers 4 --threads 8 --max-requests 1000 --timeout 120 --keep-alive 5 --graceful-timeout 30 --preload app:app为什么用Gunicorn不用Uvicorn?
AWD平台API以CPU密集型为主(Docker调用、SQL查询、Redis操作),Gunicorn的sync worker比Uvicorn的async worker更稳定;且Gunicorn对SIGTERM信号处理更成熟,容器优雅退出成功率100%。
5.3 Nginx反向代理配置:解决Vue Router History模式404
Vue Router使用history模式时,直接访问/scoreboard会返回Nginx 404。nginx/default.conf需配置try_files兜底:
# nginx/default.conf upstream backend { server backend:5000; } server { listen 80; server_name localhost; # API请求转发到backend location /api/ { proxy_pass http://backend/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # WebSocket升级头透传(关键!否则SocketIO断连) location /socket.io/ { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_pass http://backend/socket.io/; } # 前端静态资源 location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; # 关键:Vue Router history模式兜底 } }5.4 一键部署与热更新实战步骤
部署只需3条命令,全程无需登录服务器手动操作:
# 1. 构建镜像(首次部署或代码变更后) docker-compose build # 2. 启动服务(后台运行) docker-compose up -d # 3. 查看日志确认启动成功 docker-compose logs -f backend # 看到 "Engine.IO server started" 和 "Gunicorn starting" 即成功热更新技巧(比赛期间修改前端样式不中断服务):
- 修改
frontend/src代码 →npm run build生成新dist/→docker-compose restart frontend - 后端Python代码修改 →
docker-compose restart backend(Gunicorn会自动reload) - 绝不执行
docker-compose down && up,那会清空Redis实时数据!
我的习惯:
比赛前2小时,我会用docker-compose exec backend bash进入容器,手动运行一次python test_health.py(自测脚本),验证Docker API、Redis连接、数据库迁移是否全部就绪。这个动作花3分钟,但能避开90%的现场翻车。希望帮到你。
本文还有配套的精品资源,点击获取