基于Django的Web安全渗透测试工具:模块化扫描与误报治理
简介基于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_length255, db_indexTrue) scan_type models.CharField(max_length50) status models.CharField(max_length20, defaultpending) created_at models.DateTimeField(auto_now_addTrue) started_at models.DateTimeField(nullTrue, blankTrue) finished_at models.DateTimeField(nullTrue, blankTrue) class Meta: ordering [-created_at] class Vuln(models.Model): task models.ForeignKey(Task, related_namevulns, on_deletemodels.CASCADE) vuln_name models.CharField(max_length100) url models.URLField(max_length500) param models.CharField(max_length100, blankTrue) severity models.CharField(max_length10) details models.TextField(blankTrue) discovered_at models.DateTimeField(auto_now_addTrue) class ScanLog(models.Model): task models.ForeignKey(Task, related_namelogs, on_deletemodels.CASCADE) module models.CharField(max_length50) message models.TextField() level models.CharField(max_length10, defaultINFO) created_at models.DateTimeField(auto_now_addTrue)这段设计的核心是外键关联的读写路径Vuln通过Task外键归属扫描任务ScanLog记录过程信息。db_index加在target上是因为按目标查询历史任务是最频繁的操作没有索引的话数据量上来之后会走全表扫描。severity字段用字符串而不是整数是因为漏洞评级在展示层要映射颜色和排序字符串枚举比int更直观它的取值不限于high/medium/low子域名这类信息类结果用info标记报表期单独折叠展示。另一个要留意的点是on_deletemodels.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单请求超时时间10s3smax_threads全局并发上限520-30max_redirects跟随跳转次数03fake_user_agent是否随机UATrueFalse顺带一提fake_user_agent默认开是合理的因为工具要面对真实站点。但靶场环境里建议关掉理由很实际靶场日志要的是可复现性随机UA会让两次扫描结果之间失去对比意义。3. 核心扫描模块实现从子域名收集到SQL注入检测的完整链路3.1 子域名收集DNS解析与字典选择子域名收集模块用的是字典爆破加DNS解析没有引入Sublist3r这类完整实现而是自己维护了一份常用子域名词典通过dnspython做A记录解析import dns.resolver def collect_subdomains(domain, wordlist, timeout3): 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开发环境里直接跑。核心是一个ThreadPoolExecutorimport socket from concurrent.futures import ThreadPoolExecutor, as_completed def port_scan(host, ports, timeout1.0, max_workers100): 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_workersmax_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, timeout10): session requests.Session() session.headers[User-Agent] Mozilla/5.0 baseline session.get(base_url, timeouttimeout) results [] for line in wordlist: path line.strip() if not path: continue url f{base_url.rstrip(/)}/{path.lstrip(/)} try: r session.get(url, timeouttimeout, allow_redirectsFalse) 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_redirectsFalse是这里容易忽略的一个决策。默认情况下requests会跟随302跳转结果就是你误把登录页跳转后的内容当成目录内容记录下来。关闭重定向后302状态码本身就有意义——它通常说明该路径存在且做了权限控制。similar阈值的含义是响应体大小与基准的差异超过15%才算有效发现这个阈值用来过滤SPA应用那种所有路径都返回同一份index.html的情况。如果目标是Vue或React写的单页应用所有路径返回同一HTML阈值建议提到0.3否则会刷出一堆雷同结果。3.4 SQL注入与XSS检测payload的结构化表达SQL注入检测用的是「报错特征响应时间差」混合策略不逐字符布尔盲注因为工具场景里那样太慢了。检测分两步先发payload看响应里是否有数据库报错特征再对比响应时间是否显著高于基线ERROR_PATTERNS [ rsyntax error, runclosed quotation mark, rYou have an error in your SQL syntax, rWarning: mysql_, rORA-[0-9]{5}, rSQLServer JDBC Driver, rPostgreSQL.*ERROR ] def test_sqli(url, param, payloads, baseline): for payload in payloads: params {param: payload} start time.time() r requests.get(url, paramsparams, timeout15) 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传参而不是手动拼接URLrequests会自动做URL编码避免单引号、空格这类字符在请求里被截断。时间盲注的判定阈值按根路径常规请求耗时的3倍取具体数值从扫描开始前的一次采样请求中获得。SQL注入payload这块维护一张小表扫描时按目标数据库类型筛选数据库类型报错特征时间盲注函数MySQLYou have an error in your SQL syntaxSLEEP(5)SQL ServerSQLServer JDBC DriverWAITFOR DELAY 0:0:5OracleORA-[0-9]{5}DBMS_PIPE.RECEIVE_MESSAGEPostgreSQLPostgreSQL.*ERRORpg_sleep(5)XSS检测分反射型和DOM型。反射型是把img srcx onerroralert(1)这类短payload放进参数拿响应正文做正则匹配看payload是否原样回显且未被HTML编码。这里有一个细节不能只搜完整的script标签很多页面会把转义成lt;真正能利用的场景多是这种不带闭合标签的短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(idtask_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( tasktask, vuln_nameSubdomain, urlsub, detailsfresolved to {ip}, severityinfo ) 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, brokerredis://127.0.0.1:6379/0) app.conf.update( task_serializerjson, result_serializerjson, accept_content[json], task_time_limit1800, worker_max_tasks_per_child50, )task_time_limit设1800秒是防止某个扫描任务卡死在哪一步请求上worker_max_tasks_per_child50是让worker每处理完50个任务重启一次解决requests连接池长期复用后的内存增长。这两个参数是长期爬取型worker最容易忽略的两个坑也是接手这类项目第一个要检查的配置。配置项作用常用值task_serializer任务参数序列化格式jsontask_time_limit单个任务硬超时1800sworker_max_tasks_per_childworker处理任务数上限后重启50task_acks_late是否在任务执行后再确认ackTruetask_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(idtask_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)是为了避免在页面同时显示所有漏洞时产生查询N1这个优化能把漏洞渲染页的SQL查询数量从「漏洞数1」压到两条。漏洞列表按severity字段分组统计后前端用Chart.js的doughnut图展示high/medium/low占比info级别的子域名结果默认折叠展开才看。报表导出做的是CSV而不是PDF原因很实际安全团队成员拿到CSV后习惯自己筛选排序PDF只会增加中间环节。导出接口用StreamingHttpResponsedef export_csv(request, task_id): task Task.objects.get(idtask_id) response HttpResponse(content_typetext/csv) response[Content-Disposition] fattachment; filenamescan_{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. 生产落地与误报治理Waitressnginx部署及误报排查5.1 Waitressnginx双环境部署要点项目要跑在Windows和Linux两种环境下uWSGI在Windows上编译麻烦gunicorn又是POSIX-only所以生产服务选了Waitress。Waitress是纯Python实现的WSGI服务器不需要编译C扩展。pip install waitress waitress-serve --listen0.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, paramsparams, timeout15) 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两个操作按钮标记结果写回数据库再次扫描同一目标时自动过滤已标记的误报记录。这个「人工反馈进入扫描逻辑」的小闭环让工具从一次性的扫描脚本变成了能越用越准的检测平台也是它区别于普通脚本工具的核心所在。本文还有配套的精品资源点击获取