1.2 CTF中的SQL注入
Web应用开发过程中,为了内容的快速更新,很多开发者使用数据库进行数据存储。而由于开发者在程序编写过程中,对传入用户数据的过滤不严格,将可能存在的攻击载荷拼接到SQL查询语句中,再将这些查询语句传递给后端的数据库执行,从而引发实际执行的语句与预期功能不一致的情况。这种攻击被称为SQL注入攻击。
大多数应用在开发时将诸如密码等的数据放在数据库中,由于SQL注入攻击能够泄露系统中的敏感信息,使之成为了进入各Web系统的入口级漏洞,因此各大CTF赛事将SQL注入作为Web题目的出题点之一,SQL注入漏洞也是现实场景下最常见的漏洞类型之一。
本节将介绍SQL注入的原理、利用、防御和绕过方法。考虑到篇幅,同时SQL注入的原理相似,所以这里仅针对比赛出题过程中使用得最多的MySQL数据库的注入攻击进行介绍,而不对Access、Microsoft SQL Server、NoSQL等进行详细介绍。读者在阅读本章时需要有一定的SQL和PHP基础。
1.2.1 SQL注入基础
SQL注入是开发者对用户输入的参数过滤不严格,导致用户输入的数据能够影响预设查询功能的一种技术,通常将导致数据库的原有信息泄露、篡改,甚至被删除。本节用一些简单的例子详细介绍SQL注入的基础,包括数字型注入、UNION注入、字符型注入、布尔盲注、时间注入、报错注入和堆叠注入等注入方式和对应的利用技巧。
【测试环境】Ubuntu 16.04(IP地址:192.168.20.133),Apache,MySQL 5.7,PHP 7.2。
1.2.1.1 数字型注入和UNION注入
第一个例子的PHP部分源代码(sql1.php)如下(代码含义见注释)。
数据库的表结构见图1-2-1。新闻表wp_news的内容见图1-2-2。用户表wp_user的内容见图1-2-3。
图1-2-1
图1-2-2
图1-2-3
本节的目标是通过HTTP的GET方式输入的id值,将本应查询新闻表的功能转变成查询admin(通常为管理员)的账号和密码(密码通常是hash值,这里为了演示变为明文this_is_the_admin_password)。管理员的账号和密码是一个网站系统最重要的凭据,入侵者可以通过它登录网站后台,从而控制整个网站内容。
通过网页访问链接http://192.168.20.133/sql1.php?id=1,结果见图1-2-4。
图1-2-4
页面显示的内容与图1-2-2的新闻表wp_news中的第一行id为1的结果一致。事实上,PHP将GET方法传入的id=1与前面的SQL查询语句进行了拼接。原查询语句如下:
收到请求http://192.168.20.133/sql1.php?id=1的$_GET['id']被赋值为1,最后传给MySQL的查询语句如下:
我们直接在MySQL中查询也能得到相同的结果,见图1-2-5。
图1-2-5
现在互联网上绝大多数网站的内容是预先存储在数据库中,通过用户传入的id等参数,从数据库的数据中查询对应记录,再显示在浏览器中,如https://bbs.symbo1.com/t/topic/53中的“53”,见图1-2-6。
图1-2-6
下面演示通过用户输入的id参数进行SQL注入攻击的过程。
访问链接http://192.168.20.133/sql1.php?id=2,可以看到图1-2-7中显示了图1-2-2中id为2的记录,再访问链接http://192.168.20.133/sql1.php?id=3-1,可以看到页面仍显示id=2的记录,见图1-2-8。这个现象说明,MySQL对“3-1”表达式进行了计算并得到结果为2,然后查询了id=2的记录。
从数字运算这个特征行为可以判断该注入点为数字型注入,表现为输入点“$_GET['id']”附近没有引号包裹(从源码也可以证明这点),这时我们可以直接输入SQL查询语句来干扰正常的查询(结果见图1-2-9):
图1-2-7 正常的查询链接
图1-2-8
图1-2-9
这个SQL语句的作用是查询新闻表中id=1时对应行的title、content字段的数据,并且联合查询用户表中的user、pwd(即账号密码字段)的全部内容。
我们通过网页访问时应只输入id后的内容,即访问链接:http://192.168.20.133/sql1.php?id=1 union select user,pwd from wp_user。结果见图1-2-10,图中的“%20”是空格的URL编码。浏览器会自动将URI中的特殊字符进行URL编码,服务器收到请求后会自动进行URL解码。
图1-2-10
然而图1-2-10中并未按预期显示用户和密码的内容。事实上,MySQL确实查询出了两行记录,但是PHP代码决定了该页面只显示一行记录,所以我们需要将账号密码的记录显示在查询结果的第一行。此时有多种办法,如可以继续在原有数据后面加上“limit 1,1”参数(显示查询结果的第2条记录,见图1-2-11)。“limit 1,1”是一个条件限定,作用是取查询结果第1条记录后的1条记录。又如,指定id=-1或者一个很大的值,使得图1-2-9中的第一行记录无法被查询到(见图1-2-12),这样结果就只有一行记录了(见图1-2-13)。
图1-2-11
图1-2-12
图1-2-13
通常采用图1-2-13所示的方法,访问http://192.168.20.133/sql1.php?id=-1 union select user,pwd from wp_user,结果见图1-2-14,通过数字型注入,成功地获得了用户表的账号和密码。
图1-2-14
通常把使用UNION语句将数据展示到页面上的注入办法称为UNION(联合查询)注入。
刚才的例子是因为我们已经知道了数据库结构,那么在测试情况下,如何知道数据表的字段名pwd和表名wp_user呢?
MySQL 5.0版本后,默认自带一个数据库information_schema,MySQL的所有数据库名、表名、字段名都可以从中查询到。虽然引入这个库是为了方便数据库信息的查询,但客观上大大方便了SQL注入的利用。
下面开始注入实战。假设我们不知道数据库的相关信息,先通过id=3-1和id=2的回显页面一致(即图1-2-7与图1-2-8的内容一致)判断这里存在一个数字型注入,然后通过联合查询,查到本数据库的其他所有表名。访问http://192.168.20.133/sql1.php?id=-1 union select 1,group_concat(table_name)from information_schema.tables where table_schema=database(),结果见图1-2-15。
图1-2-15
table_name字段是information_schema库的tables表的表名字段。表中还有数据库名字段table_schema。而database()函数返回的内容是当前数据库的名称,group_concat是用“,”联合多行记录的函数。也就是说,该语句可以联合查询当前库的所有(事实上有一定的长度限制)表名并显示在一个字段中。而图1-2-15与图1-2-16的结果一致也证明了该语句的有效性。这样就可以得到存在数据表wp_user。
图1-2-16
同理,通过columns表及其中的column_name查询出的内容即为wp_user中的字段名。访问http://192.168.20.133/sql1.php?id=-1 union select 1,group_concat(column_name)from information_schema.columns where table_name='wp_user',可以得到对应的字段名,见图1-2-17。
图1-2-17
至此,第一个例子结束。数字型注入的关键在于找到输入的参数点,然后通过加、减、乘除等运算,判断出输入参数附近没有引号包裹,再通过一些通用的攻击手段,获取数据库的敏感信息。
1.2.1.2 字符型注入和布尔盲注
下面简单修改sql1.php的源代码,将其改成sql2.php,如下所示。
其实与sql1.php相比,它只是在GET参数输入的地方包裹了单引号,让其变成字符串。在MySQL中查询:
结果见图1-2-18。
图1-2-18
在MySQL中,等号两边如果类型不一致,则会发生强制转换。当数字与字符串数据比较时,字符串将被转换为数字,再进行比较,见图1-2-19。字符串1与数字相等;字符串1a被强制转换成1,与1相等;字符串a被强制转换成0所以与0相等。
图1-2-19
按照这个特性,我们容易判断输入点是否为字符型,也就是是否有引号(可能是单引号也可能是双引号,绝大多数情况下是单引号)包裹。
访问http://192.168.20.133/sql2.php?id=3-2,结果见图1-2-20,页面为空,猜测不是数字型,可能是字符型。继续尝试访问http://192.168.20.133/sql2.php?id=2a,结果见图1-2-21,说明确实是字符型。
图1-2-20
图1-2-21
尝试使用单引号来闭合前面的单引号,再用“--%20”或“%23”注释后面的语句。注意,这里一定要URL编码,空格的编码是“%20”,“#”的编码是“%23”。
访问http://192.168.20.133/sql2.php?id=2%27%23,结果见图1-2-22。
图1-2-22
成功显示内容,此时的MySQL语句如下:
输入的单引号闭合了前面预置的单引号,输入的“#”注释了后面预置的单引号,查询语句成功执行,接下来的操作就与1.2.1.1节的数字型注入一致了,结果见图1-2-23。
图1-2-23
当然,除了注释,也可以用单引号来闭合后面的单引号,见图1-2-24。
图1-2-24
访问http://192.168.20.133/sql2.php?id=1'and'1,这时数据库查询语句见图1-2-25。
图1-2-25
关键字WHERE是SELECT操作的一个判断条件,之前的id=1即查询条件。这里,AND代表需要同时满足两个条件,一个是id=1,另一个是'1'。由于字符串'1'被强制转换成True,代表这个条件成立,因此数据库查询出id=1的记录。
再看图1-2-26所示的语句:第1个条件仍为id=1,第2个条件字符串'a'被强制转换成逻辑假,所以条件不满足,查询结果为空。当页面显示为sqli时,AND后面的值为真,当页面显示为空时,AND后面的值为假。虽然我们看不到直接的数据,但是可以通过注入推测出数据,这种技术被称为布尔盲注。
图1-2-26
那么,这种情况下如何获得数据呢?我们可以猜测数据。例如,先试探这个数据是否为'a',如果是,则页面显示id=1的回显,否则页面显示空白;再试探这个数据是否为'b',如果数据只有1位,那么只要把可见字符都试一遍就能猜到。假设被猜测的字符是'f',访问http://192.168.20.133/sql2.php?id=1'and'f'='a',猜测为'a',没有猜中,于是尝试'b'、'c'、'd'、'e',都没有猜中,直到尝试'f'的时候,猜中了,于是页面回显了id=1的内容,见图1-2-27。
当然,这样依次猜测的速度太慢。我们可以换个符号,使用小于符号按范围猜测。访问链接http://192.168.20.133/sql2.php?id=1'and'f'<'n',这样可以很快知道被猜测的数据小于字符'n',随后用二分法继续猜出被测字符。
上述情况只是在单字符条件下,但实际上数据库中的数据大多不是一个字符,那么,在这种情况下,我们如何获取每一位数据?答案是利用MySQL自带的函数进行数据截取,如substring()、mid()、substr(),见图1-2-28。
图1-2-27
图1-2-28
上面简单介绍了布尔盲注的相关原理,下面利用布尔盲注来获取admin的密码。在MySQL中查询(结果见图1-2-29):
然后截取数据的第1位(结果见图1-2-30):
于是完整的利用SQL语句如下:
图1-2-29
图1-2-30
访问链接http://192.168.20.133/sql2.php?id=1'and(select mid((select concat(user,0x7e,pwd)from wp_user),1,1))='a'%23,结果见图1-2-31。截取第2位,访问http://192.168.20.133/sql2.php?id=1'and(select mid((select concat(user,0x7e,pwd)from wp_user),2,1))='d'%23,结果与图1-2-31的一致,说明第2位是'd'。以此类推,即可得到相应的数据。
图1-2-31
在盲注过程中,根据页面回显的不同来判断布尔盲注比较常见,除此之外,还有一类盲注方式。由于某些情况下,页面回显的内容完全一致,故需要借助其他手段对SQL注入的执行结果进行判断,如通过服务器执行SQL语句所需要的时间,见图1-2-32。在执行的语句中,由于sleep(1)的存在,使整个语句在执行时需要等待1秒,导致执行该查询需要至少1秒的时间。通过修改sleep()函数中的参数,我们可以延时更长,来保证是注入导致的延时,而不是业务正常处理导致的延时。与回显的盲注的直观结果不同,通过sleep()函数,利用IF条件函数或AND、OR函数的短路特性和SQL执行的时间判断SQL攻击的结果,这种注入的方式被称为时间盲注。其本质与布尔盲注类似,故具体利用方式不再赘述。
图1-2-32
1.2.1.3 报错注入
有时为了方便开发者调试,有的网站会开启错误调试信息,部分代码如sql3.php所示。
此时,只要触发SQL语句的错误,即可在页面上看到错误信息,见图1-2-33。这种攻击方式则是因为MySQL会将语句执行后的报错信息输出,故称为报错注入。
图1-2-33
通过查阅相关文档可知,updatexml在执行时,第二个参数应该为合法的XPATH路径,否则会在引发报错的同时将传入的参数进行输出,如图1-2-34所示。
图1-2-34
利用这个特征,针对存在报错显示的例子,将我们想得到的信息传入updatexml函数的第二个参数,在浏览器中尝试访问链接http://192.168.20.133/sql3.php?id=1'or updatexml(1,concat(0x7e,(select pwd from wp_user)),1)%23,结果见图1-2-35。
图1-2-35
另外,当目标开启多语句执行的时候,可以采用多语句执行的方式修改数据库的任意结构和数据,这种特殊的注入情况被称为堆叠注入。
部分源代码如sql4.php所示。
此时可在闭合单引号后执行任意SQL语句,如在浏览器中尝试访问http://192.168.20.133/sql4.php?id=1%27;delete%20%20from%20wp_files;%23,结果见图1-2-36,删除了表wp_files中的所有数据。
图1-2-36
本节讲述了数字型注入、UNION注入、布尔盲注、时间盲注、报错注入,这些是在后续注入中需要用到的基础。根据获取数据的便利性,这些注入技巧的使用优先级是:UNION注入>报错注入>布尔盲注>时间盲注。
堆叠注入不在排序范围内,因为其通常需要结合其他技巧使用才能获取数据。
1.2.2 注入点
本节将从SQL语句的语法角度,从不同的注入点位置讲述SQL注入的技巧。
1.2.2.1 SELECT注入
SELECT语句用于数据表记录的查询,常在界面展示的过程使用,如新闻的内容、界面的展示等。SELECT语句的语法如下:
1.注入点在select_expr
源代码如sqln1.php所示。
此时可以采取1.2.1.2节中的时间盲注进行数据获取,不过根据MySQL的语法,我们有更优的方法,即利用AS别名的方法,直接将查询的结果显示到界面中。访问链接http://192.168.20.133/sqln1.php?id=(select%20pwd%20from%20wp_user)%20as%20title,见图1-2-37。
图1-2-37
2.注入点在table_reference
上文中的SQL查询语句改为如下:
我们仍可以用别名的方式直接取出数据,如
当然,在不知表名的情况下,可以先从information_schema.tables中查询表名。
在select_expr和table_reference的注入,如果注入的点有反引号包裹,那么需要先闭合反引号。读者可以在自己本地测试具体语句。
3.注入点在WHERE或HAVING后
SQL查询语句如下:
这种情况已经在1.2.1节的注入基础中讲过,也是现实中最常遇到的情况,要先判断有无引号包裹,再闭合前面可能存在的括号,即可进行注入来获取数据。
注入点在HAVING后的情况与之相似。
4.注入点在GROUP BY或ORDER BY后
当遇到不是WHERE后的注入点时,先在本地的MySQL中进行尝试,看语句后面能加什么,从而判断当前可以注入的位置,进而进行针对性的注入。假设代码如下:
经过测试可以发现,title=id desc,(if(1,sleep(1),1))会让页面迟1秒,于是可以利用时间注入获取相关数据。
本节的情况在大部分开发者有了安全意识后仍广泛存在,主要原因是开发者在编写系统框架时无法使用预编译的办法处理这类参数。事实上,只要对输入的值进行白名单比对,基本上就能防御这种注入。
5.注入点在LIMIT后
LIMIT后的注入判断比较简单,通过更改数字大小,页面会显示更多或者更少的记录数。由于语法限制,前面的字符注入方式不可行(LIMIT后只能是数字),在整个SQL语句没有ORDER BY关键字的情况下,可以直接使用UNION注入。另外,我们可根据SELECT语法,通过加入PROCEDURE来尝试注入,这类语句只适合MySQL 5.6前的版本,见图1-2-38。
图1-2-38
同样可以基于时间注入,语句如下:
BENCHMARK语句的处理时间大约是1秒。在有写入权限的特定情况条件下,我们也可以使用INTO OUTFILE语句向Web目录写入webshell,在无法控制文件内容的情况下,可通过“SELECT xx INTO outfile"/tmp/xxx.php"LINES TERMINATED BY'<?php phpinfo();?>'”的方式控制部分内容,见图1-2-39。
图1-2-39
1.2.2.2 INSERT注入
INSERT语句是插入数据表记录的语句,网页设计中常在添加新闻、用户注册、回复评论的地方出现。INSERT的语法如下:
通常,注入位于字段名或者字段值的地方,且没有回显信息。
1.注入点位于tbl_name
如果能够通过注释符注释后续语句,则可直接插入特定数据到想要的表内,如管理员表。例如,对于如下SQL语句:
开发者预想的是,控制table的值为wp_news,从而插入新闻表数据。由于可以控制表名,我们可以访问http://192.168.20.132/insert.php?table=wp_user values(2,'newadmin','newpass')%23,访问前、后的wp_user表内容见图1-2-40。可以看到,已经成功地插入了一个新的管理员。
图1-2-40
2.注入点位于VALUES
假设语句如下:
此时可先闭合单引号,然后另行插入一条记录,通常管理员和普通用户在同一个表,此时便可以通过表字段来控制管理员权限。注入语句如下:
如果用户表的第2个字段代表的是管理员权限标识,便能插入一个管理员用户。在某些情况下,我们也可以将数据插入能回显的字段,来快速获取数据。假设最后一个字段的数据会被显示到页面上,那么采用如下语句注入,即可将第一个用户的密码显示出来:
1.2.2.3 UPDATE注入
UPDATE语句适用于数据库记录的更新,如用户修改自己的文章、介绍信息、更新信息等。UPDATE语句的语法如下:
例如,以注入点位于SET后为例。一个正常的update语句如图1-2-41,可以看到,原先表wp_user第2行的id数据被修改。
图1-2-41
当id数据可控时,则可修改多个字段数据,形如
其余位置的注入点利用方式与SELECT注入类似,这里不再赘述。
1.2.2.4 DELETE注入
DELETE注入大多在WHERE后。假设SQL语句如下:
DELETE语句的作用是删除某个表的全部或指定行的数据。对id参数进行注入时,稍有不慎就会使WHERE后的值为True,导致整个wp_news的数据被删除,见图1-2-42。
图1-2-42
为了保证不会对正常数据造成干扰,通常使用'and sleep(1)'的方式保证WHERE后的结果返回为False,让语句无法成功执行,见图1-2-43。后续步骤与1.2.1.2节的时间盲注的一致,这里不再赘述。
图1-2-43
1.2.3 注入和防御
本节将讲述常用的防御手段和绕过注入的若干方法,重点为读者提供绕过的思路,而不是作为注入宝典的参考。
1.2.3.1 字符替换
为了防御SQL注入,有的开发者直接简单、暴力地将诸如SELECT、FROM的关键字替换或者匹配拦截。
1.只过滤了空格
除了空格,在代码中可以代替的空白符还有%0a、%0b、%0c、%0d、%09、%a0(均为URL编码,%a0在特定字符集才能利用)和/**/组合、括号等。假设PHP源码如下:
使用之前的payload(见图1-2-44),由于空格被替换为空,因此SQL语句查询出错,页面中没有显示title内容。将空格替换为“%09”,效果见图1-2-45。
图1-2-44
图1-2-45
2.将SELECT替换成空
遇到将SELECT替换为空的情况,可以用嵌套的方式,如SESELECTLECT形式,在经过过滤后又变回了SELECT。将上面代码中的语句
替换为
访问http://192.168.20.132/replace.php?id=-1%09union%09selselectect%091,2,结果见图1-2-46。
图1-2-46
3.大小写匹配
在MySQL中,关键字是不区分大小写的,如果只匹配了"SELECT",便能用大小写混写的方式轻易绕过,如"sEleCT"。
4.正则匹配
正则匹配关键字"\bselect\b"可以用形如"/*!50000select*/"的方式绕过,见图1-2-47。
图1-2-47
5.替换了单引号或双引号,忘记了反斜杠
当遇到如下注入点时:
可构造如下语句进行绕过
第1个可控点的反斜杠转义了可控点1预置的单引号,导致可控点2逃逸出单引号,见图1-2-48。
图1-2-48
可以看到,sleep()被成功执行,说明可控点2位置已经成功地逃逸引号。使用UNION注入即可获取敏感信息,见图1-2-49。
图1-2-49
1.2.3.2 逃逸引号
注入的重点在于逃逸引号,而开发者常会将用户的输入全局地做一次addslashes,也就是转义如单引号、反斜杠等字符,如“'”变为“\'”。在这种情况下,看似不存在SQL注入,但在某些条件下仍然能够被突破。
1.编码解码
开发者常常会用到形如urldecode、base64_decode的解码函数或者自定义的加解密函数。当用户输入addslashes函数时,数据处于编码状态,引号无法被转义,解码后如果直接进入SQL语句即可造成注入,同样的情况也发生在加密/解密、字符集转换的情况。宽字节注入就是由字符集转换而发生注入的经典案例,读者如感兴趣,可自行查询相关文档了解。
2.意料之外的输入点
开发者在转义用户输入时遗漏了一些可控点,以PHP为例,形如上传的文件名、http header、$_SERVER['PHP_SELF']这些变量通常被开发者遗忘,导致被注入。
3.二次注入
二次注入的根源在于,开发者信任数据库中取出的数据是无害的。假设当前数据表见图1-2-50,用户输入的用户名admin'or'1经过转义为了admin\'or\'1,于是SQL语句为:
此时,由于引号被转义,并没有注入产生,数据正常入库,见图1-2-51。
图1-2-50
图1-2-51
但是,当这个用户名再次被使用时(通常为session信息),如下代码所示:
当name进入SQL语句后,变为
从而产生注入。
4.字符串截断
在标题、抬头等位置,开发者可能限定标题的字符不能超过10个字符,超过则会被截断。例如,PHP代码如下:
假设攻击者输入“aaaaaaaaa'”,自动转义为“aaaaaaaaa\'”,由于字符长度限制,被截取为“aaaaaaaaa\”,正好转义了预置的单引号,这样在content的地方即可注入。我们采取VALUES注入的方法,访问http://192.168.20.132/insert2.php?title=aaaaaaaaa\&content=,1,1),(3,4,(select%20pwd%20from%20wp_user%20limit%201),1)%23,即可看到数据表wp_news新增了2行,见图1-2-52。
图1-2-52
1.2.4 注入的功效
前面讲述了SQL注入的基础和绕过的方法,那么,注入到底有什么用呢?结合作者的实战经验,总结如下。
❖ 在有写文件权限的情况下,直接用INTO OUTFILE或者DUMPFILE向Web目录写文件,或者写文件后结合文件包含漏洞达到代码执行的效果,见图1-2-53。
❖ 在有读文件权限的情况下,用load_file()函数读取网站源码和配置信息,获取敏感数据。
❖ 提升权限,获得更高的用户权限或者管理员权限,绕过登录,添加用户,调整用户权限等,从而拥有更多的网站功能。
❖ 通过注入控制数据库查询出来的数据,控制如模板、缓存等文件的内容来获取权限,或者删除、读取某些关键文件。
❖ 在可以执行多语句的情况下,控制整个数据库,包括控制任意数据、任意字段长度等。
❖ 在SQL Server这类数据库中可以直接执行系统命令。
图1-2-53
1.2.5 SQL注入小结
本节仅选用了CTF中最简单的一些考点进行了简介,而实际比赛中会将很多的特性、函数进行结合。SQL注入类的MySQL题目中可以采用的过滤方法多种多样,同时由于SQL服务器在实现时的不同,即使是相同的功能,也会有多种多样的实现方式,而题目会将这种过滤时不容易考虑到的知识点或注入技巧作为考点。那么,为了做出题目或更深入了解SQL注入原理,最关键的是根据不同的SQL服务器类型,查找相关资料,通过fuzz得出被过滤掉的字符、函数、关键词等,在文档中查找功能相同但不包含过滤特征的替代品,最终完成对相关防御功能的绕过。
此外,平时多积累、多练习也会很有帮助,一些平台如sqli-labs(https://github.com/Audi-1/sqli-labs)提供不同过滤等级下的注入题目,其中涵盖了大多数出题点。我们通过练习、总结,在比赛中总会能找到需要的组合方式,最终解决题目。