没记错的话,这道题在攻防世界的Web区里算是一道很经典的"看着简单、入口明确、但过滤能把人卡死"的题目。页面干净得离谱:一个输入框,参数名叫inject,摆明了就是让你注。但真上手以后你会发现,前面几步走得很顺,等你习惯性地把union select打上去,一整条路直接给你焊死。这篇writeup不打算只给一串payload,我会把每一步的判断依据、中途踩过的坑、以及为什么最终能绕过去的原理都摊开讲清楚。适合正在刷Web题、或者想把SQL注入绕过思路彻底理一遍的选手参考。
1. 开场三连测:确认注入类型和注入点
1.1 先看页面和参数,再做黑盒试探
打开靶机以后,页面就是一个输入框,提交之后URL里会出现?inject=xxx。没有源码提示,也没有任何前端注释偷藏线索,所以这就是一个标准的黑盒注入题。
我的习惯是拿到这种单参数页面以后,先做三个最基础的请求,把注入类型摸清楚:
?inject=1 ?inject=1' ?inject=1' --+三次请求的结果非常典型:
1:页面正常返回一行数据,看起来像某个ID对应的记录;1':页面直接报SQL语法错误,说明单引号被拼进SQL并且打破了原本的语句结构;1' --+:恢复正常,说明用了单引号闭合,注释符把后面多余的内容吃掉了。
到这里基本可以断定:后端SQL是字符串拼接,我的输入被直接放进了一个带引号的查询里。大概率是类似这种结构:
select id,data from words where id='$inject'这个猜测后面非常重要。因为整个题的第二条路——换表名让后台自己查——就是建立在"后台查询逻辑写死了words表"这个前提上的,先记着。
1.2 测列数:order by 是唯一没有第一时间被拦的东西
确定是字符型注入之后,下一步自然是猜列数。用order by逐项试:
?inject=1' order by 1 --+ ?inject=1' order by 2 --+ ?inject=1' order by 3 --+结果:order by 1和order by 2都正常,order by 3直接报错。说明查询结果只有两列。这个信息也很有用,因为两列结构通常对应id, data或者id, flag之类的简单表。
到这里为止,一切看起来都是标准流程。我也很自然地准备上联合查询了,然后就被教育了。
2. 联合查询当场报废:一条 preg_match 挡住半张地图
2.1 union select 第一次被拦时的完整报错
当我往注入点里提交:
?inject=1' union select 1,2 --+页面返回的不是SQL错误,而是出现了类似:
return preg_match("/select|union|from|where|order|by|and|or/i", $inject);这条提示等于是把过滤规则直接甩你脸上了:后端用preg_match做了关键词黑名单,select、union、from、where这些查询类关键词全被拉黑,而且带/i修饰符,大小写绕过直接作废。
第一次看到这种提示反而安心,因为至少知道过滤在哪、过滤了什么。接下来要做的就是拿不同姿势去试边界。
2.2 常规绕过轮番试一遍:全灭
我先把平时常用的几种绕过方式都丢进去试了一圈,结果如下:
| 尝试方式 | 示例 | 结果 |
|---|---|---|
| 大小写混写 | UnIoN SeLeCt | 被拦,因为/i忽略大小写 |
| 内联注释 | /*!50000union*/ select | 被拦,注释没法绕过正则关键词匹配 |
| 空格替换为tab | union%09select | 被拦,正则不看空白字符 |
| 注释替代空格 | union/**/select | 被拦,union和select本身还在 |
| 编码绕过 | %55nion | 无效,服务端不一定解码两次 |
结论很明确:这题的过滤不是针对某个特定payload,而是把union和select这两个查询链条上的核心词全毙了。联合查询这条路在这道题里不是难走,是直接锁死。
2.3 顺手把没被过滤的词也摸一遍
既然有黑名单,那就顺便把所有可能用到的SQL关键词都过了一遍。排查结果大概是:
select、union、from、where、order、by、and、or:全部被过滤show、set、prepare、execute、rename、alter、handler、update:没被过滤
这个差异是决定性的。它意味着联合查询和直接select被堵死,但管理类、预处理类、存储引擎类的SQL语句仍然可以自由使用。这为后面的堆叠注入和预处理语句绕过提供了完整的前提。
3. 堆叠注入:分号打开的另一个世界
3.1 为什么联合查询死了,堆叠注入还能活
很多Web安全入门教程会把"注入"局限在"在一条查询里改变它的逻辑"——比如用union select追加查询,用or 1=1改变判断条件。这种方式无论怎么变形,最终都受限于当前这一条SQL语句的语法结构。
堆叠注入是另一条路:它利用的是数据库协议允许一次提交多条语句的特性。你输入1';show tables;--+,后端执行的时候就变成了:
select id,data from words where id='1';show tables;--+'分号把前一条查询结束掉,show tables成了一整条全新的独立语句。只要后端没有限制连接会话的"多语句执行"权限,堆叠注入就能绕开所有基于单查询结构的过滤。
打个不严谨的比方:联合查询相当于你在被审查的申请单里夹带私货,关键词被拦就夹不了;堆叠注入则是把申请单撕了,直接另写一张纸递给窗口。preg_match过滤的是"纸上的关键词",它管不到MySQL解析器后面执行什么。
3.2 用 show 拿库名和表名
先确认堆叠注入可用:
?inject=1';show databases;--+正常返回,能看到当前库supersqli以及其他系统库。这就说明MySQL执行了分号后面的语句。继续:
?inject=1';show tables;--+结果里出现两个表:
words 191981093111451514看到这张表名的时候,我的想法是:名字都不装了。一个是标准的words,一个是纯数字乱命名,flag大概率在191981093111451514这张表里。
3.3 新问题:堆叠只能执行,不能直接读
知道表名之后,第一反应自然是:
?inject=1';select * from `191981093111451514`;--+结果仍然被preg_match拦下。注意这里是个容易迷惑人的点:堆叠注入能执行新语句,但新语句的内容照样要被过滤器检查。你只是绕过了"单查询结构"的限制,并没有绕过"关键词黑名单"的限制。
所以这题的下一个核心问题变成了:在不能直接写出 select 关键词的情况下,如何让MySQL执行一条查询。这时浮现出两条路,一条是预处理语句,一条是让后台替我们查。
4. 预处理语句绕过:把select藏进字符串再让它复活
4.1 PREPARE 怎么就成了绕过利器
MySQL的预处理语句语法是这样:
PREPARE stmt_name FROM '任意SQL字符串'; EXECUTE stmt_name;PREPARE接收的是一个字符串,这个字符串可以由变量拼接而来,也可以直接写字面量。关键在于:PREPARE本身不是查询,它只是把字符串编译成SQL;真正的执行发生在EXECUTE。过滤器的正则扫描的是你发送的整个HTTP请求文本,它会看到PREPARE、EXECUTE、CONCAT这些词,但看不到一个完整的select查询语句。
比如这样的payload:
?inject=1';PREPARE s from concat('select * from `191981093111451514`');EXECUTE s;--+在正则扫描时,select并没有作为一个SQL关键词出现在注入点里,它只是concat()函数的一个字符串参数。MySQL拿到之后,由预处理机制在服务器内部解析并执行,过滤器管不到这一步。
4.2 最终的干净payload与执行结果
我最后打通的payload长这样:
?inject=1';PREPARE s from concat('select * from `191981093111451514`');EXECUTE s;--+提交之后,页面把191981093111451514表的内容完整显示出来,flag就在其中。
几个细节值得记住:
- 纯数字表名必须用反引号包起来。如果不加反引号,MySQL会把
191981093111451514当成一个数字字面量而不是表名,直接报语法错误。 concat()在这里不是必须的,直接用PREPARE s from 'select * from ...'也能过,因为字符串字面量里的内容不触发正则。用concat()的主要好处是后续可以接变量,灵活度更高。- 注释符必须生效。
--+在URL里实际是把加号解析成空格,注释符能吞掉后面遗留的单引号和SQL片段,保证EXECUTE之后没有多余的尾巴。
4.3 另一种预处理写法:十六进制藏select
如果担心字符串里出现select字面量也会被某个更严格的正则抓走,可以用十六进制编码绕:
?inject=1';set @a=0x73656c656374202a2066726f6d206031393139383130393331313134353135313460;PREPARE s from @a;EXECUTE s;--+0x73656c656374...是select * from ...的十六进制表示。这样整个请求文本里连select这四个连续字母都不存在了,属于更彻底的绕过方式。当时我在本地尝试过这种写法,直接可用,后来在一些其他注入题里也经常靠这一招救命。
4.4 这个解法成立的前提
堆叠注入 + 预处理能用,依赖两个条件:
- 后端连接数据库的账号对当前会话开放了多语句执行能力;
- MySQL版本支持
PREPARE/EXECUTE,这个语法从MySQL 5.0往后都支持,老环境也能用。
如果第一个条件不满足(比如PHP的mysqli->query()默认就不允许多语句执行,需要multi_query才行),那堆叠注入直接失效,预处理也就无从而起。这题的环境显然是允许的。
5. 不用select也能读表:换表名让后台替我干活
5.1 思路:后台那句写死的查询,才是最好的工具
在做题过程中我想到了第二条路,而且这条路的思路很值得玩味。还记得开场判断的后台SQL吗?
select id,data from words where id='$inject'后台是直接把表名写在代码里的。那么问题来了:我们能不能不查191981093111451514,而是把191981093111451514变成words?这样后台那句写死的select ... from words就在替我们干活,根本不需要注入select关键词了。
这个思路的本质是:注入点不只是用来拼接SQL语句的,它还能用来操作数据库对象本身。当查询关键词被过滤干净的时候,对象级别的操作往往能绕出一个新天地。
5.2 三步交换表名
实际操作需要三步:
?inject=1';rename table words to word;rename table `191981093111451514` to words;--+先执行:
?inject=1';alter table words change id id varchar(100) character set utf8;--+再提交一个恒真条件:
?inject=1'or '1'='1最终效果:
select id,data from words where id='1' or '1'='1'where条件恒真,后台会把words表的第一行记录返回,而这时的words实际上就是原来的191981093111451514表,flag直接输出。
5.3 为什么要执行alter table
很多人做到rename那两步就去提交了,结果报字段不认识。原因在于新表191981093111451514的字段名和原words表不一样。原后台查询写的是:
select id,data from words如果新words表里没有叫id和data的字段,查询执行就会出错。所以alter table ... change id id varchar(100)的核心目的是保证换表之后查询仍能成立,把字段名和类型改成后台需要的形状。这一步不是可有可无的,是整套逻辑的闭合点。
5.4 两种解法的定位差异
到这里这题其实有两条可达路径了,我整理了一下它们的定位:
| 对比维度 | 预处理语句解法 | 换表名解法 |
|---|---|---|
| 核心操作 | PREPARE + EXECUTE | RENAME + ALTER |
| 依赖条件 | 堆叠注入可用 | 堆叠注入可用 + 后台查询表名可预测 |
| 过滤绕过点 | 关键词藏在字符串里 | 根本不出现查询关键词 |
| 通用性 | 高,换一道题也能套 | 低,依赖具体后台SQL结构 |
| 踩坑点 | 纯数字表名要反引号 | 换表后字段名必须对齐 |
个人做这类题的经验是:两条路都走一遍很有价值。预处理解法更通用,换表名解法更考验对整个查询逻辑的掌控力。实际出题人想考的往往是后者,因为它的绕法更贴近真实数据库的攻击面。
6. 从这道题里提炼的注入绕过思路
6.1 过滤探测别只盯关键词,要盯"语句类型"
这道题最值得复盘的一点是:preg_match过滤了select/union/from/where,看起来封锁面很全,但show、prepare、rename这些非查询类关键词全部漏掉了。这提醒我,做过滤绕过时不要只在关键词列表里打转,要往语法树上去想。哪些语句类型能读取数据?select、show、handler、prepare+execute、load_file……当一类被锁死的时候,剩下好几类可能还活着。
MySQL的HANDLER语句也是一条没被过滤的备选:
HANDLER `191981093111451514` OPEN; HANDLER `191981093111451514` READ FIRST;它以存储引擎接口的方式直接读取表数据,和select完全无关。我在本地环境试过这个思路,部分MySQL版本可用。如果在赛场上PREPARE/EXECUTE的路也被断了,HANDLER值得一试。
6.2 注意反引号这个"逃生通道"
这道题里纯数字表名差点坑到我。表名是191981093111451514,如果直接写进SQL,MySQL会把它当成数字字面量,什么也查不出来。必须加反引号才能被识别为标识符。这个细节在平时写项目SQL时几乎不会遇到,但在CTF的拼凑式payload里极其常见。凡是目标表名带特殊字符、纯数字、或者和函数名重名,都要第一时间想到用反引号包裹。
6.3 preg_match的"看得见"与"看不见"
这题的过滤器是直接返回了报错信息,所以探测成本很低。遇到不返回过滤信息的题,判断过滤规则就需要靠盲试和响应差异对比了,难度上一个台阶。但不管过滤器藏不藏,有一个规律是不变的:它一定基于文本扫描。那么绕过思路就可以统一归结为——让目标关键词不以"明文关键词"的形式出现在请求文本里。常见手段有:十六进制编码、字符串拼接、变量存储、注释符切割。掌握这一个原则,能覆盖大多数关键词过滤场景。
7. 复现建议和几个容易踩的坑
7.1 环境准备
这题可以直接在攻防世界的在线环境里复现,不需要本地搭建。如果要在本地自建一个模拟环境,建议用PHP 5.x + MySQL 5.x 的组合,因为新版PHP和MySQL在多语句执行和报错细节上会有差异。我在本地复现时用的就是经典的 PHP + MySQL 组合,payload基本可以直接迁移。
7.2 提交payload时的细节
用浏览器直接在URL里提交时,注意几个细节:
--+里的+在URL中是空格的意思。如果直接用Burp或Python请求,可以写成--,也就是两个减号加一个空格,效果相同。- 如果提交后页面空白或报错,优先检查表名的反引号有没有遗漏。
- 堆叠注入中间的分号必须和后面的语句连在一段URL里,不要被URL编码弄丢。
7.3 实际做题时的时间分配建议
我在复现时先走的PREPARE/EXECUTE,一条payload三分钟解决战斗。后来为了验证思路才去试了rename表名路线。建议做题时先上堆叠注入探测,确认可用后直接预处理语句拿flag,时间充裕再试其他解法。因为换表名那套流程稍长,步骤一多,出错率也高,赛场上时间是硬成本。
另外多说一句:这类Web题的Writeup,重点看的不是那串payload本身,而是它背后的决策树。为什么联合查询失败后马上转堆叠?为什么堆叠可行但select还被拦?为什么最终落在PREPARE?每一步都是对SQL执行机制理解的产物。把这条决策链吃透,比背一百个payload都管用。