← 返回 Day 151 正文

Day 151 · 图解

Day 151 图解 — SQL 注入原理、参数化机制与纵深防御

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

⚠️ 本日所有示例都运行在本地内存 sqlite3 靶场上,仅用于教学与防御演示。


图 1:注入是怎么发生的(Mermaid 时序)

sequenceDiagram autonumber participant U as 攻击者 participant A as 应用服务 participant D as 数据库 U->>A: 提交 username = "nobody' UNION SELECT 1,owner,content FROM secrets--" Note over A: sql = "SELECT id,username,role FROM users WHERE username = '" + 输入 + "'"<br/>字符串拼接 ⇒ 结构 + 数据混成一段文本 A->>D: execute(sql) — 只发一整段字符串 Note over D: ① 词法分析:单引号在此闭合了字面量<br/>② `UNION` 被识别为**关键字** ⇒ 新语法生效<br/>③ 编译出"合并两表"的执行计划 D->>A: 返回 users ∪ secrets 的结果集 A->>U: 页面正常渲染"用户资料"(数据已被读出) Note over D: 数据库全程"不知情",它执行的是一条**合法**语句
sequenceDiagram
    autonumber
    participant U as 攻击者
    participant A as 应用服务
    participant D as 数据库

    U->>A: 提交 username = "nobody' UNION SELECT 1,owner,content FROM secrets--"
    Note over A: sql = "SELECT id,username,role FROM users WHERE username = '" + 输入 + "'"<br/>字符串拼接 ⇒ 结构 + 数据混成一段文本
    A->>D: execute(sql) — 只发一整段字符串
    Note over D: ① 词法分析:单引号在此闭合了字面量<br/>② `UNION` 被识别为**关键字** ⇒ 新语法生效<br/>③ 编译出"合并两表"的执行计划
    D->>A: 返回 users ∪ secrets 的结果集
    A->>U: 页面正常渲染"用户资料"(数据已被读出)
    Note over D: 数据库全程"不知情",它执行的是一条**合法**语句

图 2:SQL 解析五步流水线 —— 边界在哪一步被破坏

   SQL 文本:  SELECT id FROM users WHERE username = 'x' OR '1'='1' -- '
        │
        ▼
  ┌───────────────┐   ✅ 正常输入时:`x` 全程落在【字符串字面量】这个桶里
  │ ① 词法 Lexer  │   ❌ 攻击输入时:`'` 提前闭合字面量,
  │  切 Token     │      之后的 `OR` / `'1'` / `=` 全都变成了**语法 token**
  └───────┬───────┘
          ▼
  ┌───────────────┐
  │ ② 语法 Parser │   按新语法建 AST ⇒ 结构的**作者从程序员变成了攻击者**
  └───────┬───────┘
          ▼
  ┌───────────────┐
  │ ③ 语义 Binder │   解析表/列名(报错注入的泄漏点就在这一步)
  └───────┬───────┘
          ▼
  ┌───────────────┐
  │ ④ 计划 Planner│   "要做什么"在此定死(参数化在这里之后才填值)
  └───────┬───────┘
          ▼
  ┌───────────────┐
  │ ⑤ 执行 Exec   │   真正读数据
  └───────────────┘

 ✨ 结论:防御要卡在 ① **之前** —— 让用户数据**根本不进入词法分析**。

图 3:拼接 vs 参数化(协议层 ASCII 对照)

┌──────────────────────────── 拼接:一次往返,结构由字符串决定 ────────────────────────────┐
│  应用 ──── "SELECT * FROM users WHERE username = 'x' OR '1'='1' -- '" ────▶ 数据库      │
│                                                                                        │
│                                            收到后才开始:词法→语法→语义→计划→执行        │
│                                            用户输入参与了"结构"的构建  ✗ 可被改写        │
└────────────────────────────────────────────────────────────────────────────────────────┘

