1. 题目初印象:BUU SQL COURSE 1 到底在考什么
1.1 页面长什么样、从哪下手
先说说这道题为什么值得单独写一篇。BUUCTF 上的 SQL COURSE 1 是很多人接触 SQL 注入的第一道靶场题,它不像实战站点那样有复杂的登录框、搜索框或者各种参数,而是只留了一个非常原始的查询接口。初次打开,页面通常只有一个类似"查询用户"的输入框,或者干脆就是一个 GET 参数 id,你传什么值,页面就原样返回一部分内容。你传?id=1,页面显示一个正常结果;你传?id=2,显示另一个结果。看起来就是教学演示用的"用户信息查询"页面。
但问题恰恰就藏在这个"简单"里。正因为没有额外的 WAF、没有对输入做任何过滤、也没有用参数化查询,它把数据库查询语句直接拼进了后端代码里,才让你有机会通过修改 id 的值来改变整个 SQL 语句的语义。这类题目对于新手的价值在于:它把 SQL 注入最原始的形态暴露在你面前,让你踏踏实实走一遍"判断注入点—探测字段数—定位回显位—拿库名—拿表名—拿字段名—拿数据"的完整链路。
这篇博文我会用一手解题记录的方式,把每一步操作背后的原理、每一个 SQL 语句为什么这么写、每一个报错信息说明什么,全部拆开讲透。目标读者是刚搞懂 SQL 基础语法、但没怎么接触过 SQL 注入的 CTF 新手,也包括想系统梳理联合查询注入流程的进阶学习者。
1.2 前置知识清单
做这道题之前,你最好先掌握这么几块东西,不要求精通,但至少要认识:
第一是 SQL 的基础语法,尤其是 SELECT 语句。你得知道union是干什么的,group_concat是干什么的,where条件怎么写,from后面能跟什么表。如果连select 1,2,3都看不懂,那后面每一步都会卡住。
第二是 MySQL 的系统库information_schema。这个库记录了数据库里的所有数据库名、表名、字段名,是联合查询注入里取名字的必经之路。你只要记住information_schema.tables存了所有表的信息,information_schema.columns存了所有字段的信息,就够用。
第三是 HTTP 请求的基本概念。至少知道 GET 参数是怎么拼到 URL 里的,知道浏览器地址栏可以直接改参数,知道%20代表空格。
我不是说把这些全部学完再做题,而是建议你手边开着一个 SQL 语法速查页面,边做边查。做 SQL COURSE 1 最好的方式,就是一遍打一遍理解,卡住了再回头补基础,比死记硬背语法快得多。
2. 注入点探测:三步确认存在注入
2.1 单引号测试与报错分析
打开题目后,第一步不是急着爆数据库,而是确认这个查询接口到底能不能被注入。方法很简单,在 id 参数后面加一个单引号:?id=1'。
正常情况下,如果后端 SQL 语句写的是select xxx from xxx where id=1,你传入1'之后,拼接出来的语句就变成了where id=1',SQL 的字符串用单引号包裹,多出来一个单引号会让 MySQL 报语法错误,然后页面就会返回一条报错信息或者直接把整个查询逻辑错乱掉。BUU SQL COURSE 1 的报错通常不是那种全屏红色大报错,而是页面原样显示 SQL 语句片段,比如You have an error in your SQL syntax或者直接把拼接后的 select 语句打印出来。看到这种报错,你基本可以断定:参数没有经过过滤和预编译,直接拼进了 SQL 语句。
这个环节有一个很重要的细节:一定要认真读报错内容。很多新手看到报错就慌,其实报错里往往带着关键信息,比如它泄露了后端用的数据库类型,是 MySQL 还是别的,泄露了当前查询的表名、字段名。如果页面直接把 SQL 语句显示出来,那你连表名字段名都省了,后面直接照着抄就行。
还有一个小经验:如果只加一个单引号页面没反应,可以试试双引号、反引号、单引号加注释符,比如1'--+。不同数据库对不同符号的处理方式不一样,单引号不行不代表没有注入。
2.2 逻辑判断:and 1=1 与 and 1=2
光有报错还不够严谨,因为有的站点可能对特殊字符做了简单过滤,但并没有真正防住注入。下一步要用逻辑判断来确认。
测试两个 URL:
?id=1 and 1=1 ?id=1 and 1=2如果1 and 1=1这个条件为真,页面正常显示 id=1 的数据;1 and 1=2这个条件为假,页面返回空或者显示"无数据"。两种情况页面表现不一样,就说明我们传入的and 1=1确实参与进了 SQL 的判断逻辑,注入成立。
这里我展开解释一下后端拼接出来的语句。
原始语句大概是:
select id, username from users where id = $id你传的 id 是1 and 1=1时,实际执行的是:
select id, username from users where id = 1 and 1=1因为1=1恒真,这个语句等价于where id=1,正常返回。
你传1 and 1=2时,实际执行的是:
select id, username from users where id = 1 and 1=2因为1=2恒假,这个语句查不到任何记录,页面自然就空了。
一真一假的差异,就是注入点存在的铁证。这一步做完,后面的事情都是顺藤摸瓜。
2.3 为什么要区分字符型和数字型
做逻辑判断的时候,你可能会注意到有些参数是数字型,有些是字符型,它们的注入写法不完全一样。
数字型参数,比如 id,SQL 语句里没有引号包裹,直接拼数字进去。这时候你写and 1=1不需要考虑引号闭合的问题。
字符型参数,比如用户名admin,SQL 语句里是where username='admin',如果你直接拼and 1=1,就变成了where username='admin and 1=1',整个and 1=1被当成字符串的一部分,根本不参与逻辑判断。所以字符型注入必须先引入单引号闭合前面的引号,再用注释符把后面的引号注释掉,写法通常是admin' and 1=1 --+。
怎么判断 BUU SQL COURSE 1 里面的 id 是数字型还是字符型?直觉上是数字型,因为你传1它就显示 id=1 的结果,传2就显示 id=2 的结果。但你最好用单引号测一次:?id=1'报错,说明语句里要么没有引号包裹导致多出来一个引号语法错误,要么有引号包裹但被我们破坏了。区分方法很简单:数字型测试and 1=1直接生效,字符型需要闭合引号才生效。这道题按数字型处理就可以。
3. 字段数探测与回显定位
3.1 order by 判断字段数量
确认注入点之后,第二步是搞清楚当前查询到底查了几列。这一步直接决定了后面union select能不能用。
方法是用order by试探。order by在 SQL 里表示按某列排序,后面可以跟列号,比如order by 3表示按第 3 列排序。如果表里根本没有第 3 列,MySQL 会报Unknown column '3' in 'order clause'之类的错误。
操作方式是从小到大依次试:
?id=1 order by 1 ?id=1 order by 2 ?id=1 order by 3如果order by 2正常,order by 3报错,说明当前查询只有 2 列。BUU SQL COURSE 1 这道题就是 2 列,你试到 2 的时候页面一切正常,试到 3 的时候页面不正常,字段数量就确定了。
这里有个小技巧:不必从 1 开始一个个试。如果页面反应很快,可以用二分法,先试 5,正常就试 10,10 报错就试 7,7 报错再试 3,很快能锁定边界。不过这道题字段本身就少,从 1 试到 3 也就几秒钟,直接顺序试就行。
有一个细节值得注意:order by后面的数字会不会被过滤,取决于题目的过滤逻辑。BUU SQL COURSE 1 是老牌入门题,没有任何过滤,所以你可以直接写。如果遇到过滤数字的题,就需要换其他方法,那是后话。
3.2 利用联合查询找到回显位
知道有 2 列之后,就用union select来构造联合查询。
union的作用是把两个 SELECT 的查询结果合并到一起,要求两个查询的列数一致。我们现在知道原查询是 2 列,所以union select也必须是 2 列,写成:
?id=1 union select 1,2但直接这么写有个问题:union默认会去重,而且如果 id=1 本身有数据,第一行结果往往是原来的数据,你构造的1,2可能排到第二行,页面不一定显示。更稳的写法是把 id 变成一个不存在的值,比如-1、0或者一个很大的数:
?id=-1 union select 1,2这样原查询查不到任何记录,页面显示的就是我们构造的1,2。
执行之后,页面上应该会出现两个数字,一个是 1,一个是 2。这两个数字所在的位置,就是回显位。所谓回显位,就是页面会把查询结果直接打印到 HTML 上的位置。你之后想查数据库名、表名,就替换掉这个位置的内容。
这道题的页面通常会先在某个位置打一个类似"Hello 1"的内容,再打一个"Hello 2",看到这一幕,你的嘴角应该已经压不住了:整个数据库的信息都随你取了。
3.3 常见翻车点:注释符和空格
新手做联合查询注入,最容易栽在注释符上。
原查询后面可能有其他语句,比如:
select id, username from users where id = $id limit 0,1;你拼上union select 1,2之后,如果不用注释符把后面的东西注释掉,整个语句可能语法错误。所以标准的做法是在末尾加上注释符。
MySQL 里的注释符常用的有三种:
--(两个减号加一个空格,注意必须有空格)#/* */
在 URL 里,#是锚点标识,直接写会变成浏览器锚点,请求发不出去,所以通常 URL 编码成%23。--的写法里那个空格在 URL 里最好写成+或者%20,所以常见写法是:
?id=-1 union select 1,2 --+其中--+表示两个减号加一个空格,因为 URL 里的+会被后端解码成空格。这个写法看起来有点怪,但属于手工注入圈子里约定俗成的标准姿势,用顺了就好。
还有一个坑是空格被过滤的情况,不过 BUU SQL COURSE 1 没有这种过滤,你正常写就行。但你要有这个意识:如果后面遇到过滤空格的题,就得用/或者/**/来构造注释替代空格。
4. 核心环节:联合查询拿库名、表名、字段名、flag
4.1 查数据库名:database()
回显位置确认之后,正式进入数据获取阶段。
第一个要拿的是当前数据库名。把回显位里的 1 或者 2 替换成database()函数,比如:
?id=-1 union select 1,database()页面第二个位置就会显示当前连接的数据库名称。在 BUU SQL COURSE 1 里,你大概率会得到一个类似ctf、flag或者sqlcourse1之类的库名。不确定也没关系,把它记下来,下一步要用。
为什么第一步查数据库名,而不是直接查表名?因为information_schema.tables表里记录了所有数据库的所有表,你如果不知道当前用的哪个库,就得把每个库的表都翻一遍,太费劲。先用database()锁定当前库,后面查表、查字段的条件就清晰了。
这里我多说一句database()函数的意义。它是 MySQL 内置函数,返回当前默认数据库的名字。在注入点里它可以像普通列一样出现在 SELECT 列表中,这恰恰是联合查询注入最方便的地方:函数和常量都能当查询结果回显出来。
4.2 查表名:information_schema.tables
拿到数据库名之后,下一步查这个库下面有哪些表。语句是:
?id=-1 union select 1,group_concat(table_name) from information_schema.tables where table_schema=database()拆开解释一下:
group_concat(table_name)是把多个表名拼成一个字符串,用逗号分隔。因为union select只能显示一行结果,如果不拼接,多张表会返回多行,而很多注入页面只显示第一行,你就只能看到一张表名,太亏了。group_concat就是用来解决这个问题的。from information_schema.tables表示从这个 MySQL 系统表里取数据。where table_schema=database()是筛选条件,只取当前数据库的表。
执行之后,页面回显位会显示类似admin,flag,news,users这样的字符串。看到这样一串表名,你就能知道这个库里到底藏了什么东西。CTF 的 SQL 注入题里,表名一般非常直白,flag表、secret表、users表,一眼就能看出来哪个是目标。
这里有一个值得玩味的点:information_schema是 MySQL 5.0 之后引入的系统信息数据库。在早期的 MySQL 4.0 及更早版本中是没有这个库的,所以老版本注入时只能靠猜表名。现在绝大多数靶场都是 MySQL 5.x 以上,你大可以放心使用。但如果遇到比较奇特的靶场,表名查不出来,就要考虑是不是数据库版本问题。
4.3 查字段名:information_schema.columns
拿到表名之后,下一步查目标表的字段名。假如你查到的表名是flag,那语句就写成:
?id=-1 union select 1,group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='flag'这条语句的原理跟查表名基本一样,只不过查询目标从tables换成了columns,条件里多了一个table_name='flag',表示只查flag这张表的字段。
这里有个实用技巧:表名和字段名里的字符串可以转换成十六进制来绕过引号过滤。比如'flag'可以写成0x666c6167,效果完全一样,但在某些过滤了引号的场景里就能救命。不过 BUU SQL COURSE 1 不涉及这种过滤,新手阶段不用太纠结,知道有这回事就行。
执行之后,页面上会显示类似flag或者id,flag,username这样的字段名。我最喜欢看到的就是字段名直接叫flag,省了一大截功夫。
4.4 最终提权读 flag
字段名拿到后,最后一步就是直接查数据。假设字段名就叫flag,表名是flag,那语句就是:
?id=-1 union select 1,flag from flag执行之后,页面回显位直接输出 flag。这道题到这里就算通关了。
整个过程可以浓缩成这条命令链:
?id=-1 union select 1,2 ?id=-1 union select 1,database() ?id=-1 union select 1,group_concat(table_name) from information_schema.tables where table_schema=database() ?id=-1 union select 1,group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='目标表名' ?id=-1 union select 1,字段名 from 目标表名第一次打这道题的时候,我建议你把每一步的请求都手动敲一遍,不要复制粘贴。手动敲的好处是你会真切地感受到每一步在干什么,而不是机械地跑一遍工具。等这条路走得足够顺了,再去研究 sqlmap 的效率。
5. 踩坑记录与自动化工具的边界
5.1 常见问题速查表
我在带别人做这道题的时候,见过不少典型的卡壳现场。这里整理成一张速查表,你遇到相同的问题直接对号入座:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 加单引号后页面无变化 | 参数可能被过滤,或后端用了预编译 | 尝试双引号、反引号,或改用其他参数 |
and 1=1和and 1=2页面没区别 | 注入点可能在字符串参数而非数字参数 | 改用闭合引号的写法,比如1' and '1'='1 |
order by 3不报错 | 可能查询列数大于等于 3,或者过滤了 order by | 继续尝试更大的数字,或看报错内容分析 |
union select 1,2页面还是显示原数据 | 原查询有数据,union 结果排在后面 | 把 id 改成-1或0,让原查询无结果 |
--+写法和预期不一致 | 空格编码问题 | 确认 URL 中空格是否被正确解析,或改用#的 URL 编码%23 |
| 查表名返回空 | 当前库没有表,或者table_schema条件写错 | 先查database()确认库名,或去掉条件查所有库的表 |
| 页面回显位置有限,查到的内容显示不全 | group_concat结果太长被截断 | 用limit逐条查看,或使用substr分段截取 |
这里最容易被忽视的是"回显位置有限"这个问题。有些页面只显示一个回显位,或者显示长度有限,你group_concat出来的表名字段名太长,页面显示到一半就断了。这种情况可以用substr或者limit控制输出长度,一段一段地看。
5.2 sqlmap 能不能直接梭?
很多人打完这道题会问:既然有 sqlmap,为什么还要用手工注入?这道题直接sqlmap -u "..." --dbs不就爆出来了?
我的建议是:这道题你必须先手工打一遍,至少要完全理解每一句 SQL 拼进去之后发生了什么,再去用 sqlmap。sqlmap 是一个极其好用的自动化工具,但它不会告诉你为什么--technique=U能跑出来,也不会告诉你为什么--tamper=space2comment在某种场景下有效。
而且在一道 2 列的简单注入题上,sqlmap 的响应时间远不如你手动敲一条union select快。你可以自己对比一下差别。实际在比赛里,简单题用手工、复杂题用工具,两条腿走路才是常态。
如果你坚持要用 sqlmap,命令大概是这个样子:
sqlmap -u "http://目标地址/?id=1" --batch --dbs sqlmap -u "http://目标地址/?id=1" -D 库名 --tables sqlmap -u "http://目标地址/?id=1" -D 库名 -T flag --columns sqlmap -u "http://目标地址/?id=1" -D 库名 -T flag -C flag --dump注意,sqlmap 的原理是自动探测注入类型、自动拼 payload,但它同样受限于回显位置和过滤规则。当它跑不动的时候,你就得切换成手动模式,看它到底卡在哪一步,然后用我们前面讲的手工流程去补。
5.3 手工注入流程总结
到这里你会发现,SQL 注入的联合查询打法其实是一个固定套路。只要参数存在注入,步骤永远是这五步:确认注入点、探测列数、定位回显位、查库名表名字段名、提取数据。这并不是什么高深的东西,更像是一个工艺成熟的流水线。
但"知道流程"和"能独立打下来"完全不是一回事。我见过很多人看教程觉得简单,一上手写语句就忘了from information_schema.tables后面接什么。解决办法只有一个:在本地搭一个简陋的注入环境,或者直接在 BUUCTF 里多刷几道同类型的题。
我自己当年做 SQL COURSE 1 的时候,最爽的不是拿到 flag 那一刻,而是自己突然想明白了一件事:原来我在 URL 里输入的任何东西,都会被当成 SQL 代码的一部分去执行。这个认知一旦建立,后面学什么盲注、报错注入、堆叠注入,都是在一个已有的框架里添砖加瓦,而不是从零开始。
6. 从靶场到现实:SQL 注入怎么防御
6.1 参数化查询
做完了题目,我要专门说一说现实世界的防御手段。SQL 注入之所以存在,根本原因就是:代码把用户的输入当成 SQL 语句的一部分来拼接执行。那防御的核心思路也就很明确了:想办法让用户输入永远只是数据,而不是可执行的代码。
最有效的方案是参数化查询,也叫预编译语句。以 PHP PDO 为例,代码是这样写的:
$stmt = $pdo->prepare("SELECT id, username FROM users WHERE id = ?"); $stmt->execute([$_GET['id']]);这里?是个占位符,用户的输入通过execute绑定过去,数据库先把 SQL 语句编译好,再把真实数据作为纯参数传进去。这样一来,你输入1 or 1=1,在数据库眼里它就是一个字符串"1 or 1=1",永远不可能变成条件逻辑。这是从根上断掉注入,而不是在表面做过滤。
对比一下脆弱写法:
$sql = "SELECT id, username FROM users WHERE id = " . $_GET['id']; $result = mysqli_query($conn, $sql);这两者的差距,就是 BUU SQL COURSE 1 这道题和后端正常工程的差距。你在靶场里遇到的题目,几乎都是后面这种写法。
6.2 输入验证与最小权限
除了参数化查询,还有几道常见的防线。第一是输入验证,限制参数只能是数字、只能匹配特定格式。如果你确定 id 必须是整数,那在代码里强制转成 int 就够了,这种白名单思路虽然不如参数化彻底,但在很多场景下简单有效。
第二是数据库权限最小化。现实业务里,查询用户信息的应用应该用一个只有 SELECT 权限的数据库账号,这样即使被注入成功,攻击者也查不了information_schema之外的东西,更做不到读文件、写文件这类高危操作。很多早期数据泄露事件里,应用使用的数据库账号权限过大,一个简单的注入就能拖走全部库。权限收紧之后,攻击者的操作空间会被压制到很小。
第三是 Web 应用防火墙。这东西相当于一道外挂过滤层,可以拦截明显恶意的请求。但它只能作为补充,不能依赖它保平安。因为过滤规则总有绕过方法,你在靶场里用的那些大小写绕过、编码绕过、注释符绕过,在真实世界里一样能打穿不严谨的 WAF。
6.3 学习路径建议
如果你是在校学生,或者准备入门网络安全方向,这套学习路线可以参考。
第一站就是 BUU SQL COURSE 1 这种基础联合查询注入,把原理吃透。第二站可以接触报错注入和盲注,布尔盲注、时间盲注,理解在页面不直接回显数据的情况下如何通过条件真假的差异来逐字符提取数据。第三站学堆叠注入和文件读写,理解 SQL 语句不仅能查询数据,还能控制数据库行为。第四站再回去系统看一遍 MySQL 手册,把 information_schema、函数、字符串处理这些细节补齐。
如果你只是想快速通关拿 flag,那手工流程抄下来跑一遍就行。但如果你想靠这个吃饭,一定要把每一步的 SQL 语义搞清楚。攻击手法更新很快,但核心的原理从来没变过:数据与代码的边界到底在哪里,怎么防止用户输入变成可执行的代码,这个问题从数据库诞生那天起就存在,今天依然是所有 Web 安全防御的基石。
7. 一点个人体会
BUU SQL COURSE 1 这个题比我后来做的任何复杂靶场都重要。它只教了一件事:参数值是可以被"掰弯"的。你给一个查询接口传一个整数值,它不仅处理了这个值,还顺手执行了你夹带在值里面的其他 SQL 语句。这个概念的冲击力,只有在自己动手把数据库名一点点查出来的时候才能真正体会。
做这个题的过程中,我最深的感受是:SQL 注入没有任何神秘的地方,它的每一步都可以用白纸黑字的 SQL 语句解释清楚。所有看起来花里胡哨的 payload,本质上都是在对拼接后的 SQL 语句做结构上的改造。你理解了拼接结构,就理解了注入本身。反过来,你防御 SQL 注入,做的事情也就是规范拼接结构,让用户输入永远待在数据区,进不了代码区。
最后分享一个小技巧,做这种联合查询注入题时,我习惯先在心里把后端 SQL 语句完整地写出来,然后在每条 payload 下面同时写出拼接后的最终语句。比如写?id=-1 union select 1,database()的时候,心里就要过一遍最终被执行的语句是select id,username from users where id = -1 union select 1,database()。这个过程看起来慢,但非常能锻炼你对注入点的敏感度。熟练之后,你看任何接口都会下意识地想象它的后端 SQL 长什么样,这个习惯会让你在后面的所有题目里受益无穷。