从CTF实战解析SQL注入:绕过过滤与联合查询攻防
1. 从一道经典CTF题看SQL注入的攻防本质
最近在整理一些老的CTF题目,发现[gxyctf2019]babysqli这道题虽然名字叫“baby”,但里面涉及到的绕过技巧和思考过程,恰恰是理解SQL注入攻防核心的绝佳材料。很多刚入门安全的朋友,一提到SQL注入,可能立刻想到的是‘ or 1=1 --这种“万能密码”,但在实际环境,尤其是CTF比赛中,防御措施往往会让这种简单攻击失效。这道题就模拟了这样一种场景:开发者已经做了一些基础的过滤,但过滤得并不彻底,留下了我们可以利用的“缝隙”。今天,我就带大家完整复盘这道题的解题思路,不仅告诉你答案,更重要的是拆解每一步背后的原理和思考逻辑,让你真正掌握这种“缝隙”中寻找机会的能力。
这道题的目标很明确:通过一个存在漏洞的登录框,获取数据库中的敏感信息(也就是flag)。题目名字叫“babysqli”,暗示它可能有一些基础的过滤,但并非无懈可击。我们解题的过程,本质上就是一场与开发者过滤逻辑的博弈。你需要像侦探一样,从有限的交互中,推测出后端代码可能的样子,然后设计出能够绕过其防御的“Payload”。接下来,我们就一步步拆解这个博弈过程。
2. 初探与信息收集:理解战场环境
任何实战攻击的第一步都是信息收集,CTF也不例外。面对一个登录框,我们首先要做的就是试探它的行为边界和过滤规则。
2.1 基础试探与错误回显分析
我首先尝试了最经典的测试Payload:在用户名或密码字段输入一个单引号‘。输入admin‘并提交后,页面返回了一个SQL语法错误。这个错误信息本身就是第一个重要情报。它直接告诉我们两个关键点:第一,后端确实直接将用户输入拼接到了SQL语句中,存在注入漏洞;第二,网站开启了错误回显,这意味着我们可以通过错误信息来获取数据库的详细内容,这通常被称为“报错注入”。
注意:在生产环境中,开启详细的数据库错误回显是极度危险的做法,因为它会为攻击者提供大量信息。CTF题目中设置错误回显,是为了降低难度,引导我们使用报错注入技术。
得到错误回显后,我尝试了‘ or 1=1 --这个经典Payload,期望能直接绕过登录。但这次,登录失败了,页面没有返回错误,而是像处理了正常错误密码一样。这立刻引起了我的警觉。这说明,开发者很可能对or、and、--(注释符)等关键词进行了过滤或处理。我的初步假设是:后端可能使用str_replace、preg_replace之类的函数,将这些关键词替换为空字符串或进行了其他处理。
2.2 关键词过滤的验证与绕过思路初现
为了验证过滤规则,我设计了一系列测试:
admin‘ or ‘1‘=‘1:如果or被过滤,这个语句会变成admin‘ ‘1‘=‘1,语法错误。admin‘ and ‘1‘=‘1:测试and是否被过滤。admin‘ --:测试注释符是否被过滤。
测试发现,使用or和--时,行为异常,而使用and时,有时能引发语法错误。这暗示过滤可能不是简单的删除,或者删除后产生了新的组合。一个更可靠的测试方法是使用双写绕过。我尝试了admin‘ oorr ‘1‘=‘1。如果过滤函数只执行一次,将or替换为空,那么oorr中间的or被删除后,剩下的or又会拼接起来,从而绕过过滤。但在这个题目里,双写绕过并未成功。
这时,我转换思路。既然常见的逻辑运算符和注释符可能被干扰,那么有没有不依赖它们的方法呢?答案是肯定的。这就是“联合查询注入”(Union Injection)登场的时候。联合查询的核心是使用UNION或UNION ALL操作符,将我们精心构造的查询结果,拼接到原始查询的结果集后面。只要字段数匹配,我们就能让数据库返回我们想要的数据,而不是执行原本的登录逻辑。
3. 核心攻击链构建:字段数探测与联合查询
确定了使用联合查询的思路后,我们需要解决两个技术问题:第一,原始查询语句到底查询了几个字段?第二,我们如何确定哪些字段的回显位置是可见的?
3.1 使用ORDER BY精确探测字段数
ORDER BY子句用于根据指定列索引对结果集排序。如果ORDER BY 5表示按第5列排序,而查询结果只有3列,数据库就会报错。我们可以利用这个特性来精确探测字段数。
我从ORDER BY 1开始尝试,逐渐增加数字:
admin‘ order by 1 --:正常(假设过滤存在,我们先按此逻辑思考)admin‘ order by 2 --:正常admin‘ order by 3 --:正常admin‘ order by 4 --:报错!
这个过程说明,原始的SELECT语句查询了3个字段。这是一个至关重要的信息,因为我们的UNION SELECT语句也必须跟上相同数量的字段,否则会因字段数不匹配而语法错误。
实操心得:在实际测试中,如果
order by也被过滤,可以尝试用group by替代,原理类似。也可以使用union select null,null,null...不断递增null的个数,直到页面返回正常(不报错)来确定字段数。null兼容所有数据类型,是最安全的占位符。
3.2 构造联合查询并定位回显点
知道字段数是3后,我就可以构造联合查询了。首先,我需要让原始查询的前半部分结果为空,这样页面上显示的就全是我们UNION后面的结果。通常,可以构造一个必然为假的条件,例如:‘ and 1=2 union select 1,2,3 --。但这里and和--可能被过滤,所以需要调整。
我尝试了:admin‘ union select 1,2,3‘。这里我故意在第三个字段后加了一个单引号,目的是闭合原SQL语句中可能存在的后续引号,并让后面的内容成为注释或多余部分(可能引发错误,但有时也能执行)。经过多次测试和观察页面回显,我发现输入admin‘ union select 1,2,3时,页面发生了变化,原本登录错误的地方,显示了数字2和3。
这是一个里程碑式的发现!它意味着:
- 我的
UNION SELECT语句成功执行了。 - 页面模板会将其查询结果的第2和第3列的内容显示出来。第1列的内容可能用于其他逻辑(如用户ID判断)而未直接显示。
- 数字
2和3的位置,就是我们可以用来输出数据库信息(如表名、列名、数据)的“回显点”。
4. 信息提取:从数据库结构到最终Flag
有了可用的回显点,接下来的过程就是标准的SQL注入信息提取流程,但每一步都需要考虑题目可能存在的过滤。
4.1 获取数据库名与表名
在MySQL中,database()函数返回当前数据库名,group_concat(table_name)可以从information_schema.tables中聚合所有表名。
我构造了如下Payload,将数据库名放在回显点2,表名放在回显点3:admin‘ union select 1, database(), group_concat(table_name) from information_schema.tables where table_schema=database()‘
执行后,在页面的回显位置,我看到了:
- 位置2(数据库名):
babysqli - 位置3(表名):
news, users, flag
目标非常清晰了!数据库里有一个名为flag的表,这极大概率就是我们的目标。
4.2 获取目标表的结构(列名)
下一步是查看flag表有哪些列。这需要查询information_schema.columns。
Payload如下:admin‘ union select 1,2, group_concat(column_name) from information_schema.columns where table_schema=database() and table_name=‘flag‘
这里有一个关键点:‘flag‘这个字符串常量。如果题目过滤了单引号,这个Payload就会失效。幸运的是,这道题没有过滤单引号。如果遇到过滤,我们可以使用十六进制编码绕过,比如‘flag‘的十六进制是0x666c6167,那么Payload可以写成...table_name=0x666c6167。
执行后,在回显点3看到了列名:flag。果然,这个表只有一列,列名就是flag。
4.3 最终提取Flag数据
最后一步,直接从flag表查询flag列的数据。
Payload:admin‘ union select 1,2, flag from flag‘
执行后,在页面的回显点3,成功获取到了最终的Flag字符串,格式通常类似flag{xxxx-xxxx-xxxx}。
5. 深度复盘:过滤逻辑推测与高级绕过探讨
解题之后,我们回过头来尝试推测一下题目后端的过滤逻辑。根据我们测试时or 1=1失败而union select成功的情况,一个合理的推测是:开发者可能只对or、and、--、#等登录绕过常用的关键词进行了过滤(例如替换为空),但对union、select、from、where等信息查询常用的关键词却疏于防范。这是一种典型的“不完全防护”,以为防住了万能密码就万事大吉,却留下了更危险的信息泄露漏洞。
如果这是一道更难的题目,可能会设置以下障碍,我们也有相应的绕过思路:
- 过滤
union和select:可以使用大小写混淆UnIoN SeLeCt,或者使用双写ununionion seselectlect(如果过滤函数只执行一次)。更高级的可以用||(连接符)或&&配合子查询。 - 过滤空格:可以使用注释符
/**/代替空格,如union/**/select/**/1,2,3。Tab符%09、换行符%0a有时也能奏效。 - 过滤单引号:对于字符串,可以使用十六进制编码,如
‘flag‘变为0x666c6167。对于数字,则无需引号。 - 完全关闭错误回显:这时报错注入就失效了。我们需要转向“盲注”(Blind SQLi)。通过构造逻辑判断,根据页面返回内容的不同(真/假、时间延迟)来逐位推测数据。例如,
admin‘ and if(ascii(substr(database(),1,1))>100, sleep(2), 1) --,如果页面响应延迟2秒,说明数据库名第一个字符的ASCII码大于100。
6. 防御视角:开发者该如何避免此类问题?
作为开发者,从这道题能学到什么?仅仅过滤几个关键词是远远不够的。根本的解决方案是:
- 使用参数化查询(预编译语句):这是最有效、最根本的防御手段。让SQL语句的“骨架”和“数据”完全分离,用户输入永远只被当作数据处理,无法改变语句结构。在PHP中可以使用PDO或MySQLi的预处理功能。
- 最小权限原则:给Web应用数据库账户分配最小的、必要的权限。比如,只授予查询权限,不授予删除、修改表结构的权限。
- 关闭错误回显:在生产环境中,务必关闭数据库详细错误信息的前端展示,使用统一的、模糊的错误页面。
- Web应用防火墙(WAF):部署WAF可以帮助过滤和拦截常见的攻击Payload,但不应作为唯一防线。
- 定期安全审计与代码扫描:对代码进行人工审计或使用自动化工具扫描,及时发现潜在的注入点。
[gxyctf2019]babysqli这道题,就像它的名字一样,是一个引导初学者深入理解SQL注入的“婴儿步”。它从简单的报错注入入手,引导你思考过滤与绕过,实践联合查询的完整流程。真正掌握它,不在于记住最终的Payload,而在于理解每一步试探背后的原因,以及当某条路被堵死时,如何灵活地寻找另一条通路。这种在限制条件下寻找解决方案的思维,才是网络安全研究和CTF竞赛中最宝贵的收获。下次当你再遇到一个登录框,希望你能像解这道题一样,系统地观察、假设、测试、验证,而不仅仅是机械地输入几个所谓的“万能”Payload。
