这道题我第一次做的时候,确实被“Baby”两个字骗了。名字看着像给新手练手的入门题,结果我在用户名框里输入' or 1=1#,页面不但没有出现预期中的登录成功,反而整个状态都不对劲。卡了一会儿才发现,它并不是一道普通的SQL注入登录绕过题,而是在常规注入之上加了一层“关键字过滤锁”。这篇就完整还原我的做题过程:怎么确定注入点、怎么定位过滤规则、怎么把双写绕过用顺、最后怎么一步步把flag拖出来。适合刚理解SQL注入原理、但还没系统接触过滤绕过的新手,也适合想从“背payload”升级到“自己构造payload”的朋友。
1. 上手第一件事:判断这道题到底在考什么
1.1 一个只有用户名和密码的页面,能分析出什么
打开题目,界面极其简单:一个用户名输入框、一个密码输入框、一个登录按钮,没有多余的功能点、没有文件上传、没有搜索框。这种页面在CTF里出现,第一反应基本都指向SQL注入——因为用户输入的内容大概率会被拼接到后端的SQL查询语句里,而我们能控制的入口只有这两个输入框。
这个判断不是瞎猜。登录功能的核心逻辑通常是:把用户输入的账号密码拿去数据库里做一次匹配查询,如果查询结果存在,就放行登录。问题在于,如果后端代码是直接拼接参数,而不是用预编译处理,那我们的输入就会变成SQL语句的一部分。这种情况在真实业务里已经很少见,但在CTF题目里非常经典,因为它能很清楚地考察注入原理。
另外还要在心里给题目分个类:这是数字型注入还是字符型注入?是联合查询、报错注入、布尔盲注还是时间盲注?登录框里出现的注入,绝大多数是字符型注入,回显情况则要看登录成功后页面会不会展示数据库里的内容。BabySQL这道题从后续实测来看,属于有回显的联合查询注入,而且回显位置在用户名区域,这就让后面的操作省了很多事。
1.2 字符型注入的“引号闭合”原理
如果后端查询语句长这样:
select * from users where username = '我们输入的字符串' and password = '我们输入的密码';那我们的输入其实是被一对单引号包裹起来的。这时候如果输入一个',SQL语句就变成了:
select * from users where username = '' and password = '我们输入的密码';多出来的那个单引号会打破原有语句的结构,导致SQL语法错误。所以测试字符型注入最直接的方式就是先输入一个1'看报不报错。报了语法错,说明输入确实被拼进了SQL;完全不报错、正常回显,反而说明参数可能做了转义或参数化处理。
知道原理之后,绕过的思路也就清楚了:想办法闭合前面的单引号,再把自己构造的条件或查询语句拼进去,最后用注释符把后面多余的SQL片段“关掉”。经典payload就是' or 1=1#,单引号闭合查询条件,or 1=1让条件恒真,#注释掉后面的password判断。
但BabySQL这道题的关键点就在于:这个经典的payload扔进去之后,不按常理出牌。这说明题目对关键字做了手脚,下一步必须花时间摸清过滤规则。
2. 注入点验证与关键字过滤规则的定位
2.1 从“语法报错”到“关键词被吞”的实测记录
我先按老套路做了一组测试,把每个输入和页面表现单独记下来,这组记录非常关键:
| 输入内容 | 页面表现 | 初步推断 |
|---|---|---|
1' | 出现SQL语法错误 | 存在字符型注入点,单引号被拼入语句 |
1' or 1=1# | 异常,未进入预期状态 | or、空格或注释符可能被处理 |
1' and 1=1# | 同样异常 | and可能也被处理 |
1' oorr 1=1# | 行为恢复正常 | or被替换为空,双写可绕过 |
这里最需要留意的其实是最后一行。输入oorr的时候,页面表现恢复到了“这条语句正常执行”的状态,说明后台不是简单地拒绝包含or的输入,而是先把输入里的or字符串替换成空内容,再把处理后的结果拿去拼SQL。oorr里藏着一个被替换掉的or,替换完之后剩下的正好是or,于是语义就恢复了。
这一步是整个题目的分水岭。很多人卡住不是因为不知道SQL注入,而是因为只背了' or 1=1#这个固定句式,一旦发现它失效,就不知道该从哪里继续排查。其实只要多做一组测试,过滤方式的本质就能暴露出来。
2.2 过滤名单的细致排查方式
确认“替换为空”这个机制之后,我做的事情就是老老实实逐个测关键字。把SQL注入里高频出现的词全过一遍:or、and、select、union、from、where、order、group、by、#、--、空格。每测一个都注意两点:这个关键字是否被影响,以及它的过滤是“直接拦截”还是“替换为空”。
实测下来,这个环境的过滤名单大致包括or、and、select、union、from、where、order等,而且大小写不敏感。所谓大小写不敏感,就是你输入SeLeCt也一样会被处理,所以单纯改大小写的绕过方式在这里无效。判断方法很简单:输入一个包含SeLeCt的payload,如果页面表现跟输入select时一样异常,就说明后台的替换或匹配带了/i修饰符。
还要注意一个容易忽略的点:order by这个短语里,order本身含有一个or。所以即使后台只过滤or,钻到order by里的or也会被顺带干掉。这解释了为什么很多人在测字段数的时候,发现1' order by 1#直接失效。换句话说,这份黑名单覆盖的不只是“独立的关键词”,而是所有包含这些子串的更长单词。所以后面构造payload时,order by要写成oorrder by,where要写成whwhereere,from要写成frfromom。
注意:不同环境、不同比赛的过滤规则往往不一样,单测的结果不能直接复制到其他题目。做题的时候一定要现场测出“过滤了哪些词、用哪种方式过滤”,再决定用什么绕过姿势。
3. 关键字被吞的绕过思路:双写是核心但不是唯一
3.1 双写绕过的原理推导
先解释一下双写为什么能生效。假设后台代码是这样处理的:
$input = preg_replace('/or|and|select|union|from|where/i', '', $input);也就是把输入中所有匹配到的关键词都替换成空字符串,然后把剩下的东西拼进SQL。这种过滤方式看起来很唬人,但它犯了一个典型错误:替换操作是可预测的。如果攻击者提前知道哪些字符会被删掉,他就可以故意多写一份被删的关键词,让删完之后剩下的内容刚好还原成想要的词。
举个例子,输入ununionion,里面包含一个完整的union子串。后台替换掉这个union之后,左边剩下的un和右边剩下的ion拼接起来,正好又是union。同理,selselectect删掉中间的select后剩select,frfromom删掉中间的from后剩from,whwhereere删掉中间的where后剩where。我经常用一个不太严谨但很好记的类比:就像把一个词对折起来,剪刀剪掉重叠的那一小段,展开后这个词还在。
所以跑通第一个完整payload的条件就齐了。站点存在字符型注入点,过滤方式是替换为空,我们掌握了双写规则,下一步就可以正式进入联合查询注入流程。
3.2 空格、注释符与单引号的连带问题
关键字之外,还有几个容易被忽略的“隐性过滤点”需要处理干净。
首先是注释符。经典的#在URL里经常被特殊处理,所以我会顺手用URL编码后的%23来代替,这样能减少一个变量。--注释符在URL里也经常被截断,需要写成--+或--%20,因为加号在URL里会被解析成空格。如果测出来连注释符都被过滤,那就换一种思路:不注释,而是让整条SQL语句在引号闭合后依然语法完整。最简单的办法是在结尾补一个恒真条件,比如用|| '1'='1把后面的password判断也变成逻辑的一部分,但这样又牵扯到or的替代问题,复杂程度会上升。
其次是空格。如果后台把空格也过滤掉,那就用/**/代替空格。MySQL里/**/在绝大多数位置可以当空格用,所以union select可以写成union/**/select。不过在这个环境里,空格没有进过滤名单,所以直接用普通空格即可。
最后是单引号。虽然我们目前能用单引号,但在后面查具体表名或列名时,如果表名里有引号需要硬编码成字符串,而引号恰好被过滤,那就得换用十六进制字符串。比如'flag'可以写成0x666C6167,原理是MySQL会把十六进制字面量当作字符串处理。这个技巧这次不一定用得上,但对同类型的变形题来说,属于能救命的储备知识。
3.3 其他等价替换的备选方案
双写是这道题的最优解,但作为应对过滤题的基础能力,我建议手边常备几套等价替换方案,以备其他环境用:
or可以用||替代,and可以用&&替代,但要注意MySQL默认行为,||在某些模式下是“或”逻辑,在某些模式拼接字符串,实际使用时尽量当场验证。- 等号
=可以用like替代,不等号可以用<>替代。 - 空格可以用
/**/、换行符、tab替代。 - 字符串字面量可以用
hex或char(102,108,97,103)构造。
这些技巧不一定都用得上,但多掌握一种等价写法,就多一分过题把握。
4. 从字段数到库名表名列名:一次完整的注入链路
4.1 用 order by 探测字段数,但 order 要先过过滤这一关
联合查询要求union select后面的列数必须和原查询保持一致,否则整条语句都会报错。所以拿到注入点之后的第一件事永远是数清楚字段数,也就是原SQL查了几个列。
标准操作是order by N,按第N列排序。如果把N换成一个超过总列数的值,数据库会报“Unknown column”之类的错误。所以我依次输入:
1' oorrder by 1# 1' oorrder by 2# 1' oorrder by 3# 1' oorrder by 4#这里oorrder就是前面说过的双写,因为order里的or会被过滤,普通写法根本到不了数据库层。实测结果是:by 3的时候页面正常,by 4的时候报错,于是确定原查询的字段数是3。
4.2 联合查询第一步:确认回显位置
知道字段数是3之后,就可以构造union查询。我要把原来的查询结果清空,让后面自己构造的查询结果成为唯一的回显内容,所以把第一列写成-1:
-1' ununionion selselectect 1,2,3#如果不写-1,而写1,那么原查询很可能返回一行正常数据,union的结果可能会被数据库去重或排列规则干扰,导致我们看不到注入的内容。写成-1让前面的查询查不到数据,后面union部分的结果就能干净地显示出来。
页面出来之后,我看到的回显是数字2和3,1的位置没有显示。这说明当前页面只在第2列和第3列的位置输出数据。后面拖数据就知道该把payload写在哪个位置了。
4.3 从 information_schema 里拖库名
接下来要脱裤子放屁——走标准流程。MySQL里,information_schema是一个系统库,保存了所有数据库、表、列的信息。注入它,等于拿到了整台数据库的地图。
先把所有库名拖出来:
-1' ununionion selselectect 1,2,group_concat(schema_name) frfromom information_schema.schemata#这段payload拆开看是这样的:group_concat(schema_name)把所有的库名用逗号拼成一行;frfromom是双写后的from;information_schema.schemata本身是系统表,里面没有命中黑名单的关键词,所以不用额外处理。
结果会出来一串库名,因为回显策略是“只显示一行”,所以用group_concat把多行合并成一行。如果使用group_concat拼出来太长,可以用substr分批次截取,这是另一个常见卡点。正常来说,目标数据就在其中某一个库中。
4.4 查表名和列名:目标精确定位
库名有了,下一步查这个库里有哪些表:
-1' ununionion selselectect 1,2,group_concat(table_name) frfromom information_schema.tables whwhereere table_schema=database()#这里没有硬编码库名,而是用database()函数取当前正在使用的库,这样语句更通用。whwhereere是双写后的where。
拖完表名,找到那个看起来像存flag的表,然后查它的列名:
-1' ununionion selselectect 1,2,group_concat(column_name) frfromom information_schema.columns whwhereere table_name='表名'#如果单引号在目标环境里被过滤,把'表名'换成十六进制即可。查到列名之后,整个注入链路的最后一段就只剩下一条select语句了。
5. 拖出flag的最后一击与卡关自查指南
5.1 最终获取数据的payload
把第4步查到的表名和列名填入联合查询:
-1' ununionion selselectect 1,2,group_concat(flag) frfromom 表名#如果表名和列名不是flag,就按实际查到的结果替换。执行之后,页面回显里会出现类似flag{...}格式的字符串,复制出来提交,题目就算完成。
这里有一个小提醒:查列名的时候可能会看到多个字段,不要只看名字就默认选第一个。你可以把所有列名先拼出来看一眼,再决定取哪一列。有些题目的flag不在第一列,而在某个不起眼的字段里,需要多试几次。
5.2 卡关时最容易被忽略的四个位置
整个做题链路里,新手最容易卡住的其实就四个地方,我列成自查表:
| 卡点现象 | 可能原因 | 常见解法 |
|---|---|---|
order by一直报错 | order里的or被过滤,或字段数判断错误 | 用oorrder by测试,确认当前是第几列时开始报错 |
| union查询不回显数据 | 前面的查询结果不为空,或union结果被干扰 | 把第一列改成-1,确保原查询查不到行 |
| 回显只有一行,看不到全部数据 | 没有使用group_concat合并多行 | 用group_concat()把结果拼成一行 |
| 字符串引号被过滤 | 目标环境过滤了单引号 | 用十六进制字符串替换字面量,如0x666C6167代替'flag' |
把这四个位置查完,大多数同类题都能推进到最后一步。
6. 换个视角看这道题:过滤规则为什么这么设计
6.1 “替换为空”是典型的错误防御示范
从攻击者视角做完题之后,最好再跳出来看看出题人为什么要这么设计。黑名单过滤本身就是一个有争议的防御手段,它的问题在于:你不可能把SQL语法里所有可能的写法都枚举完。而“替换为空”更是其中最弱的一种实现方式,因为它给攻击者提供了明确的变形空间,双写绕过就是专门针对这个弱点设计的。
如果在真实业务代码里看到preg_replace('/or|and|select/i', '', $input)这种写法,我会直接判定为高危。正确的做法是使用参数化查询或预编译语句,把数据和SQL结构彻底分离,根本不给用户输入进入SQL结构的机会。黑名单顶多作为第二层防御辅助,绝对不能作为唯一的防线。
这也是做CTF题的额外收获:你从攻的角度理解了某种写法为什么脆弱,以后写代码或review代码时,就会自然绕着这些坑走。
6.2 从这道题延伸出去的能力模型
做完BabySQL之后,其实可以把这次做题的流程抽成一个可复用的方法论:先探测注入点类型,再测试过滤规则,再根据过滤方式选择绕过方案,最后逐层从元数据库提取目标数据。这套流程不仅适用于这道题,也适用于大部分联合查询注入类题目。
以后遇到其他被过滤的注入题,我会下意识先做分类:过滤是直接拦截还是替换?拦截的话是preg_match返回错误,还是输入直接被丢弃?替换的话是替换成空还是替换成其他字符?判断清楚再动手,比盲目堆payload高效得多。
顺便说一个我自己的习惯:做题时候把每次输入和页面表现记录成表格,哪怕只是很简单的一行字,也能帮助理清思路。这道题我能比较快定位到双写,靠的就是那份“输入-表现-推断”的测试记录,而不是靠记忆硬猜。这种记录习惯在真实项目的安全测试里也一样重要,排查逻辑永远比碰运气可靠。