简介:基于Python-Django构建的多功能Web安全渗透测试工具,集成漏洞检测、目录识别、端口扫描、指纹识别、域名探测、旁站探测与信息泄露检测等能力,形成从资产收集、信息收集到风险分析、漏洞验证的完整评估链路,适合安全测试人员、Web开发者及Django学习者用于安全研究与项目实战。资源包共2000个文件,以275个Python源码文件为核心,附带1325个SVG图标、71个CSS与67个JS前端资源、88个Log日志、43个PNG图片及34个HTML页面,另含8个XML、8个MD、4个JSON等配置与说明文档,整体约19.61MB。已有486人学习下载。压缩包结构清晰,除可直接运行的Django项目源码外,还配有使用说明,能帮助读者理解多模块Web工具的设计思路,掌握渗透测试流程中各环节的实现方法,并可作为二次开发的基础框架。
1. 从靶场到实战:Django为什么能当渗透测试工具的骨架
渗透测试工具见得多了,大多是单文件脚本:扫码跑完输出一个JSON就结束。可一旦要同时管理多个目标、多类漏洞、多轮复测,脚本的短板就暴露了——没有持久化,没有任务队列,更没人给你做Web界面。这个基于Python-Django的多功能Web安全渗透测试工具,把扫描引擎、漏洞管理、任务调度和报表展示收进同一个Django工程,用ORM当数据库层,用App当模块边界,扫描结果直接落到MySQL里。白帽子做复测要的是能留痕、能出报告、能反复对比的载体,这正是它解决的问题。适合需要快速搭内部安全测试平台、又不想从零手写管理前端的团队参考,也适合研究Django工程结构划分的人读源码。
2. 模块化拆解:用Django App隔离扫描引擎与数据模型
2.1 按功能边界拆分App,而不是按代码量
刚接触这个项目时,最值得看的是它对Django工程的分层方式。整个项目没有把所有功能塞进一个views.py,而是按扫描类型拆分App——subdomain、portscan、sqli、xss、task各占一个目录,每个App里放着与它相关的models、views,以及独立的检测逻辑模块detector.py。这样做的好处是新增一种扫描类型时不动旧代码,加一个新App即可;单个App处理逻辑出错也不影响其他模块加载。
在创建新模块时,先执行python manage.py startapp sec_sqli这样的命令生成骨架,再把检测函数按「输入目标、输出结果」的接口方式写进detector.py,最后在admin.py注册模型即可出现在后台。整个工程不用改别人的文件,依赖边界是从目录结构上物理保证的。
我一般会特别注意App之间的依赖方向。这里我比较认同的规范是:数据模型可以跨App引用,但检测逻辑不要互相调用。比如portscan会读取subdomain解析出来的host列表,但portscan不直接调用subdomain的解析函数,而是通过共享的task记录拿到目标。这样后续把扫描模块抽成Celery worker时,不需要重构依赖。
2.2 核心Model设计:Task、Vuln、ScanLog
看这个项目的models.py,核心数据表设计是三层:Task(扫描任务)、Vuln(漏洞记录)、ScanLog(扫描日志)。一个任务对应多条漏洞记录和日志,任务删掉之后级联清理,不留孤儿数据。
from django.db import models class Task(models.Model): target = models.CharField(max_length=255, db_index=True) scan_type = models.CharField(max_length=50) status = models.CharField(max_length=20, default='pending') created_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(null=True, blank=True) finished_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ['-created_at'] class Vuln(models.Model): task = models.ForeignKey(Task, related_name='vulns', on_delete=models.CASCADE) vuln_name = models.CharField(max_length=100) url = models.URLField(max_length=500) param = models.CharField(max_length=100, blank=True) severity = models.CharField(max_length=10) details = models.TextField(blank=True) discovered_at = models.DateTimeField(auto_now_add=True) class ScanLog(models.Model): task = models.ForeignKey(Task, related_name='logs', on_delete=models.CASCADE) module = models.CharField(max_length=50) message = models.TextField() level = models.CharField(max_length=10, default='INFO') created_at = models.DateTimeField(auto_now_add=True)这段设计的核心是外键关联的读写路径:Vuln通过Task外键归属扫描任务,ScanLog记录过程信息。db_index加在target上,是因为按目标查询历史任务是最频繁的操作,没有索引的话数据量上来之后会走全表扫描。severity字段用字符串而不是整数,是因为漏洞评级在展示层要映射颜色和排序,字符串枚举比int更直观;它的取值不限于high/medium/low,子域名这类信息类结果用info标记,报表期单独折叠展示。
另一个要留意的点是on_delete=models.CASCADE的语义:删任务时连带删漏洞和日志,避免数据库残留孤儿数据。如果之后要做审计归档,可以把CASCADE改成SET_NULL并保留task_id字段,这取决于你的数据保留策略。
2.3 扫描参数的集中管理
扫描参数散落在代码里是这类项目最常见的痛点。这个项目把超时时间、并发数、payload路径放进了settings.py的SCAN_CONFIG字典:
SCAN_CONFIG = { 'timeout': 10, 'max_threads': 20, 'max_redirects': 3, 'dirwordlist': 'wordlist/dirs.txt', 'payloads': 'wordlist/sqli.txt', 'fake_user_agent': True, }这样做的理由是:同一个扫描逻辑,在互联网扫描和内网靶场扫描,超时和并发参数完全不一样。线上目标并发通常要降到5以下,靶场可以放开到30。参数集中在settings里,运维阶段改配置不用碰代码。下面这张表是我在这些参数上常用的取值组合:
| 参数 | 作用范围 | 互联网目标 | 靶场环境 |
|---|---|---|---|
| timeout | 单请求超时时间 | 10s | 3s |
| max_threads | 全局并发上限 | 5 | 20-30 |
| max_redirects | 跟随跳转次数 | 0 | 3 |
| fake_user_agent | 是否随机UA | True | False |
顺带一提,fake_user_agent默认开是合理的,因为工具要面对真实站点。但靶场环境里建议关掉,理由很实际:靶场日志要的是可复现性,随机UA会让两次扫描结果之间失去对比意义。
3. 核心扫描模块实现:从子域名收集到SQL注入检测的完整链路
3.1 子域名收集:DNS解析与字典选择
子域名收集模块用的是字典爆破加DNS解析,没有引入Sublist3r这类完整实现,而是自己维护了一份常用子域名词典,通过dnspython做A记录解析:
import dns.resolver def collect_subdomains(domain, wordlist, timeout=3): found = [] resolver = dns.resolver.Resolver() resolver.timeout = timeout resolver.lifetime = timeout for word in wordlist: sub = f"{word}.{domain}" try: answers = resolver.resolve(sub, 'A') found.append((sub, answers[0].address)) except dns.resolver.NXDOMAIN: continue except dns.resolver.NoAnswer: continue except Exception: continue return foundNXDOMAIN和NoAnswer分开捕获是有意的:NXDOMAIN说明子域不存在,NoAnswer说明域名存在但没有A记录,后者可能指向CDN或内部服务,值得单独记到日志里。我一般会把NoAnswer的域名单独存一份,因为很多只配了MX记录的内部系统域名会从这里浮出来。
逻辑上要先对解析出的IP去重,再排后续的端口扫描任务,同一IP上的多个子域可以合并探测,减少重复连接。如果目标域名的字典很大,这个串行循环要加并发控制,做法见3.2。
3.2 端口扫描:socket并发与状态码解读
端口扫描没有调系统nmap,而是用socket连接测试自己实现,目的很直接:不依赖目标机器上装了什么,也方便在Windows开发环境里直接跑。核心是一个ThreadPoolExecutor:
import socket from concurrent.futures import ThreadPoolExecutor, as_completed def port_scan(host, ports, timeout=1.0, max_workers=100): def test(port): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) result = sock.connect_ex((host, port)) sock.close() return port if result == 0 else None open_ports = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(test, p): p for p in ports} for f in as_completed(futures): port = f.result() if port: open_ports.append(port) return sorted(open_ports)connect_ex的返回值是关键:0表示连接成功,其他值要看具体errno——113是拒绝连接,110是超时。超时和拒绝都算closed的话,这个探测速度很快,但公网目标上max_workers最好不要用函数默认的100,实际调用时从SCAN_CONFIG['max_threads']取,公网目标降到20。原因是目标防火墙对高频连接非常敏感,扫快了直接封IP,扫描任务反而跑不完。
端口字典我一般分两档:全端口(1-65535)和常见端口(FTP 21、SSH 22、Telnet 23、SMTP 25、DNS 53、HTTP 80、HTTPS 443、RDP 3389这类)。对Web安全测试来说,常见端口档位通常就够用了。
3.3 目录爆破:基于状态码与响应大小的识别
目录爆破模块更像安全人员手感的体现——它不只看HTTP状态码通不通,还要看响应内容的区分度。这个模块会先请求一次目标根路径,拿到基准响应大小和状态码,然后用字典逐路径探测:
import requests def dir_brute(base_url, wordlist, timeout=10): session = requests.Session() session.headers['User-Agent'] = 'Mozilla/5.0' baseline = session.get(base_url, timeout=timeout) results = [] for line in wordlist: path = line.strip() if not path: continue url = f"{base_url.rstrip('/')}/{path.lstrip('/')}" try: r = session.get(url, timeout=timeout, allow_redirects=False) similar = abs(len(r.content) - len(baseline.content)) / max(len(baseline.content), 1) if r.status_code in (200, 301, 302, 403) and similar > 0.15: results.append({'url': url, 'status': r.status_code, 'size': len(r.content)}) except requests.RequestException: continue return resultsallow_redirects=False是这里容易忽略的一个决策。默认情况下requests会跟随302跳转,结果就是你误把登录页跳转后的内容当成目录内容记录下来。关闭重定向后,302状态码本身就有意义——它通常说明该路径存在且做了权限控制。
similar阈值的含义是响应体大小与基准的差异超过15%才算有效发现,这个阈值用来过滤SPA应用那种所有路径都返回同一份index.html的情况。如果目标是Vue或React写的单页应用,所有路径返回同一HTML,阈值建议提到0.3,否则会刷出一堆雷同结果。
3.4 SQL注入与XSS检测:payload的结构化表达
SQL注入检测用的是「报错特征+响应时间差」混合策略,不逐字符布尔盲注,因为工具场景里那样太慢了。检测分两步:先发payload看响应里是否有数据库报错特征,再对比响应时间是否显著高于基线:
ERROR_PATTERNS = [ r'syntax error', r'unclosed quotation mark', r'You have an error in your SQL syntax', r'Warning: mysql_', r'ORA-[0-9]{5}', r'SQLServer JDBC Driver', r'PostgreSQL.*ERROR' ] def test_sqli(url, param, payloads, baseline): for payload in payloads: params = {param: payload} start = time.time() r = requests.get(url, params=params, timeout=15) elapsed = time.time() - start if any(re.search(p, r.text, re.I) for p in ERROR_PATTERNS): return {'param': param, 'payload': payload, 'elapsed': round(elapsed, 3)} if elapsed > baseline * 3: return {'param': param, 'payload': payload, 'type': 'time-based', 'elapsed': round(elapsed, 3)} return Noneparams=传参而不是手动拼接URL,requests会自动做URL编码,避免单引号、空格这类字符在请求里被截断。时间盲注的判定阈值按根路径常规请求耗时的3倍取,具体数值从扫描开始前的一次采样请求中获得。
SQL注入payload这块维护一张小表,扫描时按目标数据库类型筛选:
| 数据库类型 | 报错特征 | 时间盲注函数 |
|---|---|---|
| MySQL | You have an error in your SQL syntax | SLEEP(5) |
| SQL Server | SQLServer JDBC Driver | WAITFOR DELAY '0:0:5' |
| Oracle | ORA-[0-9]{5} | DBMS_PIPE.RECEIVE_MESSAGE |
| PostgreSQL | PostgreSQL.*ERROR | pg_sleep(5) |
XSS检测分反射型和DOM型。反射型是把<img src=x onerror=alert(1)>这类短payload放进参数,拿响应正文做正则匹配,看payload是否原样回显且未被HTML编码。这里有一个细节:不能只搜完整的<script>标签,很多页面会把<转义成<,真正能利用的场景多是这种不带闭合标签的短payload。DOM型则要看前端JS是否把url参数直接写进了innerHTML,这个靠静态特征扫,误报会比反射型高,需要人工复核。
4. 异步任务与扫描结果可视化:Celery队列与图表报表设计
4.1 用Celery把扫描任务变成异步队列
扫描任务不能放在Django请求里同步执行,一个端口扫描动辄几分钟,HTTP请求早就超时了。这个项目的做法是Celery加Redis做任务队列,视图层只负责提交任务,worker进程异步执行:
# sec_tool/tasks.py from celery import shared_task @shared_task def run_scan_task(task_id): task = Task.objects.get(id=task_id) task.status = 'running' task.started_at = timezone.now() task.save() if task.scan_type == 'subdomain': results = collect_subdomains( task.target, load_wordlist(SCAN_CONFIG['dirwordlist'])) for sub, ip in results: Vuln.objects.create( task=task, vuln_name='Subdomain', url=sub, details=f'resolved to {ip}', severity='info' ) task.status = 'finished' task.finished_at = timezone.now() task.save()shared_task装饰器让任务定义不绑定具体的App,后续把任务抽到独立模块不用改代码。这里的关键配置是task_serializer和result_backend,要用JSON序列化器,默认的pickle有反序列化风险,worker一旦放在不可信网络里,pickle会直接变成代码执行入口。
# sec_tool/celery.py from celery import Celery app = Celery('sec_tool', broker='redis://127.0.0.1:6379/0') app.conf.update( task_serializer='json', result_serializer='json', accept_content=['json'], task_time_limit=1800, worker_max_tasks_per_child=50, )task_time_limit设1800秒是防止某个扫描任务卡死在哪一步请求上;worker_max_tasks_per_child=50是让worker每处理完50个任务重启一次,解决requests连接池长期复用后的内存增长。这两个参数是长期爬取型worker最容易忽略的两个坑,也是接手这类项目第一个要检查的配置。
| 配置项 | 作用 | 常用值 |
|---|---|---|
| task_serializer | 任务参数序列化格式 | json |
| task_time_limit | 单个任务硬超时 | 1800s |
| worker_max_tasks_per_child | worker处理任务数上限后重启 | 50 |
| task_acks_late | 是否在任务执行后再确认ack | True |
task_acks_late这里要说明一下:默认False是任务拿来就确认,worker崩了任务就丢了;调成True后,worker崩溃会重新把任务投递给其他worker。代价是同一个任务有可能被执行两次,所以扫描模块要尽量做成幂等的——重复扫描同一目标最多是多跑一遍,不影响数据一致性。
4.2 实时进度轮询与结果图表
结果可视化用的是Django模板加Chart.js,没上重型前端框架。任务列表页通过轮询接口拿进度,每秒请求一次,后端返回当前已扫数量、命中数量和最近一条日志:
def task_progress(request, task_id): task = Task.objects.prefetch_related('vulns').get(id=task_id) count = task.vulns.count() return JsonResponse({ 'id': task.id, 'status': task.status, 'vuln_count': count, 'last_log': task.logs.last().message })prefetch_related('vulns')是为了避免在页面同时显示所有漏洞时产生查询N+1,这个优化能把漏洞渲染页的SQL查询数量从「漏洞数+1」压到两条。漏洞列表按severity字段分组统计后,前端用Chart.js的doughnut图展示high/medium/low占比,info级别的子域名结果默认折叠,展开才看。
报表导出做的是CSV而不是PDF,原因很实际:安全团队成员拿到CSV后习惯自己筛选排序,PDF只会增加中间环节。导出接口用StreamingHttpResponse:
def export_csv(request, task_id): task = Task.objects.get(id=task_id) response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = f'attachment; filename="scan_{task.id}.csv"' writer = csv.writer(response) writer.writerow(['vuln_name', 'url', 'param', 'severity', 'details']) for vuln in task.vulns.all(): writer.writerow([vuln.vuln_name, vuln.url, vuln.param, vuln.severity, vuln.details]) return response注意两个细节:HttpResponse默认的Content-Type是text/html,浏览器会直接显示CSV文本而不是下载,必须显式设成text/csv;中文描述在Excel里打开乱码是经典问题,writer写入前把字段统一转成utf-8-sig编码能规避。这个是Python导出CSV在国际化上最常见的坑。
图表展示本身不是重点,重点是我会看Top 10漏洞URL的分布。当Top 10被同一个页面参数占满时,说明扫描参数提取逻辑过于单一,比如只取了GET参数而忽略POST表单字段。这个判断能让工具从「能用」往「好用」挪一步。
5. 生产落地与误报治理:Waitress+nginx部署及误报排查
5.1 Waitress+nginx双环境部署要点
项目要跑在Windows和Linux两种环境下,uWSGI在Windows上编译麻烦,gunicorn又是POSIX-only,所以生产服务选了Waitress。Waitress是纯Python实现的WSGI服务器,不需要编译C扩展。
pip install waitress waitress-serve --listen=0.0.0.0:8000 config.wsgi:application前端放nginx,职责是静态文件服务和请求分发,Django的STATIC_ROOT目录通过collectstatic收集后交给nginx直接返回,应用请求则转给Waitress的8000端口处理。注意用nginx -t验证配置后再reload,避免改坏线上配置导致全站不可访问。
部署层面的顺序一般是:先collectstatic,再启动Waitress,最后reload nginx。顺序错了的话静态资源会404,浏览器控制台一片红的报错很容易误导排查方向。Celery worker单独用supervisor托管,Redis先启动,否则worker起不来。
5.2 误报治理:三次采样、WAF识别和人工确认闭环
回到前面挖的坑,SQL注入和目录爆破的误报是这个工具实际使用中最影响信任感的部分。第一个误报来源是WAF拦截页面:很多WAF的返回报文故意带SQL语法错误字样用于反探测,正则特征会直接命中。处理办法是给Vuln记录加一个source字段,记录命中时的响应Server值,凡是Server标记为WAF/CDN的自动降级并单独分组展示。
第二个误报来源是时间盲注的单次请求判定。正确的做法是同一个payload重复三次,取中位数和基线比较,三次都在3倍以上才判定存在:
samples = [] for _ in range(3): start = time.time() r = requests.get(url, params=params, timeout=15) samples.append(time.time() - start) median = sorted(samples)[1] if median > baseline * 3: return {'param': param, 'payload': payload, 'median': round(median, 3)}目录爆破的跨目录递归扫描会产生大量重复记录。我一般维护一个seen_paths集合,用path加上status_code加content_length做联合去重键,同一组合出现过就跳过,能把结果里的重复项压掉一大半。
最后是这个项目我最认可的一个设计:Vuln列表每条记录上有confirm和false_positive两个操作按钮,标记结果写回数据库,再次扫描同一目标时自动过滤已标记的误报记录。这个「人工反馈进入扫描逻辑」的小闭环,让工具从一次性的扫描脚本变成了能越用越准的检测平台,也是它区别于普通脚本工具的核心所在。
本文还有配套的精品资源,点击获取