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><script>alert(1)</script></div> ← HTML 实体编码,只是文本
必须按上下文编码,因为浏览器对同一个字符串的解释规则不同:
| 上下文 | 示例 | 编码方式 |
|---|---|---|
| HTML 文本 | <div>这里</div> | < > & |
| 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 只管“跨站”,不管子域 |
所以有三条硬规则:
- GET 不能有副作用(不能“点个链接就退出登录/下单”)。
- 写操作校验 Origin/Referer,不在白名单直接拒绝。
- 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><script>alert(1)</script></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 文本里 < 要变成 <;属性里还要处理引号闭合;而 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 幂等设计与去重。