从CTF题BabySQli剖析SQL注入攻防:UNION查询与MD5特性利用
1. 项目概述从一道CTF题看SQL注入的攻防博弈最近在复盘一些经典的网络安全挑战题又看到了这道名为“BabySQli”的题目。它虽然被归为“Baby”级别但其中蕴含的SQL注入技巧和绕过思路却非常典型足以让许多刚入门Web安全的朋友们好好琢磨一番。这道题模拟了一个简单的登录场景表面上看只是一个判断用户名密码对错的表单但背后却是一个考察开发者如何安全处理用户输入以及攻击者如何利用逻辑漏洞的绝佳案例。如果你正在学习网络安全或者对“为什么我的网站会被黑”感到好奇那么通过拆解这道题你不仅能学会一种攻击手法更能深刻理解防御的核心在哪里。简单来说这道题的核心就是“SQL注入”。攻击者通过在登录框的用户名或密码输入中插入精心构造的SQL代码欺骗后端数据库执行非预期的查询从而绕过登录验证甚至窃取、篡改数据。BabySQli这道题就像它的名字一样是一个简化但要素齐全的“婴儿级”战场非常适合我们用来剖析SQL注入从信息探测到最终利用的完整链条。接下来我会带你一步步重现解题过程并重点讲解每一步背后的原理和思考让你不仅知道“怎么做”更明白“为什么这么做”。2. 解题思路拆解逆向工程与逻辑推理面对一个黑盒的登录框我们的第一步永远是信息收集和试探。题目给了一个登录界面输入admin/admin返回“wrong pass!”这首先告诉我们用户admin是存在的只是密码不对。这是一个关键信息它缩小了我们的攻击范围。2.1 初探与源码分析常规的渗透测试思维会立刻尝试一些常见的注入载荷。比如使用万能密码admin or 11#试图让SQL查询条件永真。但题目在这里设置了一个简单的防护返回了“do not hack me!”这提示我们可能存在简单的关键词过滤。此时一个重要的习惯是查看网页源代码。在前端源码中开发者有时会留下注释掉的信息、隐藏的输入框或者像本题这样的线索。在本题的源码中我们发现了一段被注释的Base32编码字符串。这是CTF比赛中常见的“隐写”或信息传递方式。我们将其进行Base32解码得到另一段Base64编码的字符串再次解码后得到了核心的SQL查询语句select * from user where username $name。这个发现至关重要它让我们从完全的盲注变成了“半知”状态。我们知道了查询的模板知道了变量$name是直接拼接进去的这证实了SQL注入漏洞的存在并且指明了注入点就在username参数。2.2 后台逻辑推理与联合查询构造知道了SQL语句我们还需要推断后台的完整验证逻辑。通常的登录验证流程是用用户名查询数据库如果查不到记录返回“用户不存在”如果查到了再比对查询结果中的密码字段和用户提交的密码通常经过哈希处理。题目在返回“wrong pass!”时已经暗示我们它执行了密码比对环节。我们通过构造Payload来探测查询结果的结构。当我们提交nameadmin时返回“wrong pass!”说明查询到了记录。当我们提交一个不存在的用户名如nametest时返回“wrong user!”。更有趣的测试是我们提交name1‘ union select 1,2,3#。如果页面返回“wrong pass!”说明联合查询执行成功并且后端程序拿到了我们“伪造”的查询结果即1,2,3这一行去进行密码比对。通过改变数字的位置我们可以判断哪个字段被用作用户名、哪个字段被用作密码。例如Payloadname1‘ union select 1,’admin‘,3#。如果此时返回“wrong pass!”而name1‘ union select ’admin‘,1,3#返回“wrong user!”那么我们就可以确定查询结果中的第二个字段被程序当作username进行比对。这就是通过布尔状态对/错进行字段推断的方法。3. 核心攻击手法详解利用UNION与MD5特性在推断出字段顺序假设为字段1-ID字段2-username字段3-password后我们需要构造一个能够通过验证的记录。关键点在于密码比对。从源码注释或常见逻辑可知密码通常以MD5哈希值的形式存储在数据库中。后台的验证逻辑很可能是这样的$sql select * from user where username $name; $result mysqli_query($conn, $sql); $row mysqli_fetch_assoc($result); if($row[password] md5($_POST[pw])) { // 登录成功 }也就是说程序会将我们通过UNION查询“伪造”出来的password字段值与用户提交的密码经过MD5计算后的值进行比对。要让这个等式成立我们有两种经典的思路。3.1 方法一已知明文匹配哈希如果我们能猜到或者通过其他方式知道管理员密码的明文在CTF中有时是弱口令比如是abc那么其MD5值是900150983cd24fb0d6963f7d28e17f72。我们可以直接伪造一条记录其中密码字段就是这个哈希值。 构造Payloadname1‘ union select 1,’admin‘,’900150983cd24fb0d6963f7d28e17f72‘#pwabc这个Payload的意思是原查询查不到数据因为1‘导致查询失败然后通过UNION我们拼接上自己构造的一行数据(1, ‘admin‘, ‘正确的MD5哈希值‘)。当程序执行时$row得到的就是我们这行数据。此时用户提交的密码pwabc经过md5(‘abc‘)计算后正好等于我们伪造的密码字段值900150983cd24fb0d6963f7d28e17f72于是验证通过。3.2 方法二利用MD5函数特性构造NULL匹配这是一种更巧妙、更通用的方法它不依赖于知道任何明文密码。它利用了PHP中md5()函数的一个特性当传入的参数是一个数组时md5()函数会返回NULL并可能产生一个警告但程序可能配置为不显示警告。后台的比对逻辑是$row[‘password‘] md5($_POST[‘pw‘])。 如果我们能让$row[‘password‘]为NULL同时让md5($_POST[‘pw‘])的结果也为NULL那么NULL NULL在PHP的松散比较下结果是true。如何实现让查询结果的密码字段为NULL在UNION查询中我们直接将密码字段设为NULL。 构造Payloadname1‘ union select 1,’admin‘,NULL#让提交的密码参数使md5()返回NULL我们需要以数组的形式提交pw参数。在HTTP POST请求中可以通过将参数名改为pw[]来传递一个数组。例如pw[]123。 在PHP中$_POST[‘pw‘]此时接收到的是一个数组array(0 ‘123‘)。md5(array(…))的返回值就是NULL。因此完整的攻击链是前端提交name1‘ union select 1,’admin‘,NULL#pw[]123后端执行SQL得到一行数据(1, ‘admin‘,NULL)后端执行比对NULL md5(array(‘123‘))NULL NULLtrue登录验证通过。注意这种方法成功的关键在于后端使用了松散比较而不是严格比较。在严格比较下NULL NULL为真但md5(array())返回的NULL其类型和值虽然与查询得到的NULL相同但整个表达式能否依然为真取决于代码的具体写法。在实际渗透测试中这是一种需要尝试的旁路方法。4. 实战操作过程从抓包到注入理论清楚了我们来看具体操作。这里以使用Burp Suite工具为例。4.1 步骤一拦截登录请求在浏览器中打开题目登录页面。打开Burp Suite配置浏览器代理。在登录框随意输入用户名密码如test/test点击登录。此时Burp Suite会拦截到这个HTTP POST请求。4.2 步骤二修改请求参数进行注入拦截到的请求大概长这样POST /login.php HTTP/1.1 Host: target.com Content-Type: application/x-www-form-urlencoded ... nametestpwtest我们将这个请求发送到Burp Suite的Repeater模块方便反复测试。测试用户是否存在 将name参数改为admin发送请求。回应体中出现“wrong pass!”确认admin用户存在。探测查询字段数及可用字段 使用order by语句。修改name参数为admin‘ order by 1#发送请求。正常返回“wrong pass!”。依次尝试order by 2#,order by 3#,order by 4#。当尝试到order by 4#时可能会报错或返回内容不同说明查询结果共有3个字段。使用UNION SELECT确定字段回显位置 修改name参数为name1‘ union select 1,2,3#发送请求。如果返回“wrong pass!”说明UNION查询成功执行并且程序使用了我们构造的数据。如果返回“wrong user!”可能需要将1‘换成其他不存在的用户名或者调整闭合方式如将单引号闭合。确定用户名和密码字段位置 假设union select 1,2,3#返回“wrong pass!”。我们分别测试name1‘ union select 1,’admin‘,3#- 如果返回“wrong pass!”说明第二个字段被当作用户名比对。name1‘ union select 1,’admin‘,3#- 如果返回“wrong user!”则尝试name1‘ union select ’admin‘,1,3#。 通过这种布尔判断最终确定字段2是用户名字段3是密码。4.3 步骤三实施最终攻击采用上述的“NULL匹配”方法。 在Repeater中修改请求体为name1‘ union select 1,’admin‘,NULL#pw[]注意这里的pw参数名必须改为pw[]值可以为空或任意值因为它是一个数组。发送请求。4.4 步骤四获取结果如果一切顺利响应包中就不会再出现“wrong pass!”或“wrong user!”而是会显示登录成功的提示信息在这道CTF题中就是最终的flag。5. 防御策略与深度思考通过这道BabySQli我们演练了一次完整的基于UNION和逻辑绕过的SQL注入攻击。作为开发者应该如何防御呢这里提供几个层级的安全建议5.1 根本解决方案使用参数化查询预编译语句这是防御SQL注入最有效、最根本的方法。以PHP的PDO为例$stmt $pdo-prepare(SELECT * FROM user WHERE username :username); $stmt-execute([‘:username‘ $name]); $row $stmt-fetch();在这个例子中SQL语句模板SELECT * FROM user WHERE username :username被预先编译用户输入的$name值在执行时作为纯数据传入无法改变语句的结构。无论$name里面包含什么引号、SQL关键字都只会被当作一个普通的字符串值来处理。5.2 严格的输入验证与过滤虽然不如参数化查询彻底但在某些场景下可以作为补充。类型检查如果字段是整数使用intval()强制转换。白名单过滤对于已知有限集合的输入如状态、类型只接受白名单内的值。转义函数如果因历史原因必须使用字符串拼接务必使用数据库特定的转义函数如mysqli_real_escape_string()。但请注意这不是绝对安全的尤其是在宽字节等特殊字符集下可能存在绕过风险。5.3 安全的密码比对逻辑本题的漏洞也暴露了密码验证逻辑的问题。更安全的做法是使用PHP的password_hash()函数进行哈希它自动处理盐值并使用强算法如bcrypt。使用password_verify()函数进行验证。// 存储密码 $hashedPassword password_hash($plainPassword, PASSWORD_DEFAULT); // 验证密码 if (password_verify($inputPassword, $row[‘password‘])) { // 登录成功 }password_verify()函数能有效防止时序攻击并且其内部逻辑决定了它不会出现md5(array())返回NULL这类奇怪的问题。5.4 最小化错误信息与代码安全关闭错误回显在生产环境中避免将详细的数据库错误信息直接显示给用户。这能防止攻击者通过报错获取数据库结构信息报错注入。遵循最小权限原则连接数据库的账号不应具有DROP、DELETE等高危权限仅赋予其应用所需的最小权限。定期安全审计与代码扫描使用自动化工具和人工审查定期查找代码中的安全隐患。这道“BabySQli”就像一面镜子照出了Web应用安全中一个经典且危险的漏洞。对于学习者而言理解它的攻击原理是掌握防御技术的第一步。而对于开发者来说时刻牢记“永远不要信任用户输入”并采用参数化查询等现代、安全的编程实践是构建稳固应用基石的唯一途径。安全不是一个功能而是一种需要贯穿于设计、开发、测试全过程的思维方式。