38.6 常见攻击 (注入 / XSS / CSRF / SSRF / 越权 / 重放)

一句话

六类攻击的根因只有两个:把用户输入当代码(注入、XSS)和把携带凭证的请求当成本人意图(CSRF、SSRF、越权、重放);对应两类防御——数据与代码分离、每个请求重新授权,其余手段都是补丁。

一张表看全

攻击 根因 关键防御 最容易被绕过的点
注入 输入拼进语法 参数化查询 表名/排序字段拼不了参数 → 要白名单
XSS 数据被浏览器当代码执行 按上下文输出编码 富文本、属性、JS/URL 上下文
CSRF 浏览器自动带 cookie SameSite + token + Origin 校验 顶级 GET 导航
SSRF 服务端替用户发请求 域名白名单 + 解析后校验 IP 302 跳转、DNS rebinding
越权 只校验“登录了”,不校验“是你的” 对象级鉴权 列表、导出、批量接口
重放 请求可以被重复使用 时间戳 + nonce + 签名 时间窗、nonce 的存储与 TTL

注入:SQL 只是其中一种

拼接:SELECT * FROM users WHERE name='' OR '1'='1'      ← 语法被改写,返回全表
参数化:SELECT * FROM users WHERE name=$1  args=["' OR '1'='1"]  ← 整段只是字符串

参数化为什么有效:SQL 文本先被编译成语法树,参数值在语法确定之后才绑定,值里有什么字符都不会改变语法。转义和黑名单是在“猜哪些字符危险”,永远漏:宽字节、二次注入、注释符、大小写绕过、编码差异。

同类问题不止 SQL:

类型 危险写法 正确做法
SQL 注入 "WHERE id=" + id 占位符/绑定变量
表名/列名/排序 "ORDER BY " + col 白名单映射(无法参数化的地方)
命令注入 exec("ping " + host) 不用 shell;参数数组 + 严格校验
NoSQL 注入 {"user": {"$ne": null}} 直接当查询 类型校验,禁止对象型输入
模板注入 用户输入拼进模板源码 模板只含常量,变量走数据通道
LDAP / XPath 拼接过滤器 转义 + 白名单

补两道兜底:应用连库账号只给必要权限(禁止 DDL、禁止跨库);错误信息不回显 SQL(报错里带表结构等于给攻击者画地图)。

XSS:同一个字符,在不同上下文里危险程度不同

类型 载荷存在哪 触发点
存储型 数据库(评论、昵称、工单) 任何查看该内容的用户
反射型 URL 参数 受害者点开构造好的链接
DOM 型 location.hash / innerHTML 前端 JS 自己把数据插进 DOM
<div><script>alert(1)</script></div>                         ← 被当标签执行
<div>&lt;script&gt;alert(1)&lt;/script&gt;</div>              ← HTML 实体编码,只是文本

必须按上下文编码,因为浏览器对同一个字符串的解释规则不同:

上下文 示例 编码方式
HTML 文本 <div>这里</div> &lt; &gt; &amp;
HTML 属性 <a title="这里"> 属性值加引号 + 实体编码
URL 参数 <a href="/q?x=这里"> URL 编码,且校验 scheme
JS 字符串 var a = "这里" \xNN / \uNNNN 转义,避免 </script>
CSS style="color: 这里" 白名单,不拼用户输入

一个真实教训:属性里的 href="javascript:alert(1)" 即使做了 HTML 实体编码也照样执行——编码只能防“提前闭合标签”,不能防“协议本身就是代码”。所以 URL 类属性还要校验 scheme(只允许 http/https/相对路径)。

三道纵深:输出编码(治本)、CSP(禁 inline、限制 script-src,即使漏了也难执行)、HttpOnly cookie(被 XSS 后偷不到会话,但 XSS 本身还在)。富文本必须服务端 sanitize(标签+属性白名单),不能只靠前端。

CSRF:浏览器的“好意”被利用

根因:浏览器发请求时会自动带上目标站点的 cookie,攻击者只需要让受害者的浏览器发一个请求,不需要读到响应。

场景 SameSite=Lax 是否拦截 说明
跨站 <form method=POST> 自动提交 拦截 跨站 POST 不带 cookie
跨站 <img src> / <script src> 拦截 子资源请求不带 cookie
顶级导航 GET(点链接、window.location) 不拦截 Lax 允许顶级 GET 带 cookie
同站子域(evil.example.com → app.example.com) 不拦截 SameSite 只管“跨站”,不管子域

