对大多数数据库,SQL 注入的原理基本相似,因为每个数据库都遵循一个 SQL 语法标准。但也存在细微差异,包括语法、函数的不同。
- 攻击者对数据库的注入,无非利用数据库获得更多数据或更大权限,利用方式可以归为以下几个大类:
- 查询数据
- 读写文件
- 执行命令
1. MySQL
绝大多数关系型数据库管理系统(RDBMS)在解析 SQL 语法时,对保留字和函数名是不区分大小写的。表名(Table Name)和列名(Column Name)是否区分大小写,取决于数据库类型和操作系统。Linux 默认区分,Windows/macOS 默认不区分。
1.1 MySQL 中的注释
#:从#字符到行尾;--:从--到行尾(必须有空格,注入时可用+替代空格);-
/* */:中间的字符。 -
/*!*/有特殊意义,/*!55555, ...*/意思是若 MySQL 版本号高于或等于 5.55.55,语句被执行。如果 !后面不加入版本号, MySQL 会直接执行 SQL 语句。
1.2 获取元数据
MySQL 5.0 及其以上版本提供
INFORMATION_SCHEMA,INFORMATION_SCHEMA是信息数据库,它不存储用户的业务数据,而是存储关于服务器的所有信息:有哪些库、有哪些表、有哪些列、谁有权限等等。
1.2.1 查询用户数据库名称
select SCHEMA_NAME from INFORMATION_SCHEMA.SCHEMATA LIMIT 0,1
语句解析:
从 INFORMATION_SCHEMA.SCHEMATA 表中查询出第一个数据库名称。
SCHEMATA 是 INFORMATION_SCHEMA 库中的一张表,这张表里记录了服务器上所有数据库(Schema) 的信息。
SCHEMA_NAME: 这是 SCHEMATA 表中的一个列名。这一列存放的就是具体的数据库名称(比如 mysql, information_schema, performance_schema, 或者网站自己的业务库 shop_db)。
所以 SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA 的意思就是:把所有数据库的名字都列出来。
LIMIT 0,1 切片查看:
-
通常注入点(比如网页上的一个文章标题位置)一次只能显示一条数据。如果你一下子查询出 10 个数据库名字,网页可能会报错或者只显示第一个,导致数据混乱。
-
LIMIT子句用来限制返回的数据量。语法是LIMIT [偏移量], [数量]; LIMIT 0,1表示跳过 0 行(从第一行开始),只取 1 行数据,及取出了SCHEMA表的第一行数据。
1.2.2 查询当前数据库表
select TABLE_NAME from INFORMATION_SCHEMA.TABLES where TABLE_SCHEMA=(select DATABASE()) limit 0,1
语句解析:
DATABASE(): 这是一个 MySQL 的内置函数。它的作用是返回当前网站连接的数据库名称。
where TABLE_SCHEMA=(select DATABASE()) :去查找那些 所属数据库 等于 当前数据库 的信息。
例:
以下是一张 INFORMATION_SCHEMA.TABLES 表:
| TABLE_SCHEMA (所属库) | TABLE_NAME (表名) | TABLE_ROWS (行数) |
|---|---|---|
| information_schema | SCHEMATA | 50 |
| mysql | user | 3 |
| dvwa | guestbook | 15 |
| dvwa | users | 5 |
语句执行步骤:
-
(select DATABASE()):-
数据库先执行这部分,发现当前网站连接的是
dvwa库。 -
语句变成了:
WHERE TABLE_SCHEMA = 'dvwa'。
-
-
FROM INFORMATION_SCHEMA.TABLES:- 数据库打开上面的那张 Excel 表。
-
WHERE TABLE_SCHEMA = ...:-
数据库把
information_schema和mysql开头的行全部盖住。 -
现在只剩下
dvwa下面的两行:guestbook和users。
-
-
SELECT TABLE_NAME:- 数据库只盯着 “TABLE_NAME” 那一列看。
-
LIMIT 0,1:-
数据库取出筛选结果中的第一行。
-
结果:
guestbook
-
1.2.3 查询指定表的所有字段
select COLUMN_NAME from INFORMATION_SCHEMA.COLUMNS where TABLE_NAME='Student' LIMIT 0,1
INFORMATION_SCHEMA.COLUMNS 表给出了表中的列信息。
1.3 UNION 查询
UNION操作符用于将两个或多个SELECT语句的结果集“纵向”合并成一个结果集。
- MySQL 与 Oracle 不像 SQL Server 那样可以执行多语句。所以在利用查询时,通常配合 UNION 关键字。
比如有两张表:
-
表 A(文章): 只有两列,分别是
标题和正文。 -
表 B(用户密码表): 也有两列,分别是
用户名和密码。
UNION 的作用就是把 表 B 里的数据,强行粘贴到 表 A 的下面。
当网页原本只打算展示“文章”时,如果注入了 UNION,网页就会不知情地把“用户名和密码”当作“文章标题和正文”一起显示出来。
规则
列数必须相同
这是最关键的限制。
-
如果前面的
SELECT查了 3 列(例如 id, title, content)。 -
后面的
SELECT也必须查 3 列(例如 user, pass, email)。 -
如果不一致,数据库会直接报错,注入失败。
注入前通常要先用
ORDER BY或者SELECT 1,2,3,4...来测试,目的就是摸清前面到底有几列,以便凑出相同的列数。
数据类型必须兼容
-
如果第一列是数字(ID),那么后面拼上去的第一列最好也是数字。
-
如果第二列是文本(Title),后面拼上去的最好也是文本。
-
注:MySQL 在这方面比较宽容,它会尝试自动转换类型,但在 SQL Server 或 Oracle 中这点非常严格。
基本语法
假设有两个表:
表 1: news (新闻表)
| id | title |
|---|---|
| 1 | 欢迎光临 |
| 2 | 今日特价 |
表 2: users (用户表)
| username | password |
|---|---|
| admin | 123456 |
| guest | guest888 |
执行 UNION 查询:
结果集(合并后):
| id | title |
|---|---|
| 1 | 欢迎光临 |
| 2 | 今日特价 |
| admin | 123456 |
| guest | guest888 |
注意:结果集的列名通常沿用第一个 SELECT 语句的列名(这里显示为 id 和 title)。
-
UNION: 默认会进行去重操作。如果两张表里有完全一样的数据行,它只保留一行。这需要数据库进行排序和比较,速度较慢。 -
UNION ALL: 不去重。不管有没有重复,全部拼接到一起。速度更快。 -
一般
UNION就够。
其他细节
如果网页代码写着“循环显示所有查询结果”,那么联合查询的内容就会直接显示在第一个查询结果的下方。
如果网页只显示第一条结果(如 id=1 的文章),攻击者通常会把前面的 id 改成一个不存在的值(比如 -1),强迫数据库显示后面的 UNION 结果: id = -1 UNION SELECT ...
1.4 ORDER BY
原理:利用索引排序
正常情况下,ORDER BY 后面跟的是列名(比如 ORDER BY username)。
但是,SQL 标准也允许我们使用数字(列的索引/位置)来进行排序:
-
ORDER BY 1:表示按照查询结果的第 1 列进行排序。 -
ORDER BY 2:表示按照查询结果的第 2 列进行排序。 -
ORDER BY N:如果原本的查询结果只有 \(N-1\) 列,而你试图让它按第 \(N\) 列排序,就会报错。
代码层面
假设 PHP + MySQL:
这里查了 3 个字段:id, title, content。
-
注入
ORDER BY 3时:SQL :
SELECT id, title, content FROM news WHERE id = 1 ORDER BY 3数据库:按第 3 列(content)排序。 -> 执行成功。
-
注入
ORDER BY 4时:SQL :
SELECT id, title, content FROM news WHERE id = 1 ORDER BY 4数据库:按第 4 列排序 -> 抛出异常,执行失败。
1.5 回显位利用与实战
1.5.1 回显位
后端的 SQL 语句虽然查出了 3 列数据(比如 id, title, content),但网页的前端设计可能只允许显示其中两项(比如只显示 title 和 content,而 id 是隐藏在代码里用于逻辑处理的)。
-
如果查询的数据放在第 1 列(对应
id),它虽然被查出来了,但网页不显示它,叫“无回显”。 -
如果数据放在第 2 列(对应
title),网页把它打印在了标题的位置,叫“有回显”,第 2 列就是回显位。
找回显位的目的: 了解把查询的数据(比如密码),放在 UNION SELECT 的第几个位置,才能让它显示出来。
1.5.2 如何利用 UNION SELECT 1, 2, 3 虚构列寻找
虚构列
-
SELECT username ...去
username这一列查数据 -
SELECT 1 ...直接造数据,数字 1。
假设直接在数据库里执行:
数据库会凭空生成一张“临时表”:
| 'hello' | 123 |
|---|---|
| hello | 123 |
它创建了两个临时列,第一列全是字符串 "hello",第二列全是数字 123。
利用方法
错误:
直接输入:id=1 UNION SELECT 1, 2, 3
因为网页通常只设计显示一条数据。如果不修改 ID,数据库会返回两行数据:
-
原来的数据(ID=1 的那条)
-
假的填充数据(1, 2, 3)
程序通常只取第一行显示。所以看到的还是那篇原来的文章,1, 2, 3 看不见。
正确:
让第一行(原数据)消失,强迫网页显示第二行(伪造的数据)。
Payload: id=-1 UNION SELECT 1, 2, 3
原理:
-
id=-1:数据库去查 ID 为 -1 的数据。若 ID 都是正数,则这条查不到任何数据(结果为空)。 -
UNION:把我们的1, 2, 3拼在空结果后面。 -
结果:现在结果集里只有一行数据,就是
1, 2, 3。网页只能把这行数据显示出来 -> 寻找回显位。
1.5.3 实战演示与解读
假设你输入了 id=-1 UNION SELECT 1, 2, 3 后,网页变成了这样:
网页标题:2
网页正文:3
(原本显示ID的地方什么都没显示)
解读:
-
数字 1 没有出现:说明第 1 列的数据不会显示在页面上(可能是隐藏 ID)。
-
数字 2 出现在了标题位置:说明第 2 列是回显位。
-
数字 3 出现在了正文位置:说明第 3 列也是回显位。
结论:
你可以在第 2 列或者第 3 列填入指令。
把数字换成 Payload
比如,想查当前数据库的名字和用户:
原始测试 Payload:
UNION SELECT 1, 2, 3
真正的攻击 Payload:
UNION SELECT 1, database(), user()
网页此时就会显示:
网页标题:dvwa (原本显示 2 的地方变成了数据库名)
网页正文:root@localhost (原本显示 3 的地方变成了用户名)
总结
-
确定列数: 用
ORDER BY。 -
让位: 把 ID 改成
-1或其他不存在的值。 -
占位: 用
UNION SELECT 1, 2, 3...填充。 -
观察: 哪个数字显示在屏幕上,哪个位置就是回显位。
-
替换: 把那个数字换成想查的函数(如
version(),database())或子查询。
1.6 MySQL 函数利用
数据库通常都内置许多系统函数。
1.6.1 load_file() 读取文件操作
该函数帮助用户快速读取文件。文件位置必须在服务器上,文件必须为全路径名称(绝对路径),用户必须持有 FILE 权限,文件容量必须小于
max_allowed_packet字节(默认为 16 MB,最大为 1 GB)。
一些防注入语句不允许单引号出现,使用以下语句绕过:
0x2f6574632f706173737764 是 /etc/passwd 十六进制转换的结果(利用 Burp Suite Decoder,以下同理),或者使用
使用函数组合将文件转换为十六进制数据:
1.6.2 into outfile 写文件操作
像磁盘写入文件,与
load_file()一样,必须持有 FILE 权限,并且文件必须为全路径名称。
select '<?php phpinfo();?>' into outfile 'c:\wwwroot\1.php'
select char(...) into outfile 'c:\wwwroot\1.php'
1.6.3 连接字符串 concat() concat_ws()
concat()
concat()函数中的逗号也可以用十六进制数表示(0x2c)。
concat_ws()
- MySQL 常用函数速查表
1.7 MySQL 显错式注入
存在注入点,但无数据回显,却有报错回显时,把想要的数据拼接到错误信息里。
显错注入通常利用 MySQL 中特定函数的逻辑漏洞或特性。当这些函数接收到一个格式错误的参数时,MySQL 会报错,并且在报错信息中复述刚才那个错误的参数。
- 目前最流行、最稳定的是利用 XML 解析函数:
updatexml()和extractvalue()。
1.7.1 updatexml()
-
原意: 用来替换 XML 文档中的数据。
-
语法:
updatexml(XML_document, XPath_string, new_value) -
漏洞点: 第二个参数
XPath_string需要符合 XPATH 格式(比如/root/node)。如果给它一个不符合格式的特殊字符(比如~),它就会报 XPATH syntax error。
构造 Payload
利用 ~ 作为非法字符。在 MySQL 中,~ 的十六进制编码是 0x7e。
假设想爆数据库名:
数据库的反应:
-
执行
SELECT database(),得到结果dvwa。 -
concat将结果拼接成~dvwa~。 -
updatexml试图解析路径~dvwa~。 -
发现
~是非法字符,解析失败。 -
抛出错误:
XPATH syntax error: '~dvwa~'
1.7.2 extractvalue()
和 updatexml 相比参数少了一个,写起来更短一点。
-
原意: 从 XML 文档中提取值。
-
语法:
extractvalue(XML_document, XPath_string) -
漏洞点: 同样是第二个参数不支持特殊字符。
构造 Payload
报错结果:
XPATH syntax error: '~root@localhost~'
1.7.3 限制:32 字符长度
MySQL 在报 XPATH 错误时,错误信息最长只显示 32 个字符。
如果查出的数据很长(比如一串加密的 Hash 或者 GROUP_CONCAT 后的长字符串),后面的部分会被截断。
解决方案:切片法 (substr)
如果数据太长,就分段查。利用 substr() 函数。
-
查前 30 个字:
... concat(0x7e, substr((your_query), 1, 30), 0x7e) ... -
查第 31 到 60 个字:
... concat(0x7e, substr((your_query), 31, 30), 0x7e) ...
(注:为了给前后的 ~ 留位置,通常一次截取 30 个字符比较稳妥)
1.7.4 老版本:floor() 报错 (Double Query Injection)
在 MySQL 5.1.5 之前(没有 XML 函数时),或者 XML 函数被禁用时,这是唯一的选择。
它利用了 count()、group by 和 rand() 结合时的内部计数 bug。
公式(非常复杂,直接背下来):
AND (SELECT 1 FROM (SELECT count(*), concat((你的查询语句), 0x7e, floor(rand(0)*2)) x FROM information_schema.tables GROUP BY x) a)
特点:
-
优点: 兼容性极好,几乎通杀所有版本。
-
缺点: 语句极其冗长,容易被 WAF 拦截;生成的报错信息比较杂乱;偶尔需要刷新几次才能出结果(随机性)。
由于 updatexml 更简洁,现在 floor 报错用得相对少了,但在极端的过滤环境下依然是必杀技。
1.7.5 补充:XML 文档
简单来说,XML (可扩展标记语言) 就是一种专门用来“存数据”和“运送数据”的文本格式。
-
HTML : 作用是 “显示” 数据。比如把标题加粗、把图片放中间。
-
XML (后端的数据格式): 作用是 “传输” 数据。不负责字号多大、颜色多红,只负责 “这是什么数据” 。
树形结构
示例:
<website>
<user id="1">
<username>admin</username>
<password>123456</password>
<level>super_admin</level>
</user>
<user id="2">
<username>guest</username>
<password>guest888</password>
<level>normal</level>
</user>
</website>
关键特征:
-
自定义标签: 这里的
<username>、<password>并不是规定好的,而是写代码的人自己定义的。这就叫“可扩展”(Extensible)。 -
严格的嵌套: 就像文件夹一样,
<user>在<website>里面,不能乱放。 -
唯一的根: 最外面必须只能有一个大标签包着所有内容(这里是
<website>)。
XML 与 SQL 注入
有时候业务系统会把一堆数据打包成 XML 格式存到数据库的一个字段里。
MySQL 为了读取这些数据,提供了 XPath 语法。
-
XPath 就像是文件系统的路径。
-
如果要找上面的用户名,路径大概是:
/website/user/username。
回顾 updatexml 注入原理:
当我们构造 Payload:
updatexml(1, concat(0x7e, (SELECT user()), 0x7e), 1)
-
正常情况:
updatexml的第二个参数期待一个合法的路径(比如/website/user)。 -
攻击情况: 我们给了它一个
~root@localhost~。 -
触发报错:
~在 XML 路径语法中是非法字符。MySQL 解析器一看:“这~是什么鬼?这不是合法的 XML 路径结构啊!” -
结果: MySQL 报错说:“你的路径
~root@localhost~写法有误”。这就把我们查到的数据直接卖出来了。
1.8 宽字节注入
宽字节注入 (Wide Byte Injection) 是一种专门用来绕过代码层防御(特别是转义函数)的技术,常见于 PHP + MySQL 中。
1.8.1 核心原理:字符集的不匹配
理解两个前提:
A. addslashes()
为了防止 SQL 注入,开发者通常会使用 PHP 的 addslashes() 函数或者开启 magic_quotes_gpc(旧版本)。
它的作用是:在特殊字符(单引号 '、双引号 "、反斜杠 \、NULL)前面强制加一个反斜杠 \ 进行转义。
-
输入:
1' OR 1=1 -
转换后变成:
1\' OR 1=1 -
数据库看到: 这是一个普通的字符串,包含了一个单引号,而不是闭合符号。注入失败。
B. GBK 编码 (双字节)
-
数据库连接使用了 GBK 双字节编码,或其他宽字节字符集,如 BIG5。
-
GBK 规则: 如果一个字节的值大于 128 (hex 0x80),它会被认为是一个汉字的首字节,把后面紧跟的一个字节与前面合起来当作一个汉字解析。
1.8.2 过程分析
利用 GBK 的这个特性,在单引号 ' 前面故意加一个奇怪的字符,比如 %df。
构造 Payload
攻击者输入:%df%27
-
%df:一个高位字节。 -
%27:单引号'。
经过 PHP 过滤 (关键时刻)
PHP 的 addslashes() 识别到了单引号 %27,于是在它前面加了一个反斜杠 \ (也就是十六进制的 %5c)。
此时的数据流变成了三个字节:
%df + %5c (\) + %27 (')
进入 MySQL (GBK 环境)
MySQL 使用 GBK 编码来解析这串数据。
-
读到了
%df。这是一个大于 128 的值。 -
%df%5c在 GBK 编码中正好是一个合法的汉字 —— “縗” (运)。 -
单引号
%27不再被转义!
1.9 MySQL 长字符截断
MySQL 长字符截断是指:当攻击者输入的字符串长度超过了数据库字段规定的最大长度(比如
VARCHAR(10))时,MySQL 在某些配置下不会报错,而是自动把多余的部分截断,只保留前面的部分存入数据库。
1.9.1 核心原理:非严格模式的“宽容”
这个漏洞之所以存在,核心原因在于 MySQL 的 SQL Mode 配置。
-
Strict Mode (严格模式): 如果你输入的数据太长,MySQL 会直接报错(Error),拒绝写入。这是安全的。
-
Non-Strict Mode (非严格模式): MySQL 只会报 Warning,然后把数据截断,强行塞进去。
1.9.2 攻击场景演示:管理员账号注册劫持
假设某网站的注册逻辑如下:
-
检查用户名是否存在: 代码查询
SELECT count(*) FROM users WHERE username = '$input'。 -
如果不存在,则注册: 执行
INSERT INTO users ...。
数据库结构:
-
username字段类型为VARCHAR(7)(最大长度 7 个字符)。 -
已经存在管理员账号:
admin。
攻击步骤:
-
攻击者输入 Payload:
攻击者尝试注册一个用户名为:
admin x(注意:
admin后面跟了 2 个空格,最后加个x。总长度为 8) -
第一步:代码层检查 (Bypass)
后台执行 SQL:
SELECT * FROM users WHERE username = 'admin x'-
数据库里只有
admin。 -
因为
'admin'不等于'admin x'。 -
结果: 用户名未被占用,允许注册。
-
-
第二步:写入数据库 (Truncation)
后台执行 SQL:
INSERT INTO users (username, password) VALUES ('admin x', '123456')-
username字段限制长度为 7。 -
输入的数据
admin x长度为 8。 -
MySQL(非严格模式)触发截断机制,切掉第 8 个字符
x。 -
实际存入的数据:
admin(注意:admin 后面带了两个空格)。
-
-
第三步:登录验证 (The Magic)
现在数据库里有两条记录(假设没有唯一性约束,或者利用空格绕过):
-
id=1, username='admin'(真管理员) -
id=99, username='admin '(攻击者)
当攻击者用
admin登录时,MySQL 在进行字符串比较时(在默认校对规则下),通常会忽略末尾的空格。即:
WHERE username = 'admin '等同于WHERE username = 'admin'。后果:
-
如果注册的账号被系统认为是“管理员”,你可能直接拥有了管理员权限。
-
或者,如果你利用这个机制去“修改密码”,系统可能会查找到第一个匹配的账号(真管理员),从而把真管理员的密码给改了。
-
-
这利用了 MySQL “存的时候区分空格,查的时候忽略空格” 的模糊地带。
1.9.3 漏洞复现的前提条件
-
MySQL 处于非严格模式
-
业务逻辑缺陷:
注册时检查用户是否存在的逻辑,必须是在代码层通过 SQL 查询结果判断,而不是依赖数据库的 Unique 报错。
-
字段长度已知:
攻击者需要知道(或猜到)
username字段的具体长度(比如 7),才能构造出刚好在第 8 位被截断的 Payload。
1.10 延时注入
延时注入是 SQL 注入中最无奈、最慢、但也是最通用的一种手段。当没有数据回显,没有错误回显时,(此时的注入也叫做盲注)只能以页面加载时间作为注入内容的响应结果。
- 手动延时注入很痛苦,通常使用 Python 脚本或 SQLMap 等工具。
1.10.1 核心函数
A. SLEEP(N)
-
作用: 让数据库暂停执行 N 秒。
-
示例:
SELECT SLEEP(5);-> 数据库会卡住 5 秒。
B. IF(condition, true, false)
-
作用: 逻辑判断。
-
语法: 如果
condition为真,执行true部分;否则执行false部分。 -
示例:
IF(1=1, SLEEP(5), 0)-> 因为 1=1 是真,所以执行 SLEEP(5)。
C. BENCHMARK(count, expr) (备用方案)
-
作用: 当管理员禁用了
SLEEP函数时使用。它让数据库重复执行某条指令 N 次,消耗 CPU 资源来制造延时。 -
示例:
BENCHMARK(10000000, MD5(1))-> 做一千万次 MD5 运算,以此来拖慢时间。
1.10.2 逐字猜解
-
目标 URL:
http://example.com/product.php?id=1 -
已知条件: 页面没有任何回显,输入
and 1=2页面也没有任何变化(无布尔反馈)。 -
攻击目标: 确认当前数据库名的第一个字母是不是
d。
-
ASCII(SUBSTR(DATABASE(),1,1))=100(Condition):-
DATABASE(): 获取当前数据库名(假设是dvwa)。 -
SUBSTR(..., 1, 1): 从第 1 个字符开始,截取 1 个字符。结果是'd'。 -
ASCII(...): 把'd'转换成 ASCII 码。字符'd'的 ASCII 码刚好是 100。 -
判断:
100 = 100,结果为 真 (True)。
-
-
SLEEP(5)(True 分支):- 因为前面的判断为真,数据库会执行这个函数,暂停 5 秒 响应。
-
1(False 分支):- 如果判断为假(比如第一个字母不是 'd'),则返回数字 1,页面会秒开。
-
如果把条件改成
length(database())=?,可以做猜解数据库名长度等操作。 -
可以通过浏览器的刷新按钮或开发者工具(F12)的 Network 面板查看时间。
1.10.3 提高效率
-
二分法 (Binary Search):
不要按顺序猜 (a, b, c...),而是猜范围。
-
ASCII > 100?-> 是 (Sleep)。 -
ASCII > 110?-> 否 (秒开)。 -
那就在 100 到 110 之间猜。
-
-
自动化工具:
使用 Python 脚本或 SQLMap。