网站的SQL 注入:从攻击原理到防御措施

一、什么是 SQL 注入

SQL 注入(SQL Injection)是 Web 应用中最古老、危害最大的安全漏洞之一。简单来说就是攻击者通过在用户输入中注入了恶意的SQL 代码,让数据库执行非正常的操作,从而能窃取数据、篡改内容甚至控制整个服务器。

核心原理

SQL 注入的本质是代码与数据混淆。当开发者直接把用户输入拼接到 SQL 语句中时,数据库无法区分哪些是 SQL 语法、哪些是用户数据。攻击者可以利用单引号、注释符等闭合原有语句,插入自己的新 SQL 逻辑。

举一个最经典的登录,危险代码例子:直接拼接用户输入

复制代码
$username = $_POST['username'];
$password = $_POST['password'];

$sql = "SELECT * FROM users WHERE username='$username' AND password='$password'";
$result = mysql_query($sql);

正常输入 admin / 123456,SQL 执行的是:

复制代码
SELECT * FROM users WHERE username='admin' AND password='123456'

但如果用户名username输入了特殊的字符 ' OR '1'='1' -- ,SQL 就变成了:

复制代码
SELECT * FROM users WHERE username='' OR '1'='1' -- ' AND password='xxx'

'1'='1' 条件成立,-- 符号注释掉后面的代码,攻击者无需密码就能直接登录。这就是最基础的万能密码攻击。

二、SQL 注入的背景

SQL 注入最早在 1998 年被正式公开讨论,几乎与 PHP 的流行同步。PHP 作为早期 Web 开发的主流语言,因其语法灵活、入门门槛低,大量新手开发者写出了不安全的代码。

  • 1998 年:Phrack 杂志发表《NT Web Technology Vulnerabilities》,首次系统描述 SQL 注入
  • 2000 年代初:PHP+MySQL 架构普及,SQL 注入成为网站被黑的首要原因
  • 2004 年 :mysql_real_escape_string() 成为主流转义方案,但仍频繁被误用
  • 2005 年后:PDO 扩展推出,预处理语句成为官方推荐方案
  • 至今:尽管防御手段已非常成熟,但新写的代码和老旧系统中仍大量存在注入漏洞

为什么 PHP 网站特别 "中招"

  1. 历史遗留代码多 :大量老项目仍在使用 mysql_* 系列已废弃函数
  2. 入门门槛低:PHP 开发者水平参差不齐,安全意识薄弱
  3. 字符串拼接太方便:PHP 的字符串插值特性让开发者不自觉地拼接 SQL
  4. 老旧教程泛滥:网上很多老教程仍在教不安全的写法
  5. 框架使用不当:即使使用 Laravel、ThinkPHP 等框架,乱用原生 SQL 仍会出问题

三、PHP 中最常见的 5 种 SQL 注入类型

1. 普通注入:返回显示数据

这是最直接的注入方式,页面会直接返回数据库查询结果。常见于详情页、列表页的 ID 参数。

危险原代码举例:

php 复制代码
<?php
$id = $_GET['id'];
$sql = "SELECT title, content FROM articles WHERE id = $id";

攻击(需保证前后查询列数一致,本例中 articles 表查询 2 列,users 表也取 2 列):

?id=1 UNION SELECT username,password FROM users --

实际执行:

php 复制代码
SELECT title, content FROM articles WHERE id = 1 
UNION SELECT username,password FROM users -- 

攻击者可以直接看到用户名和密码哈希。

2. 布尔盲注

页面不直接返回数据,但可以根据页面返回差异(正常 / 报错、有内容 / 无内容)判断 SQL 条件的真假,逐位猜解数据。

危险原代码举例:

复制代码
$id = $_GET['id'];
$sql = "SELECT * FROM products WHERE id = $id";

重点:$id 直接拼接,外面没有单引号,属于数字型注入。

场景 1:正常访问

✅ 传入 URL 参数:?id=1

$_GET['id'] 获取到:"1"

拼接后最终 SQL:

复制代码
SELECT * FROM products WHERE id = 1;

结果:有查到数据,查询正常;

场景 2:测试真条件(payload)

传入 URL 参数:?id=1 AND 1=1

$_GET['id'] 获取到:"1 AND 1=1"

拼接后最终 SQL:

复制代码
SELECT * FROM products WHERE id = 1 AND 1=1;

1=1 为真,条件成立 → 查到数据 → 页面正常

场景 3:测试假条件(payload)