所以有三条硬规则:

  1. GET 不能有副作用(不能“点个链接就退出登录/下单”)。
  2. 写操作校验 Origin/Referer,不在白名单直接拒绝。
  3. CSRF token 双提交:cookie 里一个随机值,表单/请求头里再带一个,服务端比对。攻击者能自动带 cookie,但读不到 cookie 的值,也就填不出第二个。
Origin=https://evil.com          cookie=t1 form=t1 → 拒绝(Origin 不在白名单)
Origin=https://app.example.com   cookie=t1 form=t2 → 拒绝(双提交不一致)
Origin=https://app.example.com   cookie=t1 form=t1 → 放行

SSRF:让服务器替你去它去不了的地方

根因:服务端提供“抓取 URL / 导入图片 / Webhook 回调”的能力,攻击者用它访问用户自己访问不到的内网:

目标 危害
http://169.254.169.254/latest/meta-data/ 云主机元数据,可能拿到临时凭证
http://127.0.0.1:6379/ 本机 Redis,可写 key / 主从复制 getshell
http://10.0.0.5:9200/ 内网 ES / K8s API / 管理后台
http://evil.com@10.0.0.5/ 字符串校验看域名、实际连内网 IP

绕过手法(每一种都对应一条防御):

绕过 防御
evil.com@内网IP(userinfo) 解析后校验 Host,禁止 userinfo
十进制/八进制 IP、IPv6 映射 解析后 net.ParseIP + 网段判断
短链接、302 跳转 禁止跟随跳转,或每一跳重新校验
DNS rebinding(解析先公网后内网) 解析一次拿 IP,用这个 IP 建连,不再二次解析
只封 127.0.0.1 封全部私网/环回/链路本地/保留网段
http://169.254.169.254/...      → 拒绝:目标是内网/环回/链路本地地址
http://127.0.0.1:6379/          → 拒绝:同上
http://evil.com@10.0.0.5/admin  → 拒绝:含 userinfo
https://api.partner.com/v1/...  → 放行:白名单命中
https://api.partner.com.evil.cn → 拒绝:域名不在白名单

最后一条最常被忽略:endsWith(".partner.com") 会把 api.partner.com.evil.cn 判为合法。域名匹配必须按“标签边界”比较(等于,或以 . 开头做后缀),不能用裸字符串包含。

越权:最像“正常请求”的漏洞

类型 例子 根因
水平越权(IDOR) 把 /orders/A1001 改成 /orders/A1002 只查了订单存在,没查归属
垂直越权 普通用户直接调 /admin/deleteUser 只在页面隐藏入口,接口没鉴权
租户越权 改 tenant_id 读别家数据 查询没带租户条件
当前用户=user-42  订单归属=user-42 → 放行
当前用户=user-99  订单归属=user-42 → 拒绝:订单不属于当前用户

防御的核心是把授权写进查询条件,而不是“先查出来再 if 判断”:

-- 正确:不属于你的根本查不出来(少一次判断,也少一次遗漏)
SELECT * FROM orders WHERE id = $1 AND owner_id = $2;

if order.OwnerID != userID { deny } 也能用,但在列表、导出、批量、关联查询里极容易漏一处。每一个“按 ID 取对象”的接口都要过一遍这个检查,包括:详情、编辑、删除、导出、分享、附件下载、消息已读。

⚠️ WAF 挡不住越权:请求完全合法(合法的登录态、合法的参数、合法的路径),只是“这个对象不是你的”。这类漏洞只能靠业务级鉴权发现,测试时用两个账号互刷 ID 是最有效的办法。

重放:把一次合法请求用两次

根因:请求本身没有“一次性”属性——签名只证明“这条消息是合法的”,不证明“它是新的”。

防御要素 作用 参数建议
时间戳 限制请求的有效期 ±5 分钟,依赖 NTP
nonce 保证一次性 存储 TTL ≥ 时间窗(如 10 分钟)
HMAC 签名 防篡改 密钥不落客户端;签名覆盖 method+path+body+ts+nonce
幂等键 区分“重试”与“重放” 见 25.4 幂等设计与去重
第一次请求     → 放行
原样重放       → 拒绝:nonce 已用过
10 分钟后重签  → 拒绝:时间戳超出 5 分钟窗口

