资讯详情

资讯详情

SQL注入之union注入:原理、实战与防御

SQL注入里的union注入算是我认为最直观、也最容易上手的一种利用方式。刚接触Web安全那会儿我在本地搭了一个带留言板功能的模拟项目参数一报错就挨个试试到union select时数据一下子出来了那种感觉至今记得。简单说union注入就是利用SQL语法中union操作符的“合并查询结果”能力在正常查询后面追加一条自定义查询让数据库把攻击者想要的数据一并返回。它不需要复杂的盲注脚本只要页面有显式回显往往几分钟就能定位。这篇文章适合正在学习Web安全的同学、准备CTF比赛的人以及刚接手安全测试的开发者阅读核心目标是把union注入的底层原理、判断步骤、实战思路和防御方法讲透。1. 认识union注入核心原理与适用场景1.1 从SQL查询的“合并”能力说起union是SQL标准里一个非常实用的操作符作用是把两个或多个select查询的结果合并成一个结果集返回。比如有一个商品表和一个优惠券表两张表结构恰好一样你可以把两个查询结果上下拼接在一起展示。生活里可以这样理解你手上有两组名单一组是上午签到的一组是下午签到的你想知道一共有谁就把两张纸上下贴在一起union干的就是这件事。不过union有一个硬性要求参与合并的每个select语句返回的列数必须一致。第一句查3列第二句也必须返回3列而且每一列的数据类型最好能对得上否则数据库会直接报错。正是因为这个特征union注入才变得可探测、可利用。在正常业务代码里开发者一般不会主动用union去拼接用户输入。一旦出现union注入往往意味着整个SQL语句是“字符串拼接”出来的用户输入被直接塞进了查询里。此时攻击者可以闭合原有查询追加一个union select把数据库里的其他内容“借”正常查询的通道返回出来。1.2 union注入为什么存在先决条件是什么union注入的本质是应用程序未对用户输入做安全处理将输入直接拼接到SQL语句中。举个例子某订单查询的后端逻辑可能是这样的SELECT order_id, user_name, total_price FROM orders WHERE id 用户输入;如果用户输入的是1 union select 1,user(),3-- -拼出来的语句就变成SELECT order_id, user_name, total_price FROM orders WHERE id 1 union select 1,user(),3-- -;数据库执行时会先查订单再执行union后面的select最终把当前数据库账号信息也带出来。这种利用方式之所以常见是因为它满足几个条件页面有显式回显查询结果会展示在响应内容里否则union注入的意义不大原始查询的字段数可以被探测比如用order by去试当前数据库账号有读取目标表或系统元数据的权限代码没有过滤union、select等关键字也没有使用参数化查询。很多人以为注入只是“加个引号报错”其实更关键的是“查询结构可被改变”。如果代码用参数化查询输入只会被当作一个字符串值传给数据库union、select都无法改变查询结构注入自然不成立。1.3 适用场景与边界union注入最适合的场景是页面会把数据库查询结果直接显示出来比如商品详情、文章内容、用户信息、搜索列表等。这类接口只要存在拼接型注入攻击者通常可以很快拿到库名、表名、字段名甚至直接读取整张表。但它也有明确的边界如果页面没有回显union注入看不到结果只能考虑盲注如果数据库返回详细错误信息报错注入往往比union更快如果目标系统对union关键字做了过滤需要先研究绕过而绕过的前提仍然是先理解union在执行层的特性。所以我的建议是学习union注入时不要只背payload而是把“原查询长什么样”“为什么能闭合”“为什么列数必须一致”“为什么数据能回显”这几个问题想清楚。把这条链路弄明白后面学报错注入、时间盲注都会轻松很多。2. 动手前的准备环境、工具与安全边界2.1 推荐实验环境学习union注入强烈建议先搭一个本地实验环境。不要直接对着线上的真实站点测试那是违法行为而且出了问题很难收场。推荐三类环境本地自建模拟项目自己写一个最简单的商品查询页面后端用你熟悉的技术数据库用某种关系型数据库故意用字符串拼接的方式查询制造一个“学习用漏洞”开源的靶场类项目社区里很多专门用于安全教学的在线商店或博客系统自带漏洞环境解压后就能跑虚拟机和镜像快照把环境装进虚拟机做一个快照测坏了随时还原。我习惯每个实验阶段拍一个快照方便对比前后状态。如果你是初学者建议从“带留言板功能的模拟项目”练手。原因很简单这类项目逻辑少、回显直观、数据库表结构简单非常适合观察union select的结果到底显示在哪个位置。2.2 常用检测工具与手工验证工具方面我不建议一上来就用全自动扫描器。union注入的学习重点在于手工判断过程工具可以辅助抓包和重放但不能替你思考。我常用的组合是浏览器开发者工具看请求参数、响应内容快速测试简单payload常用抓包软件拦截POST请求、修改参数、重放请求观察响应变化终端下的命令行工具写脚本批量测试时很方便也能避免浏览器自动转义某些字符。手工验证的核心思路是“对比”。先正常访问一次记录响应再在参数后加一个单引号看是否报错或返回异常然后尝试and 11和and 12看返回结果是否有差异。如果差异明显基本可以确认存在注入点再继续用order by探测字段数最后上union。2.3 必须划清的安全红线这一点我必须放在最前面讲清楚所有注入测试只能在自己的服务器、靶场环境或取得书面授权的系统上进行。哪怕你只是“看一眼”“试一下”只要目标不是你的都属于越权行为。安全技术本身是中性的学习它的目的是防御而不是攻击他人。实操中我见过不少同学因为好奇把测试请求发到了某个在线站点结果自己机器上的扫描行为被对方安全团队记录差点惹上麻烦。所以每次测试前先确认授权范围测试过程中尽量只查询必要的数据不批量下载、不篡改、不删除。测试结束后把临时添加的数据清理干净保留一份截图和过程记录方便复盘。3. 五步定位从判断注入点到确定字段数3.1 第一步寻找疑似注入点union注入的入口通常是那个“会把用户输入拼进SQL”的参数。最常见的注入点包括URL里的id参数、搜索框的关键字、排序字段、分页参数以及POST表单里的查询条件。以我在模拟项目里遇到的商品详情页为例正常URL是一个商品IDhttps://target.local/product?id1当我改成id1时页面直接报错出现了SQL语句片段。这个现象说明后端很可能把1直接拼进了SQL语句由于单引号没有闭合语法出错。如果应用开启了详细错误回显你甚至能看到完整的查询语句那基本就等于把门牌号告诉你了。当然不是所有报错都是注入。有些系统会把非法输入统一返回“参数错误”所以还需要下一步的差异判断。3.2 第二步确认注入类型确认注入点后要判断参数是数字型还是字符型。这个判断决定了后面如何闭合语句。对于数字型参数常见做法是用逻辑运算测试。比如id1 and 11 -- 页面正常 id1 and 12 -- 页面无数据或异常如果两种返回存在差异说明and后面的逻辑参与了查询判断参数是数字型注入。对于字符型参数因为值会被引号包裹直接拼and 11可能破坏语法需要先闭合引号比如nameadmin and 11在模拟项目中我最常用的是观察报错内容来区分。报错信息里如果出现了附近的语法错误多半是字符型如果是Unknown column可能是数字型拼接。类型判断对了后面构造union语句才能闭合成功。3.3 第三步使用order by爆破字段数量字段数量是union注入最关键的一个数字。联查时union后面的select列数必须和原查询完全一致否则数据库会直接报“列数不同”的错误。最经典的手工探测方式是使用order by。order by在SQL里用于排序可以接受字段名也可以接受字段的序号。如果把一个不存在的序号传给order by数据库就会报错id1 order by 1 -- 正常 id1 order by 2 -- 正常 id1 order by 3 -- 正常 id1 order by 4 -- 报错这样就能判断原查询共有3个字段。原理很简单order by 4相当于让数据库按不存在的第4列排序自然找不到列。实际测试时我一般从1开始往上加一旦发现某个数字报错字段数就是“当前数字减1”。3.4 第四步确认可显示的回显位置知道字段数后先用占位符探测页面显示的字段位置id1 union select 1,2,3但这样做有个问题原查询结果可能依然存在页面第一行显示的是原来的商品数据union后面的数字看不到。所以我通常会把id改成负数或一个不存在的值让原查询结果为空这样页面就只显示union注入的结果id-1 union select 1,2,3如果页面上出现了2、3这些数字就说明原查询的第2列、第3列会被渲染出来。这些位置就是你的“回显位”后面要把目标数据放到这里。3.5 第五步构造union select语句确认回显位后就可以把占位符替换成真实查询。假设第2列和第3列都能回显目标数据库又是标准的关系型数据库那么先获取库名和版本id-1 union select 1,database(),version()如果页面正常输出库名和版本说明前面所有判断全部正确union注入链路已经打通。后面要做的事情就是围绕系统元数据表继续查询表名、字段名逐步把数据结构摸清楚。整个五步过程里最需要耐心的是order by和回显位探测很多人在这里因为字段数不对而卡住其实多试几次就能掌握规律。4. 实战拆解union注入的典型利用过程4.1 获取数据库基本信息假设我已经通过前面的步骤确认了某教学靶场中的订单查询接口存在union注入且回显位是查询结果的第2列和第3列。接下来第一步是摸清数据库的“底细”。我先执行id-1 union select 1,database(),version()页面显示当前数据库shop_demo 数据库版本8.xdatabase()返回的是当前连接所处的库名version()返回数据库版本。看起来这两列都有回显而且当前账号能查询这些系统信息。这里我要多说一句版本信息很重要不同版本的数据库在元数据表结构、函数支持上有差异后面构造语句时要根据版本调整思路。比如在较新的数据库版本中部分系统表结构发生了变化但information_schema这个元数据视图依然是通用的入口。我通常先看版本再决定下一步避免在错误的方向上浪费时间。4.2 获取表名与字段名拿到库名后下一步是枚举这个库里的所有表。在标准SQL环境中表信息通常存储在information_schema.tables视图里。我习惯用下面的方式查询id-1 union select 1,table_name,3 from information_schema.tables where table_schemashop_demo如果表数量很多页面可能只显示第一行这时候可以用group_concat把多个表名拼成一行再返回。不同的数据库对group_concat的支持不同如果当前环境不支持也可以用limit分页逐条查询。就我在模拟项目里的观察这套写法通用性很高前提是当前数据库账号有读取元数据表的权限。确定目标表后继续查字段。假设我关注的是users表查询语句如下id-1 union select 1,column_name,3 from information_schema.columns where table_nameusers这一步会把users表的所有字段名列出来比如id、username、password、email等。到这一步你已经能还原出目标表的结构接下来就是验证具体内容。4.3 提取核心数据并验证提取数据是union注入最直观的展示环节。以下语句会把users表里的用户名和密码摘要同时查出来id-1 union select 1,group_concat(username,0x3a,password),3 from shop_demo.users0x3a是冒号的十六进制写法用来把两个字段拼接成易读的格式。group_concat会把多行结果合并成一行避免因为页面只显示第一行而漏数据。我在实际测试中通常会先查询一条已知数据验证union查询结果的真实性。比如先用一个显然存在的用户ID去查询如果页面返回了预期内容说明这条链路完全可用。之后再查询全部数据时我会注意控制返回量不做无意义的全量拉取。靶场数据无足轻重但如果在真实授权测试中这种操作需要格外谨慎能证明漏洞存在即可。4.4 站在攻击者角度思考防御整个实战过程跑完你会发现union注入的链条其实很清晰拼接型SQL语句、可探测字段数量、页面回显位置、系统元数据表泄露结构。站在攻击者角度思考一切的关键是“能否改变查询结构”。如果后端改成参数化查询我上面所有payload都只会变成一个普通的字符串参数union、select这些关键字都无法影响SQL语义。这也是为什么我一直强调理解攻击路径的最好方式是亲手走一遍因为只有当你真的用union把数据查出来才会理解“预编译”三个字背后的分量。5. 踩坑与排错union注入的常见问题5.1 回显位置不对导致没数据这是新手最容易踩的坑。明明union select写对了但页面上什么也没有。我遇到过的情况是原查询结果不为空页面优先显示原数据union结果被挤到页面渲染范围之外或者模板只取了第一行结果。解决方法很简单让原查询返回空。最常用的做法是把参数改成负数或一个超大值比如id-1这样原查询查不到数据union结果自然成为唯一的结果集。还有一种情况是页面只显示某一列你要把目标数据放在页面实际显示的那一列里而不是想当然地放在第1列。5.2 字段数不对报错执行union select时如果报“列数不同”之类的错误多半是order by阶段字段数判断错了。我遇到的最典型情况是order by探测时因为排序字段可以引用select列表外的表列导致order by 4正常但union select 1,2,3,4时却报错。这种情况在“select子句字段和表字段数量不一致”时可能发生。解决办法是不要只看order by结果还要结合union select 1,2,3...的返回情况交叉验证。如果union select的占位符数量一直报错就调整一下占位符数量再试。5.3 类型不匹配导致查询失败union合并查询要求对应列的数据类型兼容。比如原查询第1列是整数你union select写的是字符串数据库可能会因为类型不匹配而报错或者在页面上看不到预期内容。这时的技巧是把占位符统一换成NULL。NULL在SQL里可以被视为任意类型先这样测试id-1 union select NULL,database(),NULL如果页面能返回数据库名说明位置没问题如果还是不行再把NULL替换成合适类型的占位符。很多老手测试注入时习惯清一色用NULL就是为避开类型不匹配的干扰。5.4 过滤与绕过的基础思路靶场里经常会有简单的过滤规则比如把union、select替换成空字符串。这时候要理解过滤的本质是字符串替换而SQL解析发生在数据库端两者之间存在时间差。常见的练习思路包括大小写混写、内联注释、换行符替代空格、用等价格式重写关键字。这些技巧在靶场里可以锻炼思维但我建议不要在真实授权测试里盲目尝试更不要针对任意外部系统尝试。绕过的价值是让你更深刻理解数据库的语法解析规则而不是让你跟安全设备死磕。6. 防御视角为什么union注入能成功以及如何修补6.1 参数化查询与预编译如果说union注入是一把钥匙参数化查询就是把锁彻底换掉。参数化查询的核心思想是让SQL语句结构和用户输入数据分离。后端先向数据库提交带有占位符的SQL模板再把用户输入作为参数单独传递。数据库在执行时参数值永远只是值不会成为SQL语法的一部分。例如一个订单查询可以写成SELECT order_id, user_name, total_price FROM orders WHERE id ?用户输入的1 union select ...只会被当作一个完整的字符串参数数据库会去找id等于这个字符串的记录而不是执行union后面的语句。几乎所有主流后端语言都支持预编译接口这是防御SQL注入最根本、最有效的一招。6.2 输入校验与最小权限参数化查询之外输入校验是一道缓冲。对不同类型的参数做严格的类型转换比如id参数强制解析为整数非整数值直接拒绝可以在数据到达数据库之前就挡掉大部分畸形输入。对搜索、排序这类必须接受字符串的场景至少要做长度限制和字符白名单。数据库账号的权限分配同样重要。很多注入漏洞之所以能读到所有表是因为应用连接数据库时使用的是高权限账号。正确的做法是给应用单独建一个账号只授予查询特定业务表的权限禁止访问系统元数据表禁止访问其他数据库。这样即使SQL语句被拼接攻击者想从information_schema里枚举表名也会因为权限不足而失败。6.3 部署层防护与监控最后是纵深防御。在应用前面加Web应用防火墙配置SQL注入特征拦截规则可以拦截常见攻击payload。关闭数据库详细错误信息不要让SQL语句出现在页面响应里减少攻击者调试信息。同时对数据库查询做日志监控一旦发现异常查询模式比如短时间内大量union关键字请求立刻报警。不过我要提醒一句这些部署层手段只是降低风险不能替代代码层的参数化查询。防火墙规则总有绕过空间日志监控也只能事后发现。真正安全的系统是默认情况下根本不产生拼接型SQL。前面章节的所有攻击路径在参数化查询面前都会失效这是我在实际项目里反复验证过的结论。我个人在实际排查中的体会是很多开发者以为把union、select加进黑名单就万事大吉可问题从来不在于关键字本身而在于数据是否进入了查询结构。与其绞尽脑汁改过滤规则不如老老实实重构所有数据库访问逻辑。当初在模拟项目里第一次用union注入查到数据时我只觉得好玩后来参与某公司的一个遗留系统修复看到一长串拼接SQL时才发现这类问题远比想象中普遍。如果你刚学到这里建议把本地环境里的每一步都亲手敲一遍遇到报错就停下来分析原因理解透彻后再进入其他注入类型。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →