练SQL注入,很多人卡住的第一关不是“怎么注入”,而是“去哪练”。直接拿线上站点试,既不合规也容易出事;只对着文章看原理,看完就忘,下次遇到换个参数照样一脸懵。我的建议一直很明确:搭一个本地靶场,把注入的整个过程亲手走一遍,从报错、判断、猜字段到拿数据,每一步都看到实实在在的结果,这才叫练习。
这篇文章就是围绕“SQL注入练习”这条主线展开的,内容包括靶场环境搭建、注入点探测、显错注入和联合查询的完整流程、万能密码绕过登录的拆解、盲注场景的排查思路,以及最后从防御侧反推攻击原理。适合三类人看:刚接触Web安全、想系统入门SQL注入的新手;准备CTF比赛、需要刷注入题型的选手;以及后端开发人员——只有真正理解注入是怎么发生的,写代码时才会下意识避开这些坑。
1. 搭建本地靶场:练习环境是认真学习的前提
1.1 为什么推荐用靶场而不是自己写测试页
我见过不少新手自己写一个PHP页面来测试注入,代码大概长这样:
php复制$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = $id";
$result = mysqli_query($conn, $sql);
这种写法确实存在注入漏洞,但作为练习环境有两个问题:第一,没有难度梯度,注入成功了就结束了,学不到更深的绕过技巧;第二,缺少“正确答案”,你不知道当前这个注入点到底有哪些字段、哪些表,练起来心里没底。
所以我的建议是用现成的开源靶场。这类靶场本身带了完整的数据库结构、多个漏洞场景和难度分级,每一步注入结果都能对照预期,排查问题也方便。目前社区里用得比较多的几个:
| 靶场名称 | 特点 | 适合阶段 |
|---|---|---|
| sqli-labs | 共100+关,覆盖报错注入、盲注、堆叠注入、宽字节等 | 入门到进阶全覆盖 |
| DVWA | 带漏洞等级切换(low/medium/high/impossible) | 适合理解防御对比 |
| pikachu | 中文界面,注入场景贴近开发实际 | 适合新手快速上手 |
| Sqli-labs Plus | 在sqli-labs基础上增加了更多过滤绕过场景 | 进阶选手 |
我个人最推荐sqli-labs,原因是它的关卡设计非常像CTF题目,每一关都是一个明确的挑战,而且覆盖了几乎所有常见的注入类型和过滤场景。练完一遍再去看CTF的SQL注入题,你会觉得很多套路都见过。
1.2 基于Docker的环境搭建步骤
sqli-labs的搭建方式有很多种,但我最推荐用Docker,省去了PHP、MySQL环境配置的麻烦,几分钟就能跑起来。
bash复制# 拉取镜像并启动容器
docker pull acgpiano/sqli-labs
docker run -dt --name sqli-labs -p 8080:80 acgpiano/sqli-labs
# 初始化数据库
# 访问 http://localhost:8080/ 后点击"Setup/reset Database"按钮
启动后浏览器访问http://localhost:8080,页面会显示关卡列表。第一件事是点击Setup/reset Database初始化数据库,这一步会创建靶场所需的数据库和表结构,不初始化的话后面所有关卡都会报错。
这里有一个容易忽略的细节:sqli-labs默认数据库用户是root,密码是root,数据库连接配置在sql-connections/sql-connect.php里。如果你用的镜像连不上数据库,多半是账号密码和镜像内置的MySQL不一致,改一下这个文件里的$dbpass变量就行。
1.3 一句话说清楚靶场的请求结构
sqli-labs的每个关卡都对应一个PHP页面,通过URL参数传递输入。比如第一关的URL是:
http复制http://localhost:8080/Less-1/?id=1
id参数就是注入点。靶场页面上会显示当前要解决的SQL查询语句的一部分(有些关卡显示,有些不显示),方便你有针对性地构造payload。练习时建议浏览器装一个修改请求的插件(比如HackBar或者直接用Burp Suite),因为后面构造复杂payload时需要在URL里反复修改、编码、重放,纯手打地址栏太容易出错。
提示:练SQL注入建议全程开着一个数据库管理工具(如phpMyAdmin或Navicat),连上靶场的MySQL,随时查看表结构和数据内容。这样你能清楚地看到自己注入出来的每一个结果到底对应数据库里的哪条记录——这个“看见因果”的过程,比刷一百道题都有用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从一条看似正常的SQL说起:注入发生的本质
2.1 SQL注入的前提:输入被拼进了查询语句
很多人把SQL注入想得很玄,其实它发生的条件就一个:用户输入的数据直接拼接到SQL查询语句中,且拼接后的语句被数据库当作命令执行。
看这个最常见的例子,后端代码是这样的:
php复制$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = $id";
当你正常访问?id=1时,SQL变成:
sql复制SELECT * FROM users WHERE id = 1
一切正常。但当你传?id=1 OR 1=1时,SQL变成:
sql复制SELECT * FROM users WHERE id = 1 OR 1=1
OR 1=1恒为真,所以这条语句会把users表里的所有记录都查出来。这就是注入的基本原型——你在输入框里输入的不再是一个值,而是一段被数据库解释执行的SQL代码。
理解了这一步,你就能理解为什么SQL注入被列为危害最大的Web漏洞之一:它直接攻击的是数据存储层,攻击者可以通过它读数据库里的所有数据、修改数据、甚至在某些配置下执行系统命令。
2.2 不同类型的注入点:数字型、字符型和搜索型
不是所有注入点的写法都一样,要根据后端SQL语句的拼接方式区分类型,因为不同类型的闭合方式完全不同。
- 数字型:
WHERE id = $id,输入直接作为数字拼接,不需要闭合引号,输入1 AND 1=1即可测试。 - 字符型:
WHERE username = '$name',输入被单引号包裹,需要先闭合前面的引号,比如admin' --,后面的内容被注释掉。 - 搜索型:
WHERE title LIKE '%$keyword%',输入被百分号包围,需要构造%' OR 1=1 --来闭合和注释。
判断注入类型的常用方法:在参数后面加'(单引号),如果页面报错或行为变化,说明是字符型;如果加'没反应,直接加AND 1=1和AND 1=2看页面变化,说明是数字型。
2.3 为什么叫“万能密码”:一个payload的完整解析
谈SQL注入绕不开“万能密码”。早年很多网站的登录逻辑写成这样:
php复制$sql = "SELECT * FROM users WHERE username = '$user' AND password = '$pass'";
这个$user和$pass直接来自登录表单。此时,如果在用户名输入框输入:
code复制admin' OR '1'='1
密码随便填(比如x),拼接后的SQL变成:
sql复制SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = 'x'
注意SQL的优先级:AND比OR高,所以实际执行是username = 'admin' OR ('1'='1' AND password = 'x')。password = 'x'是假的,但'1'='1'恒真,所以整个条件的结果取决于username = 'admin'——如果admin用户存在,这个查询就会返回admin那一行的数据;如果数据库里没有admin,把用户名换成' OR '1'='1' -- ,也能返回第一行用户的数据。
更经典的是用注释符把后面全部截断:
code复制' OR 1=1 --
拼接后变成:
sql复制SELECT * FROM users WHERE username = '' OR 1=1 -- ' AND password = 'x'
-- 后面的内容是注释,数据库直接忽略密码判断,OR 1=1恒真,查询返回表中所有用户。代码如果只取了第一条结果且不做更多校验,攻击者就直接以第一个用户的身份登录了。
这就是万能密码的真相——不是因为存在什么“超级密码字符串”,而是因为登录逻辑本身把输入直接拼进了SQL,且查询结果与登录状态产生了直接的信任关系。
2.4 从CTF热搜题看:绕登录的常见变体
CTF比赛里最常见的Web题就是“绕过登录拿Flag”。典型的场景是:你只知道一个用户名(或者连用户名都不知道),目标是通过登录框拿到后台权限。常见的绕过写法有这么几种:
| Payload | 原理 |
|---|---|
admin' -- |
闭合引号后注释掉密码判断 |
admin' OR '1'='1' -- |
闭合引号后用恒真条件兜底 |
' OR 1=1 -- |
不依赖用户名,返回第一行用户 |
admin'/* |
用/*注释后面内容,部分数据库支持 |
admin'# |
MySQL的#也是注释符 |
这里需要特别提醒一个新手常踩的坑:注释符后面要加空格。--在MySQL里后面必须跟一个空格或控制字符才能生效,写成--'常常导致SQL语法错误。我在靶场里见过很多人折腾半天注入不成功,最后发现只是少了一个空格。
3. 联合查询显错注入:入门阶段绕不开的第一课
3.1 核心原理:让两次查询的结果拼在一起
联合查询注入的核心是利用UNION操作符,把攻击者自己构造的查询结果和原始查询结果合并返回。前提有两个:第一,原始查询的返回结果会直接显示在页面上;第二,你知道原始查询的字段数量(列数)。
为什么必须知道列数?因为UNION要求前后两个查询的列数一致。比如原始查询SELECT id, username, password FROM users有3列,你的联合查询也得是3列:
sql复制SELECT id, username, password FROM users WHERE id = 1 UNION SELECT 1, 2, 3
如果列数不一致,数据库直接报错。所以拿到一个注入点后,第一件事就是判断列数。
3.2 完整流程演示:从报错到拖库
以sqli-labs第一关作为演示环境,完整走一遍联合查询注入。
第一步:探测注入点
访问http://localhost:8080/Less-1/?id=1',页面报错,提示SQL语法错误。这说明参数被拼进了SQL,且是字符型注入。
第二步:判断列数
使用ORDER BY逐步增加序号:
http复制?id=1' ORDER BY 1 --
?id=1' ORDER BY 2 --
?id=1' ORDER BY 3 --
当ORDER BY 4报错时,说明原始查询只有3列。
我实测的经验是:不要每次只加1去试,可以先用ORDER BY 50快速判断上限,再用二分法缩小范围。面对一个真实目标时,列数判定越快,留给后续操作的时间越多。
第三步:确定显示位
把id的值改成一个不存在的数字(比如-1),让原始查询返回空集,这样联合查询的结果就能直接显示出来:
http复制?id=-1' UNION SELECT 1, 2, 3 --
页面上显示2和3,说明第2列和第3列是数据显示位,可以在这些位置替换成需要查询的数据。
第四步:获取数据库信息
把显示位替换成函数或子查询:
http复制?id=-1' UNION SELECT 1, database(), version() --
页面显示当前数据库名(比如security)和MySQL版本(比如5.7.x)。database()是获取当前数据库名,version()是获取数据库版本。
第五步:获取表名
知道库名后,可以用information_schema获取所有表名:
http复制?id=-1' UNION SELECT 1, table_name, 3 FROM information_schema.tables WHERE table_schema='security' LIMIT 0,1 --
LIMIT 0,1控制从第一条开始取,每次取一条。实际练习时可以配合group_concat()函数一次性把所有表名拼到一行显示:
http复制?id=-1' UNION SELECT 1, group_concat(table_name), 3 FROM information_schema.tables WHERE table_schema='security' --
第六步:获取字段名和数据
假设找到users表,先看字段:
http复制?id=-1' UNION SELECT 1, group_concat(column_name), 3 FROM information_schema.columns WHERE table_name='users' --
然后脱数据:
http复制?id=-1' UNION SELECT 1, group_concat(username), group_concat(password) FROM users --
到这里,一次完整的显错注入练习就闭环了。重点不是记住这些payload,而是理解每一步背后的逻辑:为什么要用-1、为什么要找列数、为什么查information_schema。
3.3 新手最容易翻车的三个细节
练联合查询时,我见过无数新手在同一个地方卡壳。这里集中说一下。
第一,-1是关键不是玄学。用?id=-1是为了让原始查询查不到数据,这样页面上显示的内容才全是联合查询的结果。如果你用?id=1,UNION的结果会拼接在原始结果后面,可能被截断或者看起来没有变化。
第二,列数必须完全一致。UNION SELECT 1, 2, 3是3列,原始查询就必须是3列。数字多一个少一个都会报错,要通过ORDER BY或不断调整UNION SELECT里的NULL数量来确定。
第三,注释符后面有细节。在URL中直接传-- (含空格)时,有些浏览器或中间件会把空格吃掉,导致注释无效。这种情况下可以用+号代替空格:--+,或者用%23表示#注释符。这一点在CTF题和真实渗透中经常遇到,练习时就要养成习惯。
4. 从显错到盲注:页面不显示数据时怎么判断结果
显错注入直观、见效快,但现实(和CTF进阶题)里更常见的是另一种情况——页面上什么都不显示,报错也被关掉了,你无法直接看到查询结果。这时候就需要盲注。
4.1 布尔盲注:用页面的真假来判断SQL结果
布尔盲注利用的是“页面是否正常显示”这个唯一信号。比如原始查询WHERE id = 1正常显示,如果构造WHERE id = 1 AND 1=1也正常显示,但WHERE id = 1 AND 1=2不显示,说明存在布尔盲注。
判断出注入点后,用条件判断逐位猜数据:
http复制?id=1' AND SUBSTRING((SELECT database()), 1, 1) = 's' --
如果页面正常,说明当前数据库名的第一个字符是s。换下一位再试:
http复制?id=1' AND SUBSTRING((SELECT database()), 2, 1) = 'e' --
这样一位一位地猜,效率很低,但逻辑非常清晰。实际练习时可以用二分法加速——先判断字符的ASCII码是否大于某个值,而不是逐个字符遍历。
http复制?id=1' AND ASCII(SUBSTRING((SELECT database()), 1, 1)) > 100 --
如果页面正常,说明第一个字符的ASCII码大于100,再继续二分,最多7次就能确定一个字符。
4.2 时间盲注:用数据库的等待来传递信号
时间盲注适用于页面完全无差异的情况——无论条件真假页面都显示同样的内容。这时利用数据库的延时函数,让“真条件”和“假条件”在响应时间上有明显差异。
MySQL下最常用的是sleep()和if()结合:
sql复制if(condition, sleep(5), 0)
比如判断数据库名第一个字符是不是s:
http复制?id=1' AND IF(SUBSTRING((SELECT database()), 1, 1) = 's', SLEEP(3), 1) --
如果请求等待了3秒才返回,说明条件为真;如果瞬间返回,条件为假。通过这个时间差,逐字符推断出完整数据。
时间盲注是最容易把新手搞崩溃的注入类型,因为网络波动、数据库负载都可能造成误判。练习时有几个经验供参考:
- 判断延时是否生效,先发一条
sleep(0)的请求做基线,再发sleep(5)看差值。 - 延时不要设置太短,建议3~5秒,太短会被网络抖动掩盖;也不要太长,否则整个猜解过程会非常漫长。
- 用
SLEEP(3)配合LIMIT 1可以避免对多行数据逐一延时,显著提速。
4.3 数据库版本差异:一个被很多人忽略的坑
不同数据库的注释符、函数、拼接语法都不一样。MySQL、MariaDB、Oracle、SQL Server、PostgreSQL各有差异。最典型的例子:
- MySQL:
--、#、/* */都是注释符,支持information_schema。 - Oracle:注释符只支持
--,不支持#,字符串拼接用||不是+,且UNION SELECT后面必须带FROM dual。 - SQL Server:注释符支持
--和/* */,分号可用于堆叠查询,但默认不允许UNION后的子查询带排序。
所以练习注入时,第一步永远先确定数据库类型,而不是上来就套MySQL的payload。在靶场里可以用version()、@@version这些函数确认版本,为后续构造payload定方向。
注意:盲注是CTF的高频考点,但也是真实渗透场景中最常遇到的形态。很多线上系统关闭了错误回显、做了基本过滤,但盲注依旧能从数据库里把数据一点一点“抠”出来。理解盲注的本质,你在做题和实战时都会稳很多。
5. 绕过过滤与WAF:为什么你写的payload会被拦
靶场里练习到一定阶段,你会发现真正让你头疼的不是注入本身,而是“注入被拦了”。靶场设计的各种过滤规则,模拟的是真实业务系统里的输入防护。
5.1 常见过滤方式:黑名单、参数转义、关键字过滤
开发人员常用的防护手段有几种,应对思路各不相同。
第一种:黑名单过滤。把select、union、or、and这些关键字直接替换为空,或者替换成其他字符。这种过滤最容易被绕过,因为过滤不彻底时存在各种变形空间。
第二种:参数转义。比如PHP的addslashes()函数,在单引号、双引号、反斜杠前加\,让输入无法闭合SQL语句。这种防御对常规字符型注入有效,但如果数据库使用GBK等宽字节编码,攻击者可以用宽字节吃掉转义符。
第三种:关键词替换。很多WAF会把union select替换为空,如果只替换一次,攻击者可以利用嵌套两次:ununionion selselectect,第一次替换后中间的union被去掉,剩下union select,成功绕过。
5.2 绕过过滤的练习思路:别记payload,学思维
我见到很多人背了一堆绕过payload,换一个过滤规则就不会用了。这里分享一套我自己练习时用的思维框架:
第一步:看过滤了什么。先尝试提交特殊字符('、"、\、#、--等),观察页面行为变化,推断后端过滤机制。
第二步:判断过滤位置。是应用层的关键字正则,还是数据库层的转义处理,还是前面加了WAF?行为特征不一样,绕过思路也不同。
第三步:列绕过方案。可用手段包括:
- 大小写混淆:
UnIoN SeLeCt - 注释符拆分:
UN/**/ION SEL/**/ECT - 内联注释:
/*!50000UNION*/ SELECT(MySQL专用) - 等价函数替代:
SUBSTRING换成MID、SUBSTR;SLEEP换成BENCHMARK - 编码绕过:URL编码、双重URL编码、十六进制编码(用于字符串值)
- 加括号改变语义:
(SELECT 1) UNION (SELECT 2)
第四步:验证绕过效果。构造payload后在靶场逐条测试,对比响应差异,留下有效的方案。
以空替换过滤为例,如果后端代码做了preg_replace('/union/i', '', $input),一次替换无法直接使用union,但提交ununionion会经过一次替换变成union。类似的还有selselectect变select、oorr变or。这种双层构造是过滤绕过的经典入门思维。
5.3 时间盲注在过滤下的特殊价值
绕过过滤时,时间盲注往往是最保底的手段。因为它不需要从页面上读取数据,只需要观察请求耗时,所以很多黑名单过滤对它无用——除非后端对sleep、benchmark这类延时函数做了专门过滤。
在靶场练习时,我建议按这个顺序构建自己的绕过能力:先练不用任何技巧的直注,再练黑名单过滤下的大小写和注释绕过,再练空替换下的双层构造,最后练完全无反显时的盲注。每一步都在前一步的基础上增加变量,这样你能清楚地知道哪种手段在解决哪一层问题。
6. 防御侧复盘:搞懂注入以后,更要搞懂怎么防
练完注入再谈防御,是一个安全从业者的基本素养。懂攻击不懂防御,写出来的系统依然是靶场;懂防御的人才能在设计阶段就堵住漏洞。
6.1 方案一:参数化查询(PreparedStatement)——根本性解法
SQL注入的根源是数据和代码混在了一起。参数化查询的核心思路,是让SQL语句的结构和数据分离:先定义SQL骨架,再把参数作为数据传给数据库,数据库不再把参数内容当作SQL代码解析。
Java中的正确写法:
java复制String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, userId);
ResultSet rs = pstmt.executeQuery();
这里userId无论传什么值,都会被当作字符串数据处理,不会改变SQL结构。PHP中使用PDO也是同样的道理:
php复制$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);
参数化查询能防住绝大多数SQL注入,包括上面所有提到的绕过手段。这是我做代码审计时最先确认的一环——如果目标代码全用预编译语句,注入这条路基本就断了。
6.2 方案二:输入校验与白名单
参数化查询不是万能的。有些场景下无法使用预编译(比如动态表名、动态排序字段),或者历史代码一时改不动,这时输入校验是第二道防线。
- 数字型参数:强制类型转换或正则校验,只允许数字。
- 字符串参数:设定格式白名单,比如邮箱、手机号、日期等明确格式的字段做格式校验。
- 枚举参数:查询类型、排序字段等参数直接指定可选范围,不在范围内的拒绝请求。
URL上最常见的例子是?id=1判断时,后端先执行is_numeric($id),非数字直接返回错误。白名单校验可以极低成本解决大量基础注入问题。
6.3 方案三:最小权限与数据库账号隔离
这是很多开发团队容易忽略的一层。业务连接数据库的账号,只应该给它执行所需操作的权限:只读业务就SELECT;需要写入才给INSERT/UPDATE;绝对不要用具有FILE、SUPER权限的高权限账号跑业务。
如果注入点存在但数据库账号只有SELECT权限,攻击者能读数据但写不了数据,危害会被显著降低。在MySQL中建立低权限账号:
sql复制CREATE USER 'app_read'@'localhost' IDENTIFIED BY 'password';
GRANT SELECT ON appdb.* TO 'app_read'@'localhost';
6.4 方案四:报错信息隐藏与WAF兜底
安全编码规范里有一条:生产环境关闭详细错误回显。mysqli_error()、异常堆栈、SQL语句片段都不能暴露给前端。很多注入就是因为页面直接回显了数据库错误信息,攻击者才得以快速判断注入点和表结构。
代码上线时加上全局异常处理,把详细错误记入后端日志,前端只返回统一错误页面。这一招配合WAF(Web应用防火墙)能形成纵深防御:即使攻击者突破了应用层校验,WAF还能拦截明显的恶意请求。
提示:如果你在练习注入时习惯开着错误回显,你有很好的测试条件,但一定要知道——真实线上系统几乎不会给你这么友好的反馈。建议练习到中后期,主动把靶场的错误显示关掉,让自己适应“盲打”状态。
7. 练习中的几个心态问题和效率建议
SQL注入的知识点本身不算多,但练习过程中很容易陷入两种极端:一种是一直刷低难度关卡,刷到条件反射但不懂原理;另一种是一头扎进高难度绕过题,攻不下来就受挫放弃。说说我自己带人练习时总结的几点体会。
7.1 建议按“原理→流程→绕过”三阶段推进
第一阶段先把联合查询注入的六步走完整走通,每一步都能解释为什么这么做。第二阶段换不同靶场(比如DVWA、pikachu)反复练同样的流程,熟悉不同业务场景下的注入形态。第三阶段再上sqli-labs的过滤关卡,系统梳理绕过方案。
不要一上来就做第54关的绕过联合查询题。原理没吃透,只会背答案,换个题目照样不会。网上的WriteUp可以用来对答案,但不要边看答案边做题,那样练的是打字能力,不是注入能力。
7.2 记录自己的payload字典
熟练之后,你会总结出自己惯用的一套payload。我建议用文档或笔记工具维护一个属于自己的payload字典,按注入类型和过滤场景分类,每条payload后面标注核心原理和适用条件。
这样做的好处有两个:第一,你会在整理过程中理解“为什么这条payload能生效”,而不是机械地复制;第二,CTF比赛或者真实项目中遇到类似的过滤规则,你能直接从自己的字典里找到可用的组合,不用现想。别人的payload字典再全也是别人的,自己整理的过程本身就是一次重要学习。
7.3 学会看源码:靶场的后门功能别忽略
sqli-labs这类靶场最容易被忽略的宝藏是它的源码。每个关卡页面都直接给出了后端SQL语句的拼接方式。练习时遇到看不懂的报错或奇怪的过滤行为,直接打开对应的PHP文件查看源码,搞清楚后端到底做了什么处理。
我自己练习时一直保持一个习惯:每做完一个关卡,浏览一遍源码,确认自己的理解与真实逻辑一致。这个习惯让我对SQL拼接、过滤顺序、数据库特性的理解扎实了很多。靶场的每一行代码都是作者精心设计的教学材料,只用它跑payload不读源码,相当于白白浪费了一半资源。
7.4 关于练习边界的提醒
最后说一个技术之外但很重要的问题。无论练习还是测试,都只能在你自己拥有或有明确授权的靶场、系统上进行。公网上的陌生网站、未授权的业务系统,都不应该作为注入练习的目标。我见过有人把靶场练熟的payload直接往线上站打,结果给自己惹上大麻烦。真正的安全能力,是在授权范围内把技术玩透,而不是用技术去踩法律红线。这个边界意识,从第一天练习就要建立起来。