┌──────────────────────────── 参数化:两次往返,结构编译期冻结 ────────────────────────────┐
│  应用 ──── PREPARE "SELECT * FROM users WHERE username = ?" ─────────────▶ 数据库      │
│                                                              │                         │
│                          编译完成 ⇒ 结构冻结,`?` 是"只能装值"的洞(缓存执行计划)        │
│                                                              │                         │
│  应用 ──── EXECUTE 参数 = ["x' OR '1'='1 -- '"] ────────────────────────▶ 数据库        │
│                                                              │                         │
│                    值作为**纯数据**绑定进洞:不经过 Lexer ⇒ 永远是"用户名"  ✅            │
└────────────────────────────────────────────────────────────────────────────────────────┘

  ⚠️ 两者都"把用户输入传给了数据库" —— 区别只在**传的是不是一个语法参与者**。

图 4:五类注入的"信息信道"决策树

                     攻击者能收到什么信号?
                              │
        ┌─────────────────────┴─────────────────────┐
        │  有没有"数据回显"?                        │
        └───────┬───────────────────────────┬───────┘
             有 │                           │ 没有
                ▼                           ▼
      ┌──────────────────┐        ┌──────────────────────┐
      │  UNION 注入       │        │ 有没有"报错回显"?     │
      │  直接读走数据      │        └───┬──────────────┬───┘
      │  需要先探列数      │          有 │              │ 没有
      └──────────────────┘            ▼              ▼
                            ┌──────────────┐  ┌─────────────────────┐
                            │  报错注入      │  │ 响应有"真/假"差异?   │
                            │  泄漏表名/列名  │  └──┬───────────────┬──┘
                            │  再升级为 UNION│   有 │               │ 没有
                            └──────────────┘     ▼               ▼
                                          ┌────────────┐  ┌──────────────┐
                                          │ 布尔盲注    │  │ 时间盲注      │
                                          │ 1bit/请求   │  │ 测量耗时      │
                                          └────────────┘  │ 1bit/请求     │
                                                          └──────────────┘

   另一条独立轴线:能不能一次执行多条语句?
        └── 能 ──▶ 堆叠注入(`; DROP TABLE ...`)⇒ 从"读"升级到"写/删"

图 5:盲注的数学 —— 为什么速率限制如此关键

  提取 1 个字符(候选集 = 36 个小写字母 + 数字)需要的最坏请求数 = 36 次
  提取 32 位 token                                           = 1152 次

  无限制(1000 req/s):  ▓                                       ≈ 1.2 秒
  限速 10 req/s       :  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓            ≈ 115 秒
  限速 1 req/s        :  ▓▓▓▓▓▓▓▓▓…(1152 秒)                   ≈ 19 分钟
  限速 + 封禁异常源 IP :  ████████████████████████████████      ≈ 攻击失败

  ✨ 论点:盲注在信息论上"永远可行",但**成本可以被人为抬高到不可接受**。
     速率限制 + 异常请求模式告警是"经济手段",参数化才是"物理手段"。

图 6:ORM 自动防注入的真相(翻译层)

   你的代码                          ORM 翻译层                     数据库收到
┌──────────────────────┐        ┌────────────────────┐       ┌──────────────────────────┐
│ User.objects.filter( │        │ 组装 SQL 模板       │       │ PREPARE:                 │
│   username=user_in)  │───────▶│ SELECT ... WHERE   │──────▶│ SELECT ... WHERE         │
│                      │        │ username = ?       │       │ username = ?             │
└──────────────────────┘        │ 参数: [user_in]     │       │ EXECUTE: [user_in]       │
                                └────────────────────┘       └──────────────────────────┘
     user_in 是数据                     值走参数通道                    值只能当"用户名"

  ❌ 三种让这层保护失效的写法:
     ① extra() / RawSQL / text() + f-string   → 自己写拼接,绕开翻译层
     ② order_by(request.GET["sort"])         → ORM **无法参数化标识符**
     ③ cursor.execute(sql % params)          → 值在进驱动前就被拼进字符串

  ✨ 判据:**SQL 变量里出现 f-string / + / % / .format() ⇒ 保护已失效。**

图 7:二次注入的时间线

 请求 ① 注册                                              请求 ② 改密码 / 更新资料