关键细节:时间窗与 nonce 的 TTL 必须匹配。时间窗 5 分钟、nonce 只存 1 分钟,攻击者等 2 分钟后重放就能成功。反过来,nonce 存太久会吃内存,通常 TTL = 2× 时间窗。

分层防御:每层挡什么

层 手段 挡住 挡不住
输入 类型/长度/格式校验 畸形输入、部分注入 语义合法的攻击(越权)
数据访问 参数化、最小权限 注入 业务逻辑滥用
输出 上下文编码、CSP XSS 服务端已中招的数据
会话 SameSite、token、Origin CSRF 同站 XSS 发起的请求
授权 对象级鉴权、权限矩阵 越权 设计缺陷(如“删除不校验租户”)
出网 白名单、代理、禁跳转 SSRF 白名单域名自身被攻破
传输 TLS、签名、时间戳、nonce 窃听、篡改、重放 密钥泄露
边界 WAF、限流 已知特征、暴力破解 逻辑漏洞(越权)

可运行的攻防对照

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"html"
	"net"
	"net/url"
	"strings"
	"time"
)

// ---------- ① SQL 注入:拼接 vs 参数化 ----------

func sqlDemo(payload string) {
	naive := "SELECT * FROM users WHERE name='" + payload + "'"
	// 参数化后 SQL 文本是常量,payload 只是参数值,永远不参与语法解析
	fmt.Printf("  拼接:%s\n  参数化:SELECT * FROM users WHERE name=$1   args=[%q]\n", naive, payload)
}

// ---------- ② XSS:输出编码按上下文 ----------

func xssDemo(payload string) {
	fmt.Printf("  未编码:<div>%s</div>\n", payload)
	fmt.Printf("  HTML 编码:<div>%s</div>\n", html.EscapeString(payload))
}

// ---------- ③ SSRF:解析后校验 Host,不用字符串包含 ----------

var allowHosts = []string{"api.partner.com", "cdn.partner.com"}

func safeURL(raw string) (bool, string) {
	u, err := url.Parse(raw)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
		return false, "只允许 http/https"
	}
	if u.User != nil {
		return false, "含 userinfo(evil.com@内网IP 的绕过手法)"
	}
	host := u.Hostname()
	if ip := net.ParseIP(host); ip != nil {
		if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
			return false, "目标是内网/环回/链路本地地址"
		}
		return false, "禁止直连 IP,只允许域名白名单"
	}
	for _, h := range allowHosts {
		if host == h || strings.HasSuffix(host, "."+h) {
			return true, "白名单命中"
		}
	}
	return false, "域名不在白名单"
}

// ---------- ④ CSRF:Origin 校验 + 双提交 token ----------

func csrfDemo(origin, cookieToken, formToken string) (bool, string) {
	if origin != "https://app.example.com" {
		return false, "Origin 不在白名单"
	}
	if cookieToken == "" || cookieToken != formToken {
		return false, "双提交 token 不一致"
	}
	return true, "放行"
}

// ---------- ⑤ 越权(IDOR):对象级鉴权 ----------

type order struct {
	ID     string
	UserID string
	Amount int
}

func idorDemo(sessionUser string, o order) (bool, string) {
	if o.UserID != sessionUser {
		return false, "订单不属于当前用户"
	}
	return true, "放行"
}

// ---------- ⑥ 重放:时间窗 + nonce + HMAC ----------

func sign(secret, ts, nonce, body string) string {
	m := hmac.New(sha256.New, []byte(secret))
	m.Write([]byte(ts + "|" + nonce + "|" + body))
	return hex.EncodeToString(m.Sum(nil))
}

func replayDemo(secret, ts, nonce, body, sig string, seen map[string]bool, now time.Time) (bool, string) {
	t, err := time.Parse(time.RFC3339, ts)
	if err != nil {
		return false, "时间戳格式错误"
	}
	if d := now.Sub(t); d > 5*time.Minute || d < -time.Minute {
		return false, "时间戳超出 5 分钟窗口"
	}
	if !hmac.Equal([]byte(sig), []byte(sign(secret, ts, nonce, body))) {
		return false, "签名不匹配"
	}
	if seen[nonce] {
		return false, "nonce 已用过(重放)"
	}
	seen[nonce] = true
	return true, "放行"
}

