
1. 从“一个报错”到“一个漏洞”SQL注入漏洞的形成与理解很多刚接触Web安全的人第一次听到“SQL注入”这四个字第一反应往往是“是不是像黑客电影里那样敲两行命令就能把整个数据库拖走”说实话这种印象不算全错但离真正的原理还差着一大截。如果你也想搞清楚SQL注入到底是什么、为什么这么多年了它依然存在那我建议你从一套非常经典的靶场开始sqli-labs。sqli-labs是一套基于PHP和MySQL的本地靶场专门用来练习SQL注入的各种场景。它分成了几十关每一关都对应一种不同的注入类型或者绕过技巧。今天我只讲第一关但它恰恰是整个系列里最基础、也最能帮你建立“SQL注入直觉”的一关——基于联合查询UNION的回显注入。先回答一个很多人纠结的问题为什么2025年了SQL注入还值得学答案其实很扎心——因为它在真实世界里依然存在。许多老系统、内部管理系统、甚至一些新上线的网站依然存在参数拼接SQL的写法。只要“用户输入”和“SQL语句”之间没有做严格的隔离注入就有可能发生。而理解SQL注入最好的方式不是去背所谓的“万能密码”而是亲手在一个完全合法、本地可控的环境里把一个请求从“正常”变成“异常”再搞清楚它为什么能“越权”拿到不该看的数据。学完这一关你会掌握三件事第一判断一个参数是否存在注入点第二理解联合查询注入的核心前提——回显位置第三掌握“字段数探测”这一最关键的技巧。这三件事构成了后续所有SQL注入手法的地基。2. 环境准备与工具清单本地靶场的搭建思路2.1 用集成环境快速拉起靶场sqli-labs需要PHP和MySQL环境。如果你不想折腾手动配置直接用phpStudy或者小皮面板Windows都可以装好之后把sqli-labs的源码放进网站根目录新建一个数据库并导入sql-lab.sql文件修改一下config.php里的数据库连接信息几乎每一步都有图形界面点选完成。Mac用户则推荐用Docker拉取一个sqli-labs镜像一条命令就能跑起来。我自己在做过实验时用的是Docker方式主要原因是干净、不留垃圾环境删掉容器就能完全恢复系统原状。需要注意的一点是靶场本地URL要访问到login.php这一关。第一关的入口路径通常是http://127.0.0.1/sqli-labs/Less-1/如果你在本地访问不到页面优先检查两个东西一是PHP版本建议使用PHP 5.x或7.x的早期版本太新的PHP比如8.0以上可能会因为mysql扩展被移除而报错二是数据库密码是否填对sqli-labs默认连接root账号密码为空但有些集成环境会默认给root设置密码这需要改config.php里的$dbpass变量。2.2 工具选择浏览器插件与手工请求的取舍很多教程会强调用什么工具去“打靶场”但第一关我更建议你纯手工用浏览器访问URL。为什么因为联合查询注入每一步都需要观察页面的变化——多一个列、少一个列、哪一列回显了这些反馈信息是工具的“自动判断”给不了你的。你只有亲手在URL里改参数、提交、刷新、看页面才能建立起“参数→SQL→回显”这条完整的因果链。当然浏览器开发者工具是必须打开的。按F12切到“网络”标签页每次请求的完整URL、请求方式、响应内容都能直接看到。这样即便你改了URL但页面没变化也能确认是不是浏览器缓存搞的鬼。如果你非要用工具我建议先用浏览器“手工通关”一次再去用工具。把工具当“验证器”而不是“拐杖”学习效果会好很多。2.3 第一关的代码逻辑sqli-labs第一关的PHP源码核心逻辑大致如下$sqlSELECT * FROM users WHERE id$id LIMIT 0,1; $resultmysqli_query($con, $sql); $row mysqli_fetch_array($result); if($row) { echo font size5 color #99FF00; echo Your Login name: .$row[username]; echo br; echo Your Password: .$row[password]; echo Your ID: .$row[id]; echo /font; }注意看$id是从URL参数?id直接拿到的用双引号拼进了SQL语句还加了一对单引号。而mysqli_query执行这个SQL时完全没有做任何过滤和参数化处理。于是用户输入的任何一个字符都会成为SQL语句的一部分。这段代码的信息量极大。它告诉我们三个关键点参数是id而且SQL里用$id意味着正常请求是WHERE id1查询结果会被mysqli_fetch_array取出并直接把username和password显示在页面上整条SQL没有LIMIT语句以外的任何防护只要我们能闭合掉单引号就能改变SQL逻辑。看完源码你就会明白SQL注入的本质是用户输入被当成了SQL代码执行而数据库无法区分哪些是“代码”哪些是“数据”。如果你的程序能做到严格区分这两者注入就不可能发生。可惜的是这条代码把输入直接拼接进SQL代码和数据混在了一起。注意sqli-labs这类靶场的所有操作请严格限制在自己本机的实验环境内。对任何未经授权的系统进行测试都是违法行为。本文只讨论本地靶场的原理和防御思路请勿用于非法用途。3. 第一步探索如何判断参数是否存在注入点3.1 传入一个单引号观察页面异常打开浏览器访问第一关的URL先传一个正常参数看看效果http://127.0.0.1/sqli-labs/Less-1/?id1页面会正常显示一行用户信息包括登录名、密码和ID。这是“健康状态”。接下来我们手动在参数后面加一个单引号http://127.0.0.1/sqli-labs/Less-1/?id1此时页面会直接报错如果你开启了MySQL错误显示会在页面上看到类似这样的一段信息You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 1 LIMIT 0,1 at line 1这个报错暴露了一个极其关键的信息SQL语句的结构。从报错内容我们能反推出实际执行的SQL大概长这样SELECT * FROM users WHERE id1 LIMIT 0,1原本SQL是WHERE id1我们把单引号传进去后变成了id1也就是两个连续的单引号。MySQL解析时它会认为第一个单引号是字符串的开始1后面那个单引号把字符串结束了但紧接着还有一个单引号——这个多余的引号导致SQL语法错误。3.2 为什么报错就能证明有注入点很多人第一次看到这个报错会慌张觉得“是不是我操作错了”。其实恰恰相反这个报错就是第一关给你的“欢迎信号”。它证明了以下事实我们输入的参数被原样拼进了SQL语句单引号没有被过滤、转义或拦截SQL语法错误直接回显到了页面上。这三条叠加在一起就足以判断这个参数存在注入点。如果是一个防御良好的系统你传单引号大概率只会得到一个“参数错误”或“请求非法”的通用页面绝对不会出现“SQL语法错误”这种技术性报错。还有一条经验想分享给新手判断注入点并不是每次都要“报错”才有戏。有些场景里你传单引号后页面上什么都不显示或者页面直接空白这也可能说明注入点被你“打断”了——SQL语句出了问题导致查询无法返回数据。报错、空白、数据显示不全、页面跳转异常这些“非正常状态”都是注入可能存在的重要信号。第一关选择了最直观的报错方式其实是为了降低新手的上手门槛。3.3 用注释符闭合恢复SQL结构既然已经确定存在注入点下一步就是让它变成一个“合法”的SQL。我们需要利用注释符把单引号后面多余的部分“注释”掉让MySQL忽略它们。MySQL支持三种注释方式#井号注释从该位置到行尾都被忽略--双横线注释注意后面必须有空格/*...*/多行注释。在URL请求里#有可能被浏览器解析为锚点所以通常我们会用URL编码后的形式来传输。#的URL编码是%23--的URL编码是--因为空格会被编码为。传入这样的URLhttp://127.0.0.1/sqli-labs/Less-1/?id1--这里1把SQL原本的id1闭合掉紧接着的--把后面原本的 LIMIT 0,1整段注释掉。MySQL看到的SQL就变成了SELECT * FROM users WHERE id1-- LIMIT 0,1--之后的内容全部被忽略这条SQL完全合法而且和原始SQL的执行效果一模一样。当你看到页面恢复显示正常用户信息时说明“闭合注释”这一套组合拳已经打成功了。你可以把参数改成2--页面会显示ID为2的用户进一步确认你能控制WHERE条件。4. 联合查询注入的核心ORDER BY与字段数探测4.1 为什么联合查询需要先知道字段数联合查询注入的原理是让原SQL和你的恶意SQL“合并”成一个结果集。但UNION操作有一个硬性规则前后两个查询的列数必须一致。假如原SQL返回3列而你构造的UNION查询只有2列MySQL会直接报错“The used SELECT statements have a different number of columns”。这意味着在构造UNION注入语句之前我们必须探明原始查询返回了多少列。这就是整个联合查询注入里最基础也最关键的一步——字段数探测。我见过一些新手跳过这一步直接去猜列名、猜表名最后卡在“列数不一致”的报错上浪费了大量时间。正确的做法永远是先探字段数再查数据。4.2 用ORDER BY逐个试探ORDER BY在SQL里是用来排序的但它对数字参数的解释非常特殊。当你写ORDER BY 1时它表示按照第1列排序ORDER BY 2表示按第2列排序以此类推。这里有个很重要的特性ORDER BY后面跟的数字并不要求这个列名已经存在而是要求列号不超过实际列数。如果ORDER BY 5对应的第5列不存在MySQL会报错“Unknown column 5 in order clause”。我们正是利用这个特性从1开始递增逐个试探出临界值。操作步骤如下先传?id1 ORDER BY 1--页面正常再传?id1 ORDER BY 2--页面正常一直递增到ORDER BY 5发现页面报错。也就是说原查询最多只有4列。但为了确认我们再试试ORDER BY 4页面正常而ORDER BY 5报错——那字段数就是4。这里我补一个经验ORDER BY探测出临界值后要再复核一次。因为有些场景里某个列号虽然存在但它的排序恰好没有导致任何可见变化容易漏判。多测几次以“正常→报错”的转折点为准会更稳妥。4.3 用UNION SELECT直接验证知道了字段数之后我们可以直接用UNION SELECT来验证构造一个只有我们自己能识别的“标记值”比如数字1、2、3分别占位http://127.0.0.1/sqli-labs/Less-1/?id1 UNION SELECT 1,2,3--这里要让原查询的WHERE条件不返回任何数据才能让UNION查询的结果显示出来。我习惯把id改成负数比如id-1。因为正常的用户ID不可能为负数所以原查询返回空UNION查询的结果就会占据整个显示区域。http://127.0.0.1/sqli-labs/Less-1/?id-1 UNION SELECT 1,2,3--如果一切正常你会看到页面上显示类似“Your Login name: 2”和“Your Password: 3”的结果——说明第2列和第3列的位置上有数据回显。这个结果非常关键因为它告诉我们哪些列会被页面展示。有的列虽然存在但可能不显示在页面里比如ID列。我们后续获取数据时就需要把数据填到会被回显的列上。5. 数据获取从当前数据库到数据表、字段、记录5.1 获取当前数据库名在MySQL里获取当前数据库名有一个内置函数database()。把它放到回显列的位置上就能看到数据库的名字。http://127.0.0.1/sqli-labs/Less-1/?id-1 UNION SELECT 1,database(),3--页面上的密码位置会显示数据库名本靶场里通常是security。到此为止我们已经知道自己在哪个数据库里了。5.2 获取所有表名拿到了数据库名下一步是获取这个数据库里所有的表。MySQL提供了一个元数据表information_schema.tables。它保存了所有数据库里所有表的信息其中我们关心三列table_schema表所属的数据库名table_name表名。查询语法是这样的SELECT table_name FROM information_schema.tables WHERE table_schemasecurity把它嵌入联合查询放在回显位置上http://127.0.0.1/sqli-labs/Less-1/?id-1 UNION SELECT 1,table_name,3 FROM information_schema.tables WHERE table_schemasecurity--此时页面的Login name位置应该会显示一个表名。但问题来了数据库里往往有多个表一行只显示一个怎么看全部这里有两种处理方式。第一种用GROUP_CONCAT()函数把多条结果拼成一个字符串http://127.0.0.1/sqli-labs/Less-1/?id-1 UNION SELECT 1,GROUP_CONCAT(table_name),3 FROM information_schema.tables WHERE table_schemasecurity--这样会得到类似emails,referers,uagents,users这样的结果一目了然。第二种用LIMIT逐行查看比如LIMIT 0,1看第一行LIMIT 1,1看第二行。这种方式在数据量小的时候更直观而且不会因为字符串过长导致显示截断。我个人的习惯是先用GROUP_CONCAT看整体再用LIMIT看细节。5.3 获取users表的字段名看到users表名时几乎可以断定这就是存储用户信息的核心表。下一步是获取这张表的字段名需要查询information_schema.columns表。SELECT column_name FROM information_schema.columns WHERE table_schemasecurity AND table_nameusers对应URL请求http://127.0.0.1/sqli-labs/Less-1/?id-1 UNION SELECT 1,GROUP_CONCAT(column_name),3 FROM information_schema.columns WHERE table_schemasecurity AND table_nameusers--结果会显示类似id,username,password。这就清楚了users表有三个字段id、username、password。这一步的原理和查表名完全一致都是利用information_schema这个系统数据库。你可以把information_schema理解为“数据库的说明书”它不仅记录了每张表在哪还精确到每张表有哪些列。对于SQL注入来说它就是我们的“数据库地图”。5.4 获取用户名和密码字段名到手之后数据几乎就是“探囊取物”了。构造查询把username和password都放到回显列上http://127.0.0.1/sqli-labs/Less-1/?id-1 UNION SELECT 1,GROUP_CONCAT(username),GROUP_CONCAT(password) FROM users--页面会把你填在位置2和位置3的内容都显示出来Login name位置显示所有用户名Password位置显示所有密码。到这一步第一关的数据获取就算完整结束了。看这张表步骤请求URL参数关键信息探测字段数?id1 ORDER BY 1--到ORDER BY 4--字段总数4确认回显位置?id-1 UNION SELECT 1,2,3--位置2和位置3有回显获取数据库名?id-1 UNION SELECT 1,database(),3--当前库名security获取表名?id-1 UNION SELECT 1,GROUP_CONCAT(table_name),3 FROM information_schema.tables WHERE table_schemasecurity--emails,referers,uagents,users获取字段名?id-1 UNION SELECT 1,GROUP_CONCAT(column_name),3 FROM information_schema.columns WHERE table_schemasecurity AND table_nameusers--id,username,password获取数据?id-1 UNION SELECT 1,GROUP_CONCAT(username),GROUP_CONCAT(password) FROM users--所有用户名和密码6. 常见报错与排查第一关最容易踩的坑6.1 为什么闭合了还是报错这是新手碰到的频率最高的一个问题。我见过很多人在?id1--这一步就卡住了页面依然显示SQL语法错误。排查思路其实很简单第一检查--后面是否有空格。MySQL的--注释必须跟一个空格如果没有空格它就不算注释。在URL里空格经常会被浏览器吃掉或者被编码成所以标准的写法是--而不是--。如果你用的是--且后面什么也没有建议直接换成--试试。第二检查你输入的是不是英文单引号。有些人切换输入法后会在URL参数里带入中文或全角单引号MySQL直接罢工。这个错误非常隐蔽我把这个踩过的坑分享出来是希望你别在这上面浪费时间。第三如果一切正常但仍然报错检查PHP版本兼容性和MySQL错误显示设置。有时候报错并不是SQL语句的问题而是环境本身的兼容性问题。6.2 为什么数据没显示在预期位置有时候你明明构造了UNION SELECT 1,2,3页面却不显示2和3反而变成了“Your ID: 1”之类的信息。这说明你选的回显位置判断错了。第一关有多个显示位置ID、Login name、Password。如果页面模板只显示Login name和Password那么位置2和3是有回显的。但如果你把数据填到了位置1而位置1又没有对应的输出标签那数据就“藏”在页面里看不到。这时候不要急把数据换到位置2或3再试。还有一种情况原查询本身返回了数据UNION的结果排在后面页面只显示第一条数据导致你看不到自己的注入结果。解决办法就是把id改成负数让原查询返回空集UNION的结果自然就显示出来了。6.3 GROUP_CONCAT结果被截断怎么办MySQL的GROUP_CONCAT默认最大长度是1024个字符。如果目标表行数非常多拼接出来的字符串可能被截断看起来像数据“不全”。可以用SUBSTRING函数分段取? id-1 UNION SELECT 1,SUBSTRING(GROUP_CONCAT(username),1,64),3 FROM users--把64改成不同的偏移量就能一段一段看完所有数据。或者直接不拼接用LIMIT逐行查看? id-1 UNION SELECT 1,username,3 FROM users LIMIT 0,1--这样每行只显示一个用户名非常适合表数据较多的情况。6.4 为什么有时候Web应用防火墙毫无反应因为在本地靶场里根本没有WAF。但在真实环境里一旦有WAF拦截你会发现页面返回403、网页源码中出现“拦截”字样或者干脆不响应。这时候可以做三个检查是不是WAF拦截了information_schema关键词是不是拦截了UNION关键词是不是拦截了注释符。7. 从第一关抽象出的通用方法论7.1 联合查询注入的五个标准步骤如果你把第一关练熟了你就会发现sqli-labs第一关的核心其实就是一套可以复用的方法论发现参数测试单引号确认注入点用ORDER BY探测字段数用UNION SELECT 1,2,3,...找到回显位置查询information_schema获取数据库名、表名、字段名回显目标数据。这一步方法论不仅适用第一关也适用于sqli-labs里几乎所有联合查询类注入。后续的关卡无非是在此基础上增加了单引号过滤、双写绕过、大小写绕过、编码绕过等防护机制。只要你能把第一步到第三步做到“肌肉记忆”后续的变形再怎么复杂也只是在这五步上叠加“逃逸”逻辑。7.2 为什么information_schema这么重要整个SQL注入的数据获取环节几乎都围绕information_schema展开。它就像数据库的“档案馆”里面包含了所有数据库、表、字段的元数据。通过它你不需要猜测表名和字段名只需要精确地查询即可。但这里我要提醒一点有些数据库版本或配置会禁止普通用户访问information_schema。所以在实战环境里不能死板地依赖它。如果information_schema被禁用通常可以改用sys.schema_auto_increment_columns或其他视图甚至可以通过报错信息猜测部分结构。当然这些属于进阶话题第一关你先把information_schema用熟练就够了。7.3 第一关的防御视角练完第一关如果你只学会了“怎么打”那这门功课只完成了一半。一个合格的Web安全学习者必须同时从防守的角度重新审视这段代码。第一关的问题在于三件事:没有参数化查询、没有输入过滤、把数据库报错信息直接回显到页面。如果我去修复这个代码会做四件事:第一使用PDO预编译语句把参数和SQL彻底隔离。这是最有效的方案没有之一。参数化之后用户输入就只是“字符串字面量”永远不可能变成SQL代码。这是最理想、最彻底的修复方式。第二对$id做类型判断确保它只能是整数。既然参数是id那就必须是一个数字用intval()强制转换或者正则匹配都把字符串直接拒之门外。第三关闭MySQL错误显示。不让任何人从页面错误里反向推导SQL结构能显著提高攻击者的信息收集成本。第四配置数据库账号的最小权限。Web应用连数据库的账号只给它SELECT权限就够了不给INSERT、DELETE、UPDATE等高权限。即便注入成功攻击者能做的也非常有限。防御的本质并不在于“知道攻击者的所有套路”而在于让攻击者的输入永远无法改变SQL的语义。谁能做到这一点谁就根本不需要担心SQL注入。8. 写在最后一些实在话花了这么多篇幅拆解这一关最后说几点我的个人心得。第一我见过太多人学SQL注入一上来就急着“注出数据”跳过探测字段数跳过找回显位置直接拿网上现成的payload去套。结果就是同一个payload换个环境就失效然后反过来抱怨“这靶场怎么和教程不一样”。SQL注入不像用软件它更像解数学题——你必须先读懂题目结构再用对应的公式。第二sqli-labs第一关的每一步都有明确的“反馈信号”。报错是反馈正常显示是反馈显示的内容变化也是反馈。你要做的就是学会读懂这些反馈而不是背payload。把第一关从头到尾手工走一遍你获得的不只是注入手段更是一套“信息收集→假设验证→调整策略”的思维方式。这套思维方式才是安全工作者最值钱的东西。第三安全方向的练习一定要守住边界。靶场存在的意义就是给你一个完全合法的、可以放开手脚的练习环境。你可以在这台本机的靶场里尝试各种方法但绝不能把同样的手法用在未经授权的系统上。技术能力越强越要清楚操作的边界。合规地练习技术上才走得远。第四第一关是整个sqli-labs系列的“钥匙”。它里面的联合查询、字段探测、information_schema查询会在后续很多关里反复出现。把这一关玩熟玩透后面遇到过滤、绕过、盲注、堆叠注入时你才能游刃有余。我在实际练习中发现每次重刷第一关都会有新的体会——有时候是发现自己对注释符的理解不够细有时候是意识到自己对GROUP_CONCAT的边界情况考虑不周全。所以我建议你也别急着追求“刷完几十关”先把第一关多练几遍。磨刀不误砍柴工这句话放在CTF和漏洞研究这条路上永远不会过时。