从CTF实战解析SQL注入:Union攻击与MD5绕过防御
1. 项目概述:从一道CTF题看SQL注入的攻防博弈
最近在复盘一些经典的网络安全挑战题,又遇到了“babysqli”这个老朋友。这名字起得挺有意思,“Baby”听起来人畜无害,好像是个入门级的SQL注入练习,但真上手去解,会发现里面藏着不少需要仔细琢磨的细节。它本质上是一个模拟的Web登录场景,考察的是攻击者如何绕过前端的简单过滤,利用后端代码的逻辑缺陷和SQL语句的构造特性,最终实现未授权登录并获取目标信息(在CTF中就是flag)。对于刚接触Web安全的新手来说,这道题是一个非常好的“承上启下”的练手点:它不像最基础的注入那样直接给错误回显,也不像那些需要复杂绕过的变态题目。它要求你静下心来,像侦探一样分析源码、推测后台逻辑、并精心构造一个“合法”的Payload。今天,我就结合这道题,把其中涉及到的SQL注入技巧、代码审计思路和Payload构造的逻辑掰开揉碎了讲清楚,希望能帮你建立起一套解决此类问题的通用方法论。
2. 核心思路拆解:逆向推理与逻辑漏洞利用
面对一个登录框,我们首先得搞清楚它在“想”什么。常规思路是丢一个万能密码admin' or '1'='1'#试试水。但在这道题里,你会立刻收到“do not hack me!”的警告。这其实是一个非常重要的信号:前端或后端对输入进行了某种程度的过滤或检查。这个提示告诉我们,简单的单引号闭合和逻辑绕过被拦截了,不能硬闯。
2.1 信息搜集:从源码注释中寻找突破口
当直接注入被阻,下一步的标准动作就是查看网页源码。果然,在源码的注释里发现了一段经过编码的字符串。这是CTF题目中常见的“提示”或“线索”投放方式。经验告诉我们,常见的编码有Base64、Base32、Hex、URL编码等。这里需要一点直觉和尝试,通常先尝试Base64解码,如果乱码,再尝试Base32。本题中的字符串经过Base32解码后,得到了一段可读的Base64字符串,再次解码后,我们拿到了核心的SQL语句原型:
select * from user where username = '$name'这个发现至关重要。它明确告诉我们以下几点:
- 查询语句结构:查询的是
user表,条件是username字段等于我们输入的$name变量。 - 注入点位置:注入点就在
username这个参数上,并且是用单引号包裹的字符串。 - 没有显式错误回显:题目没有直接显示数据库错误信息,属于基于响应内容的盲注(但本题有更巧妙的解法)。
2.2 后台逻辑推测:从返回信息反推代码
拿到SQL语句后,我们结合前端的返回信息来推测后台的PHP代码逻辑。这是我们构造有效Payload的关键。
我们尝试了几次:
- 输入
username=admin,返回wrong pass!。 - 输入
username=test(一个不存在的用户),返回wrong user!。
这两种不同的错误信息,强烈暗示了后台代码的逻辑分支。一个非常可能的PHP代码结构如下:
$name = $_POST['name']; $pw = $_POST['pw']; // 执行SQL: select * from user where username = '$name' $sql = "select * from user where username = '" . $name . "'"; $result = mysqli_query($conn, $sql); $row = mysqli_fetch_array($result); if($row) { // 如果查询到了记录 if($row['username'] == 'admin') { // 首先判断用户名是不是admin if($row['password'] == md5($pw)) { // 然后判断密码的MD5值是否匹配 echo $flag; } else { echo "wrong pass!"; } } else { // 可能还有其他逻辑,或者直接wrong user? // 从题目表现看,查询到非admin用户也可能返回wrong pass? // 需要进一步测试 } } else { // 没有查询到任何记录 echo "wrong user!"; }但这里有个细节需要澄清:当我们输入一个非admin但存在的用户名时,题目返回什么?根据常见WP和我们的测试目标(拿到admin的flag),我们更关注的是如何让$row[‘username’]的值等于字符串’admin’。所以,我们的攻击目标变得非常明确:我们需要让SQL查询返回一条结果,并且这条结果的username字段的值必须是’admin’。
注意:这里的一个关键理解是,
$row[‘username’]是从数据库查询结果集中取出的“username”列的值。我们通过注入控制查询返回的结果集,从而控制这个值。
3. 关键技术解析:Union注入与数据伪造
既然知道了要控制查询结果,UNION SELECT注入就是最合适的武器。UNION操作符用于合并两个或多个SELECT语句的结果集。前提是每个SELECT语句必须拥有相同数量的列,且列的数据类型也需要相似。
3.1 确定字段数量(Order By法)
在使用UNION前,我们必须先知道原SELECT * FROM user查询究竟返回多少列。最经典的方法是使用ORDER BY子句。
ORDER BY用于对结果集按指定的列编号进行排序。如果指定的列编号超过了实际列数,数据库就会报错。我们可以利用这个特性来探测。
- 尝试
name=admin' order by 1#:如果正常,说明至少有1列。 - 尝试
name=admin' order by 2#:如果正常,说明至少有2列。 - 尝试
name=admin' order by 3#:如果正常,说明至少有3列。 - 尝试
name=admin' order by 4#:如果返回错误或页面异常,则说明原查询只有3列。
在本题目中,通过测试可以确定,原查询返回3列。
3.2 确定字段回显位置
知道有3列后,我们需要用UNION SELECT来测试哪一列的内容会最终被后台PHP代码使用到(即$row[‘username’]对应哪一列)。因为UNION需要前后列数一致,我们构造:name=admin' union select 1,2,3#
这个Payload的意思是:先执行原查询(查找用户名为admin’ …的记录,通常为空),然后联合查询我们自定义的结果(1,2,3)。如果联合查询成功,整个结果集就会是我们自定义的(1,2,3)。
提交后,页面返回了wrong pass!,而不是wrong user!。这是一个质的飞跃!它说明:
UNION查询成功执行了。- 后端代码走到了
if($row)分支(因为返回了wrong pass!,说明$row不为空)。 - 接下来,后端代码在判断
$row[‘username’] == ‘admin’。显然,现在我们返回的(1,2,3)中,作为username字段的那一列的值不是’admin’,所以很可能走了某个错误分支,但至少证明了用户“存在”。
那么,username到底是第几列呢?我们需要测试。分别尝试:
name=admin' union select 'admin',2,3#(假设第一列是username)name=admin' union select 1,'admin',3#(假设第二列是username)name=admin' union select 1,2,'admin'#(假设第三列是username)
当尝试到name=admin' union select 1,'admin',3#时,我们发现返回信息发生了变化(或者根据题目设计,可能直接成功)。由此可以判定,原查询结果集的第二列,对应的是username字段。
3.3 密码校验逻辑与MD5绕过
确定了username的位置,我们已经可以伪造一个用户名为admin的记录。但登录还需要通过密码验证。根据之前推测的代码逻辑if($row[‘password’] == md5($pw)),我们需要让$row[‘password’]的值等于我们提交的密码$pw的MD5值。
这里有两种攻击思路:
思路一:已知密码MD5,反向构造如果我们能猜到或者通过其他方式(如题目提示、社工)知道admin的密码明文,或者其MD5值,我们就可以直接伪造。例如,假设我们知道admin的密码是abc,其MD5值是900150983cd24fb0d6963f7d28e17f72。那么我们可以构造Payload:name=admin' union select 1,'admin','900150983cd24fb0d6963f7d28e17f72'#&pw=abc
这样,查询返回的密码字段值就是正确的MD5值,与我们提交的pw参数的MD5计算结果一致,通过校验。
思路二:利用MD5函数特性进行绕过(更通用)这是本题的一个精妙之处。在PHP中,md5()函数如果传入一个数组,例如md5(array()),它会返回NULL,并且会产生一个警告(但程序可能继续执行)。同时,在SQL中,NULL与任何值(包括另一个NULL)的比较,使用==时结果可能是false,但在某些宽松比较下存在绕过可能。然而,更直接利用的是字符串与NULL的MD5比较。
但本题更常见的解法是利用UNION查询,我们直接让查询返回的密码字段值为NULL。然后,在提交密码参数时,我们提交一个数组pw[]。这样:
- 后端执行
$row[‘password’] == md5($pw)。 $row[‘password’]是我们通过注入设置的NULL。$pw由于我们传的是pw[]=xxx,所以它是一个数组。md5(array(...))返回NULL。- 比较变成了
NULL == NULL。在PHP的==松散比较中,NULL == NULL的结果是true。
因此,最终的Payload构造如下:name=admin' union select 1,'admin',NULL#&pw[]=1
这个Payload实现了:
- 通过
UNION SELECT伪造了一条记录。 - 该记录的用户名(第二列)为
’admin’。 - 该记录的密码(第三列)为
NULL。 - 通过传递数组
pw[],使得md5($pw)的结果也为NULL。 - 从而满足了
$row[‘password’] == md5($pw)的条件,成功绕过密码验证。
4. 完整攻击流程实操与细节
理论清晰了,我们从头到尾梳理一遍实战攻击流程,并补充一些至关重要的细节和工具使用技巧。
4.1 第一步:环境探测与信息收集
- 打开目标网页:看到一个登录框,有
username和password输入项,以及提交按钮。 - 测试基础注入:在
username输入admin' or '1'='1,password随意填,点击提交。页面返回“do not hack me!”。确认存在基础过滤,但同时也确认了username参数可能被代入查询。 - 查看网页源码:按
F12或右键“查看页面源代码”。仔细搜索<!--和-->之间的注释内容。找到一段看似乱码的字符串。 - 解码线索:
- 将注释中的字符串复制出来。
- 使用CyberChef(一个在线编解码神器)或本地Python脚本进行解码尝试。
- 先尝试Base64解码,如果结果不可读,尝试Base32解码。本题是Base32->Base64->明文SQL。
- 得到核心SQL:
select * from user where username = '$name'。
4.2 第二步:分析逻辑与确定列数
- 分析错误信息:
- 提交
username=admin&password=123,返回wrong pass!。 - 提交
username=randomuser&password=123,返回wrong user!。 - 结论:
wrong user!意味着SQL查询结果为空($row为false)。wrong pass!意味着查询到了记录($row不为空),但密码校验失败。这说明我们的注入只要能返回一条记录,就能进入密码校验环节。
- 提交
- 确定查询列数:
- 使用Burp Suite的Repeater模块进行精确测试,比在浏览器地址栏操作更高效。
- 发送POST请求,修改
name参数为admin' order by 1#,观察响应。 - 依次增加数字,
order by 2#,order by 3#,order by 4#。 - 当
order by 4#时,页面可能返回错误或变为wrong user!,说明列数超出,原查询为3列。
4.3 第三步:实施Union注入与字段定位
- 构造基础Union测试:
name=admin' union select 1,2,3#&pw=123- 观察响应。如果返回
wrong pass!,说明UNION成功,且$row有值。如果返回wrong user!,可能是UNION前后列数不一致或类型不匹配,需检查。
- 定位用户名字段:
- 在Burp Repeater中,依次修改Union查询的第二参数为字符串
’admin’。 name=admin' union select 1,'admin',3#&pw=123- 提交后,观察响应。此时,因为我们将第二列(推测的username字段)设置为了
’admin’,后台判断$row[‘username’] == ‘admin’成立,代码会进入密码校验分支if($row[‘password’] == md5($pw))。由于我们第三列是数字3,密码校验必然失败,所以理应返回wrong pass!。这确认了第二列就是username。
- 在Burp Repeater中,依次修改Union查询的第二参数为字符串
4.4 第四步:构造最终Payload获取Flag
采用“MD5数组绕过”这种更通用的方法:
- 构造SQL注入部分:
- 我们需要让查询返回一条记录,用户名是
’admin’,密码是NULL。 - Payload:
name=admin' union select 1,'admin',NULL# - 注意:
NULL在SQL中不需要引号。
- 我们需要让查询返回一条记录,用户名是
- 构造请求参数部分:
- 为了触发
md5($pw)返回NULL,我们需要让$_POST[‘pw’]是一个数组。 - 在Burp Suite中,修改请求体有两种方式:
- 方式一(推荐):直接修改原始请求体为
name=admin%27%20union%20select%201%2C%27admin%27%2CNULL%23&pw[]=1- 这里
%27是单引号’的URL编码,%20是空格,%2C是逗号,%23是井号#。pw[]=1表示pw参数是一个数组,其第一个元素值为1。
- 这里
- 方式二:使用Burp的
Params选项卡,在pw参数的值上直接输入1,然后右键选择“Change request method”或手动在Raw视图里将pw=1改成pw[]=1。
- 方式一(推荐):直接修改原始请求体为
- 为了触发
- 发送请求:
- 将构造好的请求发送出去。
- 结果分析:
- 如果一切正确,响应页面中将不再显示
wrong pass!,而是会显示最终的flag,格式可能为flag{xxxx-xxxx-xxxx}或GXYCTF{...}等。
- 如果一切正确,响应页面中将不再显示
实操心得:在Burp Suite里操作时,务必注意URL编码。当你直接在
Repeater的原始视图(Raw)中修改参数时,特殊字符如空格、引号、井号需要被正确编码,否则请求格式会错乱。一个稳妥的方法是先在Params或Decoder模块里构造好Payload,再粘贴过去。另外,浏览器的开发者工具“网络(Network)”标签页也能看到原始请求,是学习请求格式的好地方。
5. 深度防御思考与拓展场景
通过这道“babysqli”,我们成功发起了一次攻击。但站在防御者角度,这道题暴露了多个致命的安全问题。真正的安全学习,攻防必须一体。
5.1 漏洞根因分析
- SQL注入:根本原因在于将用户输入(
$name)未经任何处理就直接拼接到了SQL语句中。这是Web安全的“万恶之源”。 - 逻辑设计缺陷:
- 错误信息过于详细:
wrong user!和wrong pass!的差异为攻击者提供了判断查询结果是否为空的关键依据,这属于信息泄露。在生产环境中,应该使用统一的、模糊的错误提示,如“用户名或密码错误”。 - 密码比较逻辑可被绕过:使用
==进行MD5值的比较,且未对用户输入参数$pw进行类型检查,导致攻击者可以通过传递数组使md5()函数返回NULL,进而实现绕过。应使用===(严格比较),并在比较前用is_string()检查输入类型。 - 前端注释泄露敏感信息:将后端SQL语句写在HTML注释中,是极其危险的行为。
- 错误信息过于详细:
5.2 安全加固方案
一个安全的登录逻辑应该如何编写?
<?php // 1. 使用预处理语句(参数化查询)—— 杜绝SQL注入 $stmt = $conn->prepare("SELECT username, password_hash FROM users WHERE username = ?"); $stmt->bind_param("s", $username); // ‘s’ 表示字符串类型 $username = $_POST['username']; $stmt->execute(); $result = $stmt->get_result(); // 2. 统一的错误信息 $error_msg = "用户名或密码错误"; if ($row = $result->fetch_assoc()) { // 3. 使用 password_verify 进行密码哈希验证 (PHP 5.5+) // 假设密码在存储时使用了 password_hash() if (password_verify($_POST['password'], $row['password_hash'])) { // 登录成功 $_SESSION['user'] = $row['username']; echo "登录成功!"; } else { // 密码错误 echo $error_msg; } } else { // 用户不存在 echo $error_msg; // 使用与密码错误相同的提示 } $stmt->close(); $conn->close(); ?>关键点解释:
- 预处理语句:将SQL语句的结构与数据分离,用户输入永远被视为数据而非代码,从根本上解决注入。
- 统一的错误信息:避免攻击者通过反馈差异进行用户枚举或状态判断。
- 使用
password_hash()和password_verify():这是PHP存储和验证密码的现代标准方法,它自动处理盐值、算法和成本因子,比直接使用md5()安全无数倍。md5早已被证明是不安全的,可以在彩虹表或GPU暴力破解下快速还原。 - 类型检查:在比较前,可以使用
if (!is_string($_POST[‘password’])) { die(‘Invalid input’); }来确保输入是字符串。
5.3 拓展场景:其他常见的SQL注入绕过技巧
“babysqli”主要考察了Union注入和逻辑绕过。在实际的渗透测试或更复杂的CTF题中,还会遇到其他过滤和绕过技巧:
关键字过滤绕过:如果系统过滤了
select,union等关键字,可以尝试:- 双写:
selselectect - 大小写混合:
SeLeCt - 内联注释:
/*!select*/(MySQL特有) - 编码:URL编码、Hex编码、Unicode编码。
- 等价替换:用
||代替or(在某些数据库如SQLite中)。
- 双写:
空格过滤绕过:如果过滤了空格,可以用以下字符代替:
/**/(注释符)+(加号,在URL中需编码为%2B)%09(Tab)%0a(换行)%0c(换页)%0d(回车)()(括号,用于包裹参数)
引号过滤绕过:如果过滤了单引号
’,对于字符串的注入,可以尝试:- 使用十六进制编码字符串。例如,
admin的十六进制是0x61646d696e,在MySQL中可以这样用:select * from users where username=0x61646d696e。 - 利用数据库函数进行转换,如
char(97,100,109,105,110)。
- 使用十六进制编码字符串。例如,
盲注(Blind Injection):如果页面没有直接的数据回显,也没有详细的错误信息,只能通过页面返回的真/假(True/False)状态来判断,这就是盲注。本题其实带有盲注特征(通过
wrong user/wrong pass判断),但利用Union可以更快解决。真正的盲注需要用到if(),sleep(),substring()等函数,通过布尔逻辑或时间延迟来逐位猜解数据,过程非常耗时,通常需要借助自动化工具如sqlmap。
这道“babysqli”就像一把钥匙,帮你打开了SQL注入攻防世界的一扇门。它融合了信息搜集、代码审计、逻辑推理和Payload构造等多个环节。真正掌握它,不在于记住最终的Payload,而在于理解每一步背后的“为什么”:为什么查看源码?为什么用order by?为什么UNION的列数要匹配?为什么传递数组可以绕过MD5?想通了这些,你就能举一反三,面对更复杂的过滤和变形时,也能找到破解的思路。安全之路,始于好奇,成于严谨。每一次成功的注入,都应该对应着一次对自身代码安全的审视和加固。
