资讯详情

资讯详情

手工SQL注入实战:闭合判断、联合查询与盲注绕过

1. 为什么要重拾手工注入自动扫描器没告诉你的那些事很多人觉得 SQL手工注入 已经是“上古时代”的技能了毕竟现在随便一个自动化扫描器都能把常见的注入点扫得七七八八漏洞管理平台一集成仿佛一切都能交给机器去跑。但我最近一次给一个内部系统做安全评估的时候恰恰是这种“交给机器”的思路让我差点漏掉一个真正的高危点。当时扫描器报告页面上只给出一条“低危信息泄露”的提示大致意思说是某个参数可能存在疑似注入点但无法进一步验证。我手动翻了一下数据包发现那个参数藏在 JSON 请求体里而且后端对单引号做了转义扫描器根本没走到触发漏洞的那条代码分支。后来我手工构造了一个闭合方式用布尔判断逐位确认最终还真从一个看似无关的业务字段里拖出了用户表的敏感数据。这个案例让我再次确认自动化是效率工具但永远不能替代人工对漏洞原理的理解。这篇内容不对新手讲那些“扫一下出结果”的自动化套路而是完整拆解 SQL手工注入 的判断流程、闭合思路、联合查询和盲注手法以及遇到过滤时的绕行逻辑。无论你是做安全测试、写代码时想了解为什么预编译能救命还是毕业后打算往安全方向走的在校生这套底层思路都能直接复用。它解决的核心问题是当你面前只有一个黑盒接口没有任何扫描器帮忙的时候你该怎么一步步确认注入点、又把数据拿出来。这里必须先强调红线下面的所有操作都只建议在本地靶场、自建实验环境或者拿到书面授权的测试范围内进行。漏洞挖掘和利用是防守视角的延伸不是拿来打真实目标的本钱。2. 先把靶场搭起来本地环境与第一个注入点2.1 环境选择尽量贴近真实场景手工注入第一步不是急着写 payload而是先把实验环境搭起来。我通常建议用一个开源靶场项目直接跑因为里面已经设计好了不同难度、不同注入类型的页面省去自己造轮子的时间。如果网络环境不便拉取开源项目也可以自己用集成环境五分钟搭一个依赖就两样一个支持 MySQL 的本地 Web 集成环境比如常见的 PHP 集成包外加一个编辑器。我自己习惯在本地跑一套 PHP 5.x MySQL 5.x 的集成环境原因很简单手工注入时经常会故意触发数据库报错新版本数据库对报错信息的默认展示收敛了很多反而不好观察。老版本在 DEBUG 模式下会把完整 SQL 语句直接甩到页面上对初学者学习闭合原理特别直观。当然这不代表老版本更好用只是为了学习阶段能“看见”更多内容。2.2 建一张实验表模拟真实业务页面如果你打算自己搭最简单的实验表可以设计成这样CREATE TABLE user ( id INT(11) NOT NULL AUTO_INCREMENT, username VARCHAR(50) DEFAULT NULL, password VARCHAR(200) DEFAULT NULL, email VARCHAR(100) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8; INSERT INTO user (username, password, email) VALUES (admin, e10adc3949ba59abbe56e057f20f883e, adminlocal.dev), (article, 25f9e794323b453885f5181f1b624d0b, articlelocal.dev), (tester, 25d55ad283aa400af464c76d713c07ad, testerlocal.dev);密码字段存的就是常见的消息摘要用来模拟真实项目里“不存明文密码”的场景。页面逻辑可以很简单传一个id参数然后就执行查询并展示用户名。这里故意不用参数化查询而是用字符串拼接这样才能模拟出注入点。$id $_GET[id]; $sql SELECT id, username, email FROM user WHERE id $id;2.3 第一个注入点的确认方式环境起来之后最基础的操作就是在地址栏里把id从正常的1改成1。如果页面报出数据库语法错误或者页面展示行为和正常访问出现明显差异基本可以认定参数拼接进了 SQL 且没有做过滤。这一步看着简单但有几个容易忽略的判断细节。正常页面返回一条记录传1之后如果报错信息直接暴露了 SQL 片段那说明两个信息已经拿到了一是后端用的是拼接而不是预编译二是大概率能从报错位置推断出闭合方式。如果页面没有报错只是空白或者返回 500也不要急着放弃这不一定代表没有注入也许是错误信息被全局捕获了这时候就要进入后面的盲注判断环节。我见过不少人在这第一步就翻车——他们在测试参数时只盯着状态码变化发现 200 没变就判定为安全。实际上很多注入点确实不会改变响应状态码而是悄悄改变页面内容里的某个字段值。所以判断注入点最靠谱的指标不是状态码而是“输入特殊字符后响应内容和正常响应相比是否出现了规律性差异”。3. 注入的底层逻辑拼接查询与闭合方式判断3.1 为什么拼接语句会产生注入要理解 SQL手工注入先得回到 SQL 查询的执行模型。数据库拿到一句 SQL 时只把它当成一段字符串文本通过词法分析和语法分析后生成执行计划。如果应用层在拼接用户输入时没有做任何边界标记用户输入的内容就会被解析器当成 SQL 代码的一部分而不是单纯的字符串数据。用人话解释后端想说的是“帮我查 id 等于 1 的那条用户记录”它拼出来的语句是SELECT ... WHERE id 1。如果用户传进来的是1 OR 11那么整条语句就变成了SELECT ... WHERE id 1 OR 11数据库执行时看到的是一个恒真的条件于是把整张表都返回了。注入的本质就是用户输入越过了“数据”和“代码”的边界。3.2 数字型、字符型与搜索型的闭合差异最常见的注入类型有三种数字型、字符型和搜索型。它们唯一的区别就是后端在拼接时给用户输入套了一层什么样的语法符号。数字型是最直接的因为输入直接参与数值比较不需要额外闭合上面的例子已经体现。字符型则要求在输入里先闭合掉单引号比如后端语句是SELECT id, username, email FROM user WHERE username $username如果直接传admin没问题但传admin AND 11时语句会变成SELECT id, username, email FROM user WHERE username admin AND 11这里的关键动作是“先闭合再注释”。闭合是把原有的引号配对截断注释是把后面的尾巴处理掉。最常用的是--注意后面必须跟一个空格或者#前者是标准 SQL 的注释方式后者是 MySQL 特有的。搜索型稍微复杂一点常见于标题搜索、内容搜索功能SQL 里涉及LIKE %$keyword%。由于百分号本身参与模糊匹配输入里往往可以直接构造通配符和闭合引号在某些场景下还能触发基于LIKE的注入。判断搜索型闭合时输入%看是否报错再尝试用% OR 11之类的组合。3.3 闭合判断的通用步骤我自己执行闭合判断时有一套固定动作不管遇到什么参数都按这个顺序来先输入一个英文单引号观察响应是否报错或行为变化。尝试输入)看看是否存在括号闭合。尝试输入))应对双重括号包裹。在每种尝试后面分别带AND 11和AND 12观察两种输入是否产生不同结果。第 4 步极其重要。如果带AND 11时页面正常、带AND 12时页面异常说明闭合方式找对了且注入点真实可用。这个“真值对比法”是判断注入点最硬核的试金石它不受代码逻辑干扰只依赖一次恒真和一次恒假的响应差异。3.4 宽字节注入的一个变体如果你测试的系统用 GBK 或者 GB2312 编码可能会碰到一种特殊情况单引号前被加上了反斜杠转义看起来输入1\后转义符把引号吃掉了注入无法成立。但 GBK 这类多字节编码允许一个双字节字符出现如果我们在单引号前补一个%df当数据库连接使用 GBK 字符集时%df%5c会被解析成一个完整汉字反斜杠被“吸收”后面的单引号便成功逃逸。这种场景现在不多但老系统里仍然存在。它给我们的启发是注入判断不只是看后端有没有加过滤还要考虑编码层面、数据库连接配置层面是否引入了额外的可乘之机。手工注入的乐趣和难度都在这里——你永远不知道语法边界会在哪一层被绕过。4. 联合查询注入快速拿数据的标准打法4.1 为什么先数列数闭合方式确认后如果页面能把查询结果直接展示出来就有了走联合查询的资格。核心思路是用UNION SELECT把自定义查询结果合并到原始查询结果里。但有一个硬性前提UNION两侧的查询必须返回相同数量的列否则数据库直接报错。所以第二步永远是确定原始查询列数。惯用手段是ORDER BY N从ORDER BY 1开始逐次加 1 直到页面报错。能用ORDER BY数列数而不是直接UNION SELECT 1是因为ORDER BY 1不依赖前面的 SELECT 列表类型而且报错信息能明确告诉你“第 N 列不存在”。例如正常请求是SELECT id, username, email FROM user WHERE id 1 ORDER BY 4如果报错提示 Unknown column 4说明原始查询只有 3 列。这时再构造联合查询SELECT id, username, email FROM user WHERE id 1 UNION SELECT 1,2,3如果页面在第 2、第 3 个位置显示了数字2和3说明这两个位置是“显示位”可以替换成敏感函数或查询语句。4.2 利用系统库获取表名与字段名列数和显示位搞定之后剩下的就是让数据库告诉我们有哪些表和字段。MySQL 里有一个系统信息库里面存着所有数据库的元数据包括数据库名、表名、字段名。手工注入时最常用的就是从中读取表清单。比如目标数据库叫webapp想拿到所有表名SELECT id, username, email FROM user WHERE id 1 UNION SELECT 1,GROUP_CONCAT(table_name),3 FROM information_schema.tables WHERE table_schemawebappGROUP_CONCAT能把多行结果压缩成一行输出这样在有限显示位里可以看到多个表名省去一条条翻数据的功夫。拿到表名后再查字段名SELECT id, username, email FROM user WHERE id 1 UNION SELECT 1,GROUP_CONCAT(column_name),3 FROM information_schema.columns WHERE table_schemawebapp AND table_nameuser这一步做完目标表结构基本就暴露了再联合查询想要的字段内容就是水到渠成的事。整条链路从数列数到读取元数据逻辑非常清晰先摸清结构再替换显示位最后读取目标数据。4.3 联合查询的常见翻车点这几年我在带人测靶场时看到最多的问题是有人直接UNION SELECT 1,2,3后发现页面只显示数据不显示数字就以为注入失败。实际上很多后端模板只提取某个固定列渲染到页面上比如只渲染第二列第一列和第三列的内容会被略过。这时不要慌先确认哪些列是真正回显的再去替换它们。另一个高频翻车点是列数判断时顺序混乱。ORDER BY只能判断列数的上界不能直接给出准确值有人数到第 5 列报错就以为原始查询有 4 列但如果第 4 列是一个非排序类型字段呢好在 MySQL 的规则是ORDER BY基于列序号报错通常发生在“第 N 列不存在”而不是“无法排序”所以上界判断在绝大多数场景依然有效。如果你遇到的是UNION两侧类型不匹配的报错可以考虑用NULL代替数字占位因为NULL能兼容任意数据类型降低报错概率。这些小技巧没有写在任何官方文档里但实战中极其救急。5. 盲注没有回显时的耐心博弈5.1 从“看得到”到“猜得到”联合查询有个前置条件页面必须把查询结果给显示出来。但现实中很多查询结果不会直接渲染比如登录接口只返回“成功”或“失败”比如查询结果被后端逻辑处理后只输出一个状态码。这时候联合查询就像对着空气开枪——你根本不知道子弹打没打中。于是要换思路既然得不到直接回显那就利用数据库的行为差异来反推信息。核心有两条路布尔盲注和时间盲注。布尔盲注利用恒真和恒假条件下页面响应的差异。比如原始请求返回“查询成功”我构造AND (SELECT LENGTH(database())) 5如果页面显示“成功”说明数据库名的长度大于 5如果页面显示“失败”说明条件不成立。通过一次次怼条件就能像猜数字一样把库名、表名、字段名逐字逼出来。语言表达上有点绕但逻辑很简单。我举个基础例子后端查询SELECT username FROM user WHERE id $id页面把username回显出来但其他字段不显示id1 AND (SELECT SUBSTRING(username,1,1) FROM user WHERE id1) a如果页面仍然回显第一个字符为a的账号那我们就知道目标值的首字母是a。逐位推进最终拼出完整用户名。5.2 时间盲注让沉默的数据库开口说话当页面在真假条件下响应完全一致时就得靠时间盲注。核心函数是SLEEP()让数据库在特定条件下推迟返回结果然后通过响应时间的长短来推断条件真假。伪代码如下SELECT ... WHERE id 1 AND IF(ASCII(SUBSTRING(database(),1,1)) 100, SLEEP(2), 0)如果请求响应时间明显超过 2 秒说明条件成立。如果响应立即返回说明条件不成立。时间盲注虽然慢但胜在通用性强因为它不依赖任何回显内容只需要一个能分辨“快慢”的通道。这里有个不起眼但非常重要的细节延时值不要选太夸张。有人图省事直接SLEEP(5)结果测试量一大一个完整的库名猜下来可能要半小时起步而且长时间占用数据库连接容易被运维侧的安全策略盯上。我个人建议是把延时控制在 1 到 2 秒之间既够区分网络波动带来的误差又不至于让测试过程煎熬。5.3 手写脚本提效纯手工盲注真的太慢了遇到库名稍微长一点一个字符一个字符地猜心态容易崩。我用过一个简单的 Python 脚本做自动化逐字提取本质是把上面的手工过程循环化import requests import string url http://local-target/index.php charset string.ascii_lowercase string.digits _.- result for pos in range(1, 20): found False for ch in charset: payload f1 AND SUBSTRING((SELECT database()),{pos},1){ch} -- data {id: payload} r requests.post(url, datadata) if 查询成功 in r.text: result ch found True print(f[] position {pos}: {ch}) break if not found: break print(f[] database: {result})脚本思路比脚本本身更重要。它把布尔盲注的“条件判断 字符集枚举 逐位推进”三个动作都固化下来了换到任何场景只需要改日志判断条件和字符集就能复用。初学者照着这个思路写一遍对盲注的理解会突飞猛进。5.4 盲注易错点提醒盲注最怕的是拿“页面本身就会变的内容”作为判断依据。比如页面里面有实时时间、有随机推荐位那你拿响应内容做真假对比就容易误判。正确做法是选一个稳定的输出字段或者干脆用响应包的长度变化来判断。另外如果目标参数被多个位置拼接你构造的条件可能被原样插入到意想不到的 SQL 片段里导致整个语句语法异常。这时候不要硬猜闭合方式返回去重新分析数据包看参数到底被拼在了什么地方。6. 过滤与防护手工注入的进阶缠斗6.1 常见的应用层过滤测试做了多了之后你大概率会遇到一些“半吊子”防护后端确实做了输入过滤但做得不够彻底既没上参数化查询又只挡了部分敏感关键词。最常见的过滤手段有这几种过滤空格把输入中的空格替换为空让UNION SELECT或AND 11这类语句直接残缺。过滤关键字把SELECT、UNION、OR等关键词替换为空或恶意字符。过滤单引号和双引号对所有引号统一加反斜杠或直接删除。注释符过滤把--和#都清理掉。这些过滤看起来挺唬人但由于没有统一走预编译逻辑上往往可以拆解。6.2 内联注释、双写与等价替换MySQL 有一个特殊语法/*! SELECT */。方括号里即使被注释符包裹只要后面的内容符合语法数据库依然会执行。这个特性可以让SELECT在被注释过滤时依然生效比如UNION /*!50000 SELECT*/ 1,2,3又或者把被过滤的关键字再套一层让过滤后恰好拼回原来的词。如果系统把SELECT直接替换成空那么输入SELSELECTECT经过过滤后会变成SELECT这就是所谓的双写绕过。逻辑上很简单但对过滤规则的顺序极其敏感同一套 payload 换个过滤模式就失效了。等价替换就更活了。空格被过滤就用%09、%0a、%0b等绕过空格OR 11被过滤可以用||11代替ORDER BY被过滤有时候能换成GROUP BY。这类替换没有银弹依赖的是对目标系统过滤逻辑的探测和对 SQL 语法的熟悉程度。6.3 绕过之前先摸清过滤规则很多人一上来就对着过滤尝试各种替换效率反而低。正确的姿势是先把过滤规则“测”出来分别提交SELECT、union、空格、单引号看哪些被处理了、哪些幸存。然后针对被处理的字符逐个验证替换方案。比如发现空格被清空但AND和OR没被处理那我就可以用AND11来替代空格。如果发现SELECT被换成select后依然能执行说明大小写过滤也没做好直接换大写就行。每一层过滤的边界都是通过这种小步试探找到的。6.4 别把绕 WAF 当唯一目的市面上围绕绕过 WAF 的讨论很多但我必须说一个反直觉的观点绕过有意思但真正值钱的不是“怎么绕”而是“为什么能绕”。绝大多数 WAF 是基于规则匹配的它对一句话 payload 的识别很准但对拆解后的逻辑组合就力不从心。如果你只会套用别人的绕过模板碰到规则库更新就寸步难行如果你理解规则匹配的盲区才能真正做到举一反三。所以遇到过滤我的建议是先把过滤逻辑当成一道推理题来解而不是急着在搜索引擎里找现成 payload。这个过程锻炼出来的语法敏感度比任何绕过技巧都保值。7. 攻防视角小结从注入点反推防护清单7.1 站在防守端重新审视注入点如果你是因为写代码时想知道“为什么大家都叫我用参数化查询”而来现在你应该已经有答案了。注入之所以存在根本原因是语句拼接破坏了数据和代码的边界。所以最稳妥的防护是消灭拼接所有涉及数据库的操作统一走预编译或参数化接口让输入永远只被当成数据。如果因为历史包袱必须用拼接那也要做两层兜底第一层是输入校验对类型、长度、字符集做严格白名单而不是靠黑名单第二层是数据库权限收敛业务账户只授权必要的表绝不使用有管理权限的账户去连业务库。7.2 从注入测试反推的失分点我每次做注入测试后都会习惯性倒推这个系统还有哪些类似问题。如果id参数有注入那其他参数大概率也有只是没测到如果这里用了字符串拼接那其他模块可能同样没走预编译。注入往往不是孤立的代码缺陷它暴露的是一整套代码规范的缺失。有一次我测完一个后台管理系统的某接口发现注入点后顺着代码仓库一查同批次写的十几个接口全部都是拼接写法。修一个接口容易把剩余的隐患全部清掉才需要真功夫。这也是手工注入训练带给开发者的额外价值你更容易在自己写的代码里闻到危险的味道。7.3 几个训练中的务实建议最后分享几条自己的经验。第一练习注入时必须有一台授权靶机或自己的虚拟机不要在未经允许的系统上做测试这既是职业操守也是红线共识。第二手工注入是内功但也不要排斥脚本化能写脚本批量验证的重复性工作就让脚本去跑留出精力思考逻辑盲区。第三每次做完测试写一份简单的笔记记录闭合方式、列数判断、显示位布局这些笔记积累起来就是你最宝贵的经验库。SQL手工注入这条路走通一遍很容易走透却需要大量练习。它最迷人的地方在于当你亲手通过一次次的真假判断、逐字提取最终还原出一段完整数据的时候你对 Web 应用安全的理解会发生一次蜕变。那种“原来代码和数据库之间的边界真的可以被撬开”的震撼是任何自动扫描工具都给不了你的。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →