1. 为什么这道题值得作为SQL注入的入门第一课
说实话,SQL注入这个方向,很多新手一开始都是懵的。看了不少文章,知道有字符型、数字型、报错注入、盲注、堆叠注入这些名词,但一打开靶场就不知道鼠标该往哪点。我见过太多人卡在第一步,不是不懂原理,而是没有一个足够“温柔”的环境让他把整个流程走通。
BUU SQL COURSE 1就是这样一个环境。它是BUUCTF平台上一道非常经典的入门级SQL注入题,题目本身不复杂,但它把联合查询注入这条链路设计得非常完整:从判断注入点、猜解列数、确认回显位置,到查库名、查表名、查字段名、拿flag,每一步都有清晰的反馈。换句话说,这道题不是让你“看会”注入,而是让你“亲手走一遍”注入。
我当时做这道题的时候,印象最深的一点是:它没有任何花里胡哨的过滤和绕过,所有请求都老老实实地交到后端拼进SQL语句里,你输入的每一个单引号、每一段UNION查询,都会得到真实的数据库响应。这种“所见即所得”的特性,对建立直觉特别有帮助。因为注入这个事儿,本质上就是“你猜后台怎么拼SQL,然后想办法让拼接结果符合你的意图”。这道题没有额外干扰,后台拼SQL的方式就是最常见、最典型的写法,你在这个环境里建立起来的分析思路,放到别的题目里照样能用。
另外,这道题涉及的知识点其实覆盖了SQL注入里最核心的一整块拼图:怎么判断注入类型,为什么用ORDER BY试列数,UNION SELECT的字段数为什么要和原查询一致,怎么用系统库 INFORMATION_SCHEMA 查元数据,等等。把这些东西吃透,后面再碰堆叠注入、报错注入、布尔盲注,都会顺很多。
所以这篇东西,我打算不按“题解”的套路来写,而是按“一个新手从零开始,应该怎么思考和操作”的顺序来写。每一个步骤,我都会讲清楚两个问题:这么做的目的是什么,这么做的原理是什么。这样你做完这道题之后,得到的不是一份答案,而是一套可以迁移到其他靶场和题目上的方法论。
2. 前置侦察:目标环境的第一轮试探
做题的第一步不是急着上payload,而是先搞清楚题目给了什么、入口在哪、参数是什么。这个习惯越早养成越好,因为真实场景下,大部分攻击面都是从“多看了一眼”里发现的。
2.1 题目入口与参数定位
打开题目,你会看到一个典型的SQL课程练习页面,大致功能是“输入一个编号,查询对应的课程信息”。页面上有个输入框,输入数字,比如1,会返回一条课程记录;输入2,返回另一条。这个交互非常直白,但恰恰是这种直白背后藏着整个漏洞的核心:用户输入被直接拼进了SQL查询语句。
先别急着动手,用浏览器开发者工具(F12)看一下请求是怎么发的。你会发现数据是通过GET请求提交的,URL里有一个明显的id参数,例如:
http://目标地址/?id=1这一步看着不起眼,但它确认了一件很重要的事:后端大概率是这样拼SQL的:
SELECT 字段列表 FROM 表 WHERE id = '用户输入'也可能是数字型,不带引号:
SELECT 字段列表 FROM 表 WHERE id = 用户输入到底是哪一种,就是接下来要测试的第一个问题。这个判断会直接影响你后面构造payload的方式——带引号的字符型,要想办法闭合引号;不带引号的数字型,直接拼接数字运算或UNION查询就行。
2.2 单引号试探法与报错信息的价值
拿到参数之后,第一个payload永远是最经典的单引号:
?id=1'输入之后,页面大概率会报错。这里要特别提醒一下:报错不是坏事,反而是天大的好事。你可以从报错信息里读出很多后端细节——数据库类型、SQL语句的拼接方式、有没有做过滤。如果页面把SQL错误信息直接回显出来,那这个题目就非常“友好”了。
我当时实操的时候,输入单引号后,页面直接吐出了一段SQL语法错误提示,大意是“在 'xxx' 附近有语法错误”。这个信息至少确认了三件事:
- 后端确实是直接把参数拼进SQL的,存在注入点;
- 数据库是SQL Server系的(不同数据库的报错格式有差异);
- 注入点是字符型,因为语句里出现了单引号闭合的痕迹。
不过要注意,不是所有环境都会回显这么明确的报错。很多生产环境会屏蔽详细报错,只给你一个500或者空页面。但靶场环境通常不会做得那么绝,因为它的目的是教新手“看到”注入过程。所以在这个题里,报错信息就是你的第一份情报。
2.3 判断注入类型的两个关键测试
确认了注入点存在之后,下一步是精确判断“字符型”还是“数字型”。这一步特别重要,因为很多人一开始就把类型判断错了,后面所有payload都不生效,然后在错误的方向上浪费了大量时间。
最简单的判断方法是输入一个让恒等式成立的payload,观察页面表现是否和正常查询一致。
先测数字型。输入:
?id=1 AND 1=1如果页面正常返回id为1的记录,再输入:
?id=1 AND 1=2如果页面返回空,说明这个位置是数字型注入,因为1=2是假条件,直接把整个查询结果过滤掉了。数字型注入的特点就在这里,你输入的东西是直接跟数字做算术和逻辑运算的。
再测字符型。输入:
?id=1' AND '1'='1页面如果正常返回,再输入:
?id=1' AND '1'='2页面如果返回空,说明是字符型注入。这里的原理是:后台拼出来的SQL大概长这样:
SELECT ... WHERE id = '1' AND '1'='1'你看,我们输入的内容把整个WHERE条件“闭合”了,原本的id = '1'变成了id = '1' AND '1'='1',恒为真。当条件改成'1'='2'时,恒为假,查询结果自然为空。
回到BUU SQL COURSE 1这个题目,实测下来,它是一个字符型注入,也就是说后台拼接SQL时用的是单引号包裹参数,大概长这样:
SELECT ... FROM course WHERE id = '用户输入'知道了这一点,后面所有构造payload的思路就清晰了,核心就一句话:想办法把前端的单引号闭合掉,然后把你自己的查询逻辑拼接到原本的SQL语句后面。
3. 联合查询注入的完整链路:从猜列数到拿flag
确认了字符型注入点之后,接下来的操作就是SQL注入里最经典的“联合查询注入”(UNION-based injection)。这一节是整个题目的高潮部分,我会按顺序拆解每一步,并解释每一步背后的原理。
3.1 用ORDER BY试出原查询的字段数
联合查询有一个铁律:UNION SELECT出来的字段数,必须和原查询的字段数一致,否则数据库会报错。所以第一个要做的事情,就是搞清楚原查询到底select了几个字段。
最常用的方法就是ORDER BY。原理很简单:ORDER BY 1表示按第1列排序,ORDER BY 2按第2列排序,如果指定的列数超过了实际列数,数据库就直接报错。所以我们可以通过不断增大ORDER BY后面的数字,测试出边界。
在字符型注入下,payload长这样:
?id=1' ORDER BY 1--+ ?id=1' ORDER BY 2--+ ?id=1' ORDER BY 3--+这里的--+是SQL里的注释符,作用是吞掉原本id = '1'后面的那个单引号。在MySQL里,--后面必须跟一个空格,URL编码后就是--+;当然,靶场环境有时候也接受#,但我习惯用--+,因为兼容性更好。SQL Server对注释符的要求不太一样,但在这个环境里,实测--+是可以用的,Burp Suite和浏览器URL栏里都试过没毛病。
实际操作时,我一般从1开始往上试,也可以直接二分猜。试到ORDER BY 2正常,ORDER BY 3报错,就能确定原查询只有两列。这个“报错拐点”就是答案。
这一步的关键心得是:不要通过“页面是否正常”来判断,而要关注“页面是否报错”。在ORDER BY的测试里,只要没报错,就说明列数还没超,继续加。我第一次做的时候,看到ORDER BY 3还能正常显示,差点以为有3列,后来仔细一看才发现返回的行完全不对,实际上是数据库报错被页面吞掉了,只显示了空白。这里要提醒一下:有些环境和题目设计不同,会把报错信息隐藏,只给你一个空页面。所以在做判断时,要同时看“页面是否有正常数据”,而不是只看有没有文字。
3.2 定位回显位:UNION SELECT的数值替换技巧
知道了有两列,下一步就是确认哪一列会把查询结果显示到页面上。这个“显示位”也叫回显位,后面查出来的库名、表名、字段名都要往这个位置上放。
先用一个占位符来测试:
?id=1' UNION SELECT 1,2--+这里有个细节值得展开说:为什么前面要写?id=1而不是?id=-1,甚至有时候用?id=9999?原因很简单——我们要让原查询的结果为空,这样UNION后面的数据才能成为唯一的返回结果。如果你让id=1,原查询先返回了一条id为1的记录,UNION的结果会追加在后面,页面上可能显示两条数据,虽然也能看出来,但不够干净。
我当时用的是?id=-1,因为正常情况下id不可能是负数,原查询必然返回空,UNION SELECT的结果就是唯一结果。然后页面上的表现是:原本显示课程名称的位置出现了“1”,原本显示课程编号的位置出现了“2”,也可能是反过来。看到这种情况,立刻就能锁定回显位:第1列、第2列都回显,但哪一个列值直接显示成我们输入的内容,取决于页面的渲染逻辑。如果页面只显示一个字段,那我们就只能把敏感信息放在那个有效回显位上;如果两个都显示,那就舒服了,一次可以查两个数据。
这一步的原理是这样的:UNION SELECT 1,2中的1和2并不是真的数字常量,而是“我们控制的字符串”。页面上回显出“1”,说明这个位置就是第1列在页面上渲染的位置。后面我们把1替换成database()、version()、表名、字段名,它就会把真实数据渲染出来。这就像你先在一个表格里填了占位符,确认了哪一格会被打印出来,然后再把真正的数据填进去。
3.3 通过系统库查询库名、表名与字段名
回显位确认之后,真正的信息收集阶段就开始了。
先查当前数据库名。SQL Server系和MySQL的语法略有不同,但这个题目环境实测支持MySQL风格,那么直接:
?id=-1' UNION SELECT 1,database()--+页面的第二个回显位会直接输出当前库名,正常情况下是一个类似buu_sql_course_1或者ctf这种名字。我当时查出来是一个看起来像课程库的名字,无所谓,先记下来,后面所有表名查询都要用到它。
接下来查这个数据库里有哪些表。在MySQL里,所有数据库的元信息都存在information_schema.tables这个系统表里。查询语句:
?id=-1' UNION SELECT 1,table_name FROM information_schema.tables WHERE table_schema='库名'--+注意,如果库名里有下划线,直接单引号包起来就行。这一步返回的table_name就是当前数据库里的所有表名。题目为了简单,表名通常不会太多,很快就看到一个关键表,比如course、flag之类的。我当时看到的是一个名称非常直白的表,一眼就知道数据在里面。
最后查这个表里有哪些字段:
?id=-1' UNION SELECT 1,column_name FROM information_schema.columns WHERE table_schema='库名' AND table_name='表名'--+字段名也是一样,清晰明了。通常是类似id、name、flag这种名字。到这里,整条链路已经走通了一大半,剩下的就是最后一步:把数据取出来。
3.4 一次性拼接多行数据的高效技巧
这里分享一个能省不少时间的小技巧。按照前面的流程,查表名时,一次UNION SELECT只能返回一行。如果一个库里有好几张表,你就得改一次table_name条件查一次,效率很低。但实际上,我们可以用GROUP_CONCAT把多行数据拼成一行,一次性输出全部表名:
?id=-1' UNION SELECT 1,GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema='库名'--+这样页面的回显位上就会一次性输出用逗号分隔的全部表名。查字段名同理:
?id=-1' UNION SELECT 1,GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_schema='库名' AND table_name='表名'--+这个技巧在真实做题和测试中非常实用。尤其是表多、字段多的场景,省下的时间不是一点半点。我后来做其他靶场时,这个习惯也一直保留着。
3.5 取出flag并验证结果
最后一步,直接查询目标表里的数据:
?id=-1' UNION SELECT 1,flag FROM 表名--+如果表里的字段不叫flag,而是叫其他名字,就换成对应的字段名。如果表有多个字段想一次全查出来,也可以这样:
?id=-1' UNION SELECT 1,GROUP_CONCAT(列名1, 列名2) FROM 表名--+这里要特别注意:如果目标是数字型注入,payload里不需要单引号和注释符,直接?id=-1 UNION SELECT 1,flag FROM 表名就行。但咱们这个题目是字符型,所以单引号和注释符一个都不能少。
我当初做的时候,这里其实卡了一下,原因是字段名搞错了。我当时查字段列表时看到好几个字段,顺手就取了一个看起来很“像”flag的字段,结果返回的是空白。后来用GROUP_CONCAT把所有字段一次性查出来,才恍然大悟,原来真正的标识字段是另一个名字。整个过程虽然简单,但这种“字段名猜错”的坑,在真实题目里太常见了。
4. 踩坑实录:新手最常遇到的三类卡点与排查思路
题解看着顺利,但真正动手做的时候,大多数人都会在某个地方卡住。这一节我把我在这个题目上以及带新人时经常见到的几个坑,展开讲一讲完整链路。不是说直接给你们答案,而是带你们走一遍“发现问题→分析原因→解决”的过程。
4.1 注释符失效:页面尾部多了一个单引号
第一个高频坑是注释符不生效。典型表现是:payload看起来完全正确,但页面一直报错,或者返回的结果不对。
比如说:
?id=1' ORDER BY 2--+这个payload发过去,页面报错,提示near '' ORDER BY 2--'' 之类的信息。注意看报错信息里,ORDER BY 2后面还有一个单引号。这说明什么?说明--+这个注释符根本没把尾部的单引号注释掉。
可能的原因有两个。第一个原因,--注释符后面必须有空格(或者用--+、--%20这种URL编码方式表示空格),如果后端对请求做了某种处理,导致空格消失了,注释就失效了。第二个原因,这个环境后台拼接时用了两层引号或者括号,单靠一个注释符闭合不了。
排查思路是:先换一种注释方式试试。比如:
?id=1' ORDER BY 2##是MySQL里的注释符,不需要尾随空格。如果#生效了,说明问题出在--+的空格处理上。如果#也不生效,那就要考虑是不是有其他字符需要闭合。先观察报错信息中“剩余”的部分,再决定是补一个单引号还是补一个右括号。
我在这个题上实测,推荐的直接方案是:用--+就行,但如果你发现不生效,立刻切#。多试两种,不要在一个注释符上死磕。
4.2 回显位置选错:数据查出来了但页面上看不到
第二个特别典型的坑是:信息查出来了,理论上应该回显在页面上,但页面就是什么都没有,或者输出位置不对。
我见过最多的情况是这样:新手用了:
?id=-1' UNION SELECT 1,2,3--+但页面依然显示了id=-1对应的正常课程记录,或者只显示了“1”,没有“2”。原因不外乎两种。
第一种,原查询和UNION查询的字段数不一致,数据库直接报错,页面显示异常。这种情况回头用ORDER BY重新确认列数即可。
第二种,你选的回显位不在页面渲染的区域内。有些页面,后端的SQL查询了多个列,但前端只渲染了其中一列,另外一列虽然查询出来但根本不会显示。这就是为什么要把database()、flag这些数据尝试放在不同位置的原因。
我的建议是,一开始测试时就养成好习惯:所有位置都放上不同的占位符。
?id=-1' UNION SELECT 1,2,3--+页面显示“1”和“2”,说明第1列和第2列都是回显位,那这两个位置都可以放数据;如果只显示“1”,那后面查数据时就要把数据放在第1个位置。当你拿到一个目标表,发现放在第1列没显示时,立刻换到第2列再试一次。这道题比较简单,一般有一个回显位就够用了,但如果你习惯性地把数据都放在同一个位置,一旦那个位置不是有效回显位,就会卡很久。
4.3 字段名与表名的“想当然”
第三个坑,就是过于相信自己的直觉。很多新手在information_schema里查到表名、字段名之后,会下意识地认为“flag肯定就叫flag”,结果查出来是NULL或者空白。
排查思路很简单:查询时不猜,直接把所有字段都列出,再决定查哪个。
比如:
?id=-1' UNION SELECT 1,GROUP_CONCAT(column_name) FROM information_schema.columns WHERE table_schema='库名' AND table_name='表名'--+这样一次就能看到所有字段名,完全不需要猜。在真实场景里,目标系统为了混淆会故意把字段名起成跟业务相关的名字,比如user_name、md5_pass,甚至是一串随机字符串。养成“先列全,再选查”的习惯,能少踩很多坑。
这道题的flag字段名其实不复杂,但如果你从一开始就直接猜,恰好猜错了,页面返回空,那就会陷入一个很尴尬的循环:你觉得payload没错,但又不知道为什么没数据。其实只需要停下来,把字段列表拉出来看清楚,一秒就解决了。
5. 从靶场到实战:这道题背后的SQL注入原理与防御思维
如果你只是照着上面的步骤把flag拿出来,那这道题只发挥了它一半的价值。另一半价值,在于你借这道题真正理解SQL注入为什么能成功,以及后端应该怎么防。这一节我会把原理和防御串起来讲,因为这决定了你能不能把一道题的经验迁移到其他地方。
5.1 注入的本质:拼接导致的语义改变
SQL注入为什么能成功?归根结底一句话:用户输入被当作SQL代码执行了,而不是当作普通数据来处理。
正常情况下,后端查询课程信息的写法应该是参数化查询:
SELECT * FROM course WHERE id = ?问号是一个占位符,用户输入的值会被数据库驱动当作纯数据传进去,不管输入什么,都不会改变SQL语句的语义。这是最安全的写法。
但这个题目(以及大量真实世界的遗留系统)为了省事,用的是字符串拼接:
SELECT * FROM course WHERE id = '用户输入'问题就出在字符串拼接上。用户输入的内容不再是“纯数据”,而是被塞进了一个SQL语句的语法上下文里。当你输入1' UNION SELECT database(),2--+时,最终的SQL变成了:
SELECT * FROM course WHERE id = '1' UNION SELECT database(),2--'这句话的语义已经彻底变了。前半句查出id为1的课程,后半句把数据库名拼进结果里。数据库并不知道哪些字符是开发者写的,哪些是用户写的,它只知道这是一条合法的SQL语句,然后忠实地执行。
理解这一点很重要,因为它直接解释了前面所有payload的设计逻辑:闭合引号,是为了打破原来的字符串字面量,让自己输入的内容跳出“数据”的边界,成为“语法”的一部分;注释符,是为了吞掉开发者原本写的剩余语句,防止语法错误。所有这些操作,本质上都是在操控SQL语句的语法结构。这道题虽然简单,但它把这个“结构操控”的过程还原得非常清晰。
5.2 常见的过滤方案及其绕过思路
如果你去查资料,会看到很多“防注入”的方案。比如过滤关键字,把UNION、SELECT、database这些词给屏蔽掉;比如对单引号做转义;比如用WAF(Web应用防火墙)来拦截恶意请求。这些方案在某种程度上有效,但都不如参数化查询来得彻底。
为什么?因为过滤方案本质上是在“猜攻击者会输入什么”。只要你在黑名单里漏掉一个写法,攻击者就可能绕过去。比如过滤了SELECT,可以用SeLeCt混淆大小写,有些系统不区分SQL关键字的大小写;比如过滤了空格,可以用/**/注释符替代空格;比如过滤了单引号,可以想办法用十六进制编码。说白了,黑名单的思路是“你设一个路障,我再找一条路”,永远处于被动。
这道题里没有过滤,所以你感受不到绕过那些技巧的复杂程度。但你应该建立起这个概念:SQL注入的攻防对抗,本质上是“拼SQL的方式”与“过滤策略”之间的博弈。只有参数化查询,才能从根本上切断“用户输入变成SQL代码”这条路。
5.3 参数化查询为何是根治方案
参数化查询的原理,是把SQL语句的结构和参数分开传输。后端先把SELECT * FROM course WHERE id = ?这个模板发给数据库,数据库完成语法解析,然后再把用户输入的值作为纯数据绑定到那个问号上。在这个过程里,用户输入永远不可能改变已经解析好的语句结构。
这就好比你去银行填单子,单子的格式是固定的,你只能在“金额”这一栏填数字。无论你在金额栏里写什么,它都只是数字,不可能突然变成“转账给XXX 100元并附加留言”这么复杂的指令。字符串拼接则相反,它相当于把用户输入直接写成一句完整的话,再念给柜员听,那用户说“顺便把我余额查一下”,柜员也只能照办。
所以,在真实的开发项目里,只要你用参数化查询,经典的联合查询注入、报错注入、盲注全都失灵。因为这个题目本身是教学用靶场,才故意保留了最原始的拼接写法。看题的时候,你可以把注意力放在“怎么打”上;但如果你自己是写代码的人,我更希望你把注意力放在“为什么参数化查询能彻底防住”上。
5.4 从这一题延伸出去的学习路线
做完BUU SQL COURSE 1之后,你对联合查询注入应该已经有了一套完整的肌肉记忆。但SQL注入的世界远不止这一种形态,我建议你按下面的顺序继续练:
- 先练报错注入:核心是利用
extractvalue、updatexml这类函数,让数据库把我们要查的内容通过报错信息吐出来。适合页面没有回显位的场景。 - 再练布尔盲注:页面完全没有数据输出,只有“正常”和“异常”两种状态,用
substr、ascii函数逐字符猜数据。 - 然后练时间盲注:连“正常/异常”都看不出来,只能用
sleep函数的延迟来判断条件真假。 - 最后可以碰一下堆叠注入:多条SQL语句用分号隔开,一次执行多个操作,和联合查询的注入思路又有区别。
每一步都可以在BUUCTF平台或者其他训练靶场上找到对应的题目。你会发现,所有花里胡哨的注入技巧,底层都离不开这道题教给你的那两件事:判断拼接方式,构造闭合逻辑。把这两件事吃透,剩下的都是在不同限制条件下变着花样做同一个动作。
我在实际带新人时,通常会让对方把这道题连做三遍:第一遍看题解照着敲,第二遍关掉题解自己走通,第三遍故意制造一些错误(比如注释符写错、列数猜错),看看报错信息长什么样。三遍下来,SQL注入的骨架基本就长在脑子里了。后面再碰复杂题目,你会发现万变不离其宗。