🛡️ 第四章 系统漏洞的防范
📄 一句话概括

SQL 注入攻击(SQL 注入)的本质只有一句话:程序把"用户输入的数据"和"要执行的 SQL 语句"拼成了一串文字,于是输入里的引号可以提前把字符串关掉,后面跟的内容就被数据库当成了命令来执行——它是本章里唯一一个"输入本身就是代码"的漏洞,也是唯一一个可以靠一个标准动作(参数化)一次性根除的漏洞。

📝 本节和前面几节的关系

4-3 讲的是"没有补丁可打"的难处,这一节回到最可防的那一类:SQL 注入属于把整类问题根除得最干净的一种漏洞。

它和 第二章 的整条线是连着的:第二章讲的攻击目标是"通道"(窃听、篡改、冒充),这一节讲的目标是"数据"——而 SQL 注入是"直接对着数据库说话"的最短路径,所以它长期排在漏洞榜的第一位。

一、先说清一句话:数据变成了代码

正常的程序逻辑是"两部分分开"的:

SQL 注入之所以能成立,是因为很多程序偷懒,把这两部分用字符串拼接合成了一句话:

# 拼接式(错):数据和代码混在一根字符串里
sql = "SELECT * FROM user WHERE name = '" + user_input + "'"

这样一来,用户在输入框里敲的内容,直接变成了 SQL 语句的一部分。 他只要在输入里放一个单引号,就能把开发者写的那个字符串提前"关掉",然后在后面接上自己想执行的 SQL。

所以 SQL 注入不是"破译了数据库",而是"数据库太听话"——数据库忠实地执行了它收到的那条语句,只不过那条语句是攻击者写的。

二、为什么会成立:一个引号的连锁反应

把拼接的过程一步步摊开,就非常清楚了。假设用户输入是:

x' OR '1'='1

程序拼出来的语句是:

SELECT id, name, role FROM user WHERE name = 'x' OR '1'='1'

看这个结果里的引号:开发者写的那对引号,被用户输入里的那个单引号提前闭合了,于是:

一个"用户不存在"的查询,变成了"返回所有用户"。 这就是 SQL 注入的完整原理——它不复杂,只是很有效。

数据库本身并没有做错任何事:它执行的就是"名字是 x,或者 1 等于 1 的那些用户",这完全符合语法。 问题出在拼接这个动作上,让"数据"获得了"代码"的身份。

三、动手 Demo:真跑一个数据库,看两种写法的差别

下面这段脚本用 Python 自带的 sqlite3 建了一个内存数据库(不是模拟,是真的数据库),库里 4 个用户,然后用两种写法执行同一个查询:

import sqlite3
con = sqlite3.connect(":memory:")          # 内存数据库,关机即消失
cur = con.cursor()
cur.execute("CREATE TABLE user(id INTEGER, name TEXT, role TEXT)")
cur.executemany("INSERT INTO user VALUES(?,?,?)",
                [(1, "alice", "admin"), (2, "bob", "user"),
                 (3, "carol", "user"), (4, "dave", "user")])

evil = "x' OR '1'='1"
q1 = "SELECT id, name, role FROM user WHERE name = '%s'" % evil   # 拼接
q2 = "SELECT id, name, role FROM user WHERE name = ?"             # 参数化
print("拼接取到", len(cur.execute(q1).fetchall()), "行")
cur.execute(q2, (evil,))
print("参数化取到", len(cur.execute(q2, (evil,)).fetchall()), "行")

实跑输出:

  库里一共 4 行:alice(admin) / bob / carol / dave

一、用户输入:x' OR '1'='1
  拼接式实际执行的 SQL:
    SELECT id, name, role FROM user WHERE name = 'x' OR '1'='1'
      取到 1 / alice / admin
      取到 2 / bob / user
      取到 3 / carol / user
      取到 4 / dave / user
  → 返回 4 行(本应 0 行)

  参数化实际执行的 SQL:
    SELECT id, name, role FROM user WHERE name = ?    参数 = x' OR '1'='1
  → 返回 0 行

二、换成带注释符的输入:x' OR 1=1 --
    SELECT id, name, role FROM user WHERE name = 'x' OR 1=1 --'
  → 返回 4 行(-- 把后面的引号全注释掉了)

  → 结论:【拼接让数据变成了代码,参数化把两者彻底分开】。
     2026 上半年 CWE-89(SQL 注入)仍是被报漏洞第一名(1,116 个)。

读这个输出:

四、⚠️ 2026 现状

30 秒实验

