38.1 威胁建模 (STRIDE)
一句话
威胁建模不是“列一张攻击清单”,而是先画数据流与信任边界、再用 STRIDE 六类把每个格子的威胁认领干净、最后按“可利用性 × 影响”排序——产出必须是“改哪里、谁改、多久改完”,不是一张吓人的表。
五个流行说法,先拆掉
| 误区 | 现实 |
|---|---|
| 威胁建模 = 安全评审会 | 评审会只有结论没有格子。产出物是可关闭的缓解项,每条带 owner 与期限;没落到 backlog 的威胁等于没建模 |
| 只对登录/支付做 | 每条跨信任边界的数据流都要过一遍。登录只是 S(伪造)最集中的地方,I(泄露)和 T(篡改)大多在内部服务之间 |
| 一次做完归档,年度复审 | 威胁模型跟着架构变:加一个内部接口、改一次鉴权、接一个第三方,都要重跑受影响的边界。架构变更不触发威胁建模 = 新边界默认无防护 |
| 用攻击库/漏洞库逐条对照 | 漏洞库是症状,STRIDE 是分类。拿 CVE 清单对不上你的数据流,因为清单里没有你的信任边界 |
| 威胁越多越安全 | 威胁不分级 = 没排优先级。24 条威胁里前 33% 吃掉近一半风险,把清单铺平只会让团队修最便宜的那几条 |
STRIDE:六类威胁 = 六条被违反的安全属性
STRIDE 的价值在于“每类威胁对应一个可以写进需求的安全属性”,所以它天生能落地:
| 类别 | 破坏什么 | 一句话问法 | 典型落点 |
|---|---|---|---|
| Spoofing 伪造 | 认证 Authentication | 他凭什么证明“他是他” | token 可复制、共享密钥、无设备绑定 |
| Tampering 篡改 | 完整性 Integrity | 数据从产生到落地,哪一段可被改而无人知 | 金额取自客户端、回调不验签、应用账号有 DDL 权限 |
| Repudiation 抵赖 | 不可否认 Non-repudiation | 事后能不能还原“谁在何时做了什么” | 无审计事件、日志无主体、无 binlog 归档 |
| Information disclosure 泄露 | 机密性 Confidentiality | 谁能读到本不该读的数据 | 越权查单、备份桶公共读、错误页回显堆栈 |
| Denial of service 拒绝服务 | 可用性 Availability | 一个请求能放大成多少资源消耗 | 无限流、无超时、大 SQL 拖垮实例 |
| Elevation of privilege 提权 | 授权 Authorization | 普通身份能不能拿到管理员能力 | 调试路由未摘除、反序列化不限类型 |
🔑 六类不是六份清单,是六个提问角度。 同一个元素要挨个问六遍——只问“会不会被黑”得到的是一句“应该不会”,挨个问 S/T/R/I/D/E 得到的是六个可验收的答案。
第一步:DFD 元素 × STRIDE 矩阵
威胁枚举不能凭经验,先按元素类型确定“这个格子该检查哪几类”。这张矩阵是 STRIDE 的标准用法:
| 元素类型 | 示例 | 适用威胁 | 为什么是这几类 |
|---|---|---|---|
| 外部实体 | 浏览器、运营后台、第三方支付 | S、R | 它在你控制之外:身份只能被“声称”,行为只能被“记录” |
| 进程 | API 网关、订单服务 | S、T、R、I、D、E | 代码在这里跑,六类全占 |
| 数据存储 | MySQL 订单库、对象存储 | T、R、I、D | 进程已认证,存储层只关心数据被谁改、被谁读、能不能删 |
| 数据流 | 支付回调、Kafka 事件 | T、I、D | 传输中的东西:能被改、能被旁听、能被洪泛,但不能“自己提权” |
一个 7 元素的电商下单链路,按矩阵算出 26 个待检查格子:
| 元素 | 类型 | 应检查类别 | 格子数 |
|---|---|---|---|
| 浏览器 | 外部实体 | S、R | 2 |
| 运营后台 | 外部实体 | S、R | 2 |
| API 网关 | 进程 | S、T、R、I、D、E | 6 |
| 订单服务 | 进程 | S、T、R、I、D、E | 6 |
| MySQL 订单库 | 数据存储 | T、R、I、D | 4 |
| 支付回调 | 数据流 | T、I、D | 3 |
| Kafka 订单事件 | 数据流 | T、I、D | 3 |
| 合计 | 26 |
26 个格子就是“这个系统的威胁建模做完了没有”的分母。 实际认领 24 条、覆盖 92.3%,剩下 2 个空格(Kafka 的 D 类)要么写出威胁,要么写明“为什么不适用”——空着就是漏项。
第二步:信任边界决定“哪几个格子最贵”
元素是坐标,信任边界是权重。每跨一条边界,就多一个攻击面和一个授权决策点:
| 边界 | 从 → 到 | 这段上最贵的威胁 | 常见漏洞 |
|---|---|---|---|
| ① 公网 → 网关 | 任意客户端 → API 网关 | S、T、D | 签名不校验来源、header 可注入内部身份 |
| ② 网关 → 服务 | 网关 → 订单服务 | T、E | 调试路由、内部头未剥离、服务间共享密钥 |
| ③ 服务 → 存储 | 订单服务 → MySQL | T、I | 应用账号 DDL 权限、备份未加密 |
| ④ 服务 → 消息 | 订单服务 → Kafka | T、I | 任意 topic 可写、事件带手机号明文 |
| ⑤ 第三方 → 回调 | 支付平台 → 支付回调 | T、D | 不验签、重试无幂等键 |
| ⑥ 内网 → 后台 | 运营后台 → 内网服务 | S、R | 账号共享、改价无审批留痕 |
⚠️ 边界②最容易被漏掉:它是“内网”,团队默认它安全。可 header 注入就发生在这里——网关把
X-User-Id写进去,后端无条件信任,客户端只要自己能设这个头就完成了水平越权。
第三步:排序,只有排序才能决定“修不修”
威胁不分级,团队会按“改起来最便宜”的顺序修,而不是按风险。给两个维度各 4 档,风险分就是乘积:
| 可利用性 L | 判据 |
|---|---|
| 4 | 无需任何账号,公网可达 |
| 3 | 需要普通用户账号 |
| 2 | 需要内部权限 / 供应链位置 |
| 1 | 需要物理接触或厂商配合 |
| 影响 I | 判据 |
|---|---|
| 4 | 批量资金损失 / 全量用户数据 |
| 3 | 单用户资金 / 单租户全量数据 |
| 2 | 内部数据 / 单用户隐私 |
| 1 | 体验受损,可自愈 |
风险分 = L × I,满分 16,阈值定 ≥12 必须修(当前迭代)、9–11 排期、≤8 记录并接受。阈值不是拍脑袋:它对应“一个普通账号能做、且能造成资金或全量数据损失”——这类组合必须当缺陷修,而不是当技术债排期。
可运行的威胁模型计算器
输入是“元素 + 类型 + 认领的威胁 + 两个评分”,输出覆盖率、STRIDE 分布、Top 风险、帕累托与门槛统计——它不产生威胁,只保证已认领的威胁被排序、不重不漏:
package main
import (
"fmt"
"sort"
)
// 元素类型决定"哪几类 STRIDE 适用"——这张矩阵是威胁枚举的起点,
// 没有它就只能凭经验拍脑袋,漏掉的往往是数据存储的篡改与数据流的泄露。
type elemType string
const (
external elemType = "外部实体"
process elemType = "进程"
store elemType = "数据存储"
flow elemType = "数据流"
)
// 微软给出的经典适用矩阵:外部实体只受 S/R,进程六类全受。
var matrix = map[elemType][]string{
external: {"S", "R"},
process: {"S", "T", "R", "I", "D", "E"},
store: {"T", "R", "I", "D"},
flow: {"T", "I", "D"},
}
var catName = map[string]string{
"S": "Spoofing 身份伪造", "T": "Tampering 数据篡改", "R": "Repudiation 抵赖",
"I": "Information disclosure 信息泄露", "D": "DoS 拒绝服务", "E": "Elevation 提权",
}
type threat struct {
elem string
etype elemType
cat string
desc string
live int // 可利用性 1-4:4=无需认证、3=需普通账号、2=需内部权限、1=需物理/供应链
impact int // 影响 1-4:4=批量资金/全量用户数据、3=单用户资金、2=内部数据、1=体验
}
func (t threat) score() int { return t.live * t.impact }
func main() {
elems := []struct {
name string
etype elemType
}{
{"浏览器", external}, {"运营后台", external}, {"API 网关", process},
{"订单服务", process}, {"MySQL 订单库", store}, {"支付回调", flow}, {"Kafka 订单事件", flow},
}
// 人工认领的威胁:每条都必须落在"元素 + STRIDE 类别"上,不落在元素上的威胁说明还没建模
threats := []threat{
{"浏览器", external, "S", "C 端 token 可被复制到其他设备重放(无设备指纹绑定)", 4, 2},
{"运营后台", external, "S", "后台账号共享,操作无法归到人", 3, 3},
{"运营后台", external, "R", "批量改价无审批留痕,事后否认", 2, 3},
{"API 网关", process, "S", "仅校验签名不校验来源,伪造回调", 3, 4},
{"API 网关", process, "T", "网关层重写 header 时未校验内部头,可注入 X-User-Id", 4, 4},
{"API 网关", process, "R", "网关日志只记路径不记主体,事后查不到谁调的", 2, 2},
{"API 网关", process, "I", "错误响应回显堆栈与内网地址", 4, 2},
{"API 网关", process, "D", "单 IP 无限流,被刷下单接口", 4, 3},
{"API 网关", process, "E", "调试路由 /internal/* 未摘除,绕过鉴权直连后端", 3, 4},
{"订单服务", process, "S", "服务间调用用固定共享密钥,无法区分调用方", 2, 4},
{"订单服务", process, "T", "下单金额取自客户端,服务端不复算", 3, 4},
{"订单服务", process, "R", "状态机变更无审计事件,用户投诉无法举证", 2, 3},
{"订单服务", process, "I", "订单查询未校验归属,遍历 order_id 拉全站订单", 3, 4},
{"订单服务", process, "D", "单次请求触发全表扫描,10 个并发打满 DB", 4, 2},
{"订单服务", process, "E", "反序列化未限制类型,可构造任意对象", 2, 4},
{"MySQL 订单库", store, "T", "应用账号拥有 DROP 权限,被利用即可删库", 2, 4},
{"MySQL 订单库", store, "R", "无 binlog 归档,无法还原谁在何时改了什么", 1, 3},
{"MySQL 订单库", store, "I", "备份文件未加密,落地在对象存储公共读桶", 2, 4},
{"MySQL 订单库", store, "D", "慢查询无超时,一条大 SQL 拖垮实例", 3, 3},
{"支付回调", flow, "T", "回调 body 未验签,可伪造支付成功", 4, 4},
{"支付回调", flow, "I", "回调 URL 带订单明文信息,出现在第三方日志", 2, 2},
{"支付回调", flow, "D", "回调重试无幂等键,重复入账", 3, 4},
{"Kafka 订单事件", flow, "T", "生产端可写任意 topic,伪造已支付事件", 2, 4},
{"Kafka 订单事件", flow, "I", "事件含用户手机号明文,全链路可订阅", 2, 3},
}
// 一、适用矩阵:每个元素按类型生成"应检查"的威胁类别数
fmt.Println("【一】DFD 元素 × STRIDE 适用矩阵(应检查的格子数)")
fmt.Printf("%-14s %-10s %s\n", "元素", "类型", "应检查类别")
totalCells := 0
for _, e := range elems {
cats := matrix[e.etype]
totalCells += len(cats)
fmt.Printf("%-14s %-10s %d 类:%v\n", e.name, e.etype, len(cats), cats)
}
fmt.Printf("合计 %d 个格子,人工认领 %d 条威胁 → 覆盖率 %.1f%%\n\n",
totalCells, len(threats), float64(len(threats))/float64(totalCells)*100)
// 二、按 STRIDE 分类计数
fmt.Println("【二】威胁按 STRIDE 分布(看哪一类被系统性漏掉)")
byCat := map[string]int{}
riskByCat := map[string]int{}
for _, t := range threats {
byCat[t.cat]++
riskByCat[t.cat] += t.score()
}
for _, c := range []string{"S", "T", "R", "I", "D", "E"} {
fmt.Printf("%-32s %2d 条 风险分合计 %3d\n", catName[c], byCat[c], riskByCat[c])
}
// 三、总分与排序
var totalRisk int
for _, t := range threats {
totalRisk += t.score()
}
sorted := append([]threat(nil), threats...)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].score() != sorted[j].score() {
return sorted[i].score() > sorted[j].score()
}
return sorted[i].desc < sorted[j].desc
})
fmt.Printf("\n【三】风险分 = 可利用性 × 影响(满分 16),合计 %d 分\n", totalRisk)
fmt.Printf("%-3s %-6s %-14s %-4s %-4s %-4s %s\n", "#", "类别", "元素", "可用", "影响", "分值", "威胁")
for i, t := range sorted[:8] {
fmt.Printf("%-3d %-6s %-14s %-4d %-4d %-4d %s\n", i+1, t.cat, t.elem, t.live, t.impact, t.score(), t.desc)
}
// 四、帕累托:前 20% 的威胁占多少风险
cut := len(sorted) / 5
if cut < 1 {
cut = 1
}
var topRisk int
for _, t := range sorted[:cut] {
topRisk += t.score()
}
fmt.Printf("\n【四】帕累托:前 %d 条(%.0f%%)合计 %d 分,占总风险 %.1f%%\n",
cut, 100*float64(cut)/float64(len(sorted)), topRisk, 100*float64(topRisk)/float64(totalRisk))
// 五、修复门槛
thresholds := []int{12, 9, 6}
for _, th := range thresholds {
n, r := 0, 0
for _, t := range threats {
if t.score() >= th {
n++
r += t.score()
}
}
fmt.Printf("分值 ≥ %2d:%2d 条(%.0f%% 的条数),压掉 %3d 分 = 总风险的 %.1f%%\n",
th, n, 100*float64(n)/float64(len(threats)), r, 100*float64(r)/float64(totalRisk))
}
// 六、只修两类能降多少
for _, cats := range [][]string{{"E", "S"}, {"T", "I"}} {
r := 0
for _, t := range threats {
for _, c := range cats {
if t.cat == c {
r += t.score()
break
}
}
}
fmt.Printf("只修 %v:压掉 %d 分(%.1f%%)\n", cats, r, 100*float64(r)/float64(totalRisk))
}
}
本机运行结果(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):
【一】DFD 元素 × STRIDE 适用矩阵(应检查的格子数)
元素 类型 应检查类别
浏览器 外部实体 2 类:[S R]
运营后台 外部实体 2 类:[S R]
API 网关 进程 6 类:[S T R I D E]
订单服务 进程 6 类:[S T R I D E]
MySQL 订单库 数据存储 4 类:[T R I D]
支付回调 数据流 3 类:[T I D]
Kafka 订单事件 数据流 3 类:[T I D]
合计 26 个格子,人工认领 24 条威胁 → 覆盖率 92.3%
【二】威胁按 STRIDE 分布(看哪一类被系统性漏掉)
Spoofing 身份伪造 4 条 风险分合计 37
Tampering 数据篡改 5 条 风险分合计 60
Repudiation 抵赖 4 条 风险分合计 19
Information disclosure 信息泄露 5 条 风险分合计 38
DoS 拒绝服务 4 条 风险分合计 41
Elevation 提权 2 条 风险分合计 20
【三】风险分 = 可利用性 × 影响(满分 16),合计 215 分
# 类别 元素 可用 影响 分值 威胁
1 T 支付回调 4 4 16 回调 body 未验签,可伪造支付成功
2 T API 网关 4 4 16 网关层重写 header 时未校验内部头,可注入 X-User-Id
3 T 订单服务 3 4 12 下单金额取自客户端,服务端不复算
4 S API 网关 3 4 12 仅校验签名不校验来源,伪造回调
5 D API 网关 4 3 12 单 IP 无限流,被刷下单接口
6 D 支付回调 3 4 12 回调重试无幂等键,重复入账
7 I 订单服务 3 4 12 订单查询未校验归属,遍历 order_id 拉全站订单
8 E API 网关 3 4 12 调试路由 /internal/* 未摘除,绕过鉴权直连后端
【四】帕累托:前 4 条(17%)合计 56 分,占总风险 26.0%
分值 ≥ 12: 8 条(33% 的条数),压掉 104 分 = 总风险的 48.4%
分值 ≥ 9:10 条(42% 的条数),压掉 122 分 = 总风险的 56.7%
分值 ≥ 6:21 条(88% 的条数),压掉 204 分 = 总风险的 94.9%
只修 [E S]:压掉 57 分(26.5%)
只修 [T I]:压掉 98 分(45.6%)
逐条读这份输出:
- 覆盖率 92.3% 不是成绩,是待办:26 格对 24 条,差的 2 格是 Kafka 的 D 类——不是“没有风险”,是“还没人想”。威胁建模的完成判据是每格都有结论(威胁或豁免理由),不是“条数够多”。
- 条数与风险分不同向:R(抵赖)4 条只有 19 分,T(篡改)5 条 60 分。审计驱动的 R 类常被写满,而真正会花钱的 T 类反而不够细。看分布表,别只看总条数。
- 前 33% 的威胁吃掉 48.4% 的风险:8 条 ≥12 分的威胁,就是这一轮必须修完的清单。剩下的 16 条写进登记册并标注“接受”,比“全都排期”更可执行。
- 只修 E+S 只能压 26.5%:因为本模型的权重在 T(篡改)与 I(泄露)。这解释了为什么“上个 WAF、加个二次验证”之后风险曲线不动——它们主要治 S/D,治不了 T/I。
一条交付标准:威胁模型的验收
一次威胁建模什么时候算做完?四个条件,缺一不可:
| 条件 | 判据 | 反例 |
|---|---|---|
| 格子闭合 | DFD 元素 × STRIDE 每个格子有威胁或豁免理由 | 只覆盖进程,数据流一列空白 |
| 可关闭 | 每条威胁有缓解项、owner、期限,能标“已修/已接受” | 报告写“建议加强鉴权”,没有下一步 |
| 可复算 | 评分规则与阈值写清,别人换人也能算出同样的分 | 分数靠“感觉严重” |
| 可回归 | 架构变更(新接口/新第三方/新边界)触发受影响边界重跑 | 模型停在两年前的架构图上 |
🔑 威胁模型的寿命等于架构图的寿命。没有 DFD 就没有威胁建模——只有代码没有数据流图时,团队会退化成“按组件列清单”,而漏掉的正好是组件之间那几条最危险的流。
什么时候不该做全套
- 原型 / 内部工具、无真实用户数据:跑 30 分钟的 STRIDE 白板会(六个问题各问一句)足够,不必出 26 格的登记册。
- 纯前端静态页、无鉴权无数据:S/T/I 都无处落脚,写清单是浪费。
- 已经上线且被大规模利用的漏洞:先止血(修补、限流、下架接口),再回来建模——建模是防未来,不是救当前。
反模式
| 反模式 | 后果 |
|---|---|
| 只对登录与支付建模 | 内部服务间、消息、备份三条流全部无防护,而泄露与篡改主要发生在那里 |
| 威胁不落到元素上 | 清单变成“安全通用建议”,无法判断哪条已修、哪条不适用 |
| 用严重性形容词(高/中/低)代替评分 | 十个人给出十种“高”,排序失效,最后按改起来省事的顺序修 |
| 不记录“已接受”的威胁 | 同一批威胁每年被重新讨论一遍,且没人记得当初为什么接受 |
| 建模时把缓解措施写成“加强管理” | 不可验收、不可关闭,等于没提 |
| 架构变更后不重跑 | 新加的第三方回调、新开的内网调试口默认无防护,且没人知道它没被审过 |
关联
- 前置:38.6 常见攻击(STRIDE 是分类,具体利用手法在那一节)、35.1 可用性定义与 9 的换算(D 类威胁最终折算成可用性损失)
- 并列:38.2 认证(S 的主场)、38.3 授权(E 的主场)、38.4 加密(I 的手段)、38.5 数据分级与脱敏(影响 I 的评分)
- 下游:38.8 零信任(边界②的长期解法)、4.6 决策框架与决策记录(威胁模型与豁免决策的归档)
自检
Q1 团队做完 STRIDE,登记册 41 条威胁,其中 12 条标“高”。三周后修了 3 条,剩下的没有 owner。这套建模的问题出在哪一步?
▸ 答案:出在排序之后的落地,不是枚举。判据有三:① “高/中/低”是形容词不是分数,换个人重排结果会变;② 12 条“高”里很可能混着“改动小”和“改动大”两类,前者盖住了后者;③ 没有 owner 与期限 = 威胁从未进入 backlog。正确做法是给 L×I 打分、按 ≥12 / 9–11 / ≤8 分档,并把“已接受”也写进登记册且注明接受人与理由——接受也是决策,必须留痕。
Q2 某系统只对 API 网关做了威胁建模,共 18 条威胁,结论是“网关防护已到位”。这个结论为什么不可信?给出两个具体漏点。
▸ 答案:威胁建模的分母是元素 × STRIDE 格子,只做网关就把分母砍到 6 格(占 26 格里的 23%)。两个必然漏点:① 数据流——支付回调不验签导致的伪造支付(T,16 分),发生在网关之后,网关的签名校验管不到;② 数据存储——应用账号拥有 DDL 权限、备份桶公共读(T/I),也是网关视野之外。一句判据:网关只保护“进来的请求”,保护不了“已经进来的数据怎么存、怎么传、怎么读”。
Q3 同样的 24 条威胁,把评分规则从 L×I 改成“L+I”,排序会怎么变?对决策有什么影响?
▸ 答案:乘法惩罚“单维极端”,加法奖励“双维均衡”。本例中 16 分(4×4)在加法下是 8,而 12 分(3×4 或 4×3)是 7——顺序大体不变但差距被压缩;真正变化的是像“4×1=4 分(加法 5)”这类高可利用、低影响的威胁:乘法把它排到最后,加法会把它排到中等,从而与 3×3=9(加法 6)几乎并列。影响是:如果线上被扫的是“低影响但无需认证”的洞(比如错误页回显内网地址),加法会给出更符合直觉的排期;如果目标是“优先防大额损失”,乘法更对。
Q4 一个内部服务加了新接口 GET /internal/order/export,某天被发现可被普通用户调用并拉走全站订单。哪个环节本应拦住它,为什么没拦住?
▸ 答案:本应在边界②(网关 → 服务) 的 E 类(提权)格子上被认领:新接口新增了一条可从外部触达的路径,属于“架构变更触发威胁建模”的范畴。没拦住有三个典型原因:① 变更流程只审“有没有鉴权注解”,没审“这个路径是否可被外部路由到”;② 内部路由与外部路由共用一套网关规则,/internal/* 没有在入口做拒止;③ 威胁模型没有“新增接口 = 重跑受影响格子”的触发点。修法不是补一次评审,而是把“新路由必须在入口默认拒绝、显式放行”变成平台能力——结构问题不要用流程解决。