从CTF题babysqli剖析SQL注入:联合查询与MD5特性绕过实战
1. 项目概述从一道CTF题看SQL注入的攻防艺术最近在复盘一些经典的网络安全竞赛题目其中一道名为“babysqli”的题目让我印象颇深。这道题虽然名字听起来很“baby”但其中蕴含的SQL注入技巧、代码审计逻辑以及对后端验证机制的绕过思路却非常值得每一位Web安全初学者乃至有一定经验的从业者仔细琢磨。它不像那些复杂的、需要多重绕过的“炫技”题而是清晰地展示了当开发者对用户输入过滤不严且前后端逻辑存在理解偏差时攻击者如何一步步抽丝剥茧最终达成未授权登录或信息窃取的目的。今天我就结合这道“babysqli”题目为你彻底拆解一次完整的、基于联合查询Union和MD5特性绕过的SQL注入攻击链。无论你是正在学习网络安全的学生还是希望加固自家应用安全的开发者相信这篇近万字的深度解析都能给你带来实实在在的收获。这道题模拟了一个最经典的场景用户登录。我们面前只有一个简单的登录框输入用户名和密码。作为攻击者我们的第一直觉就是尝试是否存在SQL注入漏洞。而这道题的巧妙之处在于它不仅仅考察了注入点的发现和利用更深入到了后端代码的逻辑判断、密码的比对机制甚至利用了数据库查询结果与PHP类型比较的特性。通过手动复现和调试这个过程我们能深刻理解“漏洞”从来不是孤立存在的它往往是开发链条上多个环节的疏忽共同作用的结果。接下来我将带你从黑盒测试开始一步步推理、验证、构造Payload最终拿到象征胜利的“flag”。这个过程本身就是一次绝佳的安全思维训练。2. 核心漏洞原理与测试环境搭建2.1 SQL注入漏洞的本质与联合查询注入在深入“babysqli”之前我们必须夯实基础。SQL注入之所以长期位居OWASP Top 10前列其根源在于“将用户输入的数据当成了代码来执行”。具体到登录场景开发者预期的SQL语句可能是这样的SELECT * FROM users WHERE username 用户输入的用户名 AND password 用户输入的密码通常经MD5等哈希后理想情况下用户输入admin和123456语句变为SELECT * FROM users WHERE username admin AND password e10adc3949ba59abbe56e057f20f883e但如果用户在用户名字段输入admin --注意最后的空格语句就变成了SELECT * FROM users WHERE username admin -- AND password ...--在大多数数据库中是单行注释符这意味着其后的AND password...条件被完全注释掉了只要users表里存在用户名为admin的记录这条查询就会成功返回数据从而绕过密码验证。“babysqli”这道题考察的是另一种更强大、也更经典的注入技术联合查询注入Union Injection。UNION操作符用于合并两个或多个SELECT语句的结果集。关键前提是每个SELECT语句必须拥有相同数量的列且列的数据类型也需要相似。攻击者利用这一点可以“拼接”一个自己构造的查询结果到原始查询结果中。例如如果原查询是SELECT id, username, password FROM users WHERE id1那么攻击者可以构造UNION SELECT 1, admin, 5f4dcc3b5aa765d61d8327deb882cf99从而让应用程序返回攻击者精心伪造的用户数据。注意在实际注入前我们通常需要先确定原查询返回的列数。常用方法是通过ORDER BY子句递增数字来测试直到报错。例如 ORDER BY 5 --不报错但 ORDER BY 6 --报错则说明原查询返回5列。2.2 题目环境分析与初步信息收集根据题目描述和网络上的Writeup解题报告我们面对的“babysqli”环境是一个典型的PHPMySQL Web应用。我们获得了一个登录页面通常是一个index.php或login.php。作为攻击者我们的第一步永远是信息收集。黑盒试探我们在用户名和密码框分别输入admin和admin点击登录。返回信息是“wrong pass!”。这是一个非常重要的信号它告诉我们用户名admin是存在的只是密码不对。如果用户不存在常见的提示是“用户不存在”或“wrong user”。这为我们后续的注入指明了方向我们需要围绕一个已知存在的用户如admin来做文章。尝试万能密码我们输入经典的Payload用户名填admin OR 11#密码随意。点击登录后返回了“do not hack me!”。这说明应用程序对输入进行了一些基础的过滤或检查可能直接检测了OR、#、--等常见注入字符并返回了警告信息。这阻止了最简单的注入但往往也暗示着存在更隐蔽的注入点或过滤绕过方式。审查前端代码题目提示“看了下源码里面有一段数据被注释了”。在任何Web测试中查看HTML源码都是必须的步骤快捷键CtrlU。我们果然在源码中发现了一段被HTML注释!-- --包裹的奇怪字符串。它看起来像Base32编码字符集通常包含A-Z和2-7可能以等号填充。这是出题人留下的“提示”或“线索”是CTF比赛中常见的做法模拟了开发者不小心将敏感信息如SQL语句结构泄露在前端的情况。2.3 关键信息解码与SQL语句还原我们假设从HTML注释中获取的字符串是MZWGCZ33MVZGQZLJMVZGC3TDMNSTGQZTMQSTINJTGRCTGMRRGI4TGNRTGQ2A此为示例实际题目编码不同。解码过程如下Base32解码Base32编码每5个比特表示一个字符常用于在不区分大小写的环境中传输二进制数据。我们可以使用在线工具或命令行如echo “编码字符串” | base32 -d进行解码。解码后我们可能得到另一串看起来像Base64的字符串。Base64解码将上一步得到的字符串进行Base64解码。最终我们得到了一条清晰的SQL语句select * from user where username $name这条语句就是后端处理登录请求时执行的查询核心它证实了我们的猜想程序直接将用户输入的用户名$name拼接进了SQL语句且没有任何过滤。同时它也告诉我们查询的表是user查询条件是username字段等于我们的输入。实操心得在实际渗透测试中这种直接将SQL语句泄露的情况极少。但信息泄露漏洞如.git泄露、备份文件、调试信息确实可能暴露程序逻辑、数据库结构甚至密钥。这道题以此形式巧妙地“给予”了攻击者关键信息降低了盲注的难度将考察重点放在了利用上。3. 注入点确认与联合查询构造3.1 确定查询结果列数与字段位置虽然我们知道了SQL语句但使用UNION注入前我们必须知道SELECT * FROM user到底返回几列。*代表所有列我们无法直接知晓。这里就需要用到ORDER BY探测法。由于直接输入admin ORDER BY 1#可能会被“do not hack me”拦截我们需要观察。题目中用户通过Burp Suite抓包修改POST请求参数来绕过前端的简单检查这是一个非常实用的技巧。前端JavaScript验证可以轻易被绕过真正的校验在服务器端。我们使用Burp Suite拦截登录请求。假设原始POST数据为nameadminpw123我们将其发送到Repeater模块进行手动测试。我们将name参数修改为nameadmin ORDER BY 5--注意--后面必须有一个空格在URL编码中常写作--或--%20。发送请求观察响应。如果返回“wrong user!”或正常页面说明ORDER BY 5成功执行即列数至少为5列。我们继续增加数字测试ORDER BY 6, 7, 8...直到收到数据库报错信息如“Unknown column 8 in order clause”或页面行为异常如变成“do not hack me”或空白。假设测试发现ORDER BY 4成功而ORDER BY 5失败那么我们就可以确定原查询返回4列。接下来我们需要确定这4列中哪一列对应的是用户名username哪一列对应的是密码password这对于我们后续伪造数据至关重要。我们使用UNION SELECT配合可识别的占位符来测试。Payload如下name UNION SELECT 1,2,3,4--发送请求。此时整个SQL语句变为select * from user where username UNION SELECT 1,2,3,4-- 由于username条件不成立假设没有空用户查询结果将只返回我们联合查询的结果(1,2,3,4)。观察页面回显。页面可能会直接显示其中的某个数字例如在列表或欢迎信息中也可能通过“wrong pass!”和“wrong user!”这种间接方式来判断。题目中采用了间接判断法通过输入一个不存在的用户名触发“wrong user!”而输入一个存在的用户名但密码错误触发“wrong pass!”。因此我们可以这样测试发送nametest UNION SELECT 1,admin,3,4--。如果页面返回“wrong pass!”说明我们联合查询结果中的第二列值为admin被应用程序识别为username字段并且这个admin用户在数据库中存在只是密码我们提供的第3或第4列不匹配。如果返回“wrong user!”则说明admin没有被识别为用户名我们可以尝试将admin放在第1、3、4列的位置进行测试。根据题目Writeup测试后发现第二列是用户名username字段。这是一个关键进展。3.2 分析后端登录验证逻辑仅仅通过联合查询返回了admin用户还不足以登录。我们需要让密码验证也通过。通常登录逻辑的伪代码如下$sql select * from user where username $name; $result mysqli_query($conn, $sql); $row mysqli_fetch_assoc($result); // 获取查询结果的第一行 if($row) { // 如果查询到用户 if($row[password] md5($_POST[pw])) { // 密码正确登录成功 echo $flag; } else { // 密码错误 echo wrong pass!; } } else { // 用户不存在 echo wrong user!; }从上面的逻辑和题目提示可知后端会将我们输入的密码pw参数进行MD5哈希然后与数据库查询结果中password字段的值进行比较。如果相等则输出flag。因此我们的攻击目标变得明确我们需要通过SQL注入让查询返回的$row[password]的值等于我们提交的密码的MD5哈希值。但我们不知道数据库里admin的真实密码MD5值无法直接伪造。这时就需要利用一个关键技巧。4. 核心攻击利用MD5与NULL绕过密码验证4.1 方法一已知密码哈希值的伪造如果我们能通过某种方式比如题目其他部分提示、社工、或默认密码猜到一个弱密码我们就可以计算其MD5然后直接伪造包含该哈希值的用户记录。假设我们通过信息搜集猜测admin的密码可能是abc。我们计算md5(abc)得到900150983cd24fb0d6963f7d28e17f72。 那么我们构造的Payload如下name UNION SELECT 1,admin,900150983cd24fb0d6963f7d28e17f72,4-- pwabc解释UNION SELECT构造了一条新记录。第1列任意值此处为1。第2列用户名必须为admin以通过用户存在性检查。第3列密码字段填入我们猜测的密码abc的MD5哈希值。第4列任意值此处为4。pw参数我们提交的明文密码abc。后端执行流程执行SQL由于username不成立查询结果为我们构造的记录(1, admin, 900150983cd24fb0d6963f7d28e17f72, 4)。程序获取$row[password]其值为900150983cd24fb0d6963f7d28e17f72。程序计算md5($_POST[pw])即md5(abc)结果也是900150983cd24fb0d6963f7d28e17f72。两者相等验证通过输出flag。这种方法成功的前提是能猜到或获取到某个有效密码的哈希值在实战中难度较大。4.2 方法二利用MD5函数与NULL比较的特性更通用这是一种更巧妙、更通用的方法它利用了PHP中字符串与NULL比较以及md5()函数处理数组时的特性。原理深度解析PHP中md5()函数对数组的处理在PHP中md5()函数的参数预期是一个字符串。如果你传入一个数组例如md5(array())PHP会产生一个警告Warning但函数会返回NULL。即md5($_POST[pw])如果$_POST[pw]是一个数组那么结果就是NULL。PHP中松散比较的规则在PHP的松散比较中NULL NULL为true。更重要的是一个字符串与NULL进行比较在某些情况下也可能为true这里需要更精确在PHP中NULL与任何其他类型除了NULL本身进行比较结果通常是false。但是这里有一个关键点如果数据库中的密码字段值本身就是NULL呢构造NULL密码字段我们可以通过SQL注入让查询返回的password字段值为NULL。在MySQL中UNION SELECT时我们可以直接使用NULL作为一列的值。构造数组密码参数同时我们在提交登录请求时将密码参数pw设置为一个数组。在POST表单中可以通过给参数名加上方括号来实现例如pw[]anything。当PHP接收到pw[]时$_POST[pw]就是一个数组。组合攻击链SQL注入Payloadname UNION SELECT 1,admin,NULL,4--这条语句让查询返回一条记录其中用户名是admin密码字段是NULL。POST数据构造pw[]123或任何值因为pw被当作数组处理这使得$_POST[pw]是一个数组。后端执行流程执行SQL得到结果$row其中$row[password] NULL。计算md5($_POST[pw])因为$_POST[pw]是数组所以md5()返回NULL。进行条件判断$row[password] md5($_POST[pw])即NULL NULL。在PHP中NULL NULL的结果是true。条件成立登录成功输出flag。注意事项这种方法成功的关键在于后端使用了松散比较而非严格比较。如果代码是$row[password] md5($pass)那么NULL NULL虽然为真但我们需要确保两边都是NULL且类型一致。然而如果数据库密码字段是VARCHAR且存有哈希值我们注入NULL可能因为类型不同而导致严格比较失败。但在本题描述的松散比较场景下此方法通用且优雅。在实际漏洞利用中需要根据实际情况判断比较运算符。5. 完整攻击复现与Burp Suite实操理论清晰后我们通过Burp Suite来完整演练一遍攻击流程。这里我们采用更通用的“NULL-数组”绕过法。步骤一启动环境与抓包启动靶机环境如BUU CTF平台提供的题目环境和Burp Suite。在浏览器中配置代理指向Burp如127.0.0.1:8080。访问登录页面在用户名和密码框随意输入如test/test点击登录。此时Burp Suite会拦截到POST请求。将其发送到Repeater模块以便反复测试。步骤二确定列数与用户名字段位置在Repeater中操作修改name参数探测列数nameadmin ORDER BY 4--发送请求。观察响应是“wrong pass!”还是“wrong user!”。如果是“wrong pass!”说明admin用户存在且ORDER BY 4语法正确列数4。继续测试ORDER BY 5如果返回错误或行为改变则确定列数为4。提示--是--注释符加空格的URL编码形式在URL中代表空格。有时也需要用%20表示空格即--%20。确定用户名字段位置。发送Payloadname UNION SELECT 1,2,3,4--响应应为“wrong user!”因为用户名不是已知用户。分别测试用户名出现在各列的情况name UNION SELECT admin,2,3,4--- 观察响应name UNION SELECT 1,admin,3,4---如果返回“wrong pass!”则成功说明第二列是用户名字段。以此类推。步骤三构造最终注入Payload确认用户名字段在第二列后我们构造返回NULL密码的Payload。name UNION SELECT 1,admin,NULL,4--注意NULL不需要引号。在Burp Suite的Repeater中直接输入NULL即可。步骤四修改POST请求体传递数组参数原始的POST请求体格式可能是namexxxpwyyy。我们需要将pw参数改为数组形式。 有两种常见方式直接修改原始文本将pw123修改为pw[]123。使用Burp Suite的Params功能在Repeater的Params标签页找到pw参数在其值上右键选择“Change body encoding”或类似选项然后可以看到参数类型。将其从“Text”改为“Array of text”或直接添加[]。修改后的完整POST请求体示例name UNION SELECT 1,admin,NULL,4--pw[]123步骤五发送请求并获取结果发送这个精心构造的请求。如果一切顺利服务器响应中将不再包含“wrong pass!”或“wrong user!”而是直接输出我们梦寐以求的flag格式可能类似flag{xxxx-xxxx-xxxx-xxxx}或GXYCTF{...}。6. 防御方案与安全编程实践通过解剖“babysqli”我们看到了一个漏洞如何被利用。作为开发者如何避免自己的应用成为下一个“babysqli”呢以下是从这道题中提炼出的、必须落实的防御措施。6.1 根本措施使用参数化查询预编译语句这是防止SQL注入最有效、最根本的方法。无论是PHP的PDO、MySQLi还是Java的PreparedStatement、Python的sqlite3或SQLAlchemy都支持参数化查询。原理将SQL语句的结构哪里是命令哪里是条件与数据用户输入的值分开处理。数据库引擎会先编译SQL语句结构然后将用户输入的数据纯粹当作“数据”来处理即使数据中包含SQL元字符如引号、分号也不会改变原语句的结构。PHPPDO示例?php $stmt $pdo-prepare(SELECT * FROM user WHERE username :username); $stmt-execute([username $_POST[name]]); $row $stmt-fetch(); ?PHPMySQLi示例?php $stmt $conn-prepare(SELECT * FROM user WHERE username ?); $stmt-bind_param(s, $_POST[name]); // s表示字符串类型 $stmt-execute(); $result $stmt-get_result(); $row $result-fetch_assoc(); ?只要使用了参数化查询像admin UNION SELECT...这样的输入会被整体视为一个字符串值去匹配username字段而不会拆解执行。这是黄金法则。6.2 辅助措施严格的输入验证与输出编码输入验证在业务逻辑允许的范围内对输入进行严格限制。例如用户名可以限制为特定长度、仅包含字母数字和下划线。if (!preg_match(/^[a-zA-Z0-9_]{3,20}$/, $username)) { die(Invalid username format); }注意输入验证不能替代参数化查询它只是增加一层防护过滤掉明显不合规的输入。最小权限原则连接数据库的账户不应具有root或db owner权限。应该为Web应用创建专属用户并只授予其必要表的最小操作权限如SELECT,INSERT,UPDATE绝对不能授予DROP,CREATE,FILE等危险权限。安全的密码存储与比较永远不要明文存储密码。使用强哈希算法如PHP的password_hash()使用bcrypt它自动处理盐值salt防止彩虹表攻击。// 存储密码 $hash password_hash($plain_password, PASSWORD_DEFAULT); // 验证密码 if (password_verify($input_password, $stored_hash)) { // 登录成功 }如果因历史原因必须使用MD5强烈不建议务必使用“加盐”哈希并且比较时使用严格比较。// 存储$stored_hash md5($salt . $password); // 验证 if ($stored_hash md5($salt . $input_password)) { // 登录成功 }使用可以避免类型转换带来的意外问题比如NULL NULL为真这种情况。错误处理切勿将详细的数据库错误信息直接返回给前端用户。应使用自定义的错误页面并在生产环境中关闭PHP的display_errors设置。日志记录详细的错误信息供管理员排查。6.3 代码审计与安全意识这道题也暴露了逻辑漏洞前端注释泄露SQL语句。这提醒我们在代码上线前需清理调试信息、注释中的敏感内容。进行定期的代码安全审计检查是否存在拼接SQL、使用了不安全的比较运算符、函数使用不当如md5接收不可信输入等问题。对开发团队进行持续的安全培训让安全编码成为肌肉记忆。“babysqli”虽小五脏俱全。它像一枚棱镜折射出Web安全中输入验证、代码逻辑、密码学和安全编程等多个层面的问题。希望这次深入的拆解不仅能让你掌握这道题的解法更能建立起一套发现、利用和防御SQL注入漏洞的完整思维模型。在实际工作中时刻保持对用户输入的不信任并严格遵循安全编码规范是构筑应用安全防线的基石。