作为一个在Web安全方向摸爬滚打多年的从业者每当有新人跑来问“SQL注入到底怎么学”我给出的答案几乎都是同一个先把Union联合查询注入练明白。SQL注入这个名词听起来玄乎说白了就是程序把用户输入的内容当成了可执行的SQL语句的一部分让数据库在不知不觉中做了超出开发者预期的事情。而Union注入就是利用数据库自带的“结果集合并”能力把别人藏在数据库内部的数据借前台页面这个“窗口”给带出来。这篇文章是写给小白的完整拆解版。我会用手工注入的方式从判断是否存在注入点开始一步步讲到如何确定列数、找显示位、拿库名表名字段名、最后把数据提取出来。每一步的原理、报错原因、操作意图都会讲透而不是丢几个Payload让你背。所有演示都建议在你本地的靶场环境里完成这句话我必须放最前面别拿这套思路去测试任何你没有权限的系统。学习技术归学习技术守住边界才走得更远。1. 先从原理说起SQL注入到底是怎么发生的1.1 SQL语句拼接漏洞的根源很多刚入行的人觉得SQL注入高深莫测其实它的根源可以用一句话概括程序把用户输入的数据原封不动地拼进了一条SQL语句里。正常的业务流程是用户提交一个参数后台用这个参数去数据库里查点东西回来。开发者图省事经常这么写先写好一段SQL模板再把用户传进来的值直接“粘”到SQL后面。问题在于SQL本身是一门完整的语言它支持单引号、注释符、逻辑运算、子查询、多语句执行。当用户输入没有被校验、没有被隔离他传进来的就不仅仅是普通字符串而是变成了SQL语法里的“零件”。数据库执行的时候根本不会区分哪些是开发者原本写好的逻辑、哪些是用户后来塞进去的它只会把整条语句当成命令老老实实执行一遍。于是用户输入里的关键字开始生效注入就此出现。这个现象有点像你把同学作业直接抄进自己的作业模板结果这位同学在末尾写了一句“顺便帮我也交了”你交上去的时候这句话也变成了作业内容。编程语言和数据库之间本来应该有清晰的边界但拼接式的代码让这条边界彻底消失了。1.2 一个最容易理解的最小示例我用最常见的用户查询场景来演示。某个页面的URL里有一个id参数后台代码大概是这样的逻辑$id $_GET[id]; $sql SELECT username, email, age FROM users WHERE id . $id;注意看这里用的是字符串拼接$id这个由用户自由控制的值被直接接在了SQL语句末尾。正常情况下访问页面时传id1最终执行的SQL是SELECT username, email, age FROM users WHERE id 1这条语句很好理解查ID等于1的用户信息然后页面正常显示。但如果攻击者传进去的不是1而是1 OR 11拼接后的SQL就变成了SELECT username, email, age FROM users WHERE id 1 OR 11“id等于1”或者“1等于1”11恒为真所以这个条件对表中的每一行都成立。数据库会把users表里所有用户的用户名、邮箱、年龄全查出来页面上出现一整排不属于当前请求的数据。如果换个更激进的输入比如1; DROP TABLE users;数据库支持堆叠查询时甚至会直接执行后面的删除语句。在这一步你不需要急着背各种Payload你只需要抓住一件事用户输入直接参与SQL语法构造就是注入的温床。后面所有判断方法、绕过技巧全部建立在这个“拼接”事实上。1.3 数据库和编程语言之间的边界问题想彻底理解SQL注入还要明白一个概念数据库里最终执行的都是“完整的SQL语句”。不管是你在命令行里敲的语句还是程序发送过去的语句本质上都是文本。这个文本由谁来组装用参数化查询时由数据库引擎自己处理数据和结构天然分离用字符串拼接时由开发者手工组装一旦混入用户输入边界就破了。我经常跟新人打比方SQL语句就像一张已经填好内容的试卷拼接方式等于把学生自己写的纸条直接贴在答题区阅卷老师分不清哪些是试卷原有的哪些是贴上去的。而参数化查询相当于给选择题留了空学生只能在括号里填选项内容再离谱也只是个选项永远没法变成题目本身。所以当你理解了拼接是根源之后再回头看无数扫描器报出来的“漏洞点”就不会觉得玄了。本质上都是同一类问题在不同写法下的变体。2. 注入前传如何判断一个参数是否存在SQL注入2.1 注入点是怎么被找到的一个网站有成百上千个参数不可能全测一遍。有经验的人会优先关注几类位置URL里的GET参数尤其是和查询、筛选、排序相关的字段比如?page1、?type2、?uid5POST表单里的关键词搜索、登录用户名、分页参数特殊场景下Cookie和请求头里也可能藏着SQL拼接。凡是最终会落到数据库查询语句里的参数都可以成为测试对象。找注入点的通用方法其实很朴素往参数里塞一个正常情况下不会出现的字符比如单引号再看页面反应和正常请求有没有差异。单引号在SQL中有特殊含义它是字符串的边界符一旦和代码里原有的引号发生错位就会打破SQL语法的原本结构触发数据库报错。如果页面依然风平浪静、返回内容和正常请求完全一致那这个参数大概率没有进入SQL查询如果页面报错、数据显示不全、或者返回行为明显改变恭喜你这个位置值得深挖。另一种更优雅的判断方式是传一个表达式。比如请求?id2-1如果页面显示的是ID为1的用户信息说明参数值2-1不是被当成普通文本去匹配而是被数据库当成了算术表达式先计算了一遍。能执行算术意味着你的输入确实进入了SQL逻辑这种判断方式比单纯看报错更准确也更不容易被WAF直接拦截。2.2 布尔判断、报错判断、时间盲注三种典型反应判断注入点是否存在最终会落到三种反应类型上这也是新手必须分清的框架。第一种是布尔判断。传?id1页面正常传?id1 AND 11页面依旧正常传?id1 AND 12页面没有任何正常数据。前后对比只是条件真假不同页面反应就不同这说明语句确实被当成了查询条件去执行注入点坐实了。这是最直观、最适合新手练习的一种。第二种是报错判断。当数据库开启了错误回显代码又没有屏蔽错误信息时传一个?id1就能让页面吐出一大段数据库语法错误。报错信息往往还会暴露表名前缀、数据库版本位信息所以很多老手会在测试初期故意制造语法错误用报错内容来快速收集信息。但对于超出自身权限的测试目标这种暴露也是生产系统管理不善的信号。第三种是时间盲注。页面既没有布尔差异也不报错但你可以用SLEEP(2)让数据库停顿两秒然后通过页面的响应时间判断条件是否成立。比如传?id1 AND SLEEP(2)页面明显卡顿说明这段代码被执行了。盲注环境最熬人因为它只能通过“是与否”一点一点猜数据效率很低。Union注入主要依赖前两种有回显或可判断的情况如果环境完全回显不了那就得用另一套盲注思路这个后面会说到。2.3 手工vs工具为什么新手必须先练手工市面上的SQL注入工具很多有些一键就能把库名表名跑出来输入URL直接出结果看起来效率惊人。但我强烈建议新手至少把手工注入的完整流程走三遍再去碰工具。原因有两个第一工具是黑盒它发出大量测试请求然后根据响应自动判断、自动提取中间发生了什么你看不到。跑出来了你很开心但没跑出来的时候你连排错都不知道从哪下手。第二工具面对过滤和变形场景经常失灵而掌握手工思路的人哪怕碰到参数被过滤了也能看出“大概是在哪一步被拦的”知道怎么构造下一轮请求。某种程度上手工能力是你的退路工具只是加速器。练手工也不需要什么复杂环境本地装一套PHPMySQL环境再部署一个开源的靶场应用几分钟就能开工。把流程想明白之后再用工具你会发现自己终于能看懂工具的输出日志了——这才是最好的状态。3. Union联合查询注入的核心逻辑与前置条件3.1 Union是什么把两次查询结果拼起来UNION是SQL里一个非常基础的关键字作用是把两个或多个SELECT语句的查询结果合并到一个结果集里。举个例子下面这条查询SELECT username FROM users WHERE id 1 UNION SELECT username FROM users WHERE id 2它会返回两行数据第一行是ID为1的用户名第二行是ID为2的用户名。注意UNION默认会对结果去重如果你希望保留重复的行可以用UNION ALL它不会过滤重复内容。对注入来说用UNION ALL往往更稳定因为你不知道原查询结果和注入出来的结果有没有重复项万一撞上了去重机制可能让你的数据少显示一行。Union注入的核心思路就是把原查询当作第一个SELECT把自己构造的查询当作第二个SELECT通过UNION把两部分结果合并。只要后台代码直接把整个查询结果渲染到页面上你注入的第二个查询里的数据就会跟着原数据一起展示出来。这就是“借页面当显示器”的原理。3.2 Union注入必须满足的三个前置条件Union注入听着美好但它不是所有场景都能用它有三个必须满足的前置条件。第一个条件是前台查询结果必须能显示在页面上。如果页面只显示“查询成功”或者“没有记录”根本不渲染查询返回的字段内容那你注入进去的数据也没有出口。这也是为什么Union注入“可回显”场景紧密绑定。第二个条件是必须能准确判断出原查询的列数。UNION要求两个SELECT结果集的列数完全一致左边查了4列右边只给3列数据库会直接报错。列数是Union注入的基石判断列数的方法下一节会详细讲。第三个条件是注入点的数据类型要能兼容。原查询的每一列都有类型比如整数、字符串、时间等。你构造的查询在对应位置上的值类型最好能自动转换或匹配。通过把占位符替换成NULL可以规避大部分类型问题因为NULL可以兼容任意类型。这三个条件缺一不可。很多新手拿着Union注入的Payload去测一个完全无回显的站点怎么测都是“没反应”原因就是前置条件不满足而不是Payload写得不对。3.3 为什么选Union它和其他注入方式的核心区别我之所以建议新手从Union注入入手是因为它在所有注入类型中“产出比”最高。它的核心优势是只要回显条件成立一条语句就能直接从数据库里拖出大量数据效率远高于盲注。盲注一次只能判断一个字符比如“数据库名的第一个字符是不是a”布尔盲注可能要发几百次请求才能把一整个库名拼出来Union注入呢一条UNION SELECT语句直接列出几十张表名体验是降维打击。但它也不是万能的。在关键字被过滤、无回显、或者页面模板固定输出的场景下Union就废了。所以它更准确的定位是“入门第一课”和“有回显场景下的首选方案”。理解Union你先建立了对注入广义工作方式的体感后续学报错注入、布尔盲注时会发现它们是同一棵树的枝条。4. 实操演示一步一步搞定Union注入4.1 准备本地靶场环境在正式开始之前先把丑话说完这部分所有操作都建议你在自己电脑的本地靶场里完成。本地靶场可以模拟真实站点的大部分逻辑但不会伤害任何真实服务也不会踩到法律红线。不要拿这套流程去探测未授权的目标系统这既是职业操守也是保护自己。部署方法很简单装一套PHPMySQL环境部署一个带用户表、能在页面显示用户信息的靶场应用。页面至少要有用户ID参数、用户名、邮箱这类的列表展示因为我们的回显需要依赖这些字段。部署完成后在浏览器正常访问一下确认页面显示正常启动靶场就算成功。4.2 第一步确认注入点打开用户列表页正常请求路径类似/page.php?id1页面显示ID为1的用户信息。现在在1后面加一个单引号/page.php?id1如果页面出现数据库语法错误、或者原来显示的内容消失了说明单引号已经破坏了原先的SQL语法结构这个参数大概率参与了SQL拼接。如果页面完全没有变化说明这个参数可能没进SQL、或者输入被过滤处理了就需要换其他参数继续测试。再做一个辅助验证请求/page.php?id2-1如果页面显示的是ID为1的用户信息说明数据库把2-1当成算式先算成了1证明输入值确实经过了SQL解析。这个信号非常硬比单纯看报错更可靠。走到这一步注入点基本确认接下来开始正式的Union注入流程。4.3 第二步用ORDER BY确定列数Union注入第一个硬性要求是确定原查询的列数最经典的手法是ORDER BY配合数字递增尝试。它的原理是ORDER BY 1表示按第1列排序ORDER BY 2表示按第2列排序依此类推。当你ORDER BY的列号大于SELECT结果集的实际列数时数据库会直接报错页面表现异常。实际操作中从1开始逐步加大/page.php?id1 ORDER BY 1 /page.php?id1 ORDER BY 2 /page.php?id1 ORDER BY 3 /page.php?id1 ORDER BY 4如果前3个请求页面都正常到ORDER BY 4时页面开始报错或数据消失那就说明原查询一共只有3列。ORDER BY 4意味着要求数据库按第4列排序但查询结果里根本没有第4列数据库自然无法执行。这里有一个细节新手容易忽略ORDER BY 1和ORDER BY 2可能都会正常显示数据但是显示出来的顺序可能不同你不需要过度关注排序本身只需要关注“什么时候开始报错”这个临界点。临界点之前最大的正常值就是原查询的列数。4.4 第三步找显示位知道原查询有3列之后下一步是搞清楚页面到底把哪几列的结果渲染出来了。做法是构造一个UNION SELECT在每一列的位置填一个数字占位符/page.php?id1 UNION SELECT 1,2,3如果页面某个位置出现了数字2说明原查询的第二个字段被页面输出到了这个位置这个位置就是你的可用“显示位”。如果页面某列没显示只显示出了2和3说明第1列虽然存在但没有被前端渲染后面注入时你的数据就只能放到第2列或第3列的位置。这里要说一个高频踩坑点原查询id1本身会返回一条真实数据UNION的结果会合并到后面真实数据会占据第一行挤占观察空间。所以实战中几乎都会把id改成一个不存在的负数比如/page.php?id-1 UNION SELECT 1,2,3原查询查不到任何数据页面上就只会剩下你注入出来的那一行1、2、3干净清爽一眼就能定位显示位。很多新手不理解为什么别人的Payload总是写成id-1原因就在这里。4.5 第四步爆破当前库、表、字段、数据显示位确定之后就开始往里面填真正的查询逻辑。先把数字占位符替换成数据库函数目标是获取当前数据库名和版本。把第2列、第3列换成DATABASE()和VERSION()/page.php?id-1 UNION SELECT 1,DATABASE(),VERSION()页面第2列会显示当前使用的数据库名第3列会显示数据库版本号。这一步的意义不只是收集信息更重要的是为下一步查表名提供条件我们需要知道当前库名才能确定从哪个库里取表。接下来查表名。MySQL中有一个名为information_schema的系统数据库你可以把它理解为数据库的“户口本”里面保存了所有数据库、数据表、字段的元数据信息。从它的TABLES表里可以查当前库下所有表名/page.php?id-1 UNION SELECT 1,TABLE_NAME,3 FROM information_schema.tables WHERE table_schemaDATABASE()如果第2列显示出一串表名比如users、orders、logs之类说明查询成功了。找到感兴趣的目标表通常叫users因为用户表里往往有账号密码继续查这张表的字段名/page.php?id-1 UNION SELECT 1,COLUMN_NAME,3 FROM information_schema.columns WHERE table_schemaDATABASE() AND table_nameusers页面会依次列出users表的所有字段名比如id、username、password、email等。最后一步就很水到渠成了把字段名填充到UNION SELECT里直接读取目标数据/page.php?id-1 UNION SELECT 1,username,password FROM users这条语句执行完页面会把users表里的用户名和密码字段原样展示出来整条提取链路就此打通。不同数据库系统查元数据的方式略有差异MySQL用的是information_schema其他数据库也有各自的系统视图但思路完全一致。新手在本地把这一条链路跑通之后遇到其他数据库类型只要照着对应文档调整表名、字段名就行。4.6 完整流程速查表我把整条Union注入链路整理成一张速查表方便你练完一遍之后对照复盘步骤目的典型请求预期结果1. 判断注入点确认参数是否拼接进SQL/page.php?id1页面报错或数据异常2. 判断列数确定原查询字段总数/page.php?id1 ORDER BY 33正常4报错得3列3. 找显示位确定页面渲染哪些字段/page.php?id-1 UNION SELECT 1,2,3页面显示出数字占位符位置4. 查库名版本获取当前库信息/page.php?id-1 UNION SELECT 1,DATABASE(),VERSION()页面显示库名和版本5. 查表名获取目标表列表/page.php?id-1 UNION SELECT 1,TABLE_NAME,3 FROM information_schema.tables页面显示所有表名6. 查字段名获取目标表字段/page.php?id-1 UNION SELECT 1,COLUMN_NAME,3 FROM information_schema.columns页面显示所有字段名7. 提取数据读取目标数据/page.php?id-1 UNION SELECT 1,username,password FROM users页面显示账号密码明文这张表建议保存下来练的时候一步步对照练完三遍之后尝试不看表格独立完成整个流程。5. 常见问题与排查技巧实录5.1 为什么ORDER BY能正常但UNION一直报错这是新手最高频的翻车场景。明明ORDER BY把列数试出来了是3结果构造UNION SELECT 1,2,3时页面却报错一时间不知道问题出在哪。按我的排查经验先检查两件事一是列数有没有数错ORDER BY 3正常、ORDER BY 4报错说明列数确实是3这个前提往往没错二是类型兼容问题原查询某一列可能是非文本类型或者页面模板对该列做了特殊处理导致数字占位符直接回显失败。一个立竿见影的排查技巧是把所有占位符都换成NULL试试/page.php?id-1 UNION SELECT NULL,NULL,NULL如果页面恢复正常说明列数是对的问题出在类型兼容上。NULL可以兼容任意类型它能帮你迅速把“列数问题”和“类型问题”区分开。确认能通之后再逐个把NULL替换成数字或函数定位具体是哪一列出了岔子。5.2 页面完全不回显数据怎么办很多人在本地靶场里顺风顺水一到另一个环境就发现连基础Payload发过去页面都“纹丝不动”于是怀疑自己操作失误。实际情况很可能是这个环境根本没有回显点也就是前端模板不渲染查询结果只反馈操作成功或失败。Union注入最大的依赖就是回显没有了它你再怎么构造UNION SELECT都白搭。这时候要立即转换思路到盲注赛道上判断。请求?id1 AND 11和?id1 AND 12对比页面内容有没有差异如果前者正常、后者异常说明页面虽然不显示具体数据但布尔条件会影响查询结果这就是布尔盲注的入口。如果连布尔差异都没有再用SLEEP(2)试验时间盲注。碰到完全无回显的环境手写盲注脚本去逐字符提取数据效率低但路子是通的。5.3 字符串参数加引号报错数字参数为什么不一样看懂了上面的流程你可能会觉得注入很简单直接在URL里拼数字就行。但实际开发中很多参数是字符串类型的后台代码写法不一样比如SELECT * FROM products WHERE name 用户传入的值这种情况下直接传abc进页面SQL会变成WHERE name abc缺少引号数据库直接报语法错误。所以字符串类型的注入比数字型多了一步“闭合引号”的操作。你需要先把自己提交的值后面的引号补上让整个SQL语句保持合法。经典的闭合方式像这样abc AND 11拼接进SQL后变成SELECT * FROM products WHERE name abc AND 11abc把原本的前引号闭合AND 11又构造了一个恒真条件SQL语法合法查询正常执行。如果后面还有其他SQL片段干扰还可以用注释符--把多余部分注释掉。核心思路只有一个你提交的内容拼进原语句之后必须是一条语法完整的SQL让数据库愿意执行下去。5.4 遇到简单过滤怎么办练到一定阶段你可能会遇到有些系统对单引号、空格、UNION、SELECT做了粗粒度过滤。网上的文章里常看到“大小写绕过”“注释符穿插”“双写绕过”这些词比如把SELECT写成SeLeCt或者写成SELSELECTECT利用WAF把中间的关键字删掉之后剩下的还能拼回完整结构。这些思路确实有意思也是很多人的兴趣点。但我不建议新手在这里花太多时间。原因是现代防御早已不靠关键词黑名单来防注入这类过滤通常出现在老旧系统或自己写的简易防护逻辑里现实中更多遇到的是参数化查询彻底堵死了注入可能。你先专注把最基础的注入链路想透再接触绕过时会发现它们只是基础逻辑的变体。而且绕过思路永远在更新依赖固定绕过技巧走不远。6. 知其所以然站在防御侧看这次注入6.1 参数化查询才是治本手段如果你能亲手把一次Union注入完整打下来你再看防御代码会瞬间理解为什么安全界都在推崇参数化查询。前面注入能成立根源就是SQL语句被字符串拼接了。参数化查询的做法是用占位符先把SQL结构写好比如用?代替输入值然后把用户输入作为参数单独传给数据库。数据库引擎先编译SQL结构再把参数值当作纯数据填入参数里的OR 11、单引号、注释符都只是数据内容根本不会变成SQL语法。从机制上隔绝了“结构”和“数据”混合这才是正解。很多开发者习惯用过滤函数清洗输入比如把单引号转义掉。这类方法在特定场景下有效但非常容易出纰漏一个编码转换就能让过滤失效一个宽字符漏洞就能把转义吃掉。参数化查询是从架构上规避问题而不是在字符串层面上打补丁。6.2 输入校验和最小权限同样不能省参数化查询之外开发侧还应该做输入校验尤其是对id这类参数直接用白名单校验只允许数字连注入尝试的机会都不给。字符串参数也要通过正则做格式约束比如邮箱必须符合邮箱格式、手机号必须是数字加号这类校验不但安全也是正常的业务逻辑要求。数据库账号也应该遵循最小权限原则。Web应用连数据库的账号只需要能够执行它业务所需的读写权限就行了不该用管理员权限去连。就算注入成功攻击者也只能在Web应用账号的权限范围内操作拿不到管理员数据库里的其他内容。生产环境还要记得关掉详细的错误回显和自定义错误页否则报错信息本身就成了信息泄露的帮凶。这些措施叠加在一起才构成完整的纵深防御而不是指望某一个函数挡住所有攻击。说到底SQL注入并不是什么神秘魔法它考验的是对SQL语法和数据流理解的熟练度。Union注入学完之后你会发现后续学报错注入、时间盲注、堆叠注入全都是同一个底层逻辑在不同条件下的变体。我个人在实际带人的过程中最强烈的感受就是新手卡壳往往不是因为Payload记不住而是对“数据库在执行一条完整语句”这件事没有体感。所以强烈建议你在本地靶场里把完整流程手写三遍第一遍对着速查表做第二遍尝试自己回忆第三遍完全脱稿独立跑通。三遍之后你的知识地基就扎实了再去看那些进阶技巧会发现它们都顺理成章。记住工具和脚本可以帮你跑得更快但替代不了你把原理想透的那一步。