┌────────────────────────────┐                ┌──────────────────────────────────────┐
│ username = x' OR '1'='1    │                │ 先按 username 查出该用户 →            │
│ INSERT ... VALUES (?, ...) │                │ UPDATE users SET role='vip'          │
│ ✅ 参数化 ⇒ 安全入库        │                │   WHERE username = '<存储的字符串>'   │
│                            │                │ ❌ 拼接 ⇒ 条件恒真 ⇒ **全表被改**     │
│ 库里存的:                  │                │                                      │
│  x' OR '1'='1 (纯文本)    │───时间过去───▶ │ 审计/扫描都看不见:它跨了两次请求      │
└────────────────────────────┘                └──────────────────────────────────────┘

  🛡️ 修复:① 数据出库**不代表可信**,二次使用仍要参数化;
          ② 定位记录用不可变主键 id,不要用用户可控的 name/email 拼 WHERE。

图 8:纵深防御七层(只有第 1 层是根治)

        用户输入
           │
           ▼
 ┌───────────────────────────────────────────────────────────────┐
 │ 第 1 层  参数化查询 / ORM 安全 API           ★★★ 根治(必做)  │
 │          静态 SQL 模板 + 参数绑定                              │
 └──────────────────────────┬────────────────────────────────────┘
                            ▼
 ┌───────────────────────────────────────────────────────────────┐
 │ 第 2 层  标识符白名单:表名 / 列名 / ORDER BY 字段与方向         │
 │          (参数化覆盖不到的盲区,只能"白名单映射")               │
 └──────────────────────────┬────────────────────────────────────┘
                            ▼
 ┌───────────────────────────────────────────────────────────────┐
 │ 第 3 层  输入校验:类型 / 长度 / 枚举 / LIKE 的 % _ 转义+ESCAPE  │
 └──────────────────────────┬────────────────────────────────────┘
                            ▼
 ┌───────────────────────────────────────────────────────────────┐
 │ 第 4 层  数据库最小权限:每应用独立账号、只读账号、禁 DROP/文件   │
 │          ("万一漏了"时把损失封顶)                              │
 └──────────────────────────┬────────────────────────────────────┘
                            ▼
 ┌───────────────────────────────────────────────────────────────┐
 │ 第 5 层  统一错误处理:对外只给错误码,SQL/堆栈只进服务端日志      │
 └──────────────────────────┬────────────────────────────────────┘
                            ▼
 ┌───────────────────────────────────────────────────────────────┐
 │ 第 6 层  速率限制 + 语句超时(抬高盲注成本、杀掉慢查询)          │
 └──────────────────────────┬────────────────────────────────────┘
                            ▼
 ┌───────────────────────────────────────────────────────────────┐
 │ 第 7 层  WAF / 审计日志告警   ⚠️ 补偿控制,**不作为主防线**      │
 └───────────────────────────────────────────────────────────────┘

   ✨ 纵深防御的意义:任何一层被绕过,其余层仍在。
      但如果只有 3~7 层而没有第 1 层,那叫"靠运气"。

图 9:检测器的两条腿(静态 + 动态)

                  ┌──────────────────────────────┐
                  │   03-sqli-detector.py        │
                  └──────────────┬───────────────┘
                                 │
          ┌──────────────────────┴──────────────────────┐
          ▼                                             ▼
 ┌──────────────────────────┐              ┌──────────────────────────────┐
 │ 静态:AST 解析源码        │              │ 动态:对本地靶场做"差分探针"    │
 │ ─ 找 f-string / + / % /   │              │ ─ 恒真 vs 恒假(结果差异)     │
 │   .format 拼进 SQL        │              │ ─ UNION 改写(不该有结果却返回)│
 │ ─ 找把拼接变量交给 execute │              │ ─ 报错泄漏(异常是否冒泡)      │
 │ ─ 找 executescript/raw()  │              │ ─ 时间差异(中位数 > 阈值)     │
 └───────────┬──────────────┘              └──────────────┬───────────────┘
             │                                            │
             └───────────────┬────────────────────────────┘
                             ▼
              ┌────────────────────────────────┐
              │ 报告:行号 + 严重级 + 原因 + 修复 │
              │ 退出码 0/1 ⇒ 可直接接进 CI       │
              └────────────────────────────────┘

 ✨ 静态检测负责"面"(全仓库扫描,可能假阳性,交给人复核),
    动态验证负责"点"(真实可执行路径,几乎无假阳性,可当断言)。
    两者结合 = 一套可落地的**注入回归测试**。