刷sqli-labs的人大多都会卡在同一个节点前24关把字符型、数字型、报错注入、盲注、堆叠注入都玩顺了到第25关突然发现自己明明写好了payload页面却开始乱报错。原因不是SQL语法问题而是从这一关开始靶场模拟了一个简单的WAF黑名单blacklist()函数会在参数拼接进SQL之前直接把or和and从输入里删掉。第25、26、26a三关本质上是一套过滤绕过小专题网上不少帖子给了payload让直接抄却没把为什么要这样绕讲清楚。这篇文章我会把三关的PHP源码拆开结合可复现的详细payload把每条必杀技背后的原理讲到透。看完你不仅能安稳过掉这三关还能把方法迁移到其他靶场和授权测试项目里。1. 三个关卡背后的同一个难题PHP黑名单过滤1.1 三关的SQL语句差别很小瓶颈全在blacklist先看源码这是最直接的切入点。第25关的核心逻辑是这样?php function blacklist($id) { $id preg_replace(/or/i, , $id); // 删除 or大小写不敏感 $id preg_replace(/AND/i, , $id); // 删除 and大小写不敏感 return $id; } $id $_GET[id]; $id blacklist($id); $sql SELECT * FROM users WHERE id$id LIMIT 0,1;第26关和第26a关共用了同一份blacklist只是最后拼接SQL的方式不同第26关是$sql SELECT * FROM users WHERE id$id LIMIT 0,1;第26a关是$sql SELECT * FROM users WHERE id($id) LIMIT 0,1;第26/26a的blacklist函数如下function blacklist($id) { $id preg_replace(/or/i, , $id); // 删除 or $id preg_replace(/and/i, , $id); // 删除 and $id preg_replace(/[\/\*]/, , $id); // 删除 /* $id preg_replace(/[--]/, , $id); // 删除 -- $id preg_replace(/[#]/, , $id); // 删除 # $id preg_replace(/[\s]/, , $id); // 删除所有空白字符 $id preg_replace(/[\/]/, , $id); // 删除 / return $id; }可以看到$_GET[id]取到参数之后先经过blacklist清洗再拼进SQL执行。前24关没有这个环节所以这三关给新手带来的最大变量不是SQL本身有多复杂而是这个清洗函数到底做了什么事。PHP的preg_replace用i修饰符表示大小写不敏感因此OR、Or、oR一律会被删掉。替换动作执行完后拼接进SQL的是清洗后的结果这一点直接决定payload的写法。1.2 替换型过滤的两个致命设计漏洞为什么这种黑名单替换特别容易绕过因为它有两个很典型的反模式。第一替换不递归只做一遍。拿anandd举例把它拆开是an and d中间那组and被删空之后剩下的an和d拼起来又变成了一个and。相当于橡皮擦只擦了一次字还在纸上。同理oorr删除中间的or后留下的or仍然可以构成关键字。这个漏洞在第25关被体现得淋漓尽致。第二过滤内容永远是有限的。你删or和and我就用||和你删空格我就想尽办法用括号来充当语法分隔符。黑名单的思路本质上是猜哪些字符会出问题而写payload的人只需要找到一个漏网之鱼。打个比方这就像让你把苹果从一段话里全部抹掉可对方换个说法叫水果意思照样能表达出来。这三关的价值就是让新手明白过滤器不是不可逾越的墙而是一套有边界的规则。1.3 环境准备与最小验证方法环境配置我不多说phpstudy、XAMPP装好PHP和MySQL把sqli-labs项目丢进Web目录就能跑。数据库导入项目自带的sql-lab.sql改一下sql-connections/db-creds.inc.php里的数据库账号密码即可。用Docker跑sqli-labs镜像也一样。有一点需要提前确认页面能不能正常显示SQL错误。第25关和第26关会把数据库错误信息直接输出到页面这个特性决定了报错注入是否可行也是你在第一轮判断闭合方式时的重要依据。登录靶场后可以先看一眼页面标题。第25关标题是Trick with OR AND第26关标题是Trick with comments space第26a关标题是Trick with comments space - Blind。标题早就把本关考点写在脸上了25关考or/and过滤26考注释和空格过滤26a则是更进一步的无回显盲注。做题之前先读题这点效率一定要有。2. 第25关AND/OR被删后用逻辑运算符和双写接管2.1 先确认第25关的过滤范围第25关的blacklist只有两行删or和and。它不删空格不删注释不删引号。这意味着这一关其实是最温柔的一关union select本身不含or/and可以直接用主要要处理的是逻辑条件比如经典payload里的or 11会被直接删没。先访问?id1页面会报SQL语法错误说明是单引号闭合。再试试?id1 and 11and被删掉之后SQL变成id111肉眼可见的语法错误。这个对比能确认过滤器确实在工作同时也说明一个关键点and不能直接用在条件里。2.2 绕过or/and的三条路线路线一用逻辑运算符替代。MySQL里||就是OR就是AND。你把or 11改写成|| 11正则匹配不到or自然能通过过滤。同时单引号闭合后面可以直接接--注释因为第25关没有过滤注释。?id1 || 11--路线二双写绕过。利用前面提到的替换一次不递归特性把and写成anandd过滤掉中间的and后剩下的还是and。同理or写成oorr。这个技巧在第25关不仅是理论后面查数据时还会用到。?id1 anandd 11-- ?id1 oorr 11--路线三绕开关键字。构造一条完全不包含or/and的payloadunion select就是典型。union这个单词里没有orselect也没有所以第25关的联合注入可以直接打。2.3 直接打union select第25关的union注入payload看起来和前几关几乎一样。先判断列数users表是3列直接用?id-1 union select 1,2,3--正常返回之后继续爆库、爆表、爆字段?id-1 union select 1,database(),3-- ?id-1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()-- ?id-1 union select 1,group_concat(column_name),3 from information_schema.columns where table_nameusers--这里有一个非常容易翻车的细节第三个payload查出来的列名里username没问题但password这个单词带or。把password直接写进payload会被过滤成passwd查询直接报错。正确写法是双写成passwoorrd过滤之后还原成password。所以查用户名和密码时要这样写?id-1 union select 1,group_concat(id,0x3a,username,0x3a,passwoorrd),3 from users--group_concat把多行拼成一个字符串0x3a是冒号的十六进制写法避免在SQL语句里裸写冒号产生语法歧义。这是很多writeup不会明说的细节第一次遇到时我排查了十分钟才反应过来。2.4 第25关容易踩的坑第一个坑是order by。order本身就包含or所以order by 3会被过滤成der by 3完全没法用。这也是为什么我直接推荐用union select 1,2,3来判断列数而不是沿用前几关的order by习惯。第二个坑是大小写绕过无效。因为正则加了i修饰符Or、aNd、oR都会被删不要浪费时间尝试大小写变形。真正有用的是双写、逻辑运算符替代以及构造不含关键字的表达式。第三个坑是information_schema里边的表名。幸好tables、columns、schema这些单词都不含or/and否则连系统库查询都要绕弯。你要有这个意识过滤不只针对payload里的逻辑关键字任何拼进SQL的字符串包括表名字段名都可能踩雷。3. 第26关空格和注释全灭报错注入成为最优解3.1 第26关的blacklist到底加了什么料第26关的blacklist一下子加了很多规则删or/and删/*和*/删--删#删所有空白字符删斜杠/。这意味着常规的注释结尾基本没戏--、#、/**/全部失效。空格也没了union select这种经典写法会直接粘成unionselect。有人会想把空格换成%0a换行符不就行了这份源码里真不行。/[\s]/会匹配普通空白字符换行、制表符、回车全部包含在内URL解码后的换行依然会被删掉。网上有些旧帖子说第26关用%0a当空格用多半是没读源码或者版本不一致照抄很容易翻车。3.2 为什么union select在第26关不好打把-1 union select 1,2,3--喂进去清洗之后大概会变成-1unionselect1,2,3。union和select中间的空格被删除MySQL不认这个关键字尾部的注释被删除后原SQL里的 LIMIT 0,1还会出来干扰语法。虽然也能用union(select(1),2,3)这种括号玩法消掉部分空格但后面一旦要接from、where空格问题又会冒出来整条payload会变得非常拧巴。相比之下报错注入根本不需要关键字之间有空格。MySQL的extractvalue(1, concat(0x7e, (select(...))))这种写法每个词都被括号和逗号天然分隔完全不依赖空白字符。所以我对第26关的判断是放弃union直接上报错注入。3.3 报错注入payload逐段拆解先给核心payload?id0||extractvalue(1,concat(0x7e,(select(database()))))||逐段拆开看0拿到URL参数后在SQL里对应的是id0这个0让前面的WHERE条件为假后面||右边的表达式才是决定结果的部分。||这是or的等价写法。源码会删掉字符串or但||两个竖线不在过滤名单里。extractvalue(1, concat(0x7e, (select(database()))))extractvalue是MySQL的XML处理函数第二个参数要求是XPath字符串。传一个非法XPath进去MySQL会把解析错误连同参数内容一起输出。concat(0x7e, ...)里的0x7e是~的十六进制写法作用是让字符串开头就不是合法XPath确保触发报错同时方便在错误信息里定位输出位置。结尾的||是精髓。因为注释和空格都被删了没法用--或#截断SQL尾部直接拼一个||让原SQL结尾处的和它配成一对空字符串。最终整条语句变成SELECT * FROM users WHERE id0||extractvalue(1,concat(0x7e,(select(database()))))|| LIMIT 0,1这里不需要注释掉LIMIT因为|| 已经把整条语句在语法上闭合了。3.4 换一个报错函数updatexml与查库查表payloadextractvalue和updatexml用法类似当其中一个报错格式不对时换另一个常常有惊喜。下面几条都是实测可跑的payload?id0||updatexml(1,concat(0x7e,(select(version()))),1)|| ?id0||extractvalue(1,concat(0x7e,(select(user()))))|| ?id0||extractvalue(1,concat(0x7e,(select(group_concat(table_name))from(information_schema.tables)where(table_schemadatabase()))))||第三行需要留意from(information_schema.tables)和where(table_schemadatabase())都是借括号绕开空格过滤。如果目标MySQL版本对from(表名)这种写法解析有差异优先用不查表的函数结果来验证比如version()、database()、user()不要死磕一条语句。3.5 报错信息有长度限制别想一口吃成胖子extractvalue的报错信息大约只显示32个字符group_concat把所有表名拼在一起很可能被截断。解决办法是分段截取用substr()把结果一段一段拿出来?id0||extractvalue(1,concat(0x7e,(select(substr((select(group_concat(table_name))from(information_schema.tables)where(table_schemadatabase())),1,31)))))||把1,31改成32,31、63,31就能继续拿下一段。报错注入只能当小窗口用不能当大屏幕这个心理预期要先建立起来。3.6 第26关实战中踩过的坑第一别用%0a当万能空格这份源码里换行符会被\s删掉。第二--和#都不能用了结尾必须用||这类闭合技巧。第三查询数据时如果列名含or/and比如users表的password需要双写成passwoorrd否则字段名会被过滤器直接改掉。常见的报错回显长这样XPATH syntax error: ~security。看到~就说明payload进入了报错分支~后面的内容就是你要的数据。如果只显示XPATH syntax error看不到具体内容检查一下concat(0x7e,...)有没有写对。4. 第26a关闭合变成)后进入无回显盲注模式4.1 26a源码只改了一处攻击思路却完全不同第26a关和26关共用同一份blacklist唯一区别在最后一行SQLWHERE id($id)。引号外面多了一层括号闭合方式从单引号变成了单引号加右括号payload开头写法完全不一样。页面表现也不一样26a不会把查询结果或SQL错误抛出来更像一个标准的盲注环境——你只能通过页面是否返回正常内容来判断条件真假。这一关的关键不是构造多复杂的查询而是确认两条信息闭合方式是什么用什么手段把数据带出来。4.2 第一步永远都是判断闭合用两个请求确定闭合方式。先访问?id1页面异常或者空白说明单引号破坏了(1的闭合。再访问?id1)如果页面恢复正常说明多出来的右括号正好把源码里的($id)结构闭合了。确认闭合方式后再构造条件后面每一步都有依据。这一步自己动手验证的价值很大它决定了payload开头写1还是1)。很多教程直接丢一个26a的payload出来但如果你不知道闭合为什么是这样换到第28关、第31关还是会懵。4.3 布尔盲注用连接条件因为and被删条件之间用连接。URL里的表示参数分隔符所以必须编码成%26%26这个细节几乎每次都会坑到人。先做基础验证?id1)%26%2611 ?id1)%26%2612条件为真时页面和原始请求几乎一样条件为假时页面没有数据输出或内容明显变化。接下来猜数据库长度?id1)%26%26(length(database())4)%26%2611逐字符猜?id1)%26%26(substr(database(),1,1)s)%26%2611 ?id1)%26%26(ascii(substr(database(),1,1))115)%26%2611用ascii()做判断的好处是方便二分法先测ascii(substr(...)) 100再根据页面返回情况缩小范围。实际操作时用浏览器开发者工具看响应长度真条件和假条件的响应长度存在稳定差异肉眼分不清的时候以长度为准。4.4 时间盲注不依赖页面内容的备选方案如果目标环境连正常/异常回显都难区分就换时间盲注?id1)%26%26(if((length(database())4),sleep(3),1))%26%2611当length(database())4为真时MySQL执行sleep(3)页面响应时间延迟约3秒条件为假时立即返回。时间盲注不依赖页面上任何可见变化但网络抖动会把测试结果带偏所以每个条件最好多测几次取稳定值。一个字一个字的猜很慢但这是无回显环境下的通用解法。4.5 用脚本代替手点把盲注当算法题做手工猜一个字符可能要发好几轮请求效率太低。建议直接上脚本用Python请求靶场页面按返回内容判断真假。下面是最小示例实际使用时把URL换成你自己的靶场地址import requests base http://127.0.0.1/sqli-labs/Less-26a/ def check(cond_encoded: str) - bool: url base ?id1)%26%26( cond_encoded )%26%2611 r requests.get(url, timeout10) return Dumb in r.text # 数据库长度是否大于1 print(check(length(database())%3e1)) # 数据库第一个字符是否为s print(check(substr(database(),1,1)%3ds))代码里%3e是的URL编码%3d是的URL编码。条件里如果出现其他特殊字符记得先编码再拼进URL。用这种脚本做二分法猜一个字符平均只要七八次请求比手工在浏览器里刷快得多。4.6 26a查表查列时的双写提示26a同样删除or/and所以一旦要查users表的password字段password会被过滤器洗成passwd导致盲注条件失效。正确写法是双写passwoorrd和25关的坑一模一样。这个坑我在第一次打26a时没有注意盲注脚本跑了几分钟才意识到是字段名出了问题。字段名、表名这些非payload主体的部分也要纳入过滤检查范围这是很多新手最容易忽略的。5. 三份源码对比绕过滤的通用方法论5.1 三关过滤规则速查表把三关的过滤规则、闭合方式、回显情况和推荐手法放一起方便对照复习关卡过滤关键字/字符闭合方式回显/报错推荐手法25or、and大小写不敏感单引号正常回显 SQL报错union select必要时26or、and、/*、--、#、空白、/单引号SQL报错回显extractvalue/updatexml报错注入26a同26单引号右括号无回显布尔盲注/时间盲注这个表不是让你背而是帮你建立先看过滤规则再选注入方式的决策顺序。5.2 从三关提炼出来的黑名单绕过五板斧等价替换or换成||and换成空格换成括号、注释或换行具体用哪个取决于哪样没被过滤。双写重试anandd、oorr、passwoorrd利用单次替换后字符串重新组合的特性让关键字在过滤后重新出现。换结果通道有回显打union有报错打报错什么都没有打盲注。第26关教会我报错注入的核心价值第26a关教会我盲注的构造思路。闭合先行先摸清SQL是、)还是数字型这决定payload开头的写法。第26a和第26的payload开头的确不同这也说明闭合判断是一票否决项。尾部收口注释被过滤时用||、11等方式把原SQL尾部补成合法表达式。能优雅地处理SQL尾部说明你对拼接后的完整语句已经有了整体把握。5.3 这三关对应的真实WAF模型第25到第26a关模拟的是一个非常基本但常见的WAF黑名单模型对输入做单次、非递归、关键词替换。真实WAF远比这个复杂会做大小写归一化、多重编码解码、SQL词法分析、请求体大小限制等等。但你在这个靶场练出来的核心能力是在遇到payload怎么突然失效了的时候退一步看源码、看报错、看过滤规则判断输入在哪里被处理、被替换成了什么然后决定用哪一招。后面用sqlmap打真实目标时也遵循这个逻辑工具很难一次性跑通往往需要先手工测出闭合和过滤规则再通过--prefix、--suffix、tamper脚本把规则告诉工具。25到26a这三关就是把手工测规则这件事练熟的最小样本。方法比单一payload值钱得多。6. 我在实测中踩过和补过的细节6.1 URL编码优先级必须写成%26%26打第26和第26a关时最容易翻车的就是URL编码。浏览器地址栏里直接写会被当成参数分隔符逻辑与必须编码成%26%26#会被当成页面锚点不能出现在payload里。空格字符在这两关会被\s删掉所以不要指望%20或者能当空格用。有个习惯值得养成把完整payload写出来之后用在线URL编码工具或Python的urllib.parse.quote做一次编码再拼到请求里。不同客户端对URL的自动转码规则并不完全一致自己动手编码能减少很多莫名其妙的差异。6.2 0x7e不是玄学是为了让报错信息可见经常看到extractvalue(1,concat(0x7e,(select(...))))这种写法0x7e是~的十六进制表示。加上它之后报错信息会变成XPATH syntax error: ~security这种形式~就是数据起始位置的标记。如果不用0x7e报错信息可能为空或者开头就是正常数据不方便定位输出。遇到报错注入只显示XPATH syntax error但看不到内容时第一反应就应该是检查concat(0x7e,...)这段前缀。6.3 注释失效时的优雅收尾充分利用原SQL尾部在不能使用注释的关卡里||比任何花哨的绕过技巧都实用。它利用的是源码自身最后一个单引号让整条SQL在不需要注释的情况下保持合法。WHERE id0||extractvalue(...)|| LIMIT 0,1||在MySQL里就是逻辑或加空字符串语法完全成立。以后遇到别的拼接方式比如双引号内拼接、数值型拼接收尾写法也要跟着变这就是为什么我一直强调先读源码、先判断闭合而不是死记某一个payload。6.4 sqlmap怎么配合手工结果提速手工打通之后想用工具提速sqlmap可以这样用。以26a为例已知闭合方式是)条件连接用可以给sqlmap指定前缀后缀sqlmap -u http://127.0.0.1/sqli-labs/Less-26a/?id1 --dbmsmysql --techniqueB --prefix) --suffix11 --batchsuffix写成11而不是11可以避免shell把当作后台任务符号。如果默认payload里的空格或注释触发了过滤可以加--tamperbetween或者自己写一个把空格替换成括号的tamper。工具能提速的前提是你已经手工掌握了规则。在不清楚闭合和过滤规则的情况下盲目跑sqlmap大概率连报错都读不懂。实际操作中我会先用浏览器把这套payload分别在25、26、26a上各跑一遍确认页面表现和预期一致再决定上不上工具。靶场里的规则是死的多试几次不会出问题到了授权测试的目标上规则是活的小心一点总没错。把这三个关卡彻底吃透往后碰到类似的过滤逻辑脑子里会直接浮现出它在哪个环节删了什么、我应该从哪个缝隙绕过去的判断路径。