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%)

逐条读这份输出:

  1. 覆盖率 92.3% 不是成绩,是待办:26 格对 24 条,差的 2 格是 Kafka 的 D 类——不是“没有风险”,是“还没人想”。威胁建模的完成判据是每格都有结论(威胁或豁免理由),不是“条数够多”。
  2. 条数与风险分不同向:R(抵赖)4 条只有 19 分,T(篡改)5 条 60 分。审计驱动的 R 类常被写满,而真正会花钱的 T 类反而不够细。看分布表,别只看总条数。
  3. 前 33% 的威胁吃掉 48.4% 的风险:8 条 ≥12 分的威胁,就是这一轮必须修完的清单。剩下的 16 条写进登记册并标注“接受”,比“全都排期”更可执行。
  4. 只修 E+S 只能压 26.5%:因为本模型的权重在 T(篡改)与 I(泄露)。这解释了为什么“上个 WAF、加个二次验证”之后风险曲线不动——它们主要治 S/D,治不了 T/I。

一条交付标准:威胁模型的验收

一次威胁建模什么时候算做完?四个条件,缺一不可:

条件 判据 反例
格子闭合 DFD 元素 × STRIDE 每个格子有威胁或豁免理由 只覆盖进程,数据流一列空白
可关闭 每条威胁有缓解项、owner、期限,能标“已修/已接受” 报告写“建议加强鉴权”,没有下一步
可复算 评分规则与阈值写清,别人换人也能算出同样的分 分数靠“感觉严重”
可回归 架构变更(新接口/新第三方/新边界)触发受影响边界重跑 模型停在两年前的架构图上

🔑 威胁模型的寿命等于架构图的寿命。没有 DFD 就没有威胁建模——只有代码没有数据流图时,团队会退化成“按组件列清单”,而漏掉的正好是组件之间那几条最危险的流。

什么时候不该做全套

  • 原型 / 内部工具、无真实用户数据:跑 30 分钟的 STRIDE 白板会(六个问题各问一句)足够,不必出 26 格的登记册。
  • 纯前端静态页、无鉴权无数据:S/T/I 都无处落脚,写清单是浪费。
  • 已经上线且被大规模利用的漏洞:先止血(修补、限流、下架接口),再回来建模——建模是防未来,不是救当前。

反模式

反模式 后果
只对登录与支付建模 内部服务间、消息、备份三条流全部无防护,而泄露与篡改主要发生在那里
威胁不落到元素上 清单变成“安全通用建议”,无法判断哪条已修、哪条不适用
用严重性形容词(高/中/低)代替评分 十个人给出十种“高”,排序失效,最后按改起来省事的顺序修
不记录“已接受”的威胁 同一批威胁每年被重新讨论一遍,且没人记得当初为什么接受
建模时把缓解措施写成“加强管理” 不可验收、不可关闭,等于没提
架构变更后不重跑 新加的第三方回调、新开的内网调试口默认无防护,且没人知道它没被审过

关联

自检

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/* 没有在入口做拒止;③ 威胁模型没有“新增接口 = 重跑受影响格子”的触发点。修法不是补一次评审,而是把“新路由必须在入口默认拒绝、显式放行”变成平台能力——结构问题不要用流程解决。