做Web安全测试的基本都绕不开SQL注入而在SQL注入所有打法里union联合注入绝对是我最推荐新手优先掌握的一种。原因很简单思路清晰、步骤固定、回显直观。拿到一个有注入点的URL通过联合查询在原查询结果的底下追加你自己的查询结果数据就直接打在页面上了比盲注那种一个字符一个字符猜的效率高太多。这篇文章就把union联合注入从原理到实操完整过一遍包括怎么判断注入点、怎么确定字段数、怎么一步步把数据库里的库名、表名、字段名、数据全抠出来。无论你是刚入门的安全方向学生还是想排查自家站点SQL漏洞的开发同学这套流程都能直接用。一点前置说明本文所有操作请务必在本地搭建的靶场或虚拟测试环境中完成任何未授权的线上系统都不要碰。安全测试的前提是“授权”两个字理解了原理防御才有基础这也正是做技术分享的初衷。1. UNION联合注入的设计思路与使用边界1.1 它本质上是一个“拼接查询”的技巧先从SQL基础说起。UNION是结构化查询语言里一个非常常见的集合操作符作用是把两条SELECT语句的查询结果纵向拼接在一起。比如我想要一张表里有三个字段ID、姓名、年龄我可以写SELECT id, name, age FROM student_a UNION SELECT id, name, age FROM student_b;这里有两个硬性要求前一条查询返回几列后一条也必须返回几列而且对应位置的列数类型最好不要相差太远否则数据库会报错。Web应用在接收用户参数后经常直接把这些参数拼到SQL里执行。union联合注入做的事情其实很简单在用户可控的位置插入一个UNION再接一条自己的SELECT语句让页面把我们想查的数据当作正常查询结果的一部分返回出来。本质上不是用了什么高深技巧只是利用了“参数拼接SQL”这个既有事实把原来的查询语句截断或者闭合掉再接上自己要执行的另一个查询。1.2 什么场景下该用union注入union注入好用但并不是所有注入点都能用它的适用条件其实很明确目标查询的结果必须以某种形式回显在页面上否则联合出来的数据显示不出来。当前注入点所在的SELECT语句必须允许追加额外的UNION子句这对应绝大多数普通的列表、详情页查询。注入位置得是查询语句能拼接的上下文比如where后面、from后面或者直接是值的位置。如果页面根本没有回显位置比如查询结果只用来做判断“查到了还是没查到”那union注入就会失效这时候要么转报错注入要么转布尔盲注得重新换思路。所以在动手前先判断“这个数据会不会回显”非常关键它直接决定了你要不要继续用union这条路。1.3 动手前必须先搭好测试靶场新手练习union注入我强烈建议不要直接拿互联网上的站点练手。自己入行时踩过的坑就是看文章觉得很简单结果找目标找了半天最后还把自己的测试IP搞得很尴尬。合规的做法是自己在本地搭建一个靶场环境不求多复杂一台虚拟机加一个常见的开源靶场项目就够了十几分钟就能准备好环境。你只需要准备一台虚拟机或本地环境装好数据库和Web服务一个开源的漏洞靶场项目这类项目在安全社区里非常多按照readme部署即可浏览器自带的开发者工具加上一个顺手的手工发包工具实战里用浏览器的地址栏手工传参其实也完全可以。靶场的好处是你可以反复造数据、反复折腾字段数猜错、注释符写错、回显位选错都不会有任何后果撞一遍就都记住了。我建议把靶场里的数据改一点改成跟自己业务比较像的用户表、订单表之类的结构练起来更有代入感。2. 注入点发现与类型判断动手的第一步2.1 数字型还是字符型的判断方法拿到一个疑似注入点第一步不是急着union而是先确认它到底是不是注入点以及是什么类型的注入。以最常见的URL参数为例假设我们看到的是http://localhost:8080/user?id1能显示“当前用户ID为1”这类的详细信息那我们就可以从下面几个步骤开始先测数字型特征传id1 and 11页面正常再传id1 and 12页面异常或者不显示内容这种通常说明后台SQL对参数做了数值处理属于数字型注入。再测字符型特征直接传一个单引号id1如果页面报错、显示数据库错误信息或者只返回空白说明这个参数大概率是被拼进了SQL字符串里。此时再传id1 and 11如果页面恢复正常基本就锁定是单引号字符型注入。不少新手会在这个阶段卡住因为不同站点的报错设置不一样有的直接把SQL错误打到页面上有的配置了友好的错误页只返回一个统一提示。所以判断注入点的核心不是看报错而是看“传不同参数时页面的返回内容是不是有规律地变化”。页面内容有差异就说明你传入的内容正在影响SQL的执行逻辑这就是注入点的信号。2.2 闭合方式与注释符的适配字符型注入里有个绕不开的环节闭合原SQL语句的单引号、双引号甚至是括号。你传进去的参数会被插入到类似下面这样的SQL中SELECT * FROM user WHERE id $id如果我传1整个语句变成SELECT * FROM user WHERE id 1多出的那个单引号破坏了SQL语法结构所以报错。而我们要做的是把前面引号闭合掉再用注释符把末尾的引号“废掉”让后面的SQL不受影响。一个标准的字符型注入表达式长这样?id1 order by 3--这里的--是注释符门道最集中的地方。--是SQL标准注释符但MySQL要求它后面必须跟一个空格或者控制字符才能真正生效我们在URL里输入的在解析时会被当作空格所以--实际等于--。还有一些情况下你没法用--比如过滤了空格那可以用MySQL专属的#注释符URL里要编码为%23也就是?id1 order by 3%23如果原查询用了双引号比如where id$id那就要把闭合符换成如果SQL里还有括号就要把括号一起补齐比如?id1) order by 3--对应原SQL为select * from user where id ($id)。这里有一个最朴素的验证方法不断尝试不同的闭合方式直到“加了单引号之后页面不报错”说明你的闭合加注释是成功的。别怕试错本地靶场里多试几次你会很容易形成肌肉记忆。2.3 一个最容易忽略的回显技巧很多新手说我已经成功闭合了也加了union怎么页面就是不显示我查出来的内容这里涉及union注入最关键的一个细节union会把两条SELECT的结果纵向拼接也就是说正常情况下原始查询结果是排在前面的。如果原始查询返回了10条数据而页面只显示第一条数据那我们在union里查出来的信息就被“压”到后面去了自然看不见。解决办法非常朴素把原查询的参数改成一个不存在的值让它查不出任何结果。比如原来参数是id1我们直接传id-1由于库里根本没有ID为-1的记录前半部分查询结果为空union后追加的内容就能顺利补位到第一行页面也会直接显示union查询出来的数据。这个id-1的小动作是整个union注入能否在页面上看到结果的胜负手。后面所有正式注入payload里我写的例子都会默认带-1你就知道它的作用了。3. 从字段数到数据完整注入流程拆解3.1 用order by确定字段数量union语法要求两条查询的字段数一致所以第一步永远是确定原始查询有几个字段。order by在SQL里用来对结果排序它的特点是你指定的排序列数如果超过了原查询的总列数数据库会直接报错。我们就可以利用这个报错边界来反推列数。从1开始测逐步递增?id1 order by 1-- -- 页面正常 ?id1 order by 2-- -- 页面正常 ?id1 order by 3-- -- 页面正常 ?id1 order by 4-- -- 页面报错或异常当order by 4报错时说明原查询最多只有3列字段数就是3。这里有一个细节需要提醒报错不一定显示Unknown column 4这种明文有些系统会显示数据库操作失败的泛化页面但返回内容跟前面的正常状态一定有明显差异。还有一点如果一路order by叠加都没有报错有一种可能是当前页面压根没用到你参数对应的那个查询或者参数被其他逻辑处理了。这种时候就要回到第二步重新判断注入点是否成立。3.2 用union select锁定回显位置确定字段数是3之后就简单了我们直接构造?id-1 union select 1,2,3--页面如果正常返回并且渲染出来的内容里有数字2和3说明第2列和第3列是可以注入内容并显示出来的。接下来所有要偷数据的操作就把查询函数放在第2列或第3列的位置。为什么第1列可能不显示这跟页面模板怎么渲染数据有关。有的页面显示ID字段有的显示用户名和密码总之回显位就是那几个能显示出来的列。我自己习惯先把union select 1,2,...,n里面每一个列的数字设成不同的值一次性就能看全页面回显了哪些位置。回显位置的判断是后续所有步骤的前提几乎每一次实操都会先跑这一步跑完心里就有底了。假如页面连1,2,3都不显示先别急着解其他的回头看看是不是闭合方式没对、注释没生效或者字段数又猜错了。3.3 逐个击破库名、表名、字段名、数据确定了回显位在第2列和第3列之后正式开始取数据。第一步拿当前数据库名和版本信息。把database()和version()放到回显位上?id-1 union select 1,database(),version()--页面显示的对应位置就会是当前的库名和MySQL版本号。这一步非常重要因为后面查表名时我们要限定在“当前库”范围内不然还会把系统自带的库混进来。第二步拿当前库的所有表名。MySQL在information_schema.tables这张系统表里记录了所有库的所有表我们筛选table_schema等于当前库名即可?id-1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase()--如果库名里存在admin_user、message、orders这类表名一眼就知道该先查哪个。group_concat()是用来把多行结果合并成一行显示的函数对注入场景特别友好不然一行一个表名页面上显示不全。第三步拿到目标表的字段名。查information_schema.columns锁定库名和表名?id-1 union select 1,group_concat(column_name),3 from information_schema.columns where table_schemadatabase() and table_nameadmin_user--以admin_user表为例如果返回的字段里有username、password那目标就很明确了。这里习惯加上table_schemadatabase()这个条件原因很简单不同数据库里可能有一模一样的admin_user表不加限定条件会把不相干的库的表结构也查进来。第四步取数据本体?id-1 union select 1,group_concat(username,0x3a,password),3 from admin_user--0x3a是十六进制表示的冒号字符目的是把用户名和密码拼进同一个单元格里查看时一目了然。如果字段太多一行显示不完可以配合substr()函数分段取出。这个技巧在目标表数据量较大的时候会频繁用到。3.4 一次到位多用几个查询函数的组合技巧除了database()、version()MySQL内置了非常多实用函数把它们组合起来能一次性拿到很多有用信息。实战中经常在回显位放这些user()当前数据库连接用户判断是否是管理员权限通常决定了后续能不能写文件、能不能执行更多操作。version_commentMySQL发行版信息。datadir数据库文件存储路径如果有机会配合文件读写路径信息就很有用。current_user()和user()略有差别反映的是认证时的用户信息。比如一条完整的探测语句可以写成?id-1 union select 1,user(),concat(database(),0x7c,version())--0x7c是竖线|把多段信息拼进一列。我习惯于先把环境信息一口气拿全再去查业务表这样后续很多决策都方便。还有一个小技巧是使用limit配合offset。有些场景group_concat的结果太长被截断或者页面只能显示第一条记录这时候可以改用?id-1 union select 1,table_name,3 from information_schema.tables where table_schemadatabase() limit 0,1--limit 0,1表示从第0行开始取1行把0改成1、2就能逐行遍历表名。这个方法虽然慢但胜在稳定遇到特殊环境时比group_concat更可靠。4. 常见问题排查与绕过心得4.1 常见问题速查表我整理了一下自己在练习和带新人过程中最常见的问题基本可以覆盖八成以上的卡壳场景。现象可能原因处理方式加单引号后页面无变化参数不是SQL拼接点或有过滤拦截换其他参数、用存在性验证order by 1就报错闭合方式不对或注释符没生效尝试不同闭合和注释符组合union select 1,2,3无回显字段数不对或原查询结果把union结果压住了核对字段数把id改为负数查出数据但页面不全回显位选错或group_concat结果被截断换回显位置用limit逐行输出表名内带关键字被过滤输入过滤把常见关键字过滤掉了使用内联注释、大小写变形或等价函数替代4.2 实战中容易被忽略的几个坑第一个坑是注释符后面忘了空格。在URL里手写--时如果--后面直接跟字母MySQL会把后面的内容当作注释内容的一部分整个语法还是错的。很多人改了半天闭合方式其实只是空格问题。URL场景里用--最省心会被解析成空格如果是用工具发包记得写--最后带一个空格或者干脆用%23。第二个坑是忘记table_schema限定。查字段名时不带where table_schemadatabase()会把系统库里同名的表结构查出来结果一团乱麻。信息收集阶段宁可多写几个条件也不要偷懒。第三个坑是盲目相信页面回显文本。有些页面模板只输出数据的一部分比如截断前20个字符此时你看到的字段内容可能是残缺的并不代表数据库里真的没有完整数据。遇到这种情况用substr()和limit把数据切碎了慢慢看是最稳妥的。第四个坑是过滤场景下的报错信息判断。有的站点把union、select都过滤成空字符串你构造union select就变成了select页面反而正常容易误判。对付关键词过滤最常用的基础技巧是大小写变形、内联注释和等价函数替换。内联注释用法如下?id-1 un/**/ion sel/**/ect 1,database(),3--MySQL会把/**/当作空白处理这样过滤规则就很难简单匹配关键词了。不同站点过滤规则千差万别但思路是一致的找一个能绕过规则又不改变SQL语义的写法。4.3 无回显场景下的备选思路如果费了半天劲确认了注入点但页面确实没有任何回显union注入就走不通了这时候应该果断切换思路。第一种备选方案是报错注入。构造能触发数据库报错的表达式把数据带进报错信息里。比如extractvalue和updatexml函数当参数格式异常时会把数据拼在报错输出里?id1 and extractvalue(1,concat(0x7e,database(),0x7e))--页面上的报错信息就会显示数据库名。这类方案的优点是回显依赖报错不依赖业务数据渲染但限制是对函数版本和数据库类型有要求。第二种备选方案是布尔盲注。没有报错也没有回显时只能看页面状态“正常/异常”这个二值信号通过substr逐字符比对猜数据。优点是什么环境都可能适用缺点是慢一条数据可能要发几十次请求。第三种是时间盲注用sleep()让响应延迟来传递条件结果。这个属于最后的办法一般在中大型目标里配合脚本使用。我个人的建议是先把union注入练熟再掌握报错注入和布尔盲注的基本思路这样遇到任何回显情况都有应对手段。5. 从进攻到防御开发侧该怎么做5.1 参数化查询是根治方案搞安全的不能只会打还得知道怎么堵。union联合注入能成功本质原因只有一个用户输入被当作SQL代码的一部分拼接执行了。所以最有效的防御手段不是写一堆过滤黑名单而是让数据永远不能成为代码。用参数化查询预编译是最稳妥的方式。以常见的数据库操作方式为例把SQL结构固定下来用户输入只作为参数传入数据库在执行时不会把参数内容解释成SQL语法SELECT * FROM user WHERE id ?问号对应的位置只会被当作一个值来处理无论用户传什么都不可能改变查询结构。我在实际开发团队里看到过很多过滤方案禁词列表越加越长效果却越来越差本质上都是治标不治本。把SQL结构的“代码”和“数据”彻底分开才是根治。5.2 叠加几道常见防御手段参数化查询之外还有几道防御可以叠加用最小权限原则应用连数据库的账号只给必要的查询权限不要给一个能查所有库、所有表的超级权限。这样即使注入成功攻击者拿到的数据范围也是有限的。输入校验对参数类型做严格校验比如ID就只允许数字从根上把很多注入路径封死。报错信息收敛生产环境关闭详细报错输出避免把查询结构直接暴露给攻击者。上线前审计给所有SQL查询接口过一遍有没有拼接用户输入尤其是那些动态拼接条件较多的报表、筛选功能。这几层叠加起来注入的难度会提高好几个量级而且就算某层被突破后面的防线也能把损失控制在可控范围。写到这里union联合注入这条线就完整了从原理、判断、实操、排查再到对应的防御一环套一环。我自己的体会是这种注入手法虽然经典但真正掌握它的价值不只是会拼几个SQL而是通过它彻底理解了“用户输入参与SQL拼接就会出问题”这条安全底线。多花时间在本地靶场把每个步骤练到不用想就能写出来后面学其它注入类型都会快很多。最后再强调一句技术本身没有立场但使用技术的方式决定了它的价值请一定把今天学的这些用在正经的授权测试和漏洞修复上。