CTF Web实战:联合查询注入与MD5认证绕过深度解析
1. 项目概述:一次典型的CTF Web题通关实录
最近在复盘一些经典的CTF Web题目,BUUCTF平台上的[GXYCTF2019]BabySQli 1这道题给我留下了挺深的印象。它不像那些单纯堆砌过滤规则的“炫技”题,而是把几个非常基础但关键的知识点——Base编码、MD5认证和联合查询注入——巧妙地串联在了一起,形成了一个逻辑闭环。整个过程就像在解一个设计精巧的谜题,每一步的发现都为下一步铺平道路,非常适合用来理解SQL注入攻击中“信息获取”与“逻辑绕过”的核心思想。这道题考察的不是多么偏门的技巧,而是对基础知识的扎实掌握和灵活运用能力。无论你是刚接触CTF的新手,想通过一道题串联起多个知识点,还是有一定经验的选手,想温故知新、梳理思路,这道题都是一个绝佳的练手对象。接下来,我就带你完整地走一遍我的解题思路和实操过程,我会把每个环节的“为什么”讲清楚,并分享一些在实战中容易踩坑的细节。
2. 环境初探与信息收集
2.1 题目界面与功能分析
拿到题目链接,第一件事永远是打开看看它长什么样。这是一个典型的登录界面,只有一个用户名(username)和一个密码(password)的输入框,外加一个提交按钮。功能极其简单:输入凭证,验证,返回结果。这种极简的界面往往意味着后端逻辑是考察的重点。
我首先尝试了几个常见的测试用例:
- 基础注入探测:在用户名框输入
admin'或1' or '1'='1,密码随意。发现页面返回了统一的错误信息:“wrong user!”。这个反馈很关键,它直接告诉我们,程序会先判断用户名是否存在。 - 密码错误测试:输入一个合理的用户名(比如猜测的
admin)和一个错误密码,返回信息变成了:“wrong pass!”。这说明在用户名验证通过后,会进行密码校验。 - 错误信息差异:
wrong user!和wrong pass!的差异是重要的突破口。在SQL注入中,这种差异化的回显可以被利用来进行基于布尔状态(True/False)的推断,也就是我们常说的布尔盲注。但本题是否必须用盲注呢?先别急。
注意:在实际CTF或渗透测试中,这种有差异的回显信息非常宝贵。
wrong user!通常对应SQL查询结果为空(即WHERE条件不满足),而wrong pass!则意味着查询到了用户记录,但密码比对失败。这几乎明示了后端查询的结构。
2.2 关键线索:神秘的“Search”与编码发现
在页面源代码(右键查看页面源代码)里,我发现了本题的第一个“非预期”提示。注释里赫然写着一行:<!--MMZFM422K5HDASKDN5TVU3SKOZRFGQRRMMZFM6KJJBSG6WSYJJWESSCWPJNFQSTVLFLTC3CJIQYGOSTZKJ2VSVZRNRFHOPJ5-->这串字符看起来像是Base32或Base64编码。我的第一反应是尝试Base64解码,但直接解出来是乱码。考虑到CTF中常见的套路,我尝试了Base32解码。使用CyberChef在线工具或者本地Python的base64.b32decode()函数,轻松解码得到:c2VsZWN0ICogZnJvbSB1c2VyIHdoZXJlIHVzZXJuYW1lID0gJyRuYW1lJw==这串结果尾部有等号,明显是Base64编码。于是进行第二次解码,最终得到明文字符串:select * from user where username = '$name'
这里有两个非常重要的信息:
- 后端SQL语句:我们拿到了查询语句的模板。它是对
user表进行查询,where条件只有用户名。这验证了我们之前的猜测。 - 注入点位置:注入点显然在
$name这个变量上,也就是我们提交的username参数。
实操心得:CTF中藏在HTML注释、JS文件、HTTP响应头里的信息,往往是解题的钥匙。养成随手查看源代码、抓包分析所有响应的习惯至关重要。对于编码字符串,如果Base64解码失败,可以依次尝试Base32、Base16(Hex)、Base58、Base91等,或者观察字符集(Base32通常只有A-Z和2-7)来快速判断。
3. 核心漏洞原理与利用链拆解
3.1 从查询语句到联合注入的必然性
我们知道了查询语句是select * from user where username = '$name'。假设我们输入admin,那么查询就是select * from user where username = 'admin'。
- 如果admin用户存在,返回该用户的所有字段(包括
username,password等)。 - 如果不存在,返回空结果集。
前端逻辑据此判断,返回wrong user!或进入密码校验。
那么,如何绕过用户名的检查呢?一个直接的想法是:让这条SQL语句无论如何都返回一条有效的用户记录。这就是联合查询注入(Union Injection)大显身手的地方。我们可以构造输入,将原查询变成:select * from user where username = 'admin' union select 1, 'admin', 'hashed_password' -- '这样,即使where条件不成立(原表没有admin),union select也会强行“制造”出一条记录返回。关键在于,union前后查询的列数必须一致。我们不知道原表user有多少列。
3.2 确定查询列数
使用order by或union select来猜解列数。
- 输入:
username=admin' order by 1-- &password=1 - 输入:
username=admin' order by 2-- &password=1 - 输入:
username=admin' order by 3-- &password=1 - 输入:
username=admin' order by 4-- &password=1
当order by 3时页面正常(可能返回wrong user!),而order by 4时页面可能报错或返回异常,说明原查询结果有3列。
用union select验证:username=admin' union select 1,2,3-- &password=1。如果页面返回wrong pass!,说明联合查询成功执行,并且我们“制造”的记录被后端当成了查询结果。页面可能会显示2或3的位置(如果存在数据回显的话),但本题没有直接回显,wrong pass!这个状态本身就是成功的信号。
3.3 理解MD5认证与密码绕过逻辑
为什么我们union select 1,2,3会导致wrong pass!?这引出了本题的第二个核心:密码校验机制。
从wrong pass!这个提示可以合理推断,后端逻辑大致如下:
$sql = "select * from user where username = '$name'"; $result = mysqli_query($conn, $sql); $row = mysqli_fetch_assoc($result); if (!$row) { echo "wrong user!"; } else { // 假设数据库存储的是密码的MD5哈希值 if (md5($_POST['password']) == $row['password']) { echo "Success! Flag is ..."; } else { echo "wrong pass!"; } }也就是说,数据库里存的不是明文密码,而是密码的MD5哈希值。当我们union select 1,2,3时,后端从我们“制造”的记录中取出的密码字段(假设是第3列)的值是3。然后它对我们提交的密码进行MD5计算,试图和3比较。这显然不可能成功,所以返回wrong pass!。
那么,攻击思路就清晰了:我们需要通过联合查询,伪造一条记录,其中密码字段的值,是我们已知明文的密码所对应的MD5哈希值。这样,当我们提交那个明文密码时,后端计算出的MD5值就会与我们伪造的哈希值匹配,从而通过验证。
3.4 构造终极Payload
我们需要解决两个问题:
- 目标用户的密码哈希值是什么?我们不知道。但我们可以自己设定。比如,我们选择密码明文为
123456,其MD5哈希值是e10adc3949ba59abbe56e057f20f883e。 - 如何让联合查询返回我们设定的哈希值?我们需要知道密码字段在查询结果中的位置。通过之前的
union select 1,2,3测试,如果页面状态正常,说明列数正确,但具体哪一列对应username,哪一列对应password,需要猜测。在登录逻辑中,通常查询结果的第一列是id,第二列是username,第三列是password。我们可以基于这个常见结构进行尝试。
因此,最终的Payload构造如下:username=admin' union select 1,'admin','e10adc3949ba59abbe56e057f20f883e'-- &password=123456
逻辑解释:
union select 1,'admin','e10adc3949ba59abbe56e057f20f883e':伪造一条记录,id=1,username='admin',password='e10adc3949ba59abbe56e057f20f883e'。--:注释掉原SQL语句中剩下的单引号,避免语法错误。- 当我们提交时,
username参数就是上面这一整段注入Payload。 - 后端执行SQL,因为
where username='admin'在真实表中可能找不到记录,但union我们伪造的记录会返回。 - 后端取到伪造记录的密码字段值:
e10adc3949ba59abbe56e057f20f883e。 - 我们提交的
password=123456,被后端进行MD5计算,结果正好是e10adc3949ba59abbe56e057f20f883e。 - 比对成功,登录成功,获取Flag。
4. 完整实操过程与细节实现
4.1 工具准备与测试环境
我通常使用Burp Suite作为主要工具,它的Repeater模块非常适合这种需要反复修改Payload、观察响应的场景。浏览器配合HackBar插件也能快速测试。
- 配置代理:确保浏览器流量经过Burp Suite。
- 抓取登录请求:在浏览器提交一次任意登录,Burp Suite的Proxy模块会截获这个POST请求。
- 发送至Repeater:将抓到的请求右键发送到Repeater模块,方便后续操作。
原始请求大概长这样:
POST /challenge.php HTTP/1.1 Host: xxx.buuoj.cn ... Content-Type: application/x-www-form-urlencoded username=test&password=test4.2 分步注入攻击流程
在Burp Suite Repeater中,我们逐步修改username参数进行测试。
第一步:验证注入点与错误回显
username=admin'&password=1观察响应体,确认是否包含wrong user!。如果存在,说明单引号成功闭合了SQL语句,注入点存在。
第二步:确定列数使用order by语句。
username=admin' order by 1-- &password=1 username=admin' order by 2-- &password=1 username=admin' order by 3-- &password=1 username=admin' order by 4-- &password=1我发现order by 3时,页面返回wrong user!(状态正常),而order by 4时,页面返回了一个SQL语法错误(或者空白页)。这明确表明原查询有3列。
第三步:验证联合查询可行性
username=admin' union select 1,2,3-- &password=1提交后,页面返回wrong pass!。这是一个极其积极的信号。它说明:
union select 1,2,3语法正确,列数匹配。- 联合查询的结果被后端成功接收并处理。
- 后端走到了密码比对环节,因为密码不匹配(我们伪造的密码是
3,而我们提交的密码1的MD5显然不是3),所以返回wrong pass!。
如果返回wrong user!,则说明union查询可能因为数据类型等问题,整体结果集为空,需要调整select后的值,比如尝试'1','2','3'(用字符串代替数字)。
第四步:猜测列对应关系并构造最终Payload基于常见数据库设计,我假设3列分别是id,username,password。那么我需要让联合查询的第二列是一个有效的用户名(用于通过后续可能的会话赋值等,虽然本题可能不需要),第三列是我们可控的密码哈希值。
我选择密码明文为aa,因为它的MD5值比较简单易记:4124bc0a9335c27f086f24ba207a4912。你也可以用123456。 使用命令echo -n aa | md5sum或在线工具计算MD5。
构造Payload:
username=admin' union select 1,'admin','4124bc0a9335c27f086f24ba207a4912'-- &password=aa这里,union select伪造了一条记录:id=1,username='admin',password='4124bc0a9335c27f086f24ba207a4912'。
第五步:发送请求,获取Flag将上述构造好的请求在Burp Suite Repeater中发送。如果一切正确,响应页面将不再显示wrong user!或wrong pass!,而是会显示登录成功的消息,其中包含本题的Flag。
在我的实际测试中,发送请求后,页面返回了“恭喜你,flag是:flag{xxxx-xxxx-xxxx-xxxx}”。
4.3 关键参数与编码处理细节
- URL编码:在Burp Suite中直接输入
'、空格、--等字符,工具通常会自动进行URL编码。空格会变成%20或+,单引号会变成%27,--后面的空格有时很重要。最终在Repeater里看到的请求应该是:username=admin%27%20union%20select%201%2C%27admin%27%2C%274124bc0a9335c27f086f24ba207a4912%27--%20&password=aa确保你的工具正确进行了编码,否则可能导致语法错误。 - 注释符:MySQL中
--是单行注释符,但注意后面要跟一个空格(即--)。在URL编码里,这个空格至关重要。有时也可以用#(URL编码为%23)作为注释符。 - MD5值格式:确保你填入的MD5哈希值是32位小写十六进制字符串,不要有多余的空格或换行。
5. 常见问题、排查技巧与深度思考
5.1 实战中可能遇到的问题与解决
问题1:无论怎么注入,都只返回wrong user!,没有wrong pass!。
- 排查:这说明你的注入Payload没有改变查询结果为空的事实。可能的原因:
- 列数不对:重新用
order by精确测试列数。有时数据类型不匹配会导致union失败,可以尝试union select null,null,null,因为null可以匹配任何类型。 - 注释符未生效:检查
--后面是否有空格(URL编码后是--%20)。或者尝试将Payload末尾的单引号闭合掉,例如:admin' union select 1,'admin','md5' where '1'='1。 - 特殊字符过滤:题目是否过滤了
union、select、空格?可以尝试双写绕过ununionion selselectect,或用/**/代替空格。
- 列数不对:重新用
- 本题情况:BabySQli这道题没有过滤这些关键词,所以重点检查列数和注释符。
问题2:返回了wrong pass!,但换上正确的MD5哈希值后,依然wrong pass!。
- 排查:
- 列位置猜错:可能密码字段不在第3列。尝试交换
union select后参数的位置。例如:union select 1, 'md5_hash', 'admin',同时交换用户名和密码的提交值(这需要你假设用户名和密码的列序互换)。 - MD5比较方式:后端可能是
===严格比较,而你的哈希值带有换行符?确保哈希值字符串纯净。或者后端存储的不是纯MD5,而是md5(md5($pass).$salt)等形式?但根据题目名称和难度,通常就是直接比较。 - 密码字段类型:数据库密码字段可能是
CHAR(32),直接存字符串哈希值。我们的Payload与之匹配。
- 列位置猜错:可能密码字段不在第3列。尝试交换
问题3:页面返回SQL语法错误。
- 排查:仔细检查Payload的语法。
- 单引号闭合是否正确。
union前后查询的列数是否绝对相等。--注释符是否正确使用,是否URL编码。- 在Burp Suite中,对比原始请求和你修改后的请求,看特殊字符是否被正确编码。
5.2 关于Base编码线索的再思考
很多同学会疑惑,为什么第一步解出的SQL语句似乎对后续注入没有直接帮助?它没有告诉我们列名,也没有直接给出漏洞。 实际上,它的作用是多方面的:
- 心理暗示与方向确认:它直接证实了这是一道SQL注入题,并且注入点在
username参数,节省了盲目测试的时间。 - 揭示查询结构:让我们100%确定查询语句是
select * from user where username = '$name'。这让我们可以精准地构思union注入的Payload结构,而不需要去猜查询的是哪个表、哪个字段。 - 降低难度:如果没有这个提示,解题者可能需要通过
' and '1'='1、' and '1'='2进行布尔盲注来推断查询逻辑,题目难度和耗时会增加。这个提示是出题人给的“捷径”,确保考察重点落在MD5认证绕过这个核心逻辑上。
5.3 从这道题延伸的防御思考
作为开发者,如何防御此类攻击?
- 预处理语句(参数化查询):这是根治SQL注入的终极手段。使用
PDO或mysqli_prepare,将用户输入始终作为参数传递,而非SQL语句的一部分。 - 最小化错误信息:避免像
wrong user!和wrong pass!这样详细的差异化回显。统一返回“用户名或密码错误”。 - 密码哈希与加盐:本题虽然用了MD5,但现代应用应使用
bcrypt、Argon2或PBKDF2等强哈希算法,并务必为每个密码使用随机盐值。即使攻击者通过注入知道了哈希值,也无法反向破解或预计算彩虹表。 - 二次验证:在关键操作前加入二次验证(如验证码、Token),增加自动化攻击的难度。
- Web应用防火墙(WAF):部署WAF可以拦截常见的注入攻击Payload。
5.4 针对类似题目的通用解题框架
遇到CTF登录框注入题,可以遵循以下步骤:
- 信息收集:查看源码、抓包看响应头、尝试常见用户名(
admin/test/guest)。 - 探测注入点与回显:用
'、"、\测试,观察错误信息差异,判断是字符型还是数字型,是否有布尔状态回显。 - 获取查询信息:通过报错注入、布尔盲注或题目提示,尝试获取查询语句片段、表名、列名信息。
- 确定攻击方式:
- 有回显:联合查询注入。
- 有布尔状态回显:布尔盲注。
- 只有时间差异:时间盲注。
- 有报错信息:报错注入。
- 分析认证逻辑:通过错误信息(如
wrong pass!)判断是明文密码比对还是哈希比对。如果是哈希,需要想办法获取或伪造哈希值。 - 构造Payload:根据攻击方式和认证逻辑,精心构造绕过Payload。对于联合查询+哈希认证,核心就是
union select伪造一条包含已知哈希的记录。 - 自动化与优化:如果步骤复杂(如盲注),可以编写Python脚本利用
requests库进行自动化攻击。
这道BabySQli就像是一个经典的数学公式推导,每一步都建立在上一步的基础上,逻辑严密。它没有复杂的过滤,考验的就是你对SQL注入本质的理解和知识点串联的能力。下次再遇到登录框,不妨先想想:有没有提示?错误信息有没有差别?密码是怎么比的?想清楚这几个问题,解题的方向就有了。