传入 URL 参数:?id=1 AND 1=2

$_GET['id'] 获取到:"1 AND 1=2"

拼接后最终 SQL:

复制代码
SELECT * FROM products WHERE id = 1 AND 1=2;

1=2 为假,整体 WHERE 不成立 → 无数据 → 页面空白 / 异常

场景 4:布尔盲注猜字符 payload(核心)

传入 URL 参数:?id=1 AND SUBSTRING(database(),1,1)='s'

$_GET['id'] 获取到:"1 AND SUBSTRING(database(),1,1)='s'"

拼接后最终 SQL:

复制代码
SELECT * FROM products WHERE id = 1 AND SUBSTRING(database(),1,1)='s';

逻辑(程序盲猜逐一测试):

  • 如果当前库名第 1 位字符是s:表达式为真,页面正常
  • 如果不是s:表达式为假,页面空白

风险点:URL 问号后面传给 id 的那串内容,会原封不动替换掉 SQL 里$id的位置。

攻击者通过构造 AND SUBSTRING(database(),1,1)='a' 这样的语句,逐字符猜解出数据库名、表名、字段名和数据。

3. 报错注入

原始代码:同场景2

URL传入参数:?id=1 AND updatexml(1, concat(0x7e, (select version()), 0x7e), 1)

$_GET['id']拿到的值:

复制代码
"1 AND updatexml(1, concat(0x7e, (select version()), 0x7e), 1)"

拼接出来的SQL:

复制代码
SELECT * FROM products WHERE id = 1 AND updatexml(1, concat(0x7e, (select version()), 0x7e), 1);

逐个拆解函数

  1. updatexml():MySQL 原生函数,作用是更新 XML 内容。 函数签名:updatexml( xml_doc, xpath_expr, new_val )限制:第二个参数必须是合法的 XPath 表达式;如果写非法 XPath,MySQL 直接抛出报错,并且把这个非法字符串原样打印在报错里。这就是报错注入的核心原理。
  2. concat(a,b,c):字符串拼接,把几段拼成一个字符串
  3. 0x7e:十六进制,对应字符 ~,用来做标记分隔符,方便在报错里一眼定位数据
  4. (select version()):子查询,查出 MySQL 版本号,例如 5.7.36

concat(0x7e, (select version()), 0x7e) 执行后得到字符串:

复制代码
~5.7.36~

这个字符串被放进 updatexml 的第二个参数位置,当成 XPath 去解析。 ~5.7.36~ 不是合法 XPath 语法 → MySQL 触发报错,并且把这个非法字符串输出到报错信息。

页面返回示例(数据库报错)

复制代码
XPATH syntax error: '~5.7.36~'

结果:数据跟着报错信息一起输出到页面上,攻击者可直接获取,无需逐字符猜解。

注意: updatexml() 报错最多返回 32 位字符,查询长数据时需配合 substring() 分段读取。

4. 堆叠查询注入

一次性执行多条 SQL 语句,危害性极大,可以直接删表、写入文件、提权。

原始代码:

复制代码
$offset = $_GET['offset'];
$sql = "SELECT * FROM products LIMIT 20 OFFSET $offset";
场景1:正常访问:

传入:?offset=5

$_GET['offset'] 值:"5"

拼接 SQL:

复制代码
SELECT * FROM products LIMIT 20 OFFSET 5;

效果:正常偏移读取数据。

场景2:堆叠注入 payload:

传入 URL 参数:?offset=0; DROP TABLE products; --

$_GET['offset'] 获取的值:"0; DROP TABLE products; --"

拼接后最终 SQL:

复制代码
SELECT * FROM products LIMIT 20 OFFSET 0; DROP TABLE products; --

逐段解释这条 SQL:

  1. SELECT * FROM products LIMIT 20 OFFSET 0; 第一条语句,正常执行查询,; 代表第一条语句结束
  2. DROP TABLE products; 第二条独立 SQL 语句 :删除 products 表
  3. --:SQL 注释符,把后面剩余内容全部注释掉,防止原有 SQL 后缀干扰

堆叠注入核心定义:一次请求,同时执行多条相互独立的 SQL 语句。

**注意:**MySQL 的默认 API(mysql 扩展、常规 mysqli 查询、默认配置的 PDO)不支持单请求多语句执行,PostgreSQL、SQL Server 原生支持多语句;MySQL 中使用 mysqli_multi_query() 或手动开启 PDO 多语句属性时,才会存在堆叠注入风险。

5. 宽字节注入

