← 返回 Day 150 正文

Day 150 · 图解

Day 150 图解 — OAuth2 授权流程与 CSRF / SSRF 攻击路径

所有图使用 Mermaid 或 ASCII,可直接在 GitHub / VSCode(Markdown Preview Mermaid 插件)中渲染。不依赖任何图片文件。


图 1:OAuth2 四个角色与两条通道

                        ┌─────────────────────────────────────────┐
                        │          授权服务器 (Auth Server)         │
                        │   /authorize   /token   /revoke          │
                        └───────▲───────────────────┬─────────────┘
                                │                   │
             ②前端通道(浏览器重定向,                  ④后端通道(服务器↔服务器,
               只传 code + state)                    传 client_secret + code)
                                │                   │
        ┌───────────────┐       │                   │      ┌──────────────────┐
        │  用户 (资源所有者) │───────┘                   └─────▶│  客户端 Client    │
        │   浏览器 / App   │◀──────── ①同意授权 ──────────────│ (有后端 / 无后端)  │
        └───────┬───────┘                                  └────────┬─────────┘
                │  ⑤带上 access_token 访问                          │
                │        (Authorization: Bearer)                   │
                ▼                                                  ▼
                        ┌─────────────────────────────────────────┐
                        │        资源服务器 (Resource Server)        │
                        │      校验 access_token → 返回数据          │
                        └─────────────────────────────────────────┘

核心要点:code 走前端通道(不安全,所以短命 + 一次性); access_token 走后端通道(浏览器永远看不到)。


图 2:授权码 + PKCE 全流程(Mermaid sequenceDiagram)

sequenceDiagram autonumber participant U as 用户/浏览器 participant C as Client 应用 participant AS as 授权服务器 participant RS as 资源服务器 C->>C: 生成 state = secrets.token_urlsafe(32)<br/>生成 code_verifier(仅存内存)<br/>code_challenge = Base64URL(SHA256(verifier)) C->>U: 302 /authorize?response_type=code<br/>&client_id&redirect_uri&scope<br/>&state&code_challenge&code_challenge_method=S256 U->>AS: 授权请求 AS->>AS: 校验 client_id / redirect_uri 精确匹配<br/>公开客户端强制 PKCE(S256) AS->>U: 登录页 → 同意页(用户看到"要读你的邮箱") U->>AS: 同意授权 AS->>AS: 生成一次性 code(TTL 30~60s)<br/>存下 code_challenge AS->>U: 302 /callback?code=CODE&state=STATE U->>C: 回调(浏览器只看见 code,看不见 token) C->>C: 校验 state == 会话中的 state?<br/>不等 → 直接拒绝(防 CSRF)<br/>然后立即删除 state(防重放) C->>AS: POST /token<br/>grant_type=authorization_code<br/>code + redirect_uri + client_secret + code_verifier AS->>AS: 校验 code 未用/未过期/同一 client<br/>SHA256(code_verifier) == 存下的 challenge? AS->>C: access_token + refresh_token + expires_in C->>C: 令牌存服务端会话 / HttpOnly Cookie C->>RS: GET /api/me Authorization: Bearer <access_token> RS->>RS: 验签 + 校验 exp/aud/scope RS->>C: 200 用户数据
sequenceDiagram
    autonumber
    participant U as 用户/浏览器
    participant C as Client 应用
    participant AS as 授权服务器
    participant RS as 资源服务器

    C->>C: 生成 state = secrets.token_urlsafe(32)<br/>生成 code_verifier(仅存内存)<br/>code_challenge = Base64URL(SHA256(verifier))
    C->>U: 302 /authorize?response_type=code<br/>&client_id&redirect_uri&scope<br/>&state&code_challenge&code_challenge_method=S256
    U->>AS: 授权请求
    AS->>AS: 校验 client_id / redirect_uri 精确匹配<br/>公开客户端强制 PKCE(S256)
    AS->>U: 登录页 → 同意页(用户看到"要读你的邮箱")
    U->>AS: 同意授权
    AS->>AS: 生成一次性 code(TTL 30~60s)<br/>存下 code_challenge
    AS->>U: 302 /callback?code=CODE&state=STATE
    U->>C: 回调(浏览器只看见 code,看不见 token)
    C->>C: 校验 state == 会话中的 state?<br/>不等 → 直接拒绝(防 CSRF)<br/>然后立即删除 state(防重放)
    C->>AS: POST /token<br/>grant_type=authorization_code<br/>code + redirect_uri + client_secret + code_verifier
    AS->>AS: 校验 code 未用/未过期/同一 client<br/>SHA256(code_verifier) == 存下的 challenge?
    AS->>C: access_token + refresh_token + expires_in
    C->>C: 令牌存服务端会话 / HttpOnly Cookie
    C->>RS: GET /api/me  Authorization: Bearer <access_token>
    RS->>RS: 验签 + 校验 exp/aud/scope
    RS->>C: 200 用户数据