func main() {
	fmt.Println("① SQL 注入")
	sqlDemo("' OR '1'='1")

	fmt.Println("\n② XSS")
	xssDemo(`<script>alert(1)</script>`)

	fmt.Println("\n③ SSRF")
	for _, u := range []string{
		"http://169.254.169.254/latest/meta-data/",
		"http://127.0.0.1:6379/",
		"http://evil.com@10.0.0.5/admin",
		"https://api.partner.com/v1/orders",
		"https://api.partner.com.evil.cn/v1",
	} {
		ok, why := safeURL(u)
		fmt.Printf("  %-46s → %-5v %s\n", u, ok, why)
	}

	fmt.Println("\n④ CSRF")
	for _, c := range []struct{ origin, cookie, form string }{
		{"https://evil.com", "t1", "t1"},
		{"https://app.example.com", "t1", "t2"},
		{"https://app.example.com", "t1", "t1"},
	} {
		ok, why := csrfDemo(c.origin, c.cookie, c.form)
		fmt.Printf("  Origin=%-28s cookie=%s form=%s → %-5v %s\n", c.origin, c.cookie, c.form, ok, why)
	}

	fmt.Println("\n⑤ 越权")
	o := order{"A1001", "user-42", 19900}
	for _, u := range []string{"user-42", "user-99"} {
		ok, why := idorDemo(u, o)
		fmt.Printf("  当前用户=%-8s 订单归属=%s → %-5v %s\n", u, o.UserID, ok, why)
	}

	fmt.Println("\n⑥ 重放")
	now := time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC)
	secret, body := "k-123", `{"amount":100}`
	ts, nonce := now.Format(time.RFC3339), "n-001"
	sig := sign(secret, ts, nonce, body)
	seen := map[string]bool{}
	ok, why := replayDemo(secret, ts, nonce, body, sig, seen, now)
	fmt.Printf("  第一次请求 → %-5v %s\n", ok, why)
	ok, why = replayDemo(secret, ts, nonce, body, sig, seen, now.Add(time.Second))
	fmt.Printf("  原样重放   → %-5v %s\n", ok, why)
	sig2 := sign(secret, ts, "n-002", body) // 换 nonce 重新签名,但时间戳仍是旧的
	ok, why = replayDemo(secret, ts, "n-002", body, sig2, seen, now.Add(10*time.Minute))
	fmt.Printf("  10 分钟后重签 → %-5v %s\n", ok, why)
}

输出:

① SQL 注入
  拼接:SELECT * FROM users WHERE name='' OR '1'='1'
  参数化:SELECT * FROM users WHERE name=$1   args=["' OR '1'='1"]

② XSS
  未编码:<div><script>alert(1)</script></div>
  HTML 编码:<div>&lt;script&gt;alert(1)&lt;/script&gt;</div>

③ SSRF
  http://169.254.169.254/latest/meta-data/       → false 目标是内网/环回/链路本地地址
  http://127.0.0.1:6379/                         → false 目标是内网/环回/链路本地地址
  http://evil.com@10.0.0.5/admin                 → false 含 userinfo(evil.com@内网IP 的绕过手法)
  https://api.partner.com/v1/orders              → true  白名单命中
  https://api.partner.com.evil.cn/v1             → false 域名不在白名单

④ CSRF
  Origin=https://evil.com             cookie=t1 form=t1 → false Origin 不在白名单
  Origin=https://app.example.com      cookie=t1 form=t2 → false 双提交 token 不一致
  Origin=https://app.example.com      cookie=t1 form=t1 → true  放行

⑤ 越权
  当前用户=user-42  订单归属=user-42 → true  放行
  当前用户=user-99  订单归属=user-42 → false 订单不属于当前用户

⑥ 重放
  第一次请求 → true  放行
  原样重放   → false nonce 已用过(重放)
  10 分钟后重签 → false 时间戳超出 5 分钟窗口

代码里每一段都是“错误做法 vs 正确做法”的对照,不是完整的生产实现:真实系统还要做 DNS 解析固定、出网代理、CSP 头、nonce 的持久化存储、密钥轮换。

反模式

反模式 后果
用字符串拼接 SQL,靠转义函数兜底 一次编码差异就绕过
ORDER BY/表名直接拼用户输入 参数化救不了,必须白名单
只在前端转义/校验 绕过前端直接打接口
富文本只过滤 <script> onerror、<svg>、javascript: 全绕过
用 GET 做退出登录/下单 一个链接或一张图片就能触发
CSRF 只靠 Referer Referer 可能被裁剪/为空
SSRF 只校验 URL 字符串 userinfo、短链接、302、DNS rebinding 都能绕
域名匹配用 strings.Contains api.partner.com.evil.cn 命中
只校验登录,不校验对象归属 水平越权,批量刷数据
用 UUID 代替授权检查 ID 只是难猜,不是“属于你”
重放只验签名 签名合法但可以重复用
nonce 的 TTL 小于时间窗 窗口内可重放

