CTF 比赛里最折磨人的一种题型,就是你明明知道这里存在 SQL 注入,也确定了闭合方式,但 payload 一上去就被过滤得干干净净。空格被拦、union 被拦、select 被拦、引号被拦,甚至注释符都被拦。一次比赛碰到一个注入点,前四十分钟全在猜它到底过滤了什么,最后十分钟才找到一个非常规绕法把 flag 拖出来。这种经历应该不少打 CTF 的人都体会过。
这篇文章我想把 CTF 赛事里 SQL 注入题目的底层逻辑拆开讲清楚,重点放在两件事上:一是如何从题目行为反推它的过滤机制,二是怎么在这套过滤机制下找到非常规的注入路径。内容主要面向已经能手动注入基础语句、但一遇到过滤就断片的选手,也适合刚入门 CTF、想在 web 方向建立系统思路的读者。整篇没有需要服务器环境才能跑的东西,所有 payload 你都可以在 DVWA、pikachu、ctfhub、ctfshow 这些靶场里直接验证。
1. 先捋清题目设计逻辑:CTF 注入题为什么总在"过滤"上做文章
1.1 出题人和解题人的博弈边界
很多人打 CTF 打久了会有个错觉,觉得 SQL 注入题就是比谁记的 payload 多。实际上,一道好的 CTF 注入题,考点从来不是"你会不会注入",而是"你能不能在一个有约束的环境里完成注入"。过滤机制就是这套约束的具象化。
从出题人视角看,一道注入题通常包含三个层次的设计:
- 入口层:参数通过 GET、POST、Cookie、Header 或者 JSON 体传递,决定你从哪里开始测试。
- 逻辑层:注入点存在于 SQL 语句的哪个位置,是数值型、字符型还是有括号包裹,决定你如何闭合。
- 约束层:对输入内容做黑名单过滤,比如去掉空格、拦截
union、屏蔽select、转义引号,决定你的 payload 要穿几层伪装。
这三个层次叠加起来,才构成一道题的完整难度。而大多数新手卡住的地方,是在第三层——约束层。原因也很简单:你可以通过 ' 和 and 1=1 轻松确定注入点存在,但一旦 select 被过滤,你原本背下来的整套注入流程就全部失效了。
所以要解开这个局,第一步不是搜更多 payload,而是先搞清楚题目到底在过滤什么、用什么方式过滤。这决定了后面所有绕过手段的选择。
1.2 从题目行为反推过滤规则的方法
判断过滤器逻辑最常用的方法是分组输入对照法。拿一个最简单的字符型注入点来说,你可以分几组请求去探测同一参数:
| 输入内容 | 预期无过滤行为 | 常见过滤表现 |
|---|---|---|
1' |
报错或页面异常 | 同左,无法判断 |
1' and '1'='1 |
正常显示 | 若空格被滤,可能直接报错或页面空白 |
1' and '1'='2 |
无结果 | 同左,需配合上一条判断逻辑 |
1' union select 1,2-- - |
回显位出现 1、2 | 若 union/select 被滤,与原页面无差异 |
1'/**/union/**/select/**/1,2-- - |
回显位出现 1、2 | 若注释符被滤,页面异常 |
通过这组输入,你可以快速把过滤类型归入几个大类:空白字符过滤、关键字过滤、注释符过滤、引号转义。每个大类的绕过思路完全不同,所以这一步判断错了,后面就是白忙。
这里有个容易被忽略的点:观察报错信息。CTF 环境里如果打开了报错回显,数据库返回的错误信息会直接告诉你它卡在哪一步。比如 You have an error in your SQL syntax near '1 and 1=1' 说明空格被转成空字符串才拼接进 SQL;Unknown column 'union' 则说明单引号没被过滤,但 union 这个词被替换成了空。这些报错就是出题人送给你的第一份提示。
1.3 注入点位置的分类图谱
过滤之外,还要先判断注入点位于 SQL 语句的哪个位置。CTF 里最常见的四种情况:
- SELECT 的 WHERE 子句:
select * from user where id='$id',最基础,闭合方式就是'或')。 - INSERT / UPDATE 子句:常见于登录、修改资料功能,闭合后需要用
or updatexml()这类报错注入或布尔盲注配合。 - ORDER BY 子句:
order by $col,不能用 union 注入,但可以用报错注入或基于排序逻辑的盲注。 - LIKE 子句:
where name like '%$name%',有些题会对%做处理,注入时要把模糊匹配的语义考虑进去。
位置决定了闭合方式,过滤规则决定了绕过方式。两者都摸清之后,才进入真正意义上的"非常规注入"环节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三类最常见的过滤拦截点与对应绕过手感:空格、关键字、引号
这一节讲的是 CTF 注入题里最常出现的三道坎。这三道坎基本是层层递进的:多数题目先过滤空格,再过滤关键字,最后甚至把引号和注释符都处理掉。能顺畅地过掉这三层,后面的综合题才有得打。
2.1 空格被过滤之后:注释符、括号与十六进制字符的替代方案
空格过滤几乎是最基础的过滤门槛。实现方式通常是 preg_replace('/\s+/', '', $input),也就是把所有空白字符直接删掉。遇到这种情况,很多人的第一反应是 + 号,但 MySQL 里 + 在 SQL 语法中不是字符串连接符,这招在大部分场景下不好使。
更可靠的替代方案有以下几类:
用注释符代替空格,这是最经典也最通用的一种。/**/ 在 MySQL 中完全等价于一个空格,所以 union select 1,2 可以写成 union/**/select/**/1,2。但要注意,如果出题人把 /* 或 */ 也纳入黑名单,这个方法就会失效。
用括号隔离关键字。MySQL 支持用括号把函数调用和子查询分层,比如 union(select(1),(2)) 就不需要空格。更进一步,你还能用 (select(1)) 这种形式嵌套,很多过滤规则对括号内的内容不敏感,因为括号本身不是空白字符。
利用换行符和制表符。如果出题人过滤的是 \s 这个 PCRE 字符类,那么 %0a、%0b、%0c、%0d、%09 这些 URL 编码后的空白字符有的版本是能穿透的。不过这个要看数据库和应用层的解码顺序,有的环境下 %0a 会被解码成真正的换行符拼进 SQL,有的则直接被 WAF 层拦掉。我个人的习惯是测试时把这几个都打一遍,看哪个能通。
用数字和字母间的边界替代。MySQL 里 1e1 解析成浮点数 10,1. 解析成 1.0。于是在某些情况下,你可以用数字边界来分隔关键字,比如 select{version()} 这种花括号写法在少数 MySQL 版本里也能通过。这类属于奇技淫巧,不作为首选,但比赛里偶尔能救急。
2.2 关键字被过滤:大小写、双写替换与内联注释
空格解决之后,下一个坎就是关键字。union、select、from、where、sleep、extractvalue 这些都是高频拦截目标。
大小写混合是新手最先想到的办法,UnIoN sElEcT。如果过滤规则是精确匹配小写关键字,这一招就能通;但大多数认真出的题会用 strtolower 先转小写再匹配,大小写就完全失效了。
双写绕过则是在遇到 preg_replace('/select/', '', $input) 这种"删一次"的过滤时特别好用。原理很简单:你输入 selselectect,replace 删掉中间那个 select,剩下的正好拼成 select。需要注意的是,这个技巧只对"替换一次"或"循环替换但替换结果不再次检查"的逻辑有效。如果出题人用 preg_replace('/select/i', '', $input) 循环替换直到没有匹配,那双写也会被完全删掉。
内联注释是 MySQL 独有的绕过手段,格式是 /*!50000select*/。MySQL 在解析 /*! 开头的注释时,会把它当作正常的 SQL 语句执行,而不是普通注释。如果过滤规则只是简单地把 select 这个子串删掉,没有处理 /*! 前缀,那么 /*!50000select*/ 就能绕过去。这个技巧在 MySQL 5.x 系列题目里非常实用,到了 MySQL 8.0 某些版本仍然有效。
同义词替换也是被很多人低估的一招。substr 被过滤了可以用 substring,concat 被过滤了可以用 concat_ws,if 被过滤了可以用 case when 条件表达式。CTF 出题人手动维护的过滤列表通常不会特别长,同义词替换往往能找到一个漏网之鱼。
2.3 引号被过滤:十六进制字面量与 char() 的出场时机
引号过滤这道坎,处理的是字符串字面量问题。当你需要 where name='admin' 里的那个 'admin' 时,如果单引号被过滤,整个条件就没法正常拼接。
常见解法有两个方向。
第一个是十六进制字面量。MySQL 支持 0x61646d696e 这种写法,它会直接转成字符串 admin。所以 where name='admin' 可以写成 where name=0x61646d696e。在有引号过滤但没过滤十六进制写法的题目里,这一招几乎可以通吃所有需要字符串的地方。
第二个是 char() 函数。char(97,100,109,105,110) 在 MySQL 里同样返回 admin。这个方式的优点是可以动态拼接任意字符串,缺点是函数名 char 本身也可能被加入过滤名单。
这里要特别提醒一个容易翻车的细节:十六进制字面量和 char() 绕的是引号过滤,不是绕过字符串本身。如果题目把 or 1=1 里的 or 也过滤了,你就算把 '1'='1' 编码成十六进制,or 这个逻辑运算符还是会被拦截。所以引号过滤往往和逻辑运算符过滤组合出现,做题时要分别攻克。
3. 从编码和语法层面绕过滤:宽字节、报错注入与盲注思路
基础过滤过完,后面的题目会开始上难度。这里的"难度"不再是简单地多过滤几个词,而是从编码层面、信息反馈层面给你制造障碍。宽字节注入是整个 SQL 注入体系里最考验环境理解的一类,报错注入则是"无联合查询回显"场景下的主战武器,盲注则是连报错都不给你时的最后手段。
3.1 宽字节注入:编码顺序带来的秩序漏洞
宽字节注入发生的核心前提是:应用层和数据库层使用了不同的字符编码。常见组合是应用层用 addslashes 或 mysql_real_escape_string 对输入做转义,把 ' 变成 \',从而阻止注入;但数据库连接编码是 GBK,PHP 层又没做编码统一,于是反斜杠和前面输入的某个字符合并成了一个宽字节字符,' 得以保留。
最经典的利用方式是输入 %bf%27。GBK 编码下,%bf%5c 会被解析成一个合法汉字"縗"。当程序把 %27 转义成 %5c%27(即 \')时,整个序列变成 %bf%5c%27,其中 %bf%5c 被当成一个宽字节字符吃掉,后面的 %27 就变成了一个独立的单引号。于是 id 参数的闭合就被打破了。
写 payload 时的标准姿势是:?id=%bf%27 union select 1,2-- -。但这里有两个前提必须确认:一是数据库编码确实是 GBK 这类宽字节编码,二是不存在二次转义的情况。如果题目环境是 UTF-8,宽字节注入天然不成立,你再怎么试都是白费。
还有一点,很多人以为宽字节注入只针对 MySQL 和 GBK,实际上 SQL Server 的 _gbk 排序规则、某些支持多字节字符集的数据库也存在类似问题。CTF 里遇到的大多数是 MySQL + GBK 组合,但这个思路的底层逻辑是通用的:攻击者利用不同层之间对字节序列的解码差异,把本来无害的编码组合变成一个注入入口。
3.2 报错注入三件套:extractvalue、updatexml、floor
当联合查询无法回显数据、页面又不会把查询结果直接打出来时,报错注入就是最优解。它的核心思路是:构造一个会让数据库报错的表达式,并且把我们需要查询的结果拼进报错信息里,然后从页面报错中读出数据。
MySQL 里最常用的三个报错函数:
extractvalue(xml_document, xpath_expr):当第二个参数不是合法 XPath 时会报错,并且报错信息中会包含传入的内容。updatexml(xml_document, xpath_expr, new_value):同样的原理。floor(rand(0)*2)配合group by:利用主键冲突报错把查询结果带出来。
标准 payload 长这样(以 extractvalue 为例):
sql复制1' and extractvalue(1, concat(0x7e, (select database()), 0x7e))-- -
这里的 concat(0x7e, ..., 0x7e) 是为了把查询结果用 ~ 包裹起来,因为 MySQL 的 XPath 报错会截断显示长度,通常只显示 32 个字符左右。加上头尾的 ~,你才能清楚看到数据的边界在哪。
三个函数分别有不同的限制:extractvalue 和 updatexml 都只能报错 32 个字符,数据长了要配合 substr 分段取;floor 报错不截断长度,但对环境要求多一些,rand(0)*2 要保证随机序列符合预期。比赛里我一般优先用 extractvalue,因为它写起来最简洁,报错信息也最干净。
需要注意,如果过滤规则把 extractvalue、updatexml、floor 这些函数名也拦了,报错注入这条线就断了。这时候要么考虑用其他报错方式如 GTID 相关的 gtid_subtract,要么直接转入盲注流程。CTF 题里对报错函数做过滤的题目不多,一旦出现,往往就意味着这题的正确答案是"不要用报错函数"。
3.3 盲注方法论:布尔盲注和时间盲注的节奏控制
最后一道防线是盲注。页面不返回任何查询结果,也不返回报错信息,你只能通过页面是否正常显示、响应时间是否延长来推测数据内容。
布尔盲注的判断标准就是页面正常/异常两种状态。典型 payload:
sql复制1' and ascii(substr((select database()),1,1))>100-- -
通过二分法逐步收缩范围,一个字符一个字符地猜出完整数据。这个方法的优点是不依赖任何回显,缺点的效率太低,一个 30 位长度的 flag 手工猜起来非常折磨。所以布尔盲注的关键技巧是:先把判断逻辑写成脚本或者直接用 sqlmap 跑,手工只用最快的方式确认前几个字符。
时间盲注则是把判断逻辑隐藏在响应延迟里:
sql复制1' and if((select ascii(substr(database(),1,1))>100), sleep(3), 0)-- -
如果页面停顿了 3 秒,说明条件为真;如果秒回,则条件为假。这里最容易踩的坑是 sleep() 函数被过滤。替代方案包括 benchmark() 重计算函数,或者构造笛卡尔积大表查询来模拟延迟。但比赛里最好用的还是 sleep,因为它写起来最短、延迟最稳定。
无论布尔盲注还是时间盲注,判断逻辑的一致性比速度更重要。如果你把一个字符的 ASCII 范围判断写错了,后面的数据全都会偏。我的习惯是先用 length() 确认数据长度,再逐位爆破,并且每爆破完一位做一次整体校验。
4. 从 DVWA 到 ctfhub 的完整解题链路拆解
前面几节讲的是原理和手法,这一节我们来把整个解题过程走一遍。从最基础的 DVWA Low 级别开始,到 ctfhub 技能树里贴近实战的过滤题,把每一步判断、每一次 payload 调整的逻辑讲清楚。
4.1 DVWA SQL 注入 Low 级别:理解最基本的注入闭环
DVWA 的 Low 级别没有任何过滤,id 参数直接拼进 SQL,是所有人入门 SQL 注入的第一个落脚点。它的完整流程可以作为整个注入体系的"基准线":
第一步,判断注入点。输入 1',页面报错;输入 1' and '1'='1,页面正常;输入 1' and '1'='2,页面无结果。这说明存在字符型注入。
第二步,判断列数。用 1' order by 1-- -,逐步增加数字,直到报错为止。通常试到第三列就会报错,说明查询只有两列。
第三步,确定回显位。用 1' union select 1,2-- -,页面会在某个位置把 1 和 2 显示出来,这两个位置就是可以填充查询结果的地方。
第四步,爆库名、表名、字段名、数据。依次执行 select database()、select group_concat(table_name) from information_schema.tables where table_schema=database()、select group_concat(column_name) from information_schema.columns where table_name='users',最后 select group_concat(user,password) from users 拿到数据。
Low 级别的意义不在于难度,而在于让你建立一个"没有过滤时 SQL 注入的完整生命周期"的直觉。后面所有绕过技巧,本质上都是在替换这个生命周期中的某个环节,让它能在拦截条件下继续跑通。
4.2 ctfhub 技能树 SQL 注入:从"无过滤"到"带过滤"的阶梯
ctfhub 技能树的 SQL 注入部分做得比较好的一点,是把过滤场景按从小到大排列,非常适合做系统训练。整数型注入、字符型注入、报错注入、布尔盲注、时间盲注、堆叠注入、宽字节注入,一关一关地来。
我自己在带新人时推荐的主力训练路径是:DVWA Low/Medium 掌握基础 → ctfhub 技能树全通 → ctfshow web 入门 SQL 注入模块 → pikachu 靶场。这条路径的优点是每一关的过滤规则相对清晰,不会像真实 CTF 赛题那样一上来就把多个过滤点叠在一起,把新人直接打懵。
以 ctfhub 的字符型注入关卡为例,它的过滤点一般是把 select 和 union 的其中几个字符做替换,你直接用 DVWA 的 payload 会发现中间某个环节断了。这时候你要做的就是按第二章的方法去判断:到底哪个关键字被替换了、替换成了什么。慢慢磨下来,"遇到过滤先定位再动手"的习惯就养成了。
4.3 一道综合过滤题的完整逆推:过滤点叠加时怎么排序
真正的 CTF 赛题不会只设一道坎。举个我遇到过的综合场景:参数是 id,字符型注入,闭合符号是 ',过滤规则同时包含空格、union、select、注释符。
我当时的排查顺序是这样的:
先用报错测试确定注入点存在,输入 1' 和 1'' 分别看页面差异。然后测试空格:输入 1'/**/and/**/'1'='1 看页面是否正常。如果正常,说明注释符可用。
接着测试 select 和 union 的过滤方式,分别尝试 union、ununionion、/*!union*/、UnIoN 四种写法,看哪一种是有效的。这一步能直接判断出过滤逻辑是"字符串删除"还是"正则匹配"。
确认可行绕过方式后,剩下的问题就是如何配合报错注入。既然 union 被过滤,联合查询这条路基本断了,直接用 extractvalue 报错注入。把最终 payload 写成:
sql复制1'/**/and/**/extractvalue(1,concat(0x7e,(select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())),0x7e))-- -
这里注意,我用了括号包裹 select 和 from 来绕空格,用 group_concat 保证数据不超长,用 extractvalue 从报错里取值。整条链路里每一段都是先验证可用性再拼接的,不是一次性猜出来的。
这种"逆推+分步验证"的思路,是处理复杂过滤场景的核心方法。你不可能从任何一篇教程里背下来所有题目的最终 payload,但只要你掌握"怎么判断过滤了什么、怎么验证某种绕过是否可行"这两件事,任何新题都能上手。
5. 实战中最容易翻车的三个判断点:我的踩坑复盘
前面讲了很多"怎么做对",这一节我想讲讲"怎么出错"。以下三个翻车点是我自己在比赛和训练里踩得最多的,每一条都对应一个具体的判断误区。
5.1 把"过滤"当成"转义",方案直接选错
有一道题,参数传 1' 页面不报错,传 1'' 页面报错,我当时第一反应是"单引号被 addslashes 转义了",于是开始往宽字节注入方向走,试了 %bf%27、%df%27 等一堆 payload,始终不行。
后来冷静下来重新看报错信息,才发现数据库把 1'' 解析成了"空字符串连接",根本不存在转义。这道题的过滤规则只是把 ' 替换成空了,所以正确的方向是直接用双写或者找其他闭合符号。我把"没有报错"误判为"被转义",这个误判直接浪费了二十分钟。
这个教训的核心是:不要凭页面表现猜过滤方式,要凭可复现的输入输出对照去判断过滤方式。一个输入无法确认时,就换一组输入继续测,直到逻辑闭环。
5.2 忽略注入点上下文,把数值型注入当字符型来拼
有些题目从 URL 看是 ?id=1 这种纯数字参数,但实际 SQL 语句里写的是 where id='$id' 或 where id=("$id")。如果你按数值型注入的思路直接拼 and 1=1,因为 1 会被当成字符串的一部分,永远都测不出注入点。
正确做法是先测闭合符号。1' 报错说明可能有字符型闭合,1') 报错说明有括号,1" 报错说明是双引号闭合。CTF 里为了增加难度,出题人非常喜欢在闭合写法上做文章,')、"))、'-- 这类各种组合都出现过。忽略上下文、只按固定模板打 payload,是最容易错过正确答案的。
5.3 过度依赖工具,sqlmap 跑不通就心慌
sqlmap 是个好工具,但它不是万能的。遇到过滤多、需要自定义注入逻辑的题目,sqlmap 经常会误判注入点不存在,或者跑半天跑不出来。我见过不少选手在比赛里只靠 sqlmap,跑不通就干瞪眼,完全忘了自己手动拼 payload 的能力。
我的建议是:先用工具做快速探测,判断是否存在注入;一旦工具报错、超时或行为异常,立刻切到手工流程,自己判断过滤规则、构造 payload。工具是辅助,手工是兜底。在 CTF 这种比拼上限的环境里,手工能力就是你的上限。
6. 绕过过滤的 Payload 速查与解题策略收敛
最后这部分,我整理了一份按过滤类型分类的 payload 速查表。每一行都可以直接在 MySQL 环境的靶场里验证。注意,这里不追求覆盖所有数据库,因为 CTF 里 MySQL 占比极高,先把 MySQL 吃透,其他数据库的差异后面再补。
6.1 按过滤类型分类的常用绕过对照表
| 过滤类型 | 绕过思路 | 示例 payload |
|---|---|---|
| 空格被过滤 | 注释符 /**/、括号分隔、URL 编码空白符 |
1'/**/union/**/select/**/1,2-- - |
union 被过滤 |
内联注释、大小写、双写(视过滤逻辑而定) | 1'/*!50000union*//*!50000select*/1,2-- - |
select 被过滤 |
内联注释、双写、同义词 | 1' union/*!50000select*/ 1,2-- - |
| 引号被过滤 | 十六进制字面量、char() |
where name=0x61646d696e |
or、and 被过滤 |
` | |
#、-- 注释符被过滤 |
;%00 截断(视环境)、闭合后加额外条件 |
1' and '1'='1 |
| 报错函数被过滤 | 改用 GTID 相关报错或转盲注 |
1' and gtid_subtract(concat(0x7e,database()),1)-- - |
sleep 被过滤 |
benchmark()、笛卡尔积延迟 |
1' and benchmark(10000000,sha1('x'))-- - |
6.2 不同数据库的注入差异速记
CTF 里除了 MySQL,也会遇到 SQLite、PostgreSQL、SQL Server、Oracle 的题目。过滤绕过时,这些数据库的差异主要体现在注释符、字符串拼接方式、报错函数和系统表名上。
- SQLite:没有
information_schema,系统表是sqlite_master;注释符支持--和/* */;不支持if(),但支持case when。 - PostgreSQL:字符串拼接用
||;报错函数常用cast()配合异常数据类型转换;支持pg_sleep()做时间盲注。 - SQL Server:区别于 MySQL 的
limit,用top;报错常用convert(int, ...)和count(*) from sysusers;支持WAITFOR DELAY '0:0:3'做时间盲注。 - Oracle:必须用
from dual;字符串拼接用||;报错可用ctxsys.drithsx.sn()等函数;空表dual是绕不开的。
这些差异在基础注入阶段就能体现,到了过滤绕过阶段更是决定成败的关键。做题前先确认数据库类型,是永远排在第一位的事情。
6.3 解题策略的收敛:把复杂题拆成四步
不管题目怎么变,我最终都会把思路收敛到这四步:
- 定上下文:判断注入点位置、闭合方式、数据库类型。
- 探过滤规则:用分组对照法确定过滤了哪些字符、以什么方式过滤。
- 选绕过路径:根据过滤类型选择对应的绕过手段,每次只改一个变量验证。
- 拖数据:锁定回显方式后,批量拖取表名、字段名、flag。
这套方法看起来简单,但每一步都需要反复训练才能形成条件反射。平时练靶场的时候,刻意不用 sqlmap,全手工走流程,坚持一段时间之后,你遇到新题时会明显感觉到判断速度快很多。
我个人在实践里还有个习惯是:每做完一道题,把过滤规则、最终 payload、踩坑点记在一个文档里。一段时间下来回看,你会发现自己对"某类过滤习惯性对应哪几种绕过手段"的敏感度会有质的变化。CTF 里 SQL 注入的题目形态会变,但过滤和绕过的底层博弈逻辑,其实万变不离其宗。