原始代码(字符串型注入场景)

// 转义函数mysql_real_escape_string:给单引号 ' 加上反斜杠 \ 进行转义,变成 \'

复制代码
$name = $_GET['name'];
$name = mysql_real_escape_string($name);
$sql = "SELECT * FROM products WHERE name = '$name'";

背景:数据库连接字符集设为 GBK(GBK 是双字节宽字符集);mysql_real_escape_string() 只做转义,不感知 GBK 编码的组合特性。

场景1:正常测试

攻击者本来想传:?name=1' 经过 mysql_real_escape_string 转义后:1\'

拼接 SQL:

复制代码
SELECT * FROM products WHERE name = '1\''

\' 在 SQL 里代表字面量单引号,转义生效,注入失败。

场景2:宽字节注入

传入 URL 参数:?name=%df%27

  • URL 解码:%df → 字节 0xdf;%27 → 字节 0x27(也就是 ')
  • 进入 mysql_real_escape_string():识别到 0x27 是单引号,给它加反斜杠 0x5c 处理之后字节序列变成:0xdf 0x5c 0x27
  • 数据库按 GBK 解析:GBK 规定两个字节组成一个汉字 0xdf 0x5c 刚好是一个合法 GBK 汉字(「運」),被合并识别成一个完整字符 剩下单独的 0x27(')被释放出来,不再是转义后的字符,成功逃逸!

转义后的字节流:[0xdf,0x5c](汉字) + [0x27](单引号)

$_GET['name'] 经过转义处理后的值:運' 拼接最终 SQL:

复制代码
SELECT * FROM products WHERE name = '運' AND 1=1 -- '

这里逃逸出来的 ' 闭合了前面 SQL 里的引号,后面就可以拼接注入条件。

完整可用 payload 示例: ?name=%df%27 AND 1=1 --+

拼接后 SQL:

复制代码
SELECT * FROM products WHERE name = '運' AND 1=1 -- '

必要条件:

  • MySQL 连接字符集是 GBK / GB2312 这类宽字节;
  • 使用 mysql_real_escape_string(老扩展函数,若使用 mysqli 并通过 mysqli_set_charset() 正确设置连接字符集,可避免该问题);
  • 输入是字符串型注入,SQL 里变量被单引号包裹。

为什么现在很少见:

  • 现在主流统一用 utf8mb4,不存在双字节吃掉反斜杠的编码规则;
  • 废弃老 mysql 扩展,改用 mysqli、PDO 预编译;

四、彻底防御方案

方案一:使用预处理语句(核心方案)

预处理语句(Prepared Statements)是防御 SQL 注入的黄金标准。它将 SQL 结构和数据分开发送,数据库先编译 SQL 结构,再传入数据。数据永远被当作纯值处理,不可能变成 SQL 代码。

PDO 方式(推荐)
复制代码
<?php
// 连接数据库
$pdo = new PDO('mysql:host=localhost;dbname=test;charset=utf8mb4', 'user', 'pass');
// 禁用模拟预处理,使用数据库原生预处理
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);

// 问号占位符
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_GET['id']]);
$user = $stmt->fetch();

// 命名占位符
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute([':username' => $_POST['username']]);
MySQLi 方式
复制代码
<?php
$mysqli = new mysqli('localhost', 'user', 'pass', 'test');

$stmt = $mysqli->prepare("SELECT * FROM users WHERE id = ?");
$stmt->bind_param('i', $_GET['id']); // i=整数, s=字符串
$stmt->execute();
$result = $stmt->get_result();

注意 :PDO::ATTR_EMULATE_PREPARES 必须设为 false。PHP 默认开启模拟预处理,是在 PHP 层面做字符串替换,并非真正的数据库端预处理,存在安全隐患。

方案二:输入验证与类型约束

预处理能防注入,但不能防业务逻辑错误。对输入做类型校验是第二层防护。

复制代码
<?php
// 数字型参数强制转整型
$id = (int)$_GET['id'];

// 用ctype_digit严格校验纯数字
if (!ctype_digit($_GET['page'])) {
    die('参数非法');
}

// 字符串用正则匹配合法格式
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $_POST['username'])) {
    die('用户名格式不正确');
}

方案三:最小权限原则

数据库账号只授予必要权限,即使被注入,损失也可控。

  • 网站业务账号仅授予 SELECT/INSERT/UPDATE/DELETE 必要权限(删除操作建议通过 UPDATE 更新软删除字段实现,避免物理删除数据)
  • 绝对禁止使用 root 账号连接业务数据库
  • 禁止 FILE、SUPER、PROCESS 等高危权限
  • 不同业务使用不同数据库账号

