引言:为什么"完整流程"比"单个漏洞"更值钱
在乙方做交付的朋友大概都遇到过这样的场景:客户拿着某扫描器导出的报告问,"这上面 37 个高危,到底哪个能真打进来?
“——报告里躺着大量"疑似 SQL 注入”“可能存在 XSS”,却没有任何一条能给出可复现的请求包、可量化的影响范围和确定的修复建议。
问题的根源不在于工具不行,而在于流程断裂。多数人把渗透测试等同于"跑一遍 AWVS + sqlmap",跳过信息收集,跳过攻击面建模,直接进入漏洞探测。结果是:漏掉真正暴露在公网的核心资产,却在无关紧要的静态资源上刷出一堆误报。
本文以 PTES(Penetration Testing Execution Standard)与 OWASP WSTG 为骨架,串起一条从信息收集到漏洞验证的完整链路,并给出可直接改造使用的代码。
前置声明:本文所有技术手段仅适用于已签署书面授权、明确范围(IP/域名/时间窗/禁止操作清单)的目标。未授权测试在国内适用《刑法》第 285、286 条,不存在"技术研究"的免责空间。
一、流程骨架:七个阶段与三条铁律
一条可交付的 Web 渗透链路通常是:
- 范围界定与授权确认——拿到 SOW,明确 in-scope / out-of-scope,确认测试窗口与应急联系人;
- 被动信息收集——不向目标发送任何流量的前提下,从公开数据源获取资产;
- 主动信息收集——端口、服务、目录、指纹,此时开始产生目标侧日志;
- 攻击面建模——把资产转化为"参数 → 漏洞假设"的映射表;
- 漏洞探测——自动化扫描,产出候选清单;
- 漏洞验证——人工构造 PoC,确认可利用性与影响;
- 报告与复测——证据链、修复建议、回归验证。
三条贯穿始终的铁律:可复现(每条结论附原始请求响应)、最小影响(不写入、不删除、不越权读取真实用户数据)、可追溯(时间戳、操作者、工具版本全部记录)。
这三条铁律看起来像废话,但它们是区分"渗透测试"与"入侵行为"的分界线。举个具体例子:验证 SQL 注入时,AND SLEEP(5)会真实消耗数据库连接;如果目标是一个连接池只有 20 的订单库,在业务高峰跑 20 个并发时间盲注,等于自己发起了一次小型 DoS。可复现要求你记录下这次操作,最小影响要求你把SLEEP换成AND 1=1 / AND 1=2的布尔差异对比,可追溯要求你在报告里写明"本次验证仅使用布尔判断,未执行任何写操作"。这三句话在事后复盘时,就是你的免责证据。
二、核心原理:信息收集决定攻击面的边界
攻击面可以形式化地理解为一个笛卡尔积:
攻击面 = 域名 × IP × 端口 × 服务/框架 × 路径 × 参数 × 权限上下文漏洞几乎总是出现在这个集合的某个具体元素上。信息收集的价值,就是把这个集合从无穷大收敛到有限大,让后续探测有的放矢。
被动收集与主动收集的取舍很关键:证书透明日志(CT Log)、DNS 历史记录、GitHub 泄露、Wayback Machine 快照属于被动,零接触、零日志;子域爆破、端口扫描、目录枚举属于主动,必然留下痕迹,可能触发 WAF 封禁甚至 SOC 告警。在授权测试中,优先把被动阶段做透,主动阶段才有性价比。
下面这段脚本完成"CT Log 子域枚举 → 存活探测 → 指纹初筛"三步:
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""passive_recon.py —— 被动子域枚举 + HTTP 存活探测(仅用于已授权目标)"""importasyncio,json,re,sysimporthttpx CRT_SH="https://crt.sh/?q=%25.{domain}&output=json"CONCURRENCY=20defextract_subdomains(domain:str)->set[str]:"""从证书透明日志提取子域,crt.sh 偶尔返回 HTML 错误页,需容错"""try:resp=httpx.get(CRT_SH.format(domain=domain),timeout=30.0)entries=resp.json()exceptExceptionasexc:print(f"[-] crt.sh 查询失败:{exc}")returnset()pattern=re.compile(rf"^([\w-]+\.)+{re.escape(domain)}$")subs=set()forentryinentries:# name_value 可能含多行、通配符前缀fornameinentry.get("name_value","").splitlines():name=name.strip().lower().lstrip("*.")ifpattern.match(name):subs.add(name)returnsubsdefget_title(html:str)->str:m=re.search(r"<title[^>]*>(.*?)</title>",html,re.S|re.I)returnre.sub(r"\s+"," ",m.group(1)).strip()[:80]ifmelse""asyncdefprobe(client,sem,host:str):"""先试 HTTPS 再回落 HTTP,忽略证书错误(自签名在内网极常见)"""asyncwithsem:forschemein("https","http"):try:r=awaitclient.get(f"{scheme}://{host}",follow_redirects=False,timeout=8.0)return{"host":host,"scheme":scheme,"status":r.status_code,"server":r.headers.get("server",""),"title":get_title(r.text)}exceptException:continue# 两种协议都不通,说明域名存在但服务未监听(可能已下线或仅内网可达)return{"host":host,"scheme":None,"status":None,"server":"","title":""}asyncdefmain(domain:str):subs=sorted(extract_subdomains(domain))print(f"[+] 被动枚举到{len(subs)}个子域")sem=asyncio.Semaphore(CONCURRENCY)# verify=False 关闭证书校验;仅用于探测阶段,验证阶段务必开回 Trueasyncwithhttpx.AsyncClient(verify=False,headers={"User-Agent":"Mozilla/5.0"})asclient:tasks=[probe(client,sem,h)forhinsubs]results=[rforrinawaitasyncio.gather(*tasks)ifr["status"]]# 按 Server 头聚合,快速识别同一批次部署的资产(同一运维团队/同一中间件)buckets={}forrinresults:buckets.setdefault(r["server"]or"unknown",[]).append(r)forserver,itemsinsorted(buckets.items(),key=lambdax:-len(x[1])):print(f"\n==={server}({len(items)}) ===")foritinitems:print(f"{it['status']:<4}{it['scheme']}://{it['host']}{it['title']}")withopen("recon_result.json","w",encoding="utf-8")asf:json.dump(results,f,ensure_ascii=False,indent=2)if__name__=="__main__":iflen(sys.argv)!=2:sys.exit("usage: python passive_recon.py example.com")asyncio.run(main(sys.argv[1].lower()))这段代码有三个值得展开的设计点:
第一,为什么用 CT Log 而不是子域爆破起步。证书透明是 CA 必须遵守的 RFC 6962 要求,任何为某个域名签发过证书的记录都会公开。它的覆盖度往往出人意料——测试域名、临时环境、历史遗留的old-api、test-前缀,经常就躺在这里。而且查询 crt.sh 本身不接触目标,零日志。真正的盲区是从未申请过证书的内网域名和使用泛域名证书的场景,后者你只能看到*.example.com一条记录,具体子域仍需爆破。
第二,为什么先 HTTPS 后 HTTP。现代资产大概率只监听 443,先试 HTTPS 能省掉一半请求;而 80 端口在很多环境里被重定向到 443 或直接被运营商劫持页占位,先试 HTTP 会拿到一堆假存活。注意follow_redirects=False——一旦跟随跳转,你就无法区分"这个域名本身是活的"和"它跳到了某个统一门户",而后者在资产梳理阶段是噪音。
第三,为什么按 Server 头聚合。单看一个 IP 很难判断资产归属,但nginx/1.18.0+openresty/1.21.4.1这种指纹组合,往往对应同一套部署流水线。同一批次的资产通常共享同一类配置缺陷(比如都开了目录列表、都没关X-Powered-By),聚合之后你能按"批次"去验证,效率成倍提升。
常见踩坑:
crt.sh有速率限制,连续查询会被返回 502 或空数组。工程上的做法是加 2~3 秒间隔、失败重试三次,并把结果缓存到本地 SQLite,避免重复查询。name_value字段里经常混入*.example.com和a.example.com\nb.example.com这种多行值,直接split(",")会漏。必须按\n切分并lstrip("*.")。- 正则里务必
re.escape(domain)。域名里的.如果不转义,notexample.com也会被匹配进来,这在给客户交付时是低级但致命的错误。
三、主动信息收集:端口、服务与目录
被动阶段产出的是"名字",主动阶段要回答的是"这些名字背后到底开了什么"。建议的顺序是:端口扫描 → 服务指纹 → 目录/文件枚举 → 参数发现,每一步都以上一步的输出为输入。
端口扫描不必一上来就nmap -p-。全端口 SYN 扫描对单 IP 大约 1~2 分钟,但如果被动阶段给你 200 个子域,全量扫就是几个小时,而且极大概率触发边界设备的扫描告警。务实做法是分两轮:第一轮扫21,22,80,443,8080,8443,8000,9000,7001,6379,3306,5432,27017这些高频端口,第二轮只对第一轮存活的主机做全端口。
服务识别之后是目录枚举。这里最大的坑是软 404:很多框架(Spring Boot、Django、ThinkPHP)对不存在的路径返回 200 + 一个统一的错误页,导致字典里每一个词都"命中"。识别方法很简单——先请求 3 个随机字符串路径建立基线,再比对后续响应的状态码、长度、正文哈希三个维度:
# soft404.py —— 软 404 基线识别(供目录枚举前调用)importhashlib,random,stringimporthttpxdefbuild_baseline(client:httpx.Client,base_url:str,rounds:int=3):"""用 N 个随机路径采样 404 基线,返回 {(status, length, body_hash)} 集合"""baseline=set()for_inrange(rounds):rnd="".join(random.choices(string.ascii_lowercase+string.digits,k=16))try:r=client.get(f"{base_url.rstrip('/')}/{rnd}",timeout=8.0)exceptException:continuebaseline.add((r.status_code,len(r.content),hashlib.md5(r.content).hexdigest()))returnbaselinedefis_real_hit(resp:httpx.Response,baseline:set)->bool:"""命中判定:状态码不同,或长度偏离基线 5% 以上,或正文哈希不在基线中"""key=(resp.status_code,len(resp.content),hashlib.md5(resp.content).hexdigest())ifkeyinbaseline:returnFalseifresp.status_codein(301,302,401,403):returnTrue# 跳转/需鉴权,通常意味着路径真实存在forb_status,b_len,_inbaseline:ifresp.status_code==b_statusandb_len:ifabs(len(resp.content)-b_len)/b_len<0.05:returnFalsereturnTrue这段代码的价值在于:它把"命中"从布尔判断变成了概率判断。实际项目里我会把is_real_hit的返回再分三档——明确命中(403/401/302)、疑似命中(长度偏离但状态码相同)、明确未命中——只有前两档才进入人工复核队列。经验数据是,加了这个过滤之后,字典爆破的复核工作量能降 70% 以上。
参数发现常被忽略,但它直接决定后面攻击面建模的质量。三个有效途径:爬虫抓取所有带 query 的 URL(含 JS 里拼接的)、从 JS 文件里正则提取?key=/&key=/"key":、从 Wayback Machine 的历史快照里挖老参数。历史参数尤其有价值——很多接口删了前端调用,后端参数却还在,这类"僵尸参数"往往缺少校验。
四、攻击面建模:从资产清单到漏洞假设
到这一步你手上应该有一张表:域名、IP、端口、框架、路径、参数。接下来要做的是把它转换成"漏洞假设表"。所谓假设,就是基于技术栈和经验推断出的、可被验证的具体命题,而不是"这里可能有漏洞"。
一张合格的映射表长这样:
| 资产 | 技术栈线索 | 参数/入口 | 漏洞假设 | 验证手段 |
|---|---|---|---|---|
| api.example.com | Spring Boot +X-Application-Context | id(数字) | IDOR | 相邻 ID 越权读取 |
| admin.example.com | 登录页无验证码 | username | 弱口令 / 用户枚举 | 响应差异 + 小字典 |
| static.example.com | Nginx 目录列表开启 | 路径 | 敏感文件泄露 | 递归列举 |
| m.example.com | 老版 ThinkPHP | m,a | RCE(历史 CVE) | 版本比对 + 无害回显 |
| upload.example.com | 前端限制扩展名 | file | 任意文件上传 | 改包上传图片马 |
这张表是整条流程的"作战地图"。它的价值体现在三个方面:一是可分工——队友可以各领一行并行推进;二是可裁剪——测试窗口只有 3 天时,优先验证影响面最大的前几行;三是可交付——报告里写"我们对 12 个参数点建立了 27 条漏洞假设,验证确认 6 条",比写"发现 37 个高危"专业得多。
建模阶段还有一个高频动作:版本比对。拿到框架版本号后,去查该版本已知 CVE,但要注意——版本号可能被伪造,也可能打了 backport 补丁。所以 CVE 只作为假设来源,不能作为结论。曾经有个案例:目标Server: Apache/2.4.49,扫描器报 CVE-2021-41773 路径穿越,实际测试发现运维手工加了Require all denied,漏洞并不存在。如果直接写进报告,就是一条典型的误报。
五、漏洞探测:自动化扫描的正确姿势
自动化扫描器的定位是**“候选生成器”,不是"结论生成器"**。理解这一点,你才能正确使用它。
扫描器的通用工作流是:爬虫发现入口 → 对每个入口注入 payload → 根据响应特征判断。它的问题也出在这里:爬虫能力弱(大量接口在 JS 里,爬不到)、判断规则死板(看到 SQL 报错关键字就报注入,看到 payload 原样回显就报 XSS)、不理解业务上下文(无法区分"回显了我的输入"和"执行了我的输入")。
所以正确姿势是分层使用:
- Nuclei:跑指纹识别和已知 CVE 的 POC,误报率低,速度快。适合在信息收集完成后立刻跑一遍,产出"确定性问题"清单。注意它的模板质量参差,社区模板里有些只做版本匹配,这类要单独标记为"待人工确认"。
- AWVS / AppScan:跑全站爬虫 + 通用漏洞,产出量大但噪音多。用它来做覆盖面兜底,而不是找重点。
- sqlmap / XSStrike:只在人工确认了可疑点之后定向使用,不要拿它对着全站参数盲跑。
sqlmap --level=5 --risk=3会在目标库上尝试大量高代价 payload,包括UPDATE、时间盲注、堆叠查询,在授权测试里属于典型的越界操作。
一个务实的原则:扫描器只用来"发现",任何要写进报告的条目,必须经过人工验证阶段。
六、漏洞验证:从"疑似"到"确认"
验证阶段的核心是构造最小化、可复现、可量化的 PoC。以最常见的三类为例。
SQL 注入。不要一上来就sqlmap。先做手工判断:id=1'看是否报错,id=1 AND 1=1与id=1 AND 1=2对比响应差异。布尔差异是最安全的验证方式——它不写数据、不消耗额外连接、不产生大日志。确认存在后再判断类型(数字/字符、回显/盲注),最后才决定是否需要用 sqlmap 提权。证据链要包含:原始请求(含完整 Header)、两次对比响应、响应差异的量化说明(如页面长度 4523 vs 2187)。
XSS。关键区分"回显"和"执行"。payload 出现在 HTML 里不等于 XSS——如果它被htmlspecialchars编码,或者落在<textarea>、<title>里,就不构成执行。验证时用<script>alert(document.domain)</script>或更稳妥的<img src=x onerror=alert(1)>,并且必须在真实浏览器里执行确认,而不是只看响应里有没有你的字符串。截图要包含地址栏和弹窗内容,这是报告里的有效证据。
越权(IDOR)。验证越权最忌讳的是"读真实用户数据"。正确做法是:用账号 A 创建一个只有 A 能访问的资源(如一张测试订单、一个测试文件),记录其 ID;再用账号 B 请求同一 ID。如果 B 能访问,越权成立——而你访问的是自己创建的测试数据,没有触碰任何真实用户信息。这个细节在合规审查时非常关键。
文件上传。验证到"文件落地"就够了,不要再往"解析执行"走。确认文件确实被写入(如返回了访问 URL 且能下载)即可证明危害,执行 Webshell 属于超出最小影响原则的操作。如果客户明确授权了 getshell,也要用无害的echo md5(时间戳)做回显,用完立即删除并记录删除时间。
七、报告与复测
报告不是流水账,而是**"风险 → 证据 → 影响 → 修复"的四段式论证**。每条漏洞都要能回答四个问题:怎么发现的(请求包)、确认了什么(响应包)、能造成什么后果(业务语言,不是"可导致信息泄露"这种套话)、怎么修(给出具体到配置项的方案)。
复测阶段要建立回归清单:逐条重放当初的 PoC,确认是否已失效。注意一个常见陷阱——修复方式不当导致漏洞变形。比如把 SQL 注入的id参数加了intval(),注入没了,但如果同一参数在别处还用了字符串拼接,问题依然存在。复测时不能只测原来的 PoC,要覆盖同类参数的所有入口。
八、常见问题 FAQ
Q1:信息收集要花多久?整个项目时间怎么分配?
经验比例大约是 3:3:4——信息收集 + 建模占 30%,探测占 30%,验证 + 报告占 40%。很多人把 80% 时间花在探测上,结果产出全是误报。如果项目只有 3 天,宁可信息收集只做被动 + 高频端口,也要保证验证阶段有充足时间。
Q2:扫描器报了漏洞,我手工复现不出来,怎么办?
先怀疑三件事:扫描器的 payload 是否被 WAF 拦了(看响应码和拦截页)、你的请求和扫描器的请求是否一致(Header、Cookie、编码方式)、目标是否是负载均衡后的多台机器(同一请求打到不同后端,结果不同)。如果都排除了,就标为"未复现"写进报告,并附上你尝试过的所有请求。不要因为复现不出来就删掉,也不要因为复现不出来就照抄扫描器结论。
Q3:遇到 WAF 怎么办?
首先确认 WAF 是否在授权范围内(很多 SOW 会写明"禁止绕过 WAF")。如果允许,优先用语义等价替换而不是编码混淆——比如AND换成&&、空格换成/**/、大小写混写。编码混淆(%55nion)容易触发更严格的规则,而且一旦成功,你很难判断是绕过了还是规则本来就没覆盖。另外注意速率:WAF 的封禁阈值通常是"单位时间内命中规则次数",把并发降到 1、请求间隔加到 1 秒,很多封禁就不会触发。
Q4:客户只给了域名,没给 IP 段,怎么确定边界?
域名对应的资产不一定都在授权范围内。如果目标用了 CDN,你扫到的 IP 是 CDN 节点,攻击 CDN 节点可能影响到其他客户,这是严重的越界。判断方法:解析域名的 CNAME 链,如果是xxx.cdnprovider.com,则只测 HTTP 层,不测该 IP 的端口和主机层。写报告时也要说明"本次测试在 CDN 回源前的 HTTP 层完成"。
Q5:时间盲注把数据库打慢了,怎么补救?
立即停止测试,记录停止时间,通知应急联系人,说明使用过的 payload 和预估影响(连接数、持续时间)。然后在报告里如实记录。这类事件本身不一定会导致项目失败,但隐瞒一定会。
Q6:测试环境里有真实用户数据,能读吗?
不能。这是红线。验证越权时用自己的测试账号造数据,验证 SQL 注入时用SELECT 1或database()这类无害函数,绝不SELECT用户表。哪怕客户口头说"没关系",也要坚持——授权书里通常写的是"允许测试",不是"允许接触真实数据"。
九、踩坑与优化建议
踩坑一:忘了处理X-Forwarded-For。有些环境按 XFF 做灰度路由,你带上的 XFF 可能让你测到的是测试环境而非生产。测试前先发一个不带 XFF 的请求确认响应,再决定是否保留该头。
踩坑二:把蜜罐当成真资产。蜜罐的特征是:开放了大量不常见端口、页面是空壳、任何路径都返回 200、响应极快且无 Cookie。发现这类资产应当立即停止测试并上报,继续深入可能被判定为恶意行为。
踩坑三:字典用了默认配置。dirsearch默认字典、sqlmap默认 payload 集,这些特征已经被 WAF 规则覆盖得很全。自建字典是性价比最高的优化——从目标的历史快照、JS 文件、错误页里提取路径词,效果远好于任何公开字典。
踩坑四:证据链不完整。只截了一张浏览器截图,没有原始请求包,客户的安全团队无法复现。正确做法是每一条结论都附:curl命令(或 Burp 的.req文件)、响应原文(截断到关键部分)、时间戳、测试账号标识。
优化建议:
- 建立自己的指纹库:把每次项目遇到的 Server 头、favicon hash、特定路径组合记下来,下次遇到能秒判技术栈。favicon 的 mmh3 hash 尤其好用,它能识别出被改掉 Server 头的框架。
- 用差分做误报过滤:任何"疑似"结论,都用"有 payload / 无 payload"两次请求做差分,响应一致的一律丢弃。
- 把 payload 字典纳入版本管理:用 Git 管理你的字典和脚本,每次项目后提交改进。半年后你会发现自己的字典比任何公开字典都好用。
- 速率控制做成可配置:把并发数、请求间隔、超时时间都做成命令行参数,不同客户的安全水位不同,一套硬编码参数走不通。
十、结语
回到开头那个场景。当客户再问"这 37 个高危哪个能真打进来"时,你应该能拿出这样一份东西:一份收敛后的资产清单、一张漏洞假设映射表、每一条确认漏洞的完整 PoC、以及一份标注了"已确认/未复现/超出范围"的分类结论。
工具永远只是流程里的一个环节。真正决定交付质量的,是你在信息收集阶段愿不愿意多花两小时把被动数据做透,是你在探测阶段能不能忍住不直接sqlmap -u全站跑,是你在验证阶段有没有坚持"最小影响"把SLEEP(5)换成布尔对比。
流程不是束缚,它是让结果可信、让自己可追溯的那套东西。
更多硬核网安与AI工具包,请扫码获取完整源码!