ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL注入UNION攻击:如何确定列数(ORDER BY与NULL探测实战解析)

SQL注入UNION攻击:如何确定列数(ORDER BY与NULL探测实战解析) 1. 从UNION语法说起为什么攻击前必须先摸清列数先交代个背景。PortSwigger Academy 的 SQL injection 系列里UNION 攻击这块的入门实验几乎都是从“确定列数”这一步开始的。很多人第一次做的时候会觉得莫名其妙我明明是要注入点东西出去怎么先在这里折腾半天列数这其实不是实验室在故意绕圈子而是 SQL 语法本身给所有 UNION 攻击划了一条死规矩UNION 两边的查询必须返回相同数量的列。你后面想 select 的任何数据、想构造的任何 payload全都建立在这个前提上。用大白话讲UNION 就是把两个查询的结果上下拼在一起。数据库要拼就要求两边的“表格形状”一致——几列、每列什么类型都得对得上。如果后面的查询列数比前面少一列数据库直接报错你的注入根本带不出任何数据。确定列数就是先探明这条“形状”的底线。它不像直接拿UNION SELECT username, password FROM users那样立刻见效却是一切后续操作的地基。地基没打牢后面全白搭。这时候你可能会问为什么不能直接一个个猜比如先试UNION SELECT 1报错了就试UNION SELECT 1,2再报错就加列直到不报错为止。这方法理论上没问题但效率太低而且一旦遇到报错被应用层吞掉的场景反馈就不够灵敏了。PortSwigger 这批 lab 训练的就是标准打法要么用ORDER BY探测要么用NULL探子。下面逐个拆开讲。2. 两种主流探测方式ORDER BY 探测列数与 UNION SELECT NULL 探坑式探测在动手之前得先理解两种方式各自的适用场景。实话说我在实际测试中两种都会用但顺序有讲究一般先用ORDER BY因为它的反馈更直接、更不容易触发一些奇奇怪怪的类型转换问题如果ORDER BY被过滤了或者因为某种原因不生效再切换成UNION SELECT NULL。2.1 ORDER BY 探测报错位置就是答案原理其实很简单。ORDER BY接受两种参数列名或者列的序号。用序号的时候数据库会根据这个序号找到对应位置的列来做排序。一旦序号超出了实际列数数据库就会抛错类似The ORDER BY position number 3 is not in the select list不同数据库措辞不一样但意思差不多。所以操作思路就是在注入点后面拼上ORDER BY 1、ORDER BY 2、ORDER BY 3……逐个递增直到某一次请求报错为止。最后一次成功排到第几列就说明原查询一共有多少列。例如假设原始查询是SELECT name, description FROM products WHERE category Gifts在 URL 参数?categoryGifts注入时构造这样的请求GET /filter?categoryGifts ORDER BY 1--如果页面正常返回说明至少有一列可以作为排序键。继续GET /filter?categoryGifts ORDER BY 2-- GET /filter?categoryGifts ORDER BY 3--假设ORDER BY 3时页面报错或行为异常而ORDER BY 2时一切正常那列数就是 2。这里有个小细节为什么ORDER BY 2正常就说明列数至少是 2但不一定是正好 2后面还得结合 UNION 的报错来验证。实际上当你测到ORDER BY N报错而ORDER BY N-1正常时就能判定 N-1 就是当前查询的列数。因为排序序号超出实际列数才会报错所以最大成功序号就是列数。2.2 UNION SELECT NULL 探测用 NULL 避免类型干扰NULL有一个天然好处它可以被当作任意类型。在UNION拼接时数据库会对两边的列做类型匹配如果你在第二查询里直接放数字1而源查询对应列是字符串类型某些数据库会直接报类型不匹配的错误。但如果你用NULL就能绕开这个问题因为 NULL 可以被隐式转换成任何类型。所以UNION SELECT NULL的探测方式就是不断加 NULL UNION SELECT NULL-- UNION SELECT NULL,NULL-- UNION SELECT NULL,NULL,NULL--什么时候不报错了就说明列数对上了。比如SELECT name, description FROM products WHERE category Gifts UNION SELECT NULL, NULL--如果返回正常说明列数是 2。如果还没有匹配到就继续加。这里有一个容易踩的小坑NULL如果和原查询中某个非空列做 UNION结果里可能会出现“整个列表值变为 NULL”的现象页面看起来像是某些字段内容是空的而不是报错。这其实是正常的说明你成功匹配了列数只是对应的列类型和 NULL 兼容后显示成了空值。2.3 两种方式选哪个我的习惯在我做 PortSwigger 这些 lab 的时候习惯是先在 Burp Suite 的 Repeater 里发一条ORDER BY 1然后一步一步加直到报错。这个过程很快通常只要发三四条请求就能确定列数。如果确认ORDER BY这条路走不通比如 WAF 把ORDER或者BY这两个词给过滤了再转用UNION SELECT NULL。反过来如果UNION SELECT NULL一直因为字符编码、语句截断等问题报错我也会回去用ORDER BY。实际渗透测试里ORDER BY的另一个好处是即使应用不直接回显数据库报错只要排序行为发生变化也能推断出列数。但坏处是有些后端 SQL 可能会因为ORDER BY的高位序号触发排序内存消耗问题导致请求超时。相比之下UNION SELECT NULL更接近最终攻击形态而且为后续字段填充做好了铺垫。所以如果目标明确就是要做 UNION 注入我也会直接上NULL序列。3. PortSwagger Lab 实操在 Product 页面上确定真实列数PortSwigger Academy 的 SQLi 实验中这个“确定列数”的题目Lab: SQL injection UNION attack, determining the number of columns returned by the query其实是个很经典的入门靶场。里面是一个商品分类筛选页面URL 长这样https://0a9c00ad04f5d6c081a3e3f8006200c1.web-security-academy.net/filter?categoryGifts打开页面后点几个分类能明显看到不同分类返回不同商品。这个category参数就是注入点。我用自建靶场和 PortSwagger 原站都做过这里把完整的实操路径写一遍方便还没过的朋友照着推。3.1 先验证注入点存在第一步永远是验证注入点。在category参数后面加个单引号GET /filter?categoryGifts如果返回 500 错误或者页面内容有明显异常说明这个参数被拼进了 SQL 查询并且没有做参数化处理。PortSwigger 这个题的预期结果就是加单引号会报错去掉单引号恢复正常。这一步虽然简单但一定不要跳过因为后面所有 payload 都基于“注入成立”这个前提。有朋友上来就UNION SELECT NULL结果发现注入点根本不存在白忙活半天。先花五秒钟验证一下后面省半小时。3.2 用 ORDER BY 快速收束列数范围验证完注入点后打开 Burp Suite 的 Repeater或者直接用浏览器地址栏改参数也行但 Repeater 方便看响应码和响应长度发送GET /filter?categoryGifts ORDER BY 1--注意我用的注释符是--后面没有空格也通常没问题不过有些数据库要求--后带一个空格比如 PostgreSQL。稳妥起见我建议写成--两个减号加一个空格。如果 URL 里出现空格记得编码成%20或者直接用加号不过 Burp Repeater 里不编码也经常能自动处理不同版本有差异实在不行就编码。依次测试Gifts ORDER BY 1-- Gifts ORDER BY 2-- Gifts ORDER BY 3--我自己做的时候ORDER BY 2正常返回ORDER BY 3直接 500。看响应码和响应体里有没有“Internal Server Error”就能判断。所以这个 lab 中查询列数就是 2。为了保险再试一遍ORDER BY 2确认页面内容和原始请求差不多对照一下商品数量基本就锁定了。注意ORDER BY探测时如果当前查询本身带有ORDER BY子句很多分页查询会这样你的注入ORDER BY会拼接到后面变成两个 ORDER BY。这种情况下数据库会报错或者忽略后面的导致判断失真。遇到这种场景最好改用UNION SELECT NULL探测或者先想办法闭合掉原有的 ORDER BY。3.3 用 UNION SELECT NULL 复验列数锁定为 2 后再用UNION SELECT NULL,NULL做一次复验顺便为后面的数据提取做准备GET /filter?categoryGifts UNION SELECT NULL,NULL--如果响应正常没有报错进一步确认列数是 2。如果返回的页面里商品列表区域出现了空行、空字段或其他异常显示也不用慌——那是 UNION 结果集里 NULL 被渲染成空字符串造成的。至少说明 SQL 语法层面已经通了。这时候你可能会想那接下来是不是就可以带数据了对比如GET /filter?categoryGifts UNION SELECT username, password FROM users--就能把 users 表里的用户名和密码全部拼到商品列表里。但这一切的前提就是前面花一分钟确定列数是 2。这个 lab 的最终目标也正是确认完列数后提交一个UNION SELECT NULL,NULL的请求即可。不过我的建议是别光用 NULL再顺手验证UNION SELECT 1,2能正常返回这样能确认哪些列是可见的后面要回显哪个字段心里有数。UNION SELECT 1,2也很值得试正常返回后页面里商品区域会出现数字 1 和 2这能直接告诉你哪一列会被渲染在页面上。很多实战场景里前端只显示第一列或第二列知道哪个位置可见后期 select 敏感字段时就能把数据放在可见的那一列不会出现“数据库有数据但页面上看不到”的尴尬。3.4 验证响应中隐藏的细节响应里如果出现类似下面的 HTML 片段就说明注入结果已经回显出来了tr th1/th td2/td /tr当然PortSwigger 的 lab 界面会把它以表格形式展现看起来像是多了一行奇怪的商品。如果你在做的是实际渗透测试遇到这种页面回显也基本可以断定 UNION 注入成功。到这里Lab 题目要求的“确定列数”就已经完成了。但学习不能止步于此因为后面还有更多的变体。4. 翻车现场与边界情况类型转换、注释符与数据库方言的坑做这个 lab 的过程中我见过不少人卡在“明明按教程来的就是不成功”的尴尬局面上。这类问题十有八九出在下面这几个点上。4.1 NULL 不够用了某些数据库要求显式类型匹配我上面说过 NULL 能适配任意类型但实际操作中Oracle 对 UNION 的类型匹配相当严格。如果你第一个查询的列是VARCHAR2而你 SELECT NULL 没问题但一旦你换成SELECT 1去测试Oracle 就会报ORA-01790: expression must have same datatype as corresponding expression。所以在 Oracle 上做列数探测时用NULL是最稳妥的别为了图省事直接上数字。反过来MySQL 对类型比较宽松UNION SELECT 1,2几乎不会报错但如果你后面要回显字符串还是得注意列的字符集是否兼容。4.2 注释符号不是所有数据库都认--最常见的是在 MySQL 里--注释后面必须跟一个空格或者控制字符否则整条语句的语法就会出错。如果你在 URL 里传Gifts--大概率直接报错。所以我的习惯一直是--带一个空格在 URL 编码里就是--%20。另外很多教材推荐用#作为 MySQL 注释符但#在 URL 里得编码成%23否则浏览器会把它当成锚点请求直接被截断。4.3 注入点位置是字符串闭合还是数字型这个 Lab 的category参数属于字符串类型所以需要先闭合单引号Gifts。但如果你遇到数字型注入点比如?id1就不需要闭合引号直接1 UNION SELECT NULL即可。判断方法是把参数改成1如果报错就是字符串型改成1-1后页面内容变化了就是数字型。还有就是双引号闭合的情况。有些后端 SQL 会用双引号包字段值这时候单引号就不管用了得用双引号闭合payload 变成Gifts ORDER BY 1--。PortSwigger 后续的高阶 lab 里专门有这种题目现在提前说一句免得换了个场景就懵。4.4 原查询中的 WHERE 条件带了 LIMIT 或 GROUP BY如果原查询末尾带有LIMIT 1之类的限制你的 UNION 查询会被拼到 LIMIT 之后导致语法错误。这时候得想办法注释掉 LIMIT或者用UNION的优先级把 LIMIT 的作用范围修改掉。不过 PortSwigger 的这个基础 lab 没这么复杂上面的 payload 已经够用。4.5 列数过多时的手工疲劳战如果列数动辄 20 多列一条条手打NULL会很累。我的做法是先在 Burp 的 payload 里用脚本生成一串NULL,NULL,...或者直接用 Intruder 爆破列数用响应码差异自动判断。但基础 lab 阶段列数都在个位数手打完全够用。这里顺带说一句Burp Intruder 在这个场景下的用法很简单把 payload position 放在NULL重复数量上设置一个 payload 列表1..20然后在请求里用UNION SELECT加若干个占位符利用宏替换自动生成不同长度的 NULL 序列。嫌复杂的直接在 Repeater 里手动改也很快。5. 从确定列数到实际利用下一步你该知道的 UNION 注入套路确定列数只是万里长征第一步。既然这篇是讲 PortSwigger Lab 的我顺便把确定列数之后的展开思路也说一下方便你把整个攻击链条串起来。5.1 找可见列定位回显位做完UNION SELECT NULL,NULL,NULL之后下一步一定是把 NULL 依次替换成数字或字符串比如UNION SELECT 1,2,3。谁的编号出现在页面里谁就是可见列。这步在 lab 里往往能直接看到1、2、3这样的字眼显示在商品标题或描述位置。如果页面没直接显示这些数字也可以尝试把数字换成字符串比如UNION SELECT a,b,c看看字母是否出现。还能排除某些列虽然存在但因为类型限制无法渲染字符串的情况。5.2 获取数据库元数据有了可见列之后接下来可以查询数据库版本、当前数据库名、表名和列名。不同数据库的系统表不一样PortSwigger 的 lab 默认后端可能是 PostgreSQL也可能是 Oracle需要先探测版本再决定查哪张表。常见写法-- PostgreSQL / MySQL UNION SELECT version(), NULL-- -- Oracle UNION SELECT banner, NULL FROM v$version-- -- SQL Server UNION SELECT version, NULL--实际做题时遇到数据库版本信息直接回显在页面上就能精准知道下一步该查什么。比如 PostgreSQL 里查表 UNION SELECT table_name, NULL FROM information_schema.tables--然后查列 UNION SELECT column_name, NULL FROM information_schema.columns WHERE table_nameusers--最后把数据拖出来 UNION SELECT username, password FROM users--整个过程都是基于你第一步确定的列数来构造的。列数错一位后面全错。5.3 批量列数据时的格式调整如果某些字段是数字类型但你想回显字符串可以考虑用CAST或CONVERT做类型转换。比如 PostgreSQL 里 UNION SELECT NULL, CAST(username AS text) FROM users--实际上确定列数阶段就敢直接上CAST的人不多但至少心里要有这个工具遇到隐藏的 500 错误时知道去排查类型兼容问题。6. 一点个人的实战建议最后说点实在的。PortSwigger Academy 这批 SQLi lab 被我当成新人面试前的必刷题不是没有原因的。它把每一个攻击步骤拆得非常细像“确定列数”这种看起来简单的动作实际上能延伸出很多细节为什么先ORDER BY后UNION SELECT NULL为什么用NULL而不是1注释符在 MySQL 和 Oracle 里的细微差别是什么这些都是在真实渗透里会反复踩到的坑。我个人的建议是不要只满足于把 lab 刷完。你可以在本地搭一个简单的 LAMP 环境自己写一段有漏洞的查询语句用同样的手法反复测。因为 lab 环境是固定的而真实目标的查询语句千奇百怪可能套了多层子查询也可能用 UNION 处理过分页逻辑只有亲手在可控制的数据库上测过才能把“确定列数”这个动作变成肌肉记忆。还有一个细节做实验的时候尽量用 Burp Suite 的 Repeater 而不是浏览器地址栏直接改。浏览器会把 URL 里的特殊字符自动编码有时候反而掩盖了真实请求。用 Repeater 你能清楚地看到每一次发送的原始请求长什么样也能对比不同 payload 的响应差异这对培养注入直觉很有帮助。如果你做到这了下一步就是去挑战 PortSwigger 的“SQL injection UNION attack, retrieving data from other tables”那题会用上这个 lab 确定列数的全部技巧然后在此基础上拖出 users 表的数据。两题连着做基本就把 UNION 注入的常规利用流程串下来了。就写到这里。希望这篇东西能帮你少走点弯路也欢迎在评论区分享你刷 lab 时遇到的奇葩问题——尤其是那些在题目预期之外、但真实环境里经常出现的怪情况这才是最有价值的讨论话题。
返回列表