
做防守方这些年我最怕的不是攻击手法有多新而是“懂行的人太少”。红队报告交上来密密麻麻几十页核心结论往往就一句话“我们发现了高危漏洞”。拿去问开发怎么复现怎么证明修好了报告上没有。拿去问老板这次渗透测试做完我们的安全水位到底涨了没你也答不出来。这不是某个团队的问题是整个渗透测试行业的常态——测试结论严重依赖执行者个人经验过程不可复现结果不能自动断言。直到我在 arXiv 上刷到一篇有意思的预印本讲的是把渗透测试拆成“pytest 能断言的子任务”再用类似 Google 记分法的加权模型给整个攻击面打分。这篇东西的落点其实很妙表面上看是给攻击方做自动化武器论文里却明确把防守方当作最重要的用户——你完全可以借用这套框架把红队的思路搬到自己的 CI 里定期跑一遍“自我渗透”。这篇文章就围绕这个思路展开聊聊子任务怎么拆、断言写在哪、“Google 式记分法”到底在记什么分以及我们自己落地时会踩哪些坑。1. 先说清楚这篇论文想解决的问题渗透测试的结果为什么不能“自动复验”1.1 传统渗透测试报告的信任危机我见过太多“一次性渗透测试”的尴尬局面。测试团队进场拿着 Kali 跑一圈扫描器再人工测几个核心接口最后丢出一份 PDF。报告里写“存在 SQL 注入风险”但开发复现的时候发现当时的注入 payload 已经失效了写“某接口存在越权”但没人记录当时使用的 cookie 和请求顺序。三个月后再看这份报告基本就是废纸。更麻烦的是结论之间的横向比较。这轮测试发现 5 个高危、3 个中危下一轮说发现 2 个高危、6 个中危那安全水位到底是变好了还是变差了CVSS 分数看着客观但打分的人不同同一个漏洞能差出三四分。这种不可复现、不可量化的问题本质上是整个流程缺少了“自动化测试框架”里的那层断言——你没有把“漏洞存在”这个结论变成机器可执行的、可反复验证的判定条件。1.2 子任务状态机把攻击路径变成可测试的离散步骤这篇 arXiv 预印本的核心思路是把一次渗透测试看作一条攻击路径上的有限状态机。状态机里的每一个节点都是一个可以独立判定达没达成的子任务。举个例子完整攻击链可能是探测开放端口 → 发现 Spring Boot Actuator 端点 → 通过 /actuator/env 拿到明文密钥 → 用密钥登录内部管理后台 → 在后台找到命令执行点。传统渗透测试里这是一段“流畅的叙述”但在论文的框架里它被拆成五个离散子任务每一个都有明确的前置条件precondition和后置条件postcondition。前置条件决定了这个子任务“能不能开始测”比如“只有确认 8080 端口开放才去测 Actuator”。后置条件就是你要断言的结论比如“HTTP 请求 /actuator/env 的响应码不是 404且响应体里包含关键词”。只有当后置条件为真状态机才会推进到下一个阶段。任何一个子任务后置条件不满足攻击路径就断在这里。这套做法的好处很直接渗透测试不再是“一个人讲完一个故事”而是“一条流水线上一堆可以独立运行、独立复验的测试用例”。1.3 为什么偏偏选 pytest而不是自研框架或 Burp 插件论文里把 pytest 作为执行引擎我一开始也觉得有点“大炮打蚊子”仔细想想其实很合理。第一pytest 的断言机制就是为这种“验证某个条件是否成立”的场景设计的。assert 一个状态码、assert 一个响应体、assert 一个时间差这些都是原生能力。用 fixture 管理会话、cookie、认证 token也非常顺手。第二生态不需要你重新发明轮子。pytest 有插件体系有 conftest.py 做共享配置有 parametrize 做参数化用例有 marker 做用例分级。Allure 报告可以直接把测试结果变成图表这对安全团队汇报来说是天然的加分项。第三CI/CD 集成几乎零成本。你不需要维护一个独立的渗透测试平台只需要在 Jenkins 或者 GitLab CI 里加一个 stage跑一遍 pytest然后等 exit code。这套逻辑和普通代码测试完全一致安全团队和开发团队说的是同一种语言。2. 子任务拆解的实操逻辑断言到底应该写在哪2.1 拆解层次从侦察到持久化的五种状态迁移真正动手把渗透测试拆成子任务的时候最大的难点不是“怎么断言”而是“按什么粒度拆”。拆得太粗断言不够稳定拆得太细用例数量爆炸维护成本比收益还高。论文给了一个可操作的框架按攻击阶段拆每个阶段内部再按“状态迁移”定义子任务。我按自己的实践经验一般会分成五层侦察层Reconnaissance确认目标资产存在、端口开放、服务指纹识别。断言的通常是“某个端口 TCP 连接成功”“某路径返回非 404”“响应头里包含某中间件特征”。扫描层Scanning在侦察结果基础上做漏洞探测。断言的一般是“某请求响应中匹配到漏洞特征”“某接口返回未经授权的数据”。利用层Exploitation验证漏洞是否真的可被利用。断言往往会升级比如“成功读取到 /etc/passwd 的前几行”“命令执行返回了 whoami 的输出”。横向移动层Lateral Movement验证从当前节点是否能触达内部其他资源。断言通常是“某个内网 IP 的端口可达”“某类凭据能被重复使用”。持久化层Persistence验证攻击痕迹是否能长期留存。断言可能是“计划任务文件已写入并保存”“服务在重启后仍然存活”。每一层的输出恰好是下一层的输入。这就很像流水线里工序之间的交接上一道工序的后置条件就是下一道工序的前置条件。2.2 断言的本质不是判断“有没有漏洞”而是判断“状态变没变”这是论文里最值得细品的一个观点也是我踩过坑之后才真正理解的断言的本质不是判断“目标有没有漏洞”而是判断“目标系统在测试前后的状态有没有发生允许范围内的变化”。很多新手写安全自动化测试时习惯把断言写成“目标必须有漏洞”比如 assert sql injection 出现在响应里。这有两个问题一是扫描器误报率高你断言一个特征结果特征在其他场景本身就该存在二是你把自己和漏洞扫描器绑死了没能体现出渗透测试的“证据链”属性。更好的做法是断言状态变化。比如验证一个越权漏洞不是断言“接口返回了 user_id1 的信息”而是断言“使用低权限账号 A 的 session 请求高权限接口时HTTP 状态码从 401/403 变成了 200且响应体里出现了本应只有高权限账号 B 可见的数据”。前者是特征匹配后者是状态迁移验证后者的误判率会低一个数量级。2.3 示例一个真实的子任务断言设计我这里写一个实际可跑的示例主题是检测 Spring Boot Actuator 配置不当暴露。这个问题在真实渗透测试中常见而且非常适合做成断言子任务。# conftest.py import pytest import requests pytest.fixture(scopesession) def target_host(): 测试目标地址建议通过环境变量注入比如 TARGET_HOSThttp://10.0.0.5 import os return os.environ.get(TARGET_HOST, http://127.0.0.1:8080) pytest.fixture(scopesession) def http_session(): session requests.Session() # 设置超时和通用头避免被目标 WAF 直接拦截 session.headers.update({User-Agent: Mozilla/5.0 (SecurityAssertion/1.0)}) return session# test_subtask_actuator_exposure.py import pytest import requests class TestActuatorExposure: 子任务未授权访问 Actuator 端点 # 参数化要探测的端点 pytest.mark.parametrize(endpoint, [/actuator/env, /actuator/health, /actuator/beans]) def test_actuator_endpoint_not_authorized( self, target_host, http_session, endpoint ): # 前置条件目标服务在监听 probe http_session.get(target_host, timeout5, allow_redirectsFalse) assert probe.status_code not in [502, 503], 目标服务不可达跳过子任务 # 核心断言未认证请求不应返回 200 resp http_session.get( f{target_host}{endpoint}, timeout5, allow_redirectsFalse, ) assert ( resp.status_code 200 ), f未授权访问 {endpoint} 未成功响应码为 {resp.status_code} # 关键断言响应里确实泄露了敏感信息 if endpoint /actuator/env: assert resp.json(), 响应体为空无法证明存在信息泄露 keys resp.json().keys() assert ( len(keys) 0 ), env 端点响应内容为空疑似只是框架默认页这段代码里有两个值得提的细节。第一前置条件先请求根路径排障否则目标都宕机了后面的断言失败没有意义。第二断言分两级先断言“未授权拿到了 200”再断言“响应体里确实有数据”两级都通过这个子任务才算达成。这就是把“渗透测试人员的判断”翻译成“机器可执行的断言”的过程也是这套框架能落地的原因。3. Google 式记分法的内核防守方终于有了统一度量衡3.1 四个评分维度严重度、可达性、业务影响、持久性论文里把“Google 式记分法”作为一个卖点我认为重点不是山寨某家公司的内部评分而是吸收了 Google 安全评审里那种“多维加权”的思路。它不像 CVSS 那样试图构造一个绝对指标而是针对自动化子任务输出一个可比较、可行动的分数。拆开来看我给子任务评分时一般会给四个维度维度权重说明示例Severity严重度40%该子任务达成后对机密性/完整性/可用性的损害读取到云厂商密钥10探测到服务器版本2Reachability可达性30%达成该子任务所需的前置条件是否容易被触达无需认证3需要拿到内网主机权限1Business Impact业务影响20%该子任务涉及的资产是否是核心资产涉及支付交易链路5涉及后台报表2Persistence持久性10%达成后攻击结果是否容易被清除写入 cron 持久化4临时拉起一个端口1每个维度打分后按权重加权求和得到一个 0 到 10 的分数。这个分数不应该用来“报告里有面子”而是直接映射到防守动作8 分以上的子任务今天必须出修复方案并且要在修复后把对应子任务重新跑一遍断言4 到 7 分的本周内进迭代3 分以下的进观察清单。3.2 关键路径权重像搜索引擎那样看待“必经节点”我理解的“Google 式记分法”还有第二层含义就是关键路径权重。一个子任务即使本身的严重度不高但如果它是很多条攻击路径的必经节点那它的实际风险就比孤立看高很多。打个比方就像搜索引擎里某个网页本身内容一般但大量高质量页面都链接到它那它的排序就会上升。攻击图里也一样一个不起眼的“测试接口未关闭”如果同时是三条横向移动链路的起点那它的价值就要被放大。论文里提到可以借鉴 PageRank 的思路对攻击图做迭代加权计算子任务 A 被多少个子任务指向、A 又指向多少个子任务这些关系会迭代收敛出一个稳定的权重。落到工程实现上你不需要真的去算 PageRank我建议的做法是给每个子任务标注“依赖下游子任务 ID 列表”然后做一个简单的递归权重累加。你很快会看到哪些节点是“枢纽节点”这些枢纽节点通常就是防守方最应该优先处理的地方。3.3 分数怎么用从“被攻陷”到“距离业务风险还有几步”记分法对防守方的真正价值不是给你一个“安全得分 87 分”的仪表盘而是让你能回答那个我开头提到的问题我们的安全水位涨了没。假设你设置了 30 个断言子任务。第一轮跑完有 12 个子任务达成攻击链最深走到“拿到数据库只读权限”业务风险分是 6.2。你修复了其中 8 个子任务第二轮跑完只有 4 个子任务达成攻击链最深只到“读取到服务器版本号”业务风险分降到了 3.1。这个对比就很直观不是“漏洞数少了几个”而是“攻击链最深走到哪一步”发生了肉眼可见的变化。这套方法还有一层额外的好处——分数的变化能暴露测试本身的退化。如果某次升级后原本失败的子任务意外通过了但你并没有做过相关修复那很可能不是变安全了而是应用版本变化导致攻击路径变了。这种信号比任何报告都有价值。4. 防守方落地把红队的武器搬进自己的 CI4.1 环境准备pytest 基础与目录设计想把这套框架落地建议先从最小闭环开始不要上来就铺几百个用例。我的建议是单独建一个 security-tests 仓库不要和业务测试混在一起否则权限模型和运行频率都很难独立控制。建议的目录结构security-tests/ ├── conftest.py ├── requirements.txt ├── tests/ │ ├── recon/ # 侦察层子任务 │ ├── scan/ # 扫描层子任务 │ ├── exploit/ # 利用层子任务 │ ├── lateral/ # 横移层子任务 │ └── persistence/ # 持久化层子任务 ├── scoring/ │ ├── calculator.py # 记分逻辑 │ └── attack_graph.py # 关键路径权重 └── reports/ └── allure-results/requirements.txt 里主要装 pytest、requests、allure-pytest。如果你们有更复杂的目标协议可以再加 paramikoSSH、sqlalchemy数据库校验、seleniumWeb 端链路等但第一版别加太多跑通为先。运行方式非常简单TARGET_HOSThttps://staging.example.com pytest -v --alluredirreports/allure-results建议先在 staging 或灰度环境跑等用例库稳定了再考虑对生产环境做只读类型的探测。写进 CI 时用一个专门的定时器或者开发合并后的触发器而不是每次提交都全量跑避免引入过长的流水线耗时。4.2 第一个可断言的渗透测试用例除了前面那个 Actuator 示例我再给一个更贴近业务层的例子验证“登录接口是否存在账号枚举”。这个用例很经典、执行成本低、结论也容易向非安全同事解释。# tests/scan/test_login_enumeration.py import pytest import requests pytest.mark.parametrize(username, [admin, not_exist_user_9527]) def test_login_response_difference(target_host, http_session, username): 子任务判断登录失败响应是否存在可枚举差异 resp http_session.post( f{target_host}/api/login, json{username: username, password: WrongPwd_2025}, timeout5, ) # 记录响应特征状态码、消息体、响应时长 result { username: username, status_code: resp.status_code, body: resp.text, elapsed_ms: resp.elapsed.microseconds / 1000, } print(result) # 本用例只输出数据是否构成枚举漏洞由断言子任务 decide 层判断这段代码其实只算“数据采集用例”真正的断言我会放在下一个用例里把不同用户名的响应结果拉齐做差def test_enumeration_signal( target_host, http_session, fixture_known_usernames ): # 用一个绝对不存在的用户名做基线 baseline_resp http_session.post( f{target_host}/api/login, json{username: definitely_not_exists_0001, password: WrongPwd_2025}, timeout5, ) baseline_text baseline_resp.text baseline_code baseline_resp.status_code # 对目标用户名发起同样的请求 target_resp http_session.post( f{target_host}/api/login, json{username: admin, password: WrongPwd_2025}, timeout5, ) assert ( target_resp.status_code ! baseline_code or user not found in target_resp.text and user not found not in baseline_text ), 未发现账号枚举信号或者响应完全一致写这种断言时要注意一个细节不要把两个请求放在同一个用例里就完事要记录基线请求的完整上下文UA、时间戳、甚至源 IP因为很多系统会有全局限流或 IP 封禁策略基线请求被封了结论就失真了。4.3 把结果接进 Allure 与安全看板用例跑完只是第一步真正让团队愿意用起来的是报告。Allure 是 pytest 生态里很顺手的报告方案跑完 pytest 后用 allure generate 就能出一份带用例执行历史、失败趋势和步骤截图通过钩子抓取的报告。allure generate reports/allure-results -o reports/allure-report allure open reports/allure-report我通常会额外写一个很简单的 python 脚本读 pytest 的 JUnit XML 输出把我们前面定义的子任务 ID 和分数映射到安全看板。这样每轮测试一结束看板上的攻击链深度和业务风险分就会自动刷新。这件事本身不复杂但需要让 scoring 模块和测试用例一一对应。我的经验是用例名直接用“子任务 ID_描述”格式命名比如 test_subtask_0402_actuator_env_exposure这样脚本解析时省掉大量映射 work。5. 自动化渗透测试的边界以及论文没讲清楚的部分5.1 断言脆弱性一次误报足以让整个机制停摆任何跑过自动化安全测试的团队都会遇到断言脆弱性的问题。你断言“响应体里包含 password”结果目标页面有一位员工在博客里写“password is hard”。你断言“未授权返回 200”结果目标的网关默认对未匹配路由返回 200 空页面。误报一旦出现团队第一反应就是“这工具不准”然后所有人不再看报告。我的处理办法是断言宁可写得保守也不写激进最重要是确认“我们到底在证明什么”。一个粗粒度但稳定的断言远好过“看起来很精确但三天两头误报”的断言。论文把断言作为核心卖点但真实世界里断言本身就是最大的不稳定因素需要在落地时大量依靠 baseline 对比和灰度观察来校正。5.2 动态环境里的对抗因素自动化断言最怕的就是目标系统“会变”。测试环境里的动态令牌、每次启动随机生成的密码、前端加密逻辑、WAF 的 JS 挑战都会让原本正常的子任务瞬间全部失败。论文里没有详细讨论这层对抗因素但实际工程里非常折磨人。我的应对建议有三个。第一所有用例前置步骤尽量通过 fixture 统一封装比如获取动态令牌、完成 JS 挑战、初始化会话都放在 conftest.py 里不要散落在各个用例中。第二在 CI 里设置复杂的重试策略网络抖动导致的失败重试一次认证过期导致的失败重新登录后再跑一次。第三给测试环境做“快照基线”保证每次测试之前环境状态可控避免上一次测试留下的脏数据影响本轮断言。5.3 分数被游戏化的风险只要引入分数就一定会有人“刷分”。开发团队可能为了让分数好看直接给某个子任务相关的功能下线防守团队可能为了 KPI只挑容易的断言用例来扩大分母。论文里把记分法包装得很美好但它没法解决组织里的激励问题。我的经验是不要让单个分数成为团队 KPI而是让“分数变化趋势”和“攻击链最深到达点”成为讨论对象的原始数据。避免把安全分数和绩效完全挂钩否则分数很快就会变成政治工具。你要的是团队把精力放在修复子任务上而不是修饰数字。5.4 授权边界与合规问题如果把整套框架接进 CI等于让自动化工具持续扫描你们自己的资产。表面上看是自家业务没问题但实际有几个容易忽略的边界。第一资产归属要清楚。如果你们使用了第三方 CDN、云厂商托管服务自动化探测可能会覆盖到不属于你管辖的组件合规上要留个心眼。第二免责声明和授权文件是必须的至少要在项目 README 里明确说明“这套自动化扫描仅允许在已获得授权的环境中运行”。第三payload 里的某些字符串要控制不要引入真实的攻击载荷否则流量镜像被审计时会产生不必要的麻烦。我一般只用探测性质的数据比如一个唯一的随机字符串、一次无害的 HEAD 请求以判定状态迁移为目的就够用了。这轮实践做下来我个人最大的体会是安全测试断言的真正敌人不是“未知漏洞”而是“不可复现的过程”。把渗透测试拆成 pytest 可断言的子任务再加一套统一记分法核心价值不是让攻击方省事而是让防守方终于可以每天、每周用同样的尺子去量自己有没有变好。代码层面、评分模型层面都有很多糙活儿要处理但方向是对的。如果团队里有一两个愿意折腾 pytest 的同事这门手艺值得尽快铺开。