图 3:为什么授权码流程安全?(信任边界图)

  ┌───────────────────────── 不可信区域 ─────────────────────────┐
  │  浏览器地址栏 / 历史记录 / Referer / 代理日志 / 扩展程序        │
  │                                                             │
  │      code=ABC(一次性, 60s)   state=RANDOM  其余全被拦截       │
  └────────────────────────────┬────────────────────────────────┘
                               │ 攻击者能拿到: code(但换不了令牌)
  ┌────────────────────────────▼────────────────────────────────┐
  │                     可信区域(服务端)                        │
  │   client_secret(只在服务器上) + code_verifier(只在客户端内存)│
  │   → POST /token → access_token / refresh_token               │
  └─────────────────────────────────────────────────────────────┘

  攻击者拿到 code 后要换令牌,必须同时满足:
    ① 有 client_secret(机密客户端)    → 拿不到
    ② 有 code_verifier(PKCE 公开客户端)→ 拿不到
    ③ code 还没用过、还没过期           → 很可能已失效

图 4:CSRF 攻击时序(无防护 vs 有防护)

sequenceDiagram autonumber participant V as 受害者浏览器 participant E as evil.com participant B as bank.com Note over V,B: 前提:受害者已登录 bank.com,浏览器持有 session Cookie V->>B: 正常登录,获得 Cookie V->>E: 访问 evil.com E->>V: 返回恶意页面(隐藏表单 + JS 自动 submit) V->>B: POST /transfer (浏览器自动带上 bank.com 的 Cookie!) B->>B: ❌ 只看 Cookie → 认为"用户本人操作" → 执行转账 Note over B: 有防护时:<br/>SameSite=Lax 跨站POST不带Cookie → 拒绝<br/>CSRF Token 缺失/不匹配 → 403<br/>Origin 不在白名单 → 拒绝
sequenceDiagram
    autonumber
    participant V as 受害者浏览器
    participant E as evil.com
    participant B as bank.com

    Note over V,B: 前提:受害者已登录 bank.com,浏览器持有 session Cookie
    V->>B: 正常登录,获得 Cookie
    V->>E: 访问 evil.com
    E->>V: 返回恶意页面(隐藏表单 + JS 自动 submit)
    V->>B: POST /transfer (浏览器自动带上 bank.com 的 Cookie!)
    B->>B: ❌ 只看 Cookie → 认为"用户本人操作" → 执行转账
    Note over B: 有防护时:<br/>SameSite=Lax 跨站POST不带Cookie → 拒绝<br/>CSRF Token 缺失/不匹配 → 403<br/>Origin 不在白名单 → 拒绝

图 5:四种 CSRF 防护的覆盖范围(ASCII 对照)

                        ┌──────────┬──────────┬──────────┬──────────┐
                        │SameSite= │SameSite= │ CSRF     │ Origin/  │
                        │ Strict   │ Lax      │ Token    │ Referer  │
  ┌─────────────────────┼──────────┼──────────┼──────────┼──────────┤
  │ 跨站 POST 表单       │   拦截   │   拦截   │   拦截   │   拦截   │
  │ 跨站 GET 顶层导航    │   拦截   │  放行⚠️  │   拦截   │   拦截   │
  │ 跨站 <img>/<script>  │   拦截   │   拦截   │   拦截   │   拦截   │
  │ 同站 XSS 发起的请求  │   放行   │   放行   │  可读取❌ │   放行   │
  │ 老浏览器不支持时     │   失效   │   失效   │   有效✅ │   有效   │
  └─────────────────────┴──────────┴──────────┴──────────┴──────────┘
   ✅ 结论:SameSite=Lax + CSRF Token 是性价比最高的组合(纵深防御)
   ⚠️ 注意:CSRF Token 挡不住 XSS——XSS 是同源执行,能读到页面里的 token

