Day 161 — 渗透测试框架:流程编排 · 信息收集 → 检测 → 利用 · 自动化扫描框架
Phase 10 — 网络安全开发(Day 146–165) · 主题:渗透测试框架(实战)
使用边界(重要,先读这一节):
- 本课只对你自己拥有、或已获得书面授权的目标做测试。
- 本课不提供任何真实攻击载荷(exploit / webshell / 提权脚本一律没有)。
利用(exploitation)这一阶段在代码里是空实现 + 占位插件,
它只负责"标记需要人工验证的点位",不会发出任何攻击请求。
- 框架的硬门禁是 Scope:默认只允许127.0.0.1+ 实验端口段。
未经授权的主机/端口会直接抛AuthorizationError并以退出码 2 终止。
- 框架的探测全部是只读的:TCP connect、读取服务主动发来的问候语(banner),
不发送任何自定义数据、不做爆破登录、不提交表单。
- 教材中出现的 IP 一律是文档专用网段(203.0.113.0/24、198.51.100.0/24、
192.0.2.0/24)或回环地址,账号均为占位名。
1. 学习目标
完成本课后,你应该能够:
- 说清"渗透测试框架"是什么、不是什么:它是流程编排 + 范围约束 + 证据留存,
不是"一键入侵工具" - 画出并解释标准的阶段流水线(信息收集 → 检测 → 利用 → 报告),
以及为什么授权与范围校验必须排在阶段之前 - 用数据结构(而不是口头约定)表达授权范围,让越界在代码层面不可能发生
- 解释 TCP connect 扫描的三态语义:
open/closed/filtered分别意味着什么,
以及把filtered当成closed会造成什么错误结论 - 用插件注册表(registry)模式把"框架"和"具体检测项"解耦,并说清它的代价
- 用线程池 + 令牌桶限速实现并发扫描,并解释为什么要主动限速(不是性能问题)
- 把每一步动作写进审计日志,并解释为什么审计日志是框架的第一等产物
- 明确框架的伦理与法律边界:哪些能力本课刻意不做,为什么
2. 概念解释
2.1 渗透测试框架到底是什么
一个常见的误解是:"框架 = 点一下按钮就拿下目标"。真实的渗透测试框架解决的是
流程与约束问题,它由四块组成,缺一块就不能叫框架:
| 组成 | 解决什么问题 | 失败的后果(真实案例式的教训) |
|---|---|---|
| 范围模型(Scope) | 我能碰谁、不能碰谁 | 扫到隔壁客户的网段 → 合规事故,不是"技术失误" |
| 阶段编排(Orchestration) | 先做什么后做什么、结果如何流转 | 还没确认存活就开始扫描 → 大量无效工作和误报 |
| 插件/模块(Plugins) | 具体检测项可插拔、可替换 | 检测逻辑散落在脚本里 → 无法复用、无法单独测试 |
| 证据与报告(Evidence) | 谁在什么时候对什么做了什么 | 无法复现、无法交付、无法自证清白 |
一句话概括:
框架 = 范围约束 × 阶段编排 × 可插拔能力 × 可追溯证据
请注意这个式子里没有"攻击载荷"——载荷是可以换的零件,约束与证据才是框架的骨架。
这也解释了为什么本课把代码重心放在 Scope、编排器、审计日志上,
而把"利用"阶段留成空实现。
2.2 为什么需要框架:四个真实痛点
- 防遗忘:人手工做渗透测试会漏步骤。框架把"必做步骤"写成代码,
漏做会体现在报告的阶段清单里(而不是凭记忆说"我做过")。 - 防越界:口头约定的范围在紧张的执行过程中一定会被突破
("顺手扫一下这个 C 段吧")。把范围写成数据结构,越界直接抛异常。 - 可复现:半年后客户问"这个结论怎么来的",框架能给出:
当时的范围、参数、原始结果(JSON)、审计日志。手工流水账给不出这些。 - 可测试:插件可以被单元测试。一个"检测器"如果只在真实目标上跑过,
它实际上从未被验证过。
2.3 阶段模型:从侦察到报告
工业界常用的流程模型是 PTES(Penetration Testing Execution Standard)七阶段。
本课把概念模型裁剪成能自动化、且不越界的四段:
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌───────────┐ ┌──────────┐
│ 0 授权与 │──▶│ 1 信息收集 │──▶│ 2 检测 │──▶│ 3 利用 │──▶│ 4 报告 │
│ 范围校验 │ │ 侦察/扫描 │ │ 漏洞判定 │ │ 人工验证 │ │ 证据交付 │
└────────────┘ └────────────┘ └────────────┘ └───────────┘ └──────────┘
代码执行 全自动只读 全自动只读 ★ 本课为空实现 ★ 全自动
强制门禁 允许 允许(只读) (只留人工入口) 允许
阶段之间是数据流关系,不是"可选菜单":
- 阶段 1 产出
Asset(存活主机 + 开放端口 + 服务指纹) - 阶段 2 消费
Asset,产出Finding(可验证的问题点) - 阶段 3 消费
Finding,产出Verification(人工确认结论)——本课留空 - 阶段 4 消费以上全部,产出报告 + 审计日志
为什么要严格分段? 因为分段让失败可以被定位。如果扫描和判定混在一个脚本里,
"结果不对"时你无法区分是"资产判断错了"还是"检测逻辑错了"——
这正是 Day 160 日志分析里"解析/检测/报告"三段分离的同一个道理。
2.4 授权与范围:为什么必须是数据结构
口头授权在真实项目里的样子:
"客户的 Web 站点你可以测,但是不要动数据库服务器,
不要做拒绝服务,只在下班后 20:00–23:00 做。"
这句话里有四类约束:目标范围、排除项、禁止手法、时间窗口。
四类都必须落到代码里:
@dataclass
class Scope:
networks: list[str] # 允许的目标(CIDR)
ports: list[tuple[int, int]] # 允许的端口区间
excluded: list[str] # 显式排除(黑名单优先于白名单!)
allow_raw_sockets: bool = False # 本课永远 False
allow_write_actions: bool = False # 本课永远 False
ticket: str = "" # 授权单号(证据链的一部分)
expires: str = "" # 授权截止时间
关键设计:黑名单优先。 即使某个主机同时在白名单里,
只要命中 excluded 就拒绝。为什么?因为排除项通常对应"业务不能停"的机器,
一次越界的代价远大于少扫一台。
更关键的一点:范围校验发生在"动作之前",而不是"动作之后"。
很多脚本的做法是"先连接,看连上了再判断是否在范围内"——
这已经把包发出去了。正确顺序是:
解析目标 → 生成 Target 列表 → ★ scope.check(target) ★ → 才有资格创建 socket
↑
越界在这里抛异常,socket 从未被创建
2.5 插件化:注册表模式的收益与代价
框架不写死检测项,检测项通过注册表挂进来:
@framework.plugin("http_banner")
def http_banner(ctx):
...
为什么值得?三个收益:
- 框架代码稳定:加检测项不需要改编排器,降低回归风险。
- 可单独测试:每个插件可以用假
ctx跑单元测试,不需要真实网络。 - 可裁剪合规:客户不让做某类检测时,不注册即可,而不是"注册了但记得别调"。
代价也要说清(这是很多教程省略的部分):
- 隐式控制流:读代码时要跳来跳去,"谁调用了这个插件"不再一目了然。
- 上下文膨胀:
ctx慢慢变成万能对象,插件之间通过它隐式耦合。 - 注册顺序即执行顺序:如果不显式排序,执行顺序变得脆弱。
所以本课的注册表是有序注册表(记录注册顺序 + 显式 priority),
并且 ctx 是只读 dataclass(插件只能读,写回结果要 return Finding),
这样隐式耦合被掐死在设计层面。
2.6 Metasploit 的定位:理解概念,而不是抄命令
ROADMAP 里提到"Metasploit 基础"。这里只做概念层面的说明,
原因是:Msfconsole 的价值在于"模块生态 + 会话管理",而它的使用教程
不是本课能承担的责任,也不该由教材承担风险。你需要理解的概念:
| 概念 | 含义 | 对应本课的哪些代码 |
|---|---|---|
| Module | 一个可执行单元(auxiliary/exploit/post) | 本课的 Plugin |
| Auxiliary | 只做扫描/嗅探/枚举,不做利用 | 本课的阶段 1/2 |
| Exploit | 触发漏洞并建立会话 | 本课空实现(阶段 3) |
| Payload | 利用成功后执行的内容 | 本课完全不涉及 |
| Encoder | 变形载荷以规避检测 | 本课完全不涉及(规避检测属于攻击能力) |
| Workspace | 按项目隔离数据 | 本课的 --scope 文件 + 输出目录 |
| Session | 已建立的交互通道 | 本课没有(无利用能力) |
一句话:框架的架构思想可以学,攻击载荷不学也不会影响你把它用在授权测试里——
因为授权测试里真正耗时的 90% 是范围管理、资产盘点、结果复核、报告交付,
而不是"敲 exploit"。
3. 原理深入
3.1 TCP connect 扫描的三态语义
本课用 socket.connect_ex(),它内部就是完整的 TCP 三次握手:
客户端 目标
│──── SYN ──────────────▶│ connect() 发起
│◀─── SYN + ACK ─────────│ 端口有人在监听 → 返回 0 → OPEN
│──── ACK ──────────────▶│ 连接建立(随后我们立即关闭,不发送任何数据)
│──── SYN ──────────────▶│
│◀─── RST ───────────────│ 没有监听者 → ECONNREFUSED → CLOSED
│──── SYN ──────────────▶│
│ (无回应) │ 被防火墙静默丢弃 → timeout → FILTERED
三态的工程含义完全不一样:
| 状态 | errno | 含义 | 报告里应该怎么写 |
|---|---|---|---|
open |
0 | 有服务在监听 | "端口开放,需进一步确认服务与版本" |
closed |
ECONNREFUSED(111) |
主机活着但没有服务 | "主机存活,该端口无服务" |
filtered |
timeout / EHOSTUNREACH |
测不出,有设备在拦 | "不可判定:被过滤或链路问题" |
error |
其他(DNS、权限、网络不可达) | 扫描本身失败 | "该目标本轮无有效结论" |
最常见的错误:把 filtered 统计成 closed。 后果是报告写着
"该网段仅 3 个端口开放",而实际上有 50 个端口因为防火墙静默丢包
而处于"不可判定"状态——这是结论级的错误,不是精度问题。
3.2 为什么不用 SYN 半开扫描(raw socket)
教科书里的"半开扫描"需要 raw socket 自己构造 TCP 包,本课刻意不做,三个原因:
- 需要 root:裸 socket 要
CAP_NET_RAW,而"必须用 root 才能跑"的框架会
诱导使用者全程 root,扩大攻击面。 - 越界风险更高:自己构造包意味着可以构造任意包,范围约束更难保证。
- 合规与观感:半开扫描的行为特征与攻击工具高度相似,
在授权测试里没有必要为了省一次握手去冒这个风险。
connect 扫描的代价是:每次扫描都会在目标留下完整连接记录(会被记日志)。
这不是缺陷,是特性——授权测试应该留下痕迹,且框架应主动告知客户
"你会在日志里看到这些连接"。
3.3 并发模型:线程池 + 令牌桶
扫描是典型的 I/O 密集任务:每个端口 99% 的时间在等超时。
两种可选方案:
| 方案 | 优点 | 缺点 | 本课选择 |
|---|---|---|---|
ThreadPoolExecutor |
代码直观、socket 阻塞语义天然适配 |
线程数上限、GIL(这里无影响,纯 I/O) | ✅ 采用 |
asyncio |
万级并发更省资源 | 需要异步客户端、超时语义更绕 | ❌ 教学成本高 |
但"并发"必须配一个"限速",否则:
- 目标侧连接表被打满(这已经属于拒绝服务的边缘)
- 中间设备(IDS/WAF)把你标记为攻击源,客户安全团队会直接联系你
- 你的结果会因为目标丢包而充满
filtered,数据质量下降
所以框架里有一个令牌桶(token bucket):每秒最多放行 rate 个连接,
并发数 workers 限制同时在途的连接数。两个参数管的是不同的事:
workers → 同时在途的连接数(背压:防止本地端口耗尽、防止把目标打满)
rate → 每秒新增连接数(礼貌:控制对目标/中间设备的冲击)
3.4 编排器:为什么是"阶段对象"而不是"一个大函数"
@dataclass
class Phase:
name: str
fn: Callable[[Framework, list[Asset]], PhaseResult]
optional: bool = False
每个阶段返回 PhaseResult(ok / skipped / failed / reason / findings)。
编排器做三件事:
- 依赖检查:阶段 2 需要阶段 1 的产物,若阶段 1 失败(例如全部 timeout),
阶段 2 应该skipped而不是"在空列表上算出 0 个问题"。
—— 后者会让报告显示"未发现问题",属于静默失败,是最危险的失败模式。 - 统一审计:每个阶段的开始/结束/耗时/产出数量都写进审计日志。
- 失败传播:
critical阶段失败 → 立即终止;optional阶段失败 → 记警告继续。
3.5 审计日志:框架的第一等产物
审计日志的每一行回答四个问题:谁、何时、对什么、做了什么。
{"ts": "2026-09-20T09:10:01+08:00", "event": "scope_check", "subject": "127.0.0.1:8000", "result": "in_scope", "detail": {"ticket": "LAB-SELF-001"}}
{"ts": "2026-09-20T09:10:02+08:00", "event": "probe", "subject": "127.0.0.1:8000", "result": "open", "detail": {"elapsed_ms": 0.6}}
{"ts": "2026-09-20T09:10:03+08:00", "event": "phase_end", "subject": "scan", "result": "ok", "detail": {"assets": 1}}
为什么它比报告更重要?
- 报告是结论(可能出错);审计日志是过程(可以复核);
- 出争议时("你们那天到底扫了什么?")只有日志能自证;
- 它是唯一能证明"范围约束真的生效了"的东西——
因为AuthorizationError会同时写一条scope_check / result=denied记录。
结论:没有审计日志的"渗透测试"无法交付。
4. 核心机制详解:编排状态机与限速实现
4.1 阶段结果的状态机
阶段不能只有"成功/失败"两态,因为"跳过"和"失败"必须被区分:
┌──────────────────────────────────────────────────────────┐
│ 编排器 run(phases) │
└──────────────────────────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ ok │ │ skipped │ │ failed │
│ 有产出 │ │ 依赖不满足│ │ 抛异常 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ │ ├─ optional? → warn + 继续
│ │ └─ critical? → 终止 + 退出码 4
│ │
└───────────┬───────┘
▼
下一阶段:把 skipped 的产物视为"不可用"
(绝不允许在空输入上计算"0 个问题")
用代码表达(pentest_core.py 的实际实现):
@dataclass
class PhaseResult:
name: str
status: str # ok / skipped / failed
reason: str = ""
findings: list = field(default_factory=list)
@property
def usable(self) -> bool:
return self.status == "ok"
编排器里的关键判断(这是整份框架最容易被写错的一行):
for phase in phases:
if not deps_satisfied(phase, done):
result = PhaseResult(phase.name, "skipped", "上游阶段未产出可用资产")
else:
try:
result = phase.fn(self, assets)
except Exception as exc:
result = PhaseResult(phase.name, "failed", f"{type(exc).__name__}: {exc}")
...
为什么"Skipped 必须存在"? 因为报告里的一句话差别巨大:
- ❌ "检测阶段完成,发现 0 个问题"(在 0 个资产上跑的)
- ✅ "检测阶段未执行:资产发现阶段全部超时(filtered),无可用输入"
前者在客户眼里等于"没事",后者意味着"这次测试无结论,需要排期重测"。
把无结论伪装成结论,是自动化测试最严重的缺陷。
4.2 令牌桶限速:算法与实现
令牌桶的规则很简单:
桶容量 capacity(例如 = workers)
每 1/rate 秒补充 1 个令牌(不超过容量)
每次要发一个连接请求 → 先取 1 个令牌;取不到就等待
capacity = 5, rate = 2/s
tokens
5 ┤●●●●●
│ ┌─ 匀速补令牌 ─┐
3 ┤●●● │ +1 每 0.5s │
1 ┤● └──────────────┘
0 ┤ ●───●───●───● ← 取用(每次放行一个连接)
└────┬───┬───┬───┬───┬───▶ t(s)
0 0.5 1.0 1.5 2.0
与"并发上限"的区别再说一遍,因为这两个参数经常被混淆:
| 参数 | 语义 | 调大后的后果 | 调小后的后果 |
|---|---|---|---|
workers |
同时在途的连接数 | 本地端口/TIME_WAIT 堆积,目标连接表压力 | 扫得慢,超时误判增多 |
rate |
每秒新增连接数 | IDS 报警、被客户安全团队叫停 | 速度慢但"礼貌" |
实测经验值(回环/局域网教学环境):workers=8, rate=50/s 足够让
127.0.0.1 的 200 个端口在 1 秒左右扫完;真实授权环境建议从
rate=5/s 起步,根据客户许可往上调——这不是"能多快"的问题,
而是"客户允许多快"的问题。
4.3 超时的选择:为什么是 0.5 秒而不是 0.1 秒
超时值决定 filtered 的判定,而 filtered 是不可判定态,越少越好:
| 超时 | 回环 | 同局域网 | 广域网(跨区域) |
|---|---|---|---|
| 0.1s | 足够 | 大量假 filtered |
几乎全 filtered |
| 0.5s | 足够 | 基本可靠 | 部分假 filtered |
| 1–3s | 浪费(本地毫秒级就返回) | 可靠 | 可靠 |
本课默认 0.5s:在实验环境(回环)下,正常端口的 connect 是微秒级返回的,
超时参数只影响"被丢弃的包"要等多久。报告里必须回显实际超时值——
否则"开放 3 个端口"这个数字无法被复核(用 0.1s 扫出来的 filtered
和用 3s 扫出来的完全是两回事)。
4.4 幂等与去重
同一个 (host, port) 可能被多个阶段/插件重复探测,框架层做两件事:
- 去重队列:
Target是 frozen dataclass,放进set自动去重; - 结果缓存:同一轮运行内
(host, port)只探测一次,
结果存在Framework.cache,第二次命中直接复用(并记录cache_hit)。
为什么重要?因为重复探测会污染审计日志:日志里出现 3 次同一端口,
复核者无法判断是"框架重试"还是"三个人在扫"。缓存让日志与事实一一对应。
4.5 失败重试:只重试"网络抖动类"错误
不是所有失败都值得重试:
| 错误类型 | 重试? | 理由 |
|---|---|---|
ECONNREFUSED |
❌ 不重试 | 这是有效结论(端口关闭),重试只是浪费 |
| timeout | ✅ 重试 1 次 | 可能是抖动;重试后仍超时才是 filtered |
EHOSTUNREACH |
✅ 重试 1 次 | 可能是路由收敛中 |
AuthorizationError |
❌ 绝不重试 | 越界是策略拒绝,重试等于绕过门禁 |
socket.gaierror |
❌ 不重试 | 本课不接受域名目标(避免 DNS 外发) |
最后一条是安全设计原则:越界错误绝不能进入重试路径,
否则"重试逻辑"会变成"绕过范围校验的旁路"。
5. API 速查
5.1 pentest_core 模块速查
| 名称 | 类型 | 作用 | 关键点 |
|---|---|---|---|
Scope |
dataclass | 授权范围 | 黑名单优先;allow_raw_sockets/allow_write_actions 恒为 False |
Scope.check(target) |
方法 | 唯一的越界出口 | 不通过抛 AuthorizationError |
Scope.load(path) |
类方法 | 从 JSON 载入 | 缺 networks/ports 直接 ScopeError |
parse_host_spec(spec) |
函数 | IP/CIDR → 主机列表 | 拒绝域名(避免 DNS 外发) |
parse_port_spec(spec) |
函数 | "80,8000-8010" → 端口列表 |
有序去重,越界端口报错 |
Target(host, port) |
frozen dataclass | 探测单元 | frozen ⇒ 可进 set 去重 |
AuditLog.record(event, subject, result, **detail) |
方法 | 写过程证据 | 可选 JSONL 落盘 |
tcp_probe(target, timeout, retries) |
函数 | 只读探测 | 只读 banner,不发送任何字节 |
TokenBucket(rate, capacity).acquire() |
类 | 限速 | 返回本次等待秒数 |
Framework.build_targets(hosts, ports) |
方法 | 表达式 → 去重 Target 列表 | 只做解析,不校验 |
Framework.authorize(targets) |
方法 | 批量校验 | fail-fast:任一越界即中止 |
Framework.probe_many(targets) |
方法 | 线程池 + 限速探测 | 结果顺序与输入一致 |
Framework.run_phases(phases, assets) |
方法 | 阶段编排 | 空资产 ⇒ 下游 skipped |
Finding |
dataclass | 发现项 | priority = f(severity, confidence) |
build_report(...) / render_markdown(...) |
函数 | 报告 | 必含范围/参数/覆盖/审计统计 |
LabServer({port: greeting}) |
类 | 回环实验服务 | 仅用于自测 |
5.2 三态 + 退出码语义对照(最容易记混的两组)
| 概念 | 取值 | 判据 | 报告措辞 |
|---|---|---|---|
| 端口状态 | open |
connect_ex == 0 |
有服务在监听 |
closed |
errno == 111 |
主机存活、该端口无服务 | |
filtered |
timeout / errno == 11 |
不可判定(被过滤或链路问题) | |
error |
其他 OSError / gaierror | 本轮无有效结论 | |
| 退出码 | 2 |
越界 / 参数非法 | 不可执行,修范围重跑 |
4 |
存在 filtered / error | 无结论,需换路径复测 | |
3 |
有 P0/P1 发现 | 进人工复核队列 | |
0 |
干净完成 | 归档 |
优先级:2 > 4 > 3 > 0。
5.3 优先级公式
severity ∈ {info=0, low=1, medium=2, high=3, critical=4}
confidence ∈ {info=0, low=1, medium=2, high=3}
priority_score = (severity + 1) × (confidence + 1)
≥ 20 → P0 ≥ 12 → P1 ≥ 6 → P2 其余 → P3
例:version_disclosure = low + high = 2×4 = 8 → P2;
cleartext_protocol = high + medium = 4×3 = 12 → P1;
banner_evidence = info + high = 1×4 = 4 → P3(事实记录,不丢弃)。
5.4 参数速查(报告必须回显这几个)
| 参数 | 默认 | 含义 | 调大的后果 | 调小的后果 |
|---|---|---|---|---|
timeout |
0.5 s | 单次 connect 等待 | 扫描变慢 | 假 filtered 增多 |
workers |
8 | 同时在途连接数 | 本地端口耗尽、目标压力大 | 慢 |
rate |
50 /s | 每秒新增连接数 | 触发 IDS/客户投诉 | 慢但礼貌 |
retries |
1 | 抖动类错误重试次数 | 超时类错误被掩盖 | 抖动被记为不可判定 |
6. 实战代码案例:一次完整的框架运行
下面的输出全部来自 04-pentest-framework.py 的实测输出(本机 Linux + Python 3.12,
实验服务:8000 伪装 SSH、8080 伪装 HTTP;授权范围回环 + 8000-8099;扫描端口 8000-8080)。
6.1 正常运行(有开放端口、无可判定缺口)
$ python3 code/04-pentest-framework.py lab --ports 8000-8080 --rate 500
[lab] 启动本机实验服务: [8000, 8080](仅回环)
[scope] networks=['127.0.0.1/32'] ports=[(8000, 8099)] ticket=LAB-SELF-001
[collect] 目标 81 个 → open 2 / closed 79 / filtered 0 / error 0 (throttle_wait=0.981s)
[detect] 插件 4 个 → findings 9
[exploit] skipped:本课不提供利用能力;输出人工验证清单,无网络行为
[report] 已写入 days/day-161-渗透测试框架/out/day161-lab-report.json / ...md
[exit] 3
findings 9 的构成(实测):7 条来自 4 个检测插件,2 条来自 exploit 占位阶段
的 needs_manual_verification(每台开放端口的主机 1 条):
| 优先级 | 插件 | 目标 | severity/confidence |
|---|---|---|---|
| P1 | cleartext_protocol |
127.0.0.1:8080 | high / medium |
| P2 | version_disclosure |
127.0.0.1:8000 | low / high |
| P2 | version_disclosure |
127.0.0.1:8080 | low / high |
| P3 | banner_evidence |
127.0.0.1:8000 | info / high |
| P3 | banner_evidence |
127.0.0.1:8080 | info / high |
| P3 | high_port_service |
127.0.0.1:8000 | info / medium |
| P3 | high_port_service |
127.0.0.1:8080 | info / medium |
| P3 | needs_manual_verification |
8000 / 8080 | info / info |
报告节选:
## 覆盖与结论可信度
- 目标数 81:open 2 / closed 79 / filtered 0 / error 0
- **可判定 81 条;不可判定 0 条**(filtered = 被过滤或链路问题,不能当作关闭)
## 开放端口与服务
| 目标 | 服务 | banner(截断) |
| --- | --- | --- |
| 127.0.0.1:8000 | ssh | SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.4 |
| 127.0.0.1:8080 | http | HTTP/1.1 200 OK Server: lab-nginx/1.18.0 |
6.2 越界必须立即终止(这是重点)
$ python3 code/04-pentest-framework.py scan --hosts 10.0.0.5 --ports 8000
⚠️ 未指定 --scope,使用默认实验范围(仅回环 8000-8099)。
[scope] networks=['127.0.0.1/32'] ports=[(8000, 8099)] ticket=LAB-SELF-001
⛔ AuthorizationError: 目标不在授权网段内: 10.0.0.5
(已记入审计日志:scope_check denied + run_abort exit=2)
[exit] 2
注意 denied 这一条也被写进了审计日志——"我拒绝了一个请求"同样是证据。
6.3 结果不完整(存在 filtered)时退出码是 4
用不可路由的文档网段(192.0.2.0/24,RFC 5737)造出"不可判定"场景:
让范围文件允许它,但那里没有服务会应答。
$ cat /tmp/scope192.json
{"networks": ["192.0.2.0/24"], "ports": [[8000, 8002]], "ticket": "LAB-DEMO"}
$ python3 code/04-pentest-framework.py scan --hosts 192.0.2.1 --ports 8000-8002 \
--scope /tmp/scope192.json --timeout 0.2 --tag demo-inconclusive
[scope] networks=['192.0.2.0/24'] ports=[(8000, 8002)] ticket=LAB-DEMO
[collect] 目标 3 个 → open 0 / closed 0 / filtered 3 / error 0 (throttle_wait=0.000s)
[detect] skipped:上游阶段无可用资产(全部 filtered/error)
[exploit] skipped:上游阶段无可用资产(全部 filtered/error)
[exit] 4
这里最值得看的是 skipped:不是"发现 0 个问题",
而是"检测阶段未执行"。同一个空 findings 列表,两种措辞,两种完全不同的含义。
(这也是开发期真实踩到的坑:早期 tcp_probe 忘了 sock.settimeout(timeout),
回环测试完全正常,一遇到不可达目标就卡住几十秒——详见坑 13。)
7. 常见陷阱(13 个,对照表;详细复现见 code/03-pitfalls.py)
| # | 陷阱 | 症状 | 正确做法 |
|---|---|---|---|
| 1 | 把 filtered 当 closed |
报告谎称"仅 N 个端口开放" | 三态分开统计,回显不可判定数量 |
| 2 | 先连接再判断范围 | 越界包已经发出去了 | 校验在 socket() 之前 |
| 3 | 把 ECONNREFUSED 当"主机不存在" |
丢失"主机存活"情报 | closed ⇒ 主机存活 + 该端口无服务 |
| 4 | 超时拍脑袋设 0.05s | 假 filtered 遍地 |
按网络距离选值并回显 |
| 5 | 并发拉满无限速 | 事实上的拒绝服务边缘 | 令牌桶 + 由客户许可决定 rate |
| 6 | 重试策略写反 | 抖动被误判 / 越界被重试 | 只重试抖动类;越界绝不重试 |
| 7 | 范围里写域名 | 解析即外发 | 只接受 IP/CIDR |
| 8 | 空输入算出 0 个问题 | "无结论"伪装成"没问题" | 上游无资产 ⇒ 下游 skipped |
| 9 | 重复探测不缓存 | 审计日志污染 | Target 去重 + 结果缓存 |
| 10 | 直接写 banner 进报告 | 目标注入控制字符/Markdown | 净化 + 截断 + 标注不可信 |
| 11 | 用 time.time() 计时 |
校时/改时钟导致负数 | 计时用 perf_counter |
| 12 | 报告不回显范围与参数 | 结论不可复核 | 范围/参数/覆盖/审计统计必有 |
| 13 | 收了 timeout 却没 settimeout |
回环正常、遇到不可达目标就卡死 | sock.settimeout(timeout);用不可达目标验证耗时≈timeout |
8. 局限与伦理边界(必须写进交付报告,不能省略)
能力上的局限
- 只做阶段 1–2:不做利用、不做后渗透,因此任何 Finding 都不是"已验证的漏洞",
只是"需要人工验证的点位"。措辞上必须区分"疑似"与"确认"。 - 探测量级有限:只有 TCP connect 与 banner 读取,没有 UDP、没有 SNMP、
没有认证类探测。资产盘点必然是不完整的。 - 无情报关联:不知道某个 IP/服务版本在真实世界里的被利用情况,
置信度天花板因此较低。 - 未评测检出率:没有在带标签的授权环境中计算 precision/recall,
不能替代商业扫描器,也不能作为合规审计的唯一依据。
刻意不做(设计选择,不是遗漏)
| 不做的事 | 为什么不做 |
|---|---|
| 提供 exploit / 载荷 | 教材不承担武器化风险;授权测试的瓶颈在流程与证据,不在载荷 |
| 提供规避检测(encoder/混淆) | 规避能力只对"未授权攻击"有意义;授权测试应留下痕迹 |
| 自动处置(封 IP、改配置) | 误报会直接升级为生产事故;处置必须走独立的授权与变更流程 |
| raw socket / SYN 扫描 | 需 root、扩大攻击面、行为特征与攻击工具难以区分 |
| 自动扩大范围("顺手扫一下邻居") | 越界不是技术失误,是合规事故 |
9. 思考题
- 本框架把
filtered单列并让退出码变成 4。如果客户只想要一个"扫描结果",
为什么"本次无结论"这个结论反而更重要?请从责任边界角度回答。 - 范围校验选择 fail-fast(一个越界就整轮终止)而不是"跳过越界目标继续"。
在什么情况下你会反过来选择"跳过并记警告"?这两种选择分别让谁承担风险? - 插件注册表让"加检测项"变容易了,但也让"执行顺序"变得隐式依赖
priority。
如果两个插件分别需要"先有 banner"和"先有服务推断",你如何用数据结构
而不是文档约定来保证顺序?(提示:声明式依赖 vs 数字优先级) - 审计日志会记录"我对哪个 IP 的哪个端口做了什么"。这份日志本身也是敏感数据:
如果它泄露,攻击者能获得什么?你会在留存期限和脱敏粒度上怎么设计? - 本课刻意不提供利用能力。假设某天你所在的团队要加上"验证性利用",
你会要求哪些前置条件(授权、隔离环境、回滚方案、审批留痕)才允许合并?
列出至少 4 条,并说明每条对应哪一类风险。 rate参数决定了扫描压力。假如客户说"我们的 WAF 会把超过 20 连接/秒
的来源封禁 1 小时",你如何设计一个既能完成扫描、又不会被封的策略?
(提示:不要试图规避 WAF —— 应该去和客户约定白名单或维护窗口。)