自检

Q1 为什么参数化查询能防注入,而“转义引号/过滤关键字”不能?哪些地方参数化救不了?

▸ 答案:参数化把 SQL 文本先编译成语法树,参数值在语法确定后绑定,值里的 '、--、UNION 都只是字符串内容,不参与语法解析——攻击面被结构性消除。转义/黑名单是在“枚举危险字符”,而数据库的方言、字符集、注释、编码差异会不断产生新的绕过方式。参数化救不了的地方是标识符:表名、列名、ORDER BY、LIMIT 的方向——这些位置不能绑定参数,必须用白名单映射(如 sort=price → ORDER BY price)。

Q2 同一个 <script> 标签,在 HTML 文本里要编码,在 <a href> 里编码了还可能执行,为什么?

▸ 答案:编码防的是“提前闭合当前上下文”,不同上下文的“闭合方式”不同。HTML 文本里 < 要变成 &lt;;属性里还要处理引号闭合;而 href 的属性值是 URL,浏览器的 URL 解析器会把 javascript:alert(1) 当作可执行协议——这段字符串里没有需要 HTML 编码的字符,实体编码后仍然是 javascript:,照样执行。所以 URL 类属性必须在编码之外再校验 scheme(只允许 http/https/相对路径),JS 上下文要用 \xNN 转义而不是 HTML 实体。

Q3 SameSite=Lax 能防住哪些 CSRF?哪一类防不住?为什么“GET 不能有副作用”是配套要求?

▸ 答案:Lax 阻止跨站 POST 表单和跨站子资源(img/script/iframe)携带 cookie,所以“自动提交表单转账”这类攻击被挡住。它不拦顶级导航 GET:用户点一个链接(或 JS 改 location)跳到你的站点,Lax 允许带 cookie。如果退出登录/下单是 GET 接口,攻击者放一个链接就能触发。所以 Lax 必须配“GET 只读”的约束;真正的写操作还要 Origin 校验 + CSRF token,因为同站子域(evil.example.com)在 SameSite 看来是同站。

Q4 SSRF 防御里,为什么“解析 URL 里的域名,检查是不是内网 IP”还不够?DNS rebinding 怎么破?

▸ 答案:检查时解析一次、真正发请求时又解析一次,两次结果可以不同(DNS rebinding:第一次返回公网 IP 通过校验,第二次返回 127.0.0.1)。破法是只解析一次,把解析出的 IP 固定下来,用这个 IP 建连(同时保留 Host 头/SNI 做虚拟主机路由),不再让底层库二次解析。另外还要禁止跟随 302(或每一跳重新校验),因为跳转目标可能是内网;以及封禁全部私网/环回/链路本地/保留网段,而不只是 127.0.0.1。

Q5 越权漏洞为什么 WAF 和参数校验都挡不住?工程上怎么系统性地防?

▸ 答案:越权的请求在语法上完全合法——已登录、参数类型正确、路径存在,只是“这个对象不属于当前用户”,WAF 的特征库无从判断,参数校验也只看格式。系统性防御:① 把授权写进查询条件(WHERE owner_id = ?),让“不属于你”的数据根本查不出来;② 统一的对象级鉴权中间件,按“资源类型 + 动作”集中判定,避免每个接口手写;③ 把“按 ID 取对象”的接口列成清单(详情/编辑/删除/导出/分享/附件/批量),逐个过;④ 测试用两个账号互刷 ID,这是发现 IDOR 最有效的手段。

跨篇相关:威胁建模与攻击面梳理见 38.1 威胁建模 (STRIDE);认证与会话的攻防见 38.2 认证 (Session / JWT / OAuth2 / OIDC);授权模型(RBAC/ABAC)见 38.3 授权 (RBAC / ABAC / 权限模型);加密与密钥管理见 38.4 加密 (对称 / 非对称 / TLS / 密钥管理);撞库与暴力破解见 38.7 账号安全;重试与重放的边界见 25.4 幂等设计与去重。