图 6:SSRF 攻击链路(从"用户可控 URL"到"云账号沦陷")

 攻击者 ──输入──▶ ┌───────────────────────┐
                 │ 用户可控 URL 参数       │  例:avatar_url=...
                 │ "从链接导入/网页截图"   │
                 └──────────┬────────────┘
                            ▼
                 ┌──────────────────────────────────────────┐
                 │      应用服务器(有内网访问权限)            │
                 │      requests.get(user_url)               │
                 └──────────┬───────────────────────────────┘
                            │
        ┌───────────────────┼────────────────────┬───────────────────┐
        ▼                   ▼                    ▼                   ▼
 127.0.0.1:6379      10.0.0.5:8080       169.254.169.254      http://内网K8s
 (Redis 未授权)      (内部管理后台)      (云元数据服务)         (API Server)
        │                   │                    │
        ▼                   ▼                    ▼
   写 SSH key /        越权操作全站        GET /latest/meta-data/
   crontab 反弹                              iam/security-credentials/
                                                   │
                                                   ▼
                                       拿到云主机临时凭证 → 接管云账号 💀

图 7:SSRF 五层纵深防御(每层拦不同攻击)

 用户输入 URL
      │
      ▼
 ┌────────────────────────────────────────────────────────────┐
 │ 第 1 层  业务设计:根本不收任意 URL                          │
 │         用 {id: 1} → 服务端映射到固定 URL(最强,且不牺牲功能)│
 └───────────────────────────┬────────────────────────────────┘
                             ▼
 ┌────────────────────────────────────────────────────────────┐
 │ 第 2 层  协议白名单:只允许 http / https                     │
 │         拒绝 file:// gopher:// dict:// ftp:// jar://        │
 └───────────────────────────┬────────────────────────────────┘
                             ▼
 ┌────────────────────────────────────────────────────────────┐
 │ 第 3 层  主机精确白名单:host == "cdn.example.com"           │
 │         禁止 endswith / 后缀匹配 / 正则(会被 .evil.com 绕过)│
 └───────────────────────────┬────────────────────────────────┘
                             ▼
 ┌────────────────────────────────────────────────────────────┐
 │ 第 4 层  解析 → 校验 IP → 用**同一个已解析 IP** 发起连接       │
 │         封禁 127/8 10/8 172.16/12 192.168/16 169.254/16 …   │
 │         禁用自动跟随重定向(若跟随,每次跳转重新校验)          │
 └───────────────────────────┬────────────────────────────────┘
                             ▼
 ┌────────────────────────────────────────────────────────────┐
 │ 第 5 层  网络层:出网代理 + ACL;云元数据启用 IMDSv2          │
 │         内网段对应用沙箱不可达(最后一道防线)                 │
 └────────────────────────────────────────────────────────────┘

图 8:Cookie 安全标志决策树

                    要设置一个 Cookie
                            │
              ┌─────────────┴─────────────┐
              │  需要 JS 读取它吗?          │
              └──────┬──────────────┬──────┘
                  需要 │              │ 不需要(会话凭证/CSRF Token)
                     ▼              ▼
        ┌────────────────┐   ┌──────────────────────────────────────┐
        │ 明确知道为什么    │   │ HttpOnly ✅(JS 读不到,XSS 拿不走)   │
        │ 否则别这么干 ❌   │   └──────────────┬───────────────────────┘
        └────────────────┘                  ▼
                             ┌─────────────────────────────────────┐
                             │ 是否只走 HTTPS?→ Secure ✅(必须)    │
                             └──────────────┬──────────────────────┘
                                            ▼
                             ┌─────────────────────────────────────┐
                             │ 需要跨站携带吗?                      │
                             │  不需要 → SameSite=Lax(推荐默认)    │
                             │  需要   → SameSite=None + Secure     │
                             │           + CSRF Token(必须补)      │
                             └─────────────────────────────────────┘