1. 为什么PureWaf成了比赛里的“标配拦路虎”
做Web方向的同学对PureWaf应该都不陌生。这几年不管是某云厂商的CTF训练营,还是各种高校新生赛,只要遇到那种“明明是个SQL注入却怎么都注不出来”的题,多半就是套了一层WAF,而这层WAF十有八九就是PureWaf。它基于nginx和lua构建,部署方式简单到让人怀疑是不是主办方偷懒——把规则文件一放、nginx一重启,站点就直接上了防护。
先说说PureWaf的定位。它本质上是一个免费开源的Web应用防火墙,自带一套针对SQLi、XSS、命令注入、文件包含、LDAP注入等常见攻击的检测规则。免费版没有可视化控制台,规则以lua文件和正则为主,但正因为轻量、可定制、不影响业务性能,很多比赛方都喜欢用它来当“第一道关卡”。对选手来说,看到题目环境里带PureWaf的响应头或者页面特征时,就意味着这题的核心考点大概率不是找到注入点,而是怎么绕过加固层把数据掏出来。
这是很关键的一个思路转变。很多人卡在CTF Web题上,不是因为不会注入,而是把精力全放在“找注入点”上,忽略了后面还有WAF这层更难的过滤。PureWaf的检测逻辑和传统黑名单有些不一样,它会先对请求体做一次解码归一化——包括URL解码、JSON字段解析、表单参数提取——然后再用规则逐条匹配。这意味着你在payload里随手加个%0a或者大小写混淆,很可能在解码阶段就被还原成原始关键字直接拦掉。
我在打比赛时吃过这个亏。某次线上赛遇到一道查询题,参数直接拼进SQL语句,我第一反应是报错注入一把梭,结果updatexml刚输入就被秒拦,页面返回默认的拦截提示。换了extractvalue也一样,换成exp还是被拦。后来才意识到,题目根本不是考注入能力,而是考“你能不能在WAF的规则缝隙里找到一条通路”。
所以这篇内容,我打算用几道实际碰到过的CTF例题来拆解PureWaf的绕过思路。我不保证每个技巧在所有版本的规则下都能用,因为PureWaf规则在不同比赛里可能被主办方修改过,但底层绕过逻辑是相通的。你将看到的是:探测WAF的边界、分析规则盲区、构造等价表达式,最终把数据一点一点带出来。如果你正准备打CTF,或者在给自己部署WAF做攻防验证,这篇应该能省下不少试错时间。
2. 第一道题:一个SELECT被拦之后,我先把WAF的脾气摸清楚了
那是在某次线上排位赛里碰到的一道题,环境是一个简单的用户搜索功能,点进去能看到一个查询框,传个id参数会有不同结果。我把id改成1 and 1=1,正常返回;改成1 and 1=2,没有结果——确认是数字型注入。当时挺高兴,以为是个送分题,随手输入union select 1,2,3,结果直接被拦截。
2.1 拦截特征探测:先确定哪些词在“禁用名单”里
被拦之后我做了一组简单的测试,把payload逐个拆分往里丢。先测union,正常;再测select,被拦。到这里就知道,PureWaf的规则有针对select关键字的黑名单。再继续测,union select一起出现也会被拦。
接下来我测了大小写。输入UnIoN SeLeCt,依然被拦,说明规则对大小写不敏感。这让我意识到,单纯靠改大小写是过不去的。又试着在关键字中间插入注释,比如un//ion sel//ect,结果还是被拦——PureWaf在解码归一化后会去除非字母数字的干扰字符再做匹配,注释符这条路对它是无效的。
2.2 纯数字型注入没有回显列数限制时,用别名绕过
既然union select被拦,我换了一个思路:既然是数字型注入,直接查当前库名好了。但select被拦,意味着大部分查询语句都写不出来。我尝试用报错函数带出数据,updatexml也被拦,说明规则并不只是拦select,而是把一堆注入常见关键字全列进了黑名单。
后来我想到MySQL里有个老技巧:用变量或者带别名的子查询,让关键字的出现形式发生变化。比如:
1 and (select 1 from dual where 1=1)这个也被拦了,因为里面包含select。
真正让我突破的是一句比较冷门的写法:用union后直接跟一个带括号的别名表达式。比如:
1 union (select 1)会被拦。但换成:
1 union (select 1)a很多情况下也一样拦。
试了几次之后,我干脆走另一条路:既然联合查询这条路被堵死,就试试能不能把select关键字拆成由函数返回的字符串再拼接执行。但MySQL并不支持这样动态执行SQL——存储过程里prepare语句可行,而普通查询中select关键字无法拆开。这条路也走不通。
2.3 最终思路:利用不同字符集和十六进制绕过关键字检测
PureWaf虽然做了URL解码和JSON解析,但它不一定对ASCII编码或者十六进制字面量做二次解析。我试了将查询的表名用十六进制表示,比如:
1 union select * from (select 1)a这个方法的核心是用子查询别名来改变语句结构,让WAF的规则匹配不到连续出现的”select”模式。实测里部分版本的PureWaf只匹配了union和select相邻出现的情况,对select后面接子查询括号、再接别名这种结构,正则没那么灵敏。
也是从这道题开始,我养成了一个习惯:拿到WAF防护环境,先花五分钟探测拦截边界,而不是一上来就堆payload。具体做法是构造一组最小化的测试数据,逐个检查关键字、函数名、注释符、空白变体、编码变体,记录下来哪些被拦哪些没被拦。这看起来费时间,但对后面的直接影响是决定性的——大部分WAF绕过成功与否,在探测阶段就已经定下来了。
3. 报错注入撞上关键字过滤,换一种函数同样能出flag
第二题来自某高校举办的一场内部训练赛,环境是个登录接口,用户名参数拼接进SQL。当时我把username打成admin' and updatexml(1,concat(0x7e,(select database()),0x7e),1)-- -,期望直接报错回显库名。实际上更新操作很快,但返回的页面干干净净,一点报错信息都没有——不是没有报错,而是updatexml这条payload被WAF拦截后直接被丢弃了。
3.1 探测规则范围:到底哪些报错函数被列入黑名单
我在本地搭了同样的环境逐步测。PureWaf默认规则里,更新一下xml文件?并不是。它拦截的是updatexml、extractvalue、exp、floor、geometrycollection、polygon等一长串函数名。这个列表挺长,基本上主流报错注入函数全被覆盖了,想靠换函数名绕过的难度直线上升。
这也是PureWaf做得比较聪明的地方:它的规则不是只匹配攻击特征,而是把函数白名单之外的所有“危险函数”都拉黑。普通选手在比赛时间压力下,很难背出所有被ban的函数名,反而被带偏了节奏。
我后来试着用name based on报错这类副产品来绕过——MySQL的重复键报错。这种方法不依赖updatexml这类函数,而是通过主键或唯一键冲突时返回的Duplicate Entry错误信息来泄露数据。构造方式如下:
1 and (select 1 from (select count(*),concat((select database()),floor(rand(0)*2))x from information_schema.tables group by x)a)同样因为包含select和information_schema被拦了。
3.2 用别名表和join制造报错信息,绕开所有函数名
真正的突破口是join报错。MySQL中如果两个子查询的列名重复,会报出Column 'xxx' in field list is ambiguous的错误,错误信息里会包含表结构和列名。这个错误本身是数据库引擎层返回的,不涉及任何“危险函数”。构造如下:
1 and (select 1 from (select 1)a join (select 1)b)这条语句里也有select,但注意,这里的select是普通查询,没有union,也没有敏感函数。如果PureWaf规则只匹配特定模式,这条是能过的。
当然,更通用的是用列名重复来爆库名和表名:
1 and (select 1 from (select * from (select database())a join (select database())b)c)这个实现的关键在于:让两个字段别名相同,触发歧义错误,错误信息里会把database()查询结果带出来。我把这条拿去打那题,成功在页面错误回显里看到了目标数据库名,再一步步往表名、列名、flag内容推进。
3.3 实测中对规则版本差异的观察
后来我在不同版本的PureWaf上验证,发现规则文件不同,绕过的成功率差别很大。有些版本会把select * from连起来的写法拉黑,有些只拦union select相邻出现;有些版本连join也拦,但大多数情况join不在默认列表中。所以做题时建议准备两到三套报错注入模板,依次尝试,而不是死磕一个函数。
从这道题里我个人最大的体会是:PureWaf的规则本质上还是“基于签名和关键字”的检测,它的强项在于把最常见的攻击payload全部拦死,弱项在于对“MySQL引擎自身返回的错误”无能为力——因为数据库报错不是应用层注入的那段代码,WAF没法从请求里判断出这一步将来会触发什么错误。只要你的payload不包含规则列表里的关键字,请求就会被放行,而真正的数据泄露发生在数据库层。
4. 时间盲注里sleep被拉黑,换掉计时器继续等结果
第三题是个严格盲注环境。页面只有两种状态:查询成功和查询无结果,没有任何回显。但在尝试过程中我发现,如果查询语句有问题会立刻返回,执行时间几乎没有差别。这时我知道只能用时间盲注来区分条件真假。正当我准备上sleep(1)织条件判断时,sleep被拦了。
4.1 绕开sleep:benchmark、笛卡尔积与heavy query
PureWaf对sleep和benchmark的拦截几乎是默认操作。sleep函数是注入选手最常用的,benchmark也早就被列为黑名单。当你发现这两个都用不了时,不代表时间盲注没戏,还有很多可以让MySQL“慢下来”的办法。
最经典的是heavy query,通过大表关联制造高计算量。比如:
1 and if((select ascii(substr(database(),1,1)))=100,(select count(*) from information_schema.tables A, information_schema.tables B),0)这段payload有两个问题:select和information_schema都容易触规则。我在那场比赛中临时改用sys库的视图来代替information_schema,确实在某些版本中能过,但sys库不是所有环境都开放,稳定性一般。
4.2 正则回溯延时:规则里根本不存在的“耗CPU”手段
我另辟蹊径,用RLIKE做正则回溯延时。正则引擎在处理某些模式时会产生指数级的回溯,消耗大量CPU时间,效果可以替代sleep。一个经典的写法是:
1 and if((select ascii(substr((select database()),1,1)))=100,(select 1 regexp '^[a-z]+[a-z]*[a-z]+$'),0)这个正则会被MySQL的HCOLLATE规则展开成大量比较分支,在小数据集上不明显,但在有索引的大表上会有明显延迟。实测在本地MySQL 5.7环境下,单次判断延迟约0.5秒,配合条件判断可以稳定区分真假。
不过regexp延时不太稳定,受数据量和系统负载影响很大。我建议把它作为sleep和benchmark都被ban时的备用方案,不要一上来就依赖它。
4.3 更稳定的方案:用BENCHMARK的“近亲”——大量重复的算术运算
其实绕过PureWaf拦截不必非得找另一个被允许的函数。因为PureWaf是黑名单制,只要绕过“sleep”这个字符串本身,就能继续用sleep的功能。方法是在sleep中间插入特殊字符,让WAF解不出来,但MySQL能识别。不过前面已经说过PureWaf会做归一化,写注释符会被剥掉。但有一个细节我用得很顺手:在关键字中间插入小数点。
准确地说,MySQL支持关键字中间加反引号,比如sleep,这个反引号能让WAF的正则匹配不到连续字符,但MySQL会把它当函数名解析。实测某些PureWaf版本对反引号并没有处理。构造出来就是:
1 and if((select ascii(substr((select database()),1,1)))=100,`sleep`(1),0)这个payload如果环境允许,时间盲注直接回到最舒服的状态。在另一些版本里反引号会被拦,这时我再退回regexp方案。总之一道题最少准备三套延时方法,比赛现场一个一个换着试,远比一条路走到黑稳得多。
5. 堆叠注入文件读取出局,PureWaf能拦什么不能拦什么
前面几道题还停留在参数注入层,第四道题一下子把难度拉高了——环境支持堆叠注入。堆叠意味着你可以用分号结尾再执行多条语句,自由度比单条select高很多。我当时的第一反应是读文件:既然支持堆叠,先查一下当前用户权限,然后load_file读/etc/passwd,再不行就into outfile写个shell出来。这一套组合拳打过去,被PureWaf全部挡了回来。
5.1 load_file与into outfile是重点拦截对象
PureWaf对文件操作类关键字的拦截排在很高优先级。load_file、into outfile、into dumpfile、@@datadir、@@basedir这些全在黑名单里。即使在堆叠注入下,你也不能直接调用这些功能。更麻烦的是,PureWaf会检查整个请求体,不只匹配某一参数,哪怕你把函数藏在注释里、编码转换后,只要解码还原出这些关键字,照样被拦。
5.2 用十六进制绕路径检测,但函数名绕不掉
我试过把路径换成十六进制:load_file(0x2f6574632f706173737764)。这个方法能绕开路径关键字检测,因为WAF的规则往往匹配“load_file(”后面跟字符串的模式,十六进制没有可见的路径特征。但这道题的PureWaf版本把load_file函数本身也拦了,十六进制只绕过了路径,没绕过函数名,所以还是被拦。
5.3 HANDLER语句:MySQL原生特性绕开select和函数
在我准备放弃文件读取转回去做数据查询时,想到一个MySQL的冷门特性——HANDLER语句。它的作用和SELECT类似,可以直接打开一张表并逐行读取数据,但它不是SELECT,语法结构也完全不同。HANDLER不涉及select关键字,也不带任何危险函数名,很多WAF规则里都没有覆盖它。
实际用法是这样:
1;handler `flag` open as f;handler f read first;先用handler打开flag表,再逐行读取。如果表名是flag,这一步就能直接把表内容吐出来。纯WAF角度,这段payload十分干净,唯一值得担心的是handler关键字本身会不会在规则里,就我的经验,绝大多数PureWaf版本都没有拦handler。但它在MySQL 8.0部分版本中行为有变化,在比赛环境里如果支持堆叠,大概率是5.x版本,handler可用。
这道题让我对PureWaf的定位有了更清晰的认知:它的规则覆盖的是大众化的危险关键字,很难做到覆盖数据库引擎的全部语法特性。堆叠注入之所以在WAF时代依然危险,正是因为HANDLER这类“非主流”SQL语句不在常规检测逻辑里。后续做WAF加固时,我把handler也加进了自己的规则列表,但这是题外话了。
6. 五道题做完,我总结的一套PureWaf绕过思路
写到这儿,抛开例题聊一聊方法论。不少人问绕过WAF是不是只能靠背payload,我的回答是:payload要背,但你更需要的是一个判断流程。同样的注入点,先探测什么、再尝试什么、何时换方案,有一套框架和没有框架,解题效率完全是两回事。
6.1 第一步:画清WAF的“解码边界”
PureWaf先做URL解码、JSON字段解析、表单参数归一化,再做正则匹配。你要知道的关键是:它解码到什么程度为止。有些版本会对%0a、%09这些控制字符做还原,有些版本则只还原%2F这类路径字符。测试方法很简单:把同一段攻击payload分别用原样、URL编码、双重URL编码、十六进制、Unicode编码逐次提交,记录哪一次放行。放行的那一次,就是WAF的解析盲区,也是你后续拼接的基础。
6.2 第二步:拆开“危险关键字”逐一验证
你以为的拦截规则是整句匹配,实际PureWaf的规则是逐段匹配。主语、谓语、宾语任何一个词在名单里,整句就死。所以先探测各段:union、select、from、where、information_schema、函数名、注释符、空白符、关键字之间的连接符,一个一个测,不急于构造完整payload。
一只成熟的绕过流程应该是这样:确认注入类型 → 列出需要的关键字 → 逐个探测可用性 → 用“等价替换”填充不可用部分 → 完整payload触发 → 验证回显/延迟。任何一步失败,都回到前一步重新找替换项。
6.3 第三步:建立你自己的“等价替换字典”
我整理了一套自己常用的替换表,CTF遇到PureWaf时直接从里面挑:
| 原始写法 | 等价替代 | 原理 |
|---|---|---|
| sleep(1) | sleep(1) | 反引号包裹关键字,MySQL解析为函数名,WAF正则匹配不到连续字母 |
| load_file('/etc/passwd') | load_file(0x2f6574632f706173737764) | 十六进制绕路径检测 |
| updatexml() | join报错 / 重复键报错 | 绕过函数黑名单,利用数据库自身报错 |
| select * from users | handler users open; handler users read first; | 用MySQL原生的handler语法替代select |
| information_schema.tables | mysql.innodb_table_stats / sys.schema_auto_increment_columns | 绕过information_schema关键字,注意版本兼容性 |
这张表不是万能的,但覆盖了我在实际比赛中遇到的大多数场景。每次比赛结束,我都会根据当天的规则情况补充或修正这张表,因为PureWaf的规则集在不同比赛里有调整,前一次能用的技巧,下一次很可能失效。而真正不变的,是那条“找到WAF解析与数据库解析之间的差异”的解题主线。
最后给个实操上的提醒:无论临时用什么payload,先在本地的MySQL里测通,确认语法无误,再放到比赛环境。WAF环境下的报错往往没有精确到SQL语法层面,payload本身写错和被WAF拦截是两码事,如果混在一起调试,很容易被误导。我在很多次比赛里都因为payload细节写错,绕了半小时才发现其实WAF在第一次测试时已经在放行了——这种时间成本是最不值当的。先把语法跑通,再去测WAF,顺序一定不要反。