今天做两个动作,各 15 秒:

  1. 打开你常用的任何一个有"搜索"或"登录"的网页,在输入框里敲一个孤零零的单引号(就是 '),然后提交。如果页面报错、显示出数据库相关的错误信息(比如 SQL syntax、You have an error in your SQL),那说明两件事:① 这里很可能是拼接式写法;② 错误信息被直接暴露给了用户——后者本身就是一个信息泄露漏洞(正确做法是显示一句"输入有误",把细节留在服务端日志里)。
  2. 如果你手边有任何和数据库打交道的代码,今天只搜一个东西:搜 + 或 % 出现在 SQL 字符串附近的写法(比如 "SELECT ... " + name、"... '%s'" % input)。找到一个就改造一个为参数化写法。这一步的意义是:SQL 注入的修复不需要"筛查全部代码"这种大工程,它是一件可以小步持续做的事。

这一节要带走的一句是:SQL 注入的原理是"数据被当成了代码",所以它的解法也只有一条——让数据和代码永远分开放(参数化),而不是靠"过滤掉那些危险字符"。

常见误区

  1. 错误:把单引号过滤掉,就不会有 SQL 注入了。——纠正:过滤危险字符是"猜攻击者的写法",而攻击者的写法是无穷的。这一节的 Demo 里第二部分就展示了一种绕过思路:用 -- 注释符,根本不需要配对引号。除此之外还有编码、大小写混写、利用数据库特有的语法等许多变体。 唯一可靠的办法是参数化查询(预编译语句)——它的原理不是"让危险输入失效",而是"从结构上让数据不可能变成代码"。 过滤可以做一层(作为纵深防御),但它绝不能是唯一的防线。
  2. 错误:我的数据库设了强口令、只监听内网,注入打不进来。——纠正:SQL 注入走的不是"网络通道",而是"应用程序的正常入口"。攻击者不需要连上你的数据库——他是通过你的网页表单、API 参数,让【你的程序】带着【你的数据库账号】去执行他想要的语句。所以数据库的网络隔离、强口令对 SQL 注入基本无效——因为执行语句的是合法的应用程序自己。真正有效的是:应用层不拼 SQL(参数化)、数据库账号最小权限(只给应用需要的表和操作)。
  3. 错误:用 ORM 框架就自动免疫 SQL 注入了。——纠正:ORM 的常规用法是安全的(它内部就是参数化),但它是可以"绕过去"的。几乎每个 ORM 都提供"执行原生 SQL"的接口(比如各种 raw()、execute() 方法),而这些接口往往就是把用户输入拼进去的地方。2026 年的观测也印证了这一点:注入面正在向"框架里没法参数化的原始 SQL 片段"转移。所以正确的说法是:ORM 减少了出错面,但没有取消"知道自己在拼什么"这个责任。
  4. 错误:这是开发者的问题,跟我们使用者没关系。——纠正:SQL 注入是少数"用户侧也能立刻发现"的漏洞。本节 30 秒实验里的那个单引号测试,任何人都能做——你不需要看懂代码,只要看它有没有把数据库错误信息吐给你。对采购和使用者来说,这提供了一条很实用的验收动作:在选型或验收时,随手在几个输入框里敲单引号,看它报不报数据库错误。这件事的第二层意义是:一个连"错误信息不要直接暴露给用户"都没做的系统,通常在其他安全细节上也不会有多讲究。
自测三问(点开对答案) 1. SQL 注入的原理,用一句话怎么说清楚? —— **程序把"用户输入的数据"和"要执行的 SQL 代码"拼成了一根字符串,于是输入里的引号可以提前把字符串关掉,后面跟的内容被数据库当成了命令来执行**。**关键在"拼接"这个动作**:**它让数据和代码失去了边界**。**这也是为什么 SQL 注入被称为"注入"——数据被注入到了代码的位置上**。**理解了这一点,解法也就自然出来了:只要保证数据和代码从生成到执行的全过程都不混在一根字符串里(参数化查询),这个漏洞就不可能出现。** 2. 为什么说"过滤引号"不能算修好了 SQL 注入? —— **因为过滤是在"猜攻击者的写法",而写法是无穷的**。**这一节的 Demo 里,攻击者只要用 `--` 把后面的引号注释掉,就完全不需要配对引号**;**此外还有各种编码、数据库特有语法等变体**。**更根本的问题是:过滤针对的是一种"输入形式",而注入针对的是"语句结构"**——**只要结构上还有"数据被拼进代码"的位置,就总会有人找到一种过滤没覆盖到的写法。** **参数化之所以有效,是因为它不是在"检查数据长什么样",而是在"让数据根本没有机会变成代码"**——**这是结构上的修复,而不是形式上的拦截。** 3. "SQL 注入仍排第一,但占比在明显下降"——这两半分别说明了什么? —— **"仍排第一"说明它没有消失,是存量问题**:**大量多年前写的代码还在用拼接式**(尤其第三方插件里,2026 年单周 358 条插件漏洞中 SQL 注入就有 35 条),**而这些问题会随着代码存活一直被报出来**。**"占比在下降"说明解法确实有效、而且在被推广**:**从 2025 年的 3,270 个(15.43%)降到 2026 年上半年的 1,116 个**——**新写的代码大多已经参数化了**。**两半合起来给出的行动指引是:新代码必须参数化(这是"不再增加"),老代码要按风险排优先级去改(这是"逐步减少")**,**而后者正好可以用 4-13 的排序办法来决定先改哪一段。**

相关笔记