*2)原理到sqli-labs实战)
咱们先假设一个场景。你在sqli-labs靶场里一路用联合注入过关斩将打到Less-5的时候突然发现页面不再回显查询结果了无论怎么传id除了“You are in…”就是空白。这时候你想用union select username,password from users结果发现自己撞上了一堵墙——联合注入的回显点位没了。页面什么都不回显只告诉你“查询成功”是不是就只能盲注慢慢猜了不一定。这时候“报错注入”就是专门解决这类问题的思路。报错注入里最经典的一串payload长得跟天书一样开头就是floor(rand()*2)一堆count、concat、group by嵌套在一起新手看到基本都是一脸懵。这篇文章我就是想把这一长串东西彻底拆开从“为什么要报错”讲到“怎么让它自己想招把数据库信息吐出来”然后带着你在sqli-labs这类靶场里完整过一遍最后再聊几个大家容易踩的坑。这个内容适合谁看刚学会union注入、又被盲注折磨得不行的新手或者已经会用工具跑注入但完全不知道payload在干嘛的初学者。看完你就能手动写出一条完整的floor报错注入payload并且明白它到底是怎么工作的。先说明一点所有操作都是在本地靶场、开源靶机这类合法授权环境里做练习别拿去对着真实网站乱试这个底线得守住。1. 报错注入到底是什么为什么需要它1.1 联合注入失效的场景很多人学SQL注入入门都是从union注入开始的。union注入的核心逻辑很简单把正常的查询语句通过union拼一段自己的select然后把查询结果直接回显到页面上。但union继承有个硬条件——页面必须有“回显位”也就是说查询结果得被打印出来。实际上靶场里很多关卡是不回显查询结果的。拿sqli-labs的Less-5举例你输入?id1页面显示“You are in...”输入?id999页面什么都不显示但SQL语句确实是执行了。这个时候union select再怎么写都白搭因为页面根本不显示数据库返回的数据。有人会说那用盲注呗一条一条猜left()函数、ascii()逐字符比对一样能拖出数据。盲注确实可以但效率实在感人手工猜一个数据库名可能要发几百个请求。这时候报错注入的价值就体现出来了它能让数据库在报错信息里把你想查询的数据直接“吐”出来一回显就是完整结果不用像盲注那样一点一点猜。1.2 报错注入的核心思想报错注入的本质是利用数据库在SQL语句执行出错时会返回包含错误信息的提示——而错误信息里往往带着SQL语句本身的片段。那么问题就变成了怎么让SQL语句报错而且错误信息里恰好包含我想读的数据思路其实不复杂。你要做的是把一条正常的查询语句“包装”成一个会产生错误的地方。比如MySQL的extractvalue或者updatexml函数你给它的XPath参数格式不对它就会报错并且在报错信息里把那个“格式不对的参数”原样显示出来。那如果你把这个参数写成concat(0x7e, database())数据库就会报“XPATH syntax error: ~数据库名”数据就出来了。而floor(rand()*2)这一条路子走的是另一种报错机制主键冲突。它的报错信息长这样“Duplicate entry 数据~1 for key group_key”同样会把查询内容带出来。两种方式殊途同归都是“利用报错信息带数据”。1.3 主流报错注入方式一览除了floor(rand()*2)MySQL常用的报错注入方式还有下面这几种了解它们各自的适用场景后面打靶场会很方便报错方式核心函数/结构报错长度限制适用版本floor双查询报错count(*), floor(rand(0)*2), group by约64字节MySQL 5.x全系extractvalue报错extractvalue(1, concat(0x7e, ...))约32字节MySQL 5.1.5updatexml报错updatexml(1, concat(0x7e, ...), 1)约32字节MySQL 5.1.5主键重复报错构造主键重复insert/select不固定部分版本可能受限这里提到的“报错长度限制”很关键当你查询的数据超过64字节或者32字节时数据库不会完整回显中间会显示省略号。所以后面实操时长字符串要用substr()分段去读。2. floor(rand()*2)报错的完整原理2.1 这个payload到底长什么样先把标准payload摆出来后面所有步骤都围绕这个壳子展开?id1 and (select 1 from (select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)-- -拆开看就是几层最内层concat(database(), floor(rand(0)*2))把你想查的数据和一个随机数拼起来给这列起了个临时名字x。中间层select count(*), x from information_schema.tables group by x对x进行分组统计。最外层select 1 from (... )a把中间这段结果当成一张派生表然后随便查一个常数1让它能作为一个完整的子查询嵌到原SQL里。有人会问为什么非要套一层select 1 from (...)a因为MySQL规定如果子查询后面不带别名会直接报错“Every derived table must have its own alias”所以套这一层既是为了兼容语法也是为了让整个表达式统一成一个标量子查询方便拼接。2.2 rand()和rand(0)的区别理解这个payload的关键是搞清楚rand(0)和rand()的区别。rand()每次执行都会返回一个随机的0到1之间的小数没有规律每次都不一样。而rand(0)虽然也是随机数生成器但它给的序列是固定的——只要你从第1次调用开始记第1次的值、第2次的值、第3次的值所有的序列在同一个MySQL版本里都是固定的这叫“伪随机”。为什么要固定的序列因为后面跟group by配合时我们需要让“同一行数据在两次计算中参与分组键的值不一致”从而产生主键冲突。如果用rand()每次值都变化冲突虽然也可能发生但不可控而用rand(0)序列固定冲突发生在哪一行基本是确定的报错成功率非常高。注意实际payload里写的不是rand(0)而是floor(rand(0)*2)。floor()函数的作用是向下取整rand(0)本身是0到1之间的小数乘以2之后范围变成0到2之间再floor一下结果就只剩0和1两个整数了。把0和1拼到查询结果后面目的就是让分组键有概率重复从而制造冲突。2.3 group by count(*) 到底做了什么看到这儿核心机制还剩最后一环group by和count(*)这句话究竟怎么触发主键冲突MySQL在执行group by语句时会在内存里创建一张临时表这张临时表对每个分组键只保留一行并且用count(*)记录这个分组出现了多少行。具体流程是每来一条数据MySQL会去临时表里查一下“这个分组键存在吗”如果存在就把计数加1如果不存在就插入一行新的。问题出在一个特殊场景当分组键里包含rand()这类“不稳定”的值时MySQL对同一行数据可能会计算多次分组键——一次是在“查找临时表”的时候一次是在“决定插入临时表”的时候。假如查的时候算出来是0临时表里没有0于是决定插入但真正要插入的时候再算一次结果变成了1而1在临时表里已经有了——这就造成了主键重复MySQL立刻报错报错信息里会带上这个“重复的主键”也就是你concat拼进去的查询结果。我用个生活化的比喻你坐高铁出站闸机刷身份证系统先扫一次显示“未记录”准备给你开门放行但放行前又扫了一次突然显示“你已经在车站里了”——闸机就报错“重复记录”。数据库的主键冲突就类似这个逻辑。2.4 为什么一定用floor(rand(0)*2)而不是rand(0)很多新手会想既然要0和1直接写rand(0)行不行不行因为rand(0)返回的是小数比如0.1234、0.8765这些小数当分组键是几乎不会重复的冲突很难触发。而floor(rand(0)*2)把结果压到只有0和1两个可能冲突概率瞬间就上来了。同理有些人写floor(rand()*2)也能成功但成功率明显低。因为rand()每次值都不同同一行数据两次计算几乎不可能算出一样的值导致分组键从头到尾没有重复自然也就没有主键冲突。真正稳定的写法就是rand(0)。实操中如果遇到“payload跑了好几次都不报错”第一个要检查的就是括号里是不是写成了rand()而不是rand(0)。3. 靶场准备与判断注入点3.1 推荐的靶场环境要说靶场sqli-labs是练SQL注入最对口的开源项目专门为注入设计的几十个关卡涵盖了联合注入、报错注入、布尔盲注、时间盲注、堆叠注入等题目难度从易到难非常平滑。新手想完整走一遍SQL注入的学习路线用它非常合适。搭建方式也不难。sqli-labs是基于PHPMySQL的常见组合是Windows下装phpStudy把源码放进www目录启动Apache和MySQL访问setup目录完成数据库初始化就能用了。想省事的话也可以用Docker直接跑现成镜像几条命令就能起一个环境。另外DVWA和Pikachu这两个靶场也内置了SQL注入模块风格不太一样但原理是相通的。我习惯以sqli-labs为主因为它每个关卡都是独立的小场景故障定位比较清楚。这里再提一句如果靶场启动失败九成是数据库账号密码不匹配。sqli-labs的配置文件在sql-connections/db-creds.inc默认用户名root、密码为空如果你本机MySQL设置了密码记得同步改掉否则连不上数据库页面会报连接错误。3.2 判断注入点的三步走拿到一个可能存在SQL注入的url第一步肯定是判断注入点。sqli-labs的less-5原始URL长这样http://127.0.0.1/sqli-labs/Less-5/?id1三步判断法记住这个顺序就行先传一个单引号看页面是否报错。?id1如果页面出现MySQL错误或者显示异常说明参数可能被拼进了SQL语句。传?id1 and 11-- -看页面是否恢复正常。再传?id1 and 12-- -如果页面内容变空或者显示不同的结果基本可以断定存在字符型注入。这里注意sqli-labs的Less-5用的是单引号闭合但其他关卡可能是双引号或者带括号判断方式不变先打破闭合再塞逻辑判断看反应。还有一个细节注释符--后面必须带一个空格在URL里会被编码成--或者%20。很多人手工复制payload时丢掉这个空格SQL语句就不完整后面的单引号没被注释掉直接报语法错误。3.3 什么时候改用报错注入如果经过上面三步你发现页面确实存在注入但进一步测试union时发现没有回显位那就该换报错注入的思路了。判断标准其实很简单?id1 and 12-- -时页面有反应变空、变短、报错但?id1 order by 4-- -时你怎么试都没发现回显位说明这个查询结果不会直接打印给前端。这时候两条路一条是盲注慢工出细活另一条就是报错注入直接让数据库把数据报出来。4. 一步步实操用floor报错拖数据库信息4.1 第一步拖出当前的数据库名确认注入点之后先用最小的payload验证报错注入是否可行。在Less-5里构造这样一条请求http://127.0.0.1/sqli-labs/Less-5/?id1 and (select 1 from (select count(*),concat(database(),floor(rand(0)*2))x from information_schema.tables group by x)a)-- -页面如果出现类似下面的报错就说明攻击路径通了Duplicate entry security1 for key group_key看到了吗报错信息里的security就是当前数据库的名字。那个1是floor(rand(0)*2)算出来的随机结果无实际意义只是个“触发器”。这里解释一下为什么要用information_schema.tables作为数据源因为floor报错需要一个有足够多行的表来跑group by操作而information_schema是MySQL自带的元数据库里面的tables表在新建几个库表之后就会有几十上百行足够触发主键冲突。如果换成一个一行数据都没有的表group by直接空转永远不会撞主键。4.2 第二步拖出所有表名拿到了库名security下一步就是读表名。把刚才concat里的database()替换成一条子查询http://127.0.0.1/sqli-labs/Less-5/?id1 and (select 1 from (select count(*),concat((select table_name from information_schema.tables where table_schemadatabase() limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)-- -注意一定不能漏掉limit 0,1。因为table_name子查询一次性能查出很多行如果直接放到concat里MySQL会报“Subquery returns more than 1 row”整个语法就错了。limit 0,1的意思是取第一行所以每次payload只能读一个表名想读第二个就把limit改成1,1以此类推。这条请求执行后报错信息大概长这样Duplicate entry emails1 for key group_keyemails就是第一张表。继续把limit中的偏移量改成2、3、4……就能把security库里的表名一个一个撸出来。实际测试下来这个库里通常有emails、referers、uagents、users这几张表看到users就说明目标已经出现了。4.3 第三步拖出目标表的字段名知道表名是users下一步就要看表里有哪些字段。把子查询换成查字段名条件上把table_name抓死http://127.0.0.1/sqli-labs/Less-5/?id1 and (select 1 from (select count(*),concat((select column_name from information_schema.columns where table_schemadatabase() and table_nameusers limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)-- -注意这里users里的单引号在URL里直接用没问题但有些环境如果开了magic_quotes之类的东西单引号会被转义就得考虑用hex编码替代比如table_name0x7573657273users的十六进制。实操中大部分PHP靶场默认不做这种转义所以直接写也没事但遇到奇奇怪怪的情况时可以往编码方向想想。执行结果应该是类似Duplicate entry id1 for key group_key的报错id字段出来了。继续调整limit偏移量就可以读出users表里的username、password字段跟我们预想的结构完全一致。4.4 第四步拖数据内容字段名拿到了直接查数据。这里把子查询再改一层用concat把多个字段拼起来方便一次看到用户名和密码http://127.0.0.1/sqli-labs/Less-5/?id1 and (select 1 from (select count(*),concat((select concat(username,0x7e,password) from users limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a)-- -0x7e是波浪号~的十六进制表示在SQL里写~效果一样但用十六进制可以避免跟外层单引号冲突这也是实际测试中很常用的技巧。执行后报错信息会显示Duplicate entry Dumb~Dumb for key group_key用户名Dumb和密码Dumb一下就出来了。看到这里你应该发现了整个流程的套路其实非常固定你想读什么数据就把读数据的子查询嵌进concat的第一段后面雷打不动接上floor(rand(0)*2)再套一层group by count触发报错。核心骨架不变变的只是外层那个子查询的内容。5. 报错注入的进阶玩法怎么“玩出花”5.1 长度不够怎么办substr分段截取前面提过floor报错只回显约64字节extractvalue和updatexml甚至只有32字节。实际场景中数据库名、表名这些通常不长但你要读取MySQL版本号、user()、datadir这种长字符串时就很容易被截断报错信息里只剩开头一小截后面全是省略号。解决办法是分段读。以extractvalue为例?id1 and extractvalue(1,concat(0x7e,substr((select group_concat(table_name) from information_schema.tables where table_schemadatabase()),1,31)))-- - ?id1 and extractvalue(1,concat(0x7e,substr((select group_concat(table_name) from information_schema.tables where table_schemadatabase()),32,31)))-- -第一个请求读前31个字符第二个从第32个字符开始再读31个一段一段拼起来就是完整结果。为什么一次只读31因为extractvalue的报错位置只能容纳32字节左右而前面还有一个波浪号占了一位。你写32可能直接报超长最好是留出1到2个字符的安全余量。5.2 三种报错方式怎么选前面表格列了floor、extractvalue、updatexml三种常见报错实际使用场景是有区别的场景推荐方式原因只读短数据、追求稳定floor(rand(0)*2)报错长度最长约64字节只想快速验证注入点extractvaluepayload短、好写、报错稳定遇到update()语句或特殊过滤updatexml函数语义不同可能绕过部分黑名单分组查询被禁用extractvalue/updatexml不依赖group by临时表有时候目标SQL里已经有group by子句了你外层再套一个group by会导致语法冲突。这种场景下用floor就不太方便反而extractvalue这种基于函数报错的方式更干净直接在where后面拼就行。多掌握几种方式到真实环境里才能随机应变。5.3 过滤绕过的小技巧靶场打多了你会慢慢碰上各种过滤规则比如空格被过滤、逗号被过滤、注释符被过滤。这里分享几个实战中比较常用的绕过思路空格被过滤可以用注释符替代比如MySQL里/**/可以当空格用?id1/**/and/**/11-- -。如果连/*都被过滤还可以试tab键URL编码是%09有些过滤规则只处理了空格没处理tab。逗号被过滤的话limit 0,1这种写法就不能用了改成limit 1 offset 0数据还是能取到但语法里不需要逗号。substr函数的逗号也能绕过写成substr(data from 1 for 31)的语法形式。单引号被过滤是比较头疼的因为很多查询条件都要用字符串这种场景下首先想到十六进制编码把字符串整个转成hex。这需要你准确知道要查的库名、表名所以在测试阶段可以先想办法把字段值读出来再用hex去匹配。这些技巧不见得每道靶场题都用得上但思路要建立起来过滤规则永远是在黑名单上做文章你只要找到一种“不被过滤但MySQL还能识别”的写法就绕过去了。5.4 从靶场到真实环境的边界最后想说一个原则性问题。靶场练得再熟练也只是在模拟环境里验证思路。真实网站的数据库配置、WAF规则、报错回显策略千差万别很多真实场景里数据库出错后前端根本不打印错误信息报错注入天然就失效了。所以报错注入更多是靶场里的必修课练的是你对SQL执行机制的理解深度而不是说出门就靠它走天下。如果你在真实授权测试中真正要用到这类技术第一件事永远是确认授权范围第二件事是优先考虑误报率和影响面。报错注入本身的危害不只是数据泄露反复触发主键冲突会对数据库造成额外压力在生产环境乱打一通是很容易被发现的也很容易把业务搞挂。6. 常见问题与排查技巧实录6.1 问题速查表我自己在靶场里练报错注入时踩过的坑基本都集中在下面这几类整理成一个速查表方便你对照排查症状可能原因解决办法payload打了但页面无任何报错参数没被拼进SQL或闭合符不对重新判断注入点换双引号/括号闭合报错注入提示页面正常“You are in”当前不是报错型注入点可能是字符型注入但回显正常换union注入思路报错信息是“Duplicate entry”但是乱码concat里拼的内容包含特殊字符用hex或char()编码特殊字符报错信息被截断显示“...”超过报错长度限制用substr分段读取提示“Subquery returns more than 1 row”子查询没有加limit给子查询补limit 0,1提示“Every derived table must have its own alias”最外层派生表没起别名在括号外面加空格和任意别名如a用rand()不报错、用rand(0)才报错rand()不是固定序列冲突不可控统一改用rand(0)控制台能跑但页面报错URL没有正确编码对特殊字符做URL编码如空格用%20或6.2 两个特别容易忽略的细节第一个information_schema.tables这张表的行数。如果你在本地MySQL里新建的库表特别少这张元表的行数也可能很少分组统计时产生的行也少冲突可能一直触发不了。这种情况下可以在payload里多选一张表来增加循环轮数比如把information_schema.tables换成information_schema.columns行数更多冲突概率更高。第二个MySQL版本。低于5.0的版本没有information_schema库整个payload直接失效5.7以下测试得很顺的payload到了MySQL 8.0之后可能因为报错机制调整而不再显示完整信息。靶场一般用5.5或5.7就非常舒服如果你本地装的是8.0以上建议还是用Docker拉一个5.7的MySQL来搭靶场能少掉很多莫名奇妙的坑。6.3 手动调试payload的小习惯最后分享一个我自己的小习惯跑payload之前先在MySQL命令行或者phpMyAdmin里把SQL片段的“内脏”单独执行一遍看看子查询返回的结果对不对。比如单独运行select table_name from information_schema.tables where table_schemadatabase() limit 0,1如果结果正常是emails再放进完整的报错payload里。这样一旦报错信息不对你能很快判断是数据查询的问题还是外层触发机制的问题而不是对着整条长payload瞎猜。用工具也不是不行sqlmap一条命令就能跑出库表字段但问题是它跑出来以后你依然是黑盒。手动构造payload的过程才是真正理解SQL注入底层逻辑的过程。换句话说工具能让你“躺赢”这一次但理解原理才能让你在遇到新场景时永远知道该往哪个方向走。我个人在实际操作中还养成了一个习惯每次拿到一个新的报错注入payload都会先把最外层那层select 1 from ( ... )a扒掉在MySQL命令行里直接跑一遍内层看看group by到底报不报错、报的信息是什么格式。通过这个方法你能直观地感受到“为什么rand(0)能报错、rand()报不了”“为什么group by能触发Duplicate entry”比任何文档都管用。把这层窗户纸捅破之后sqli-labs里再遇到任何跟报错相关的关卡你都能一眼看出它在考什么。