从CTF实战解析SQL注入:Union攻击与MD5绕过防御

📅 2026/6/16 5:40:37
从CTF实战解析SQL注入:Union攻击与MD5绕过防御
1. 项目概述从一道CTF题看SQL注入的攻防博弈最近在复盘一些经典的网络安全挑战题又遇到了“babysqli”这个老朋友。这名字起得挺有意思“Baby”听起来人畜无害好像是个入门级的SQL注入练习但真上手去解会发现里面藏着不少需要仔细琢磨的细节。它本质上是一个模拟的Web登录场景考察的是攻击者如何绕过前端的简单过滤利用后端代码的逻辑缺陷和SQL语句的构造特性最终实现未授权登录并获取目标信息在CTF中就是flag。对于刚接触Web安全的新手来说这道题是一个非常好的“承上启下”的练手点它不像最基础的注入那样直接给错误回显也不像那些需要复杂绕过的变态题目。它要求你静下心来像侦探一样分析源码、推测后台逻辑、并精心构造一个“合法”的Payload。今天我就结合这道题把其中涉及到的SQL注入技巧、代码审计思路和Payload构造的逻辑掰开揉碎了讲清楚希望能帮你建立起一套解决此类问题的通用方法论。2. 核心思路拆解逆向推理与逻辑漏洞利用面对一个登录框我们首先得搞清楚它在“想”什么。常规思路是丢一个万能密码admin or 11#试试水。但在这道题里你会立刻收到“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的关键。我们尝试了几次输入usernameadmin返回wrong pass!。输入usernametest一个不存在的用户返回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用于对结果集按指定的列编号进行排序。如果指定的列编号超过了实际列数数据库就会报错。我们可以利用这个特性来探测。尝试nameadmin order by 1#如果正常说明至少有1列。尝试nameadmin order by 2#如果正常说明至少有2列。尝试nameadmin order by 3#如果正常说明至少有3列。尝试nameadmin order by 4#如果返回错误或页面异常则说明原查询只有3列。在本题目中通过测试可以确定原查询返回3列。3.2 确定字段回显位置知道有3列后我们需要用UNION SELECT来测试哪一列的内容会最终被后台PHP代码使用到即$row[‘username’]对应哪一列。因为UNION需要前后列数一致我们构造nameadmin 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到底是第几列呢我们需要测试。分别尝试nameadmin union select admin,2,3#假设第一列是usernamenameadmin union select 1,admin,3#假设第二列是usernamenameadmin union select 1,2,admin#假设第三列是username当尝试到nameadmin 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。那么我们可以构造Payloadnameadmin union select 1,admin,900150983cd24fb0d6963f7d28e17f72#pwabc这样查询返回的密码字段值就是正确的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构造如下nameadmin 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 11password随意填点击提交。页面返回“do not hack me!”。确认存在基础过滤但同时也确认了username参数可能被代入查询。查看网页源码按F12或右键“查看页面源代码”。仔细搜索!--和--之间的注释内容。找到一段看似乱码的字符串。解码线索将注释中的字符串复制出来。使用CyberChef一个在线编解码神器或本地Python脚本进行解码尝试。先尝试Base64解码如果结果不可读尝试Base32解码。本题是Base32-Base64-明文SQL。得到核心SQLselect * from user where username $name。4.2 第二步分析逻辑与确定列数分析错误信息提交usernameadminpassword123返回wrong pass!。提交usernamerandomuserpassword123返回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测试nameadmin union select 1,2,3#pw123观察响应。如果返回wrong pass!说明UNION成功且$row有值。如果返回wrong user!可能是UNION前后列数不一致或类型不匹配需检查。定位用户名字段在Burp Repeater中依次修改Union查询的第二参数为字符串’admin’。nameadmin union select 1,admin,3#pw123提交后观察响应。此时因为我们将第二列推测的username字段设置为了’admin’后台判断$row[‘username’] ‘admin’成立代码会进入密码校验分支if($row[‘password’] md5($pw))。由于我们第三列是数字3密码校验必然失败所以理应返回wrong pass!。这确认了第二列就是username。4.4 第四步构造最终Payload获取Flag采用“MD5数组绕过”这种更通用的方法构造SQL注入部分我们需要让查询返回一条记录用户名是’admin’密码是NULL。Payload:nameadmin union select 1,admin,NULL#注意NULL在SQL中不需要引号。构造请求参数部分为了触发md5($pw)返回NULL我们需要让$_POST[‘pw’]是一个数组。在Burp Suite中修改请求体有两种方式方式一推荐直接修改原始请求体为nameadmin%27%20union%20select%201%2C%27admin%27%2CNULL%23pw[]1这里%27是单引号’的URL编码%20是空格%2C是逗号%23是井号#。pw[]1表示pw参数是一个数组其第一个元素值为1。方式二使用Burp的Params选项卡在pw参数的值上直接输入1然后右键选择“Change request method”或手动在Raw视图里将pw1改成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题中还会遇到其他过滤和绕过技巧关键字过滤绕过如果系统过滤了selectunion等关键字可以尝试双写selselectect大小写混合SeLeCt内联注释/*!select*/(MySQL特有)编码URL编码、Hex编码、Unicode编码。等价替换用||代替or在某些数据库如SQLite中。空格过滤绕过如果过滤了空格可以用以下字符代替/**/(注释符)(加号在URL中需编码为%2B)%09(Tab)%0a(换行)%0c(换页)%0d(回车)()(括号用于包裹参数)引号过滤绕过如果过滤了单引号’对于字符串的注入可以尝试使用十六进制编码字符串。例如admin的十六进制是0x61646d696e在MySQL中可以这样用select * from users where username0x61646d696e。利用数据库函数进行转换如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想通了这些你就能举一反三面对更复杂的过滤和变形时也能找到破解的思路。安全之路始于好奇成于严谨。每一次成功的注入都应该对应着一次对自身代码安全的审视和加固。