一、什么是 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 网站特别 "中招"
- 历史遗留代码多 :大量老项目仍在使用
mysql_*系列已废弃函数 - 入门门槛低:PHP 开发者水平参差不齐,安全意识薄弱
- 字符串拼接太方便:PHP 的字符串插值特性让开发者不自觉地拼接 SQL
- 老旧教程泛滥:网上很多老教程仍在教不安全的写法
- 框架使用不当:即使使用 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);
逐个拆解函数
updatexml():MySQL 原生函数,作用是更新 XML 内容。 函数签名:updatexml( xml_doc, xpath_expr, new_val )限制:第二个参数必须是合法的 XPath 表达式;如果写非法 XPath,MySQL 直接抛出报错,并且把这个非法字符串原样打印在报错里。这就是报错注入的核心原理。concat(a,b,c):字符串拼接,把几段拼成一个字符串0x7e:十六进制,对应字符~,用来做标记分隔符,方便在报错里一眼定位数据(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:
SELECT * FROM products LIMIT 20 OFFSET 0;第一条语句,正常执行查询,;代表第一条语句结束DROP TABLE products;第二条独立 SQL 语句 :删除products表--: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 开发中,优先级如下:
- 首选:使用 PDO/MySQLi 预处理语句 + 关闭模拟预处理
- 配合:输入类型校验与格式验证
- 加固:数据库最小权限 + 关闭错误回显
- 外围:WAF + 定期安全扫描
安全不是一蹴而就的事,但 SQL 注入是为数不多 "有银弹" 的漏洞 ------ 只要坚持使用预处理语句,就能杜绝 99% 的注入风险。
技术进阶没有捷径,但有高效方法。 本号专注分享实战干货、避坑指南、性能调优、面试重难点, 每一篇都是亲手落地测试,帮你少走弯路、快速提升核心竞争力。
❤️ 点赞、在看、收藏、关注一键安排
关注账户,第一时间获取硬核技术干货,下期不见不散!