
1. 题目初印象BUU SQL COURSE 1 到底在考什么1.1 页面长什么样、从哪下手先说说这道题为什么值得单独写一篇。BUUCTF 上的 SQL COURSE 1 是很多人接触 SQL 注入的第一道靶场题它不像实战站点那样有复杂的登录框、搜索框或者各种参数而是只留了一个非常原始的查询接口。初次打开页面通常只有一个类似查询用户的输入框或者干脆就是一个 GET 参数 id你传什么值页面就原样返回一部分内容。你传?id1页面显示一个正常结果你传?id2显示另一个结果。看起来就是教学演示用的用户信息查询页面。但问题恰恰就藏在这个简单里。正因为没有额外的 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 参数后面加一个单引号?id1。正常情况下如果后端 SQL 语句写的是select xxx from xxx where id1你传入1之后拼接出来的语句就变成了where id1SQL 的字符串用单引号包裹多出来一个单引号会让 MySQL 报语法错误然后页面就会返回一条报错信息或者直接把整个查询逻辑错乱掉。BUU SQL COURSE 1 的报错通常不是那种全屏红色大报错而是页面原样显示 SQL 语句片段比如You have an error in your SQL syntax或者直接把拼接后的 select 语句打印出来。看到这种报错你基本可以断定参数没有经过过滤和预编译直接拼进了 SQL 语句。这个环节有一个很重要的细节一定要认真读报错内容。很多新手看到报错就慌其实报错里往往带着关键信息比如它泄露了后端用的数据库类型是 MySQL 还是别的泄露了当前查询的表名、字段名。如果页面直接把 SQL 语句显示出来那你连表名字段名都省了后面直接照着抄就行。还有一个小经验如果只加一个单引号页面没反应可以试试双引号、反引号、单引号加注释符比如1--。不同数据库对不同符号的处理方式不一样单引号不行不代表没有注入。2.2 逻辑判断and 11 与 and 12光有报错还不够严谨因为有的站点可能对特殊字符做了简单过滤但并没有真正防住注入。下一步要用逻辑判断来确认。测试两个 URL?id1 and 11 ?id1 and 12如果1 and 11这个条件为真页面正常显示 id1 的数据1 and 12这个条件为假页面返回空或者显示无数据。两种情况页面表现不一样就说明我们传入的and 11确实参与进了 SQL 的判断逻辑注入成立。这里我展开解释一下后端拼接出来的语句。原始语句大概是select id, username from users where id $id你传的 id 是1 and 11时实际执行的是select id, username from users where id 1 and 11因为11恒真这个语句等价于where id1正常返回。你传1 and 12时实际执行的是select id, username from users where id 1 and 12因为12恒假这个语句查不到任何记录页面自然就空了。一真一假的差异就是注入点存在的铁证。这一步做完后面的事情都是顺藤摸瓜。2.3 为什么要区分字符型和数字型做逻辑判断的时候你可能会注意到有些参数是数字型有些是字符型它们的注入写法不完全一样。数字型参数比如 idSQL 语句里没有引号包裹直接拼数字进去。这时候你写and 11不需要考虑引号闭合的问题。字符型参数比如用户名adminSQL 语句里是where usernameadmin如果你直接拼and 11就变成了where usernameadmin and 11整个and 11被当成字符串的一部分根本不参与逻辑判断。所以字符型注入必须先引入单引号闭合前面的引号再用注释符把后面的引号注释掉写法通常是admin and 11 --。怎么判断 BUU SQL COURSE 1 里面的 id 是数字型还是字符型直觉上是数字型因为你传1它就显示 id1 的结果传2就显示 id2 的结果。但你最好用单引号测一次?id1报错说明语句里要么没有引号包裹导致多出来一个引号语法错误要么有引号包裹但被我们破坏了。区分方法很简单数字型测试and 11直接生效字符型需要闭合引号才生效。这道题按数字型处理就可以。3. 字段数探测与回显定位3.1 order by 判断字段数量确认注入点之后第二步是搞清楚当前查询到底查了几列。这一步直接决定了后面union select能不能用。方法是用order by试探。order by在 SQL 里表示按某列排序后面可以跟列号比如order by 3表示按第 3 列排序。如果表里根本没有第 3 列MySQL 会报Unknown column 3 in order clause之类的错误。操作方式是从小到大依次试?id1 order by 1 ?id1 order by 2 ?id1 order by 3如果order by 2正常order by 3报错说明当前查询只有 2 列。BUU SQL COURSE 1 这道题就是 2 列你试到 2 的时候页面一切正常试到 3 的时候页面不正常字段数量就确定了。这里有个小技巧不必从 1 开始一个个试。如果页面反应很快可以用二分法先试 5正常就试 1010 报错就试 77 报错再试 3很快能锁定边界。不过这道题字段本身就少从 1 试到 3 也就几秒钟直接顺序试就行。有一个细节值得注意order by后面的数字会不会被过滤取决于题目的过滤逻辑。BUU SQL COURSE 1 是老牌入门题没有任何过滤所以你可以直接写。如果遇到过滤数字的题就需要换其他方法那是后话。3.2 利用联合查询找到回显位知道有 2 列之后就用union select来构造联合查询。union的作用是把两个 SELECT 的查询结果合并到一起要求两个查询的列数一致。我们现在知道原查询是 2 列所以union select也必须是 2 列写成?id1 union select 1,2但直接这么写有个问题union默认会去重而且如果 id1 本身有数据第一行结果往往是原来的数据你构造的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. 核心环节联合查询拿库名、表名、字段名、flag4.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_schemadatabase()拆开解释一下group_concat(table_name)是把多个表名拼成一个字符串用逗号分隔。因为union select只能显示一行结果如果不拼接多张表会返回多行而很多注入页面只显示第一行你就只能看到一张表名太亏了。group_concat就是用来解决这个问题的。from information_schema.tables表示从这个 MySQL 系统表里取数据。where table_schemadatabase()是筛选条件只取当前数据库的表。执行之后页面回显位会显示类似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_schemadatabase() and table_nameflag这条语句的原理跟查表名基本一样只不过查询目标从tables换成了columns条件里多了一个table_nameflag表示只查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_schemadatabase() ?id-1 union select 1,group_concat(column_name) from information_schema.columns where table_schemadatabase() and table_name目标表名 ?id-1 union select 1,字段名 from 目标表名第一次打这道题的时候我建议你把每一步的请求都手动敲一遍不要复制粘贴。手动敲的好处是你会真切地感受到每一步在干什么而不是机械地跑一遍工具。等这条路走得足够顺了再去研究 sqlmap 的效率。5. 踩坑记录与自动化工具的边界5.1 常见问题速查表我在带别人做这道题的时候见过不少典型的卡壳现场。这里整理成一张速查表你遇到相同的问题直接对号入座问题现象可能原因解决办法加单引号后页面无变化参数可能被过滤或后端用了预编译尝试双引号、反引号或改用其他参数and 11和and 12页面没区别注入点可能在字符串参数而非数字参数改用闭合引号的写法比如1 and 11order 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 是一个极其好用的自动化工具但它不会告诉你为什么--techniqueU能跑出来也不会告诉你为什么--tamperspace2comment在某种场景下有效。而且在一道 2 列的简单注入题上sqlmap 的响应时间远不如你手动敲一条union select快。你可以自己对比一下差别。实际在比赛里简单题用手工、复杂题用工具两条腿走路才是常态。如果你坚持要用 sqlmap命令大概是这个样子sqlmap -u http://目标地址/?id1 --batch --dbs sqlmap -u http://目标地址/?id1 -D 库名 --tables sqlmap -u http://目标地址/?id1 -D 库名 -T flag --columns sqlmap -u http://目标地址/?id1 -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 11在数据库眼里它就是一个字符串1 or 11永远不可能变成条件逻辑。这是从根上断掉注入而不是在表面做过滤。对比一下脆弱写法$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 长什么样这个习惯会让你在后面的所有题目里受益无穷。