方案四:隐藏错误信息

生产环境关闭错误回显,防止攻击者通过报错信息获取数据库结构。

复制代码
// php.ini 配置
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log

// 运行时设置
ini_set('display_errors', 0);

方案五:使用 ORM/Query Builder

现代 PHP 框架的查询构造器默认使用预处理语句,能极大降低注入风险。

复制代码
// Laravel Eloquent(自动使用预处理)
$user = User::where('id', $_GET['id'])->first();

// ThinkPHP
$user = Db::name('users')->where('id', input('id'))->find();

注意 :ORM 不是万能的。如果使用 whereRaw()、selectRaw() 等原生方法且直接拼接变量,仍然会注入。

方案六:外围防护

  • WAF(Web 应用防火墙):部署在服务器前端,拦截常见注入;
  • 安全扫描:定期使用 SQLMap、AWVS 等工具扫描漏洞;
  • 代码审计:人工审查数据库操作代码,重点排查字符串拼接;

五、常见防御误解

❌ 误解 1:用 addslashes() 转义就够了

addslashes() 只是简单地在单引号前加反斜杠,在宽字节、特殊编码场景下可以被绕过。绝对不能作为主要防御手段。

❌ 误解 2:mysql_real_escape_string() 绝对安全

这个函数比 addslashes() 靠谱,但仍依赖正确的字符集设置,且需要开发者每次都记得调用,漏一处就有漏洞。已废弃,不推荐使用。

❌ 误解 3:用了框架就不会有注入

框架只是提供了安全的 API,如果你非要手写拼接 SQL,框架也救不了你。例如:

复制代码
// 危险!即使在Laravel中这样写照样注入
DB::select("SELECT * FROM users WHERE id = " . $_GET['id']);

❌ 误解 4:前端验证了后端就不用管

前端验证只能防普通用户,攻击者可以直接抓包改请求。后端校验是最终兜底,永远不能省。

六、总结

SQL 注入虽然古老,但至今仍是 Web 安全的头号威胁。防御 SQL 注入的核心原则只有一条:

永远不要把用户输入直接拼进 SQL 语句。

落实到 PHP 开发中,优先级如下:

  1. 首选:使用 PDO/MySQLi 预处理语句 + 关闭模拟预处理
  2. 配合:输入类型校验与格式验证
  3. 加固:数据库最小权限 + 关闭错误回显
  4. 外围:WAF + 定期安全扫描

安全不是一蹴而就的事,但 SQL 注入是为数不多 "有银弹" 的漏洞 ------ 只要坚持使用预处理语句,就能杜绝 99% 的注入风险。


技术进阶没有捷径,但有高效方法。 本号专注分享实战干货、避坑指南、性能调优、面试重难点, 每一篇都是亲手落地测试,帮你少走弯路、快速提升核心竞争力。

❤️ 点赞、在看、收藏、关注一键安排

关注账户,第一时间获取硬核技术干货,下期不见不散!

相关推荐
旖旎夜光31 分钟前
【C++ 基础入门】C++ 多态(中):虚函数表、多态原理与动静绑定
开发语言·c++·笔记·学习
你看我名字43 分钟前
产品参数昨天还对,今天已过期:数眼智能与大模型应用如何做资料版本校验
开发语言·架构
老秦包你会2 小时前
计算机网络第二课 ----Socket(套接字)编程预备
开发语言·计算机网络·php
旖旎夜光2 小时前
【C++ 基础入门】C++多态(下):单/多继承虚表、多态补充与 21 道高频题
开发语言·c++·笔记·学习
Decay__2 小时前
SQL Server 数据库全栈运维实战系列 — 合集索引(24 篇)
运维·数据库·sql·mysql·架构
Thanks_ks2 小时前
SQL 的 GROUP BY 与聚合函数:从会写 SELECT 到会做统计
数据库·sql·mysql·group by·数据统计·聚合函数·having
Decay__10 小时前
12_MySQL InnoDB 内存与锁调优 —— Buffer Pool、redo log、隔离级别与锁等待
运维·数据库·sql·mysql·架构
Wang's Blog11 小时前
Java 项目实战: 交友项目 - FastDFS:入门案例
java·开发语言·交友
apcipot_rain12 小时前
C++转Java日记(03)
java·开发语言·c++