1. DVWA_Brute Force
方法思路:
服务器只是验证了参数Login是否被设置,没有任何的防爆破机制,且对参数username、password没有做任何过滤,存在明显的sql注入漏洞。可采用暴破或sql注入的方式绕过登录。
代码审计(核心部分):
<?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>' );
// z = x or y型,若x为真,则执行x,否则执行y。
if($result && mysqli_num_rows( $result )==1) {
// Get users details
$row = mysqli_fetch_assoc( $result );
$avatar = $row["avatar"]; // 特别注意,sql注入时我们仅能返回的一条用户信息,否则这个位置会报错。
// Login successful
$html .= "<p>Welcome to the password protected area {$user}</p>";
$html .= "<img src=\"{$avatar}\" />";
}
else {
// Login failed
$html .= "<pre><br />Username and/or password incorrect.</pre>";
}
((is_null($___mysqli_res = mysqli_close($GLOBALS["___mysqli_ston"]))) ? false : $___mysqli_res);
}
?>
/* 关于$GLOBALS["___mysqli_ston"]:
MySQLConverter设置该全局变量为您的数据库连接对象。如果MySQLConverter发现系统的数据库连接请求mysql_connect(),它将mysqli_connect函数的执行结果包含到$GLOBALS[“___mysqli_ston”]中,如下所示:
$GLOBALS[“___mysqli_ston”] = mysqli_connect($hostname,$username,$pwd);
*/
初步思路:
见到这样无验证码的登录框,条件反射该想到暴破和sql注入。先尝试下SQL注入,随手用户名框输入单引号'
,发现有如下报错:
可发现是单引号字符型注入的特征,但疑惑的是除了单引号我在用户名框并没有输入任何其他内容,为啥多了串d41d8cd98f00b204e9800998ecf8427e,特征疑似MD5哈希值,解密后发现代表null,空字符串。故又一步印证源码里是单引号闭合,因为闭合后无任何内容,故返回一个代表NULL的MD5值,于是就有了注入点。
payload:
-------------- 1 -----------------
用户名:' or '1'='1
-----------------------------------
注入无效。
通过对源码分析,我们在用户名框注入后原句中WHERE user = '' or '1'='1' AND password = '',相当于WHERE 0 or 1 and 0,也就是WHERE 0,自然不能成功登录。这里要说明两点:
1.由于PHP内嵌的SQL语句里WHERE子句用了AND运算符,因为AND运算优先级比OR大,很多情况下会导致逻辑运算后的实际结果与我们预期的不同
2.再者,WHERE子句里条件未赋值,默认就为NULL,而NULL作逻辑判断是默认是False。
这样的话可能会想通过密码框注入,就能解决这个优先级造成的问题。但也失败了,查看源码发现后端获取密码后做了一个MD5加密的处理,并不是直接明文存储,故我们注入的语句也随之变为MD5值而失效。
--------------- 2 -------------------
用户名:' or 1=1; #
-------------------------------------
注入无效。
由上一个分析可知,注入的点只能在用户名框处,随即想到用注释符来直接把后面的SQL语句注释掉,暴力的解决AND与OR运算符优先级的问题,但构造的注入语句' or 1=1; # 依然无效,查看源码后发现,虽然刚开始顺利通过if语句登录,但在接下来的提取用户图片等操作中,由于我们返回的是所有用户的信息,而源码中是仅从一个用户的返回信息中提取图片路径,这必然会导致错误,故转入else分支,登录失败。
--------------- 3 ------------------
用户名:' or 1=1 limit 0,1; #
------------------------------------
注入有效。
由上一个分析可知,我们虽成功进入if语句,但在后续代码执行过程中因返回了全部的用户信息触发了错误,导致转入执行else语句而登陆失败。故可在构造的注入语句中加入limit来限制返回结果数,便可登录成功。
--------------- 4 ------------------
用户名:admin' or '1'='1
------------------------------------
注入有效。
因为有了正确的用户名故不再受原SQL语句中的AND的优先级的影响,故我们在用户名框注入后原句中WHERE user = 'admin' or '1'='1' AND password = '',相当于WHERE 1 or 1 and 0,也就是WHERE 1,便可成功登录。
---------------- 5 ----------------
用户名:' or '1'='1
密码:letmein
-----------------------------------
注入有效。
因为有了正确的密码,使得我们按原句AND优先运算也能得到预期的结果,即WHERE user = '' or '1'='1' AND password = '0d107d09f5bbe40cade3de5c71e9e9b7',相当于WHERE 0 or 1 and 1。但要注意,这种方法存在失败的可能,因为若是对方数据库中有多个用户是用该密码,则我们返回的值就是多个,又回到了第二个分析上,且这次还无法limit限制返回个数,因为密码框的输入后端均用MD5加密。
1.2 方法二:暴破
由于登录界面无验证码,服务器仅通过if(isset($_GET['Login']))
验证是否设置了Login参数来接收和处理用户登录请求。
故burpsuite代理 —> 拦截包 —> 爆破模块一条龙服务,成功暴破出正确的用户名密码。
方法思路:
相比Low级别的代码,Medium级别的代码主要增加了mysql_real_escape_string()函数,这个函数会对字符串中的特殊符号进行转义,会被转移的字符有NULL(ASCII 0), \n, \r, \, ', " 和 Control-Z,基本上能够抵御sql注入攻击,但不是绝对的,因为MySQL5.5.37以下版本(高版本未知,并未测试)如果设置编码为GBK,可能会被宽字节注入进而绕过mysql_real_escape_string()函数;同时,$pass做了MD5校验,杜绝了通过参数password进行sql注入的可能性。
但是!!!依然没有加入有效的防爆破机制,虽然设置了sleep(2),登录失败时服务端会延迟两秒才继续执行当前脚本,但实在算不上一种有效防御方式,可忽略,同low级别一样暴破完事。
代码审计(核心部分):
<?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)) ? "" : ""));
/*
mysqli_real_escape_string()函数会转义字符串中的特殊字符,实现用户输入进行过滤,会被转义的特殊字符有NULL(ASCII 0), \n, \r, \, ', " 和 Control-Z。
*/
// 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);
}
?>
方法思路:
medium就加入了mysql_real_escape_string()函数防止sql注入,high级别一定更加巩固,故不考虑SQL注入。
这种机制本来是用于防止CSRF攻击的,但其实对我们的暴破也间接增加了难度,因为需要我们在暴破时,让每次发送的请求包,都得携带着上一次请求服务器返回的包里新生成的那个Token。
这种情况下,不再适用Bp的常规暴破手段(当然看网上也有人用Bp提供的py脚本模块进行暴破),为了锻炼自己写脚本的能力,所以决定自己写个py脚本进行暴破。于是有了下面这个简陋的暴破脚本:
运行脚本,虽然线程少,暴破速度慢了点,但好歹还是成功爆破出用户名和密码:admin,password。
代码审计(核心部分):
# high.php的完整代码
<?php
if( isset( $_GET[ 'Login' ] ) ) {
// Check Anti-CSRF token
checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
/*
High级别的代码加入了Token机制,可以抵御CSRF攻击,同时也增加了爆破的难度;
登录验证时提交了四个参数:username、password、Login以及user_token。
每次服务器返回的登陆页面中都会包含一个随机的user_token的值,用户每次登录时都要将user_token一起提交。
*/
// Sanitise username input
$user = $_GET[ 'username' ];
$user = stripslashes( $user );
// stripslashes()函数删除字符串中的反斜杠,用于过滤用户输入中的反斜杠。若有两个连续的反斜线,则只去掉一个,进一步抵御sql 注入。
$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)) ? "" : ""));
// 同medium级别一样,使用mysqli_real_escape_string()函数转义用户输入的特殊字符。
// 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(); // 每次服务器返回的登陆页面中都会包含一个随机的token。
?>
# dvwaPage.inc.php中定义的几个需关注的函数
function generateSessionToken() { # Generate a brand new (CSRF) token
if( isset( $_SESSION[ 'session_token' ] ) ) {
destroySessionToken();
}
// 删除上一次用户请求生成的Token,并在接下来重新创建一个。
$_SESSION[ 'session_token' ] = md5( uniqid() );
// 生成一个随机Token并储存在服务端本地Session中。
// Token实现方式:uniqid() 函数基于以微秒计的当前时间,生成一个随机且唯一的ID。
}
function tokenField() { # Return a field for the (CSRF) token
return "<input type='hidden' name='user_token' value='{$_SESSION[ 'session_token' ]}' />";
}
// 该函数用于将我们每次随机生成的Token插入index主页返回给客户端,这样每次服务器返回的登陆页面中才会包含一个随机的token。
function checkToken( $user_token, $session_token, $returnURL ) { # Validate the given (CSRF) token
if( $user_token !== $session_token || !isset( $session_token ) ) {
dvwaMessagePush( 'CSRF token is incorrect' );
dvwaRedirect( $returnURL );
}
}
// 该函数用于核查用户提交的Token(即$user_token)是否和我们服务器本地Session里存储的Token(即$session_token)一致。
安全设计分析:
Impossible级别被视为不存在此类漏洞的安全代码。
测试后发现,当服务器检测到某账户频繁的登录失败后,系统会将该账户锁定,暴破也就无法继续,这是一种比较可靠的防暴破机制。同时,后端PHP脚本采用了更加安全的PDO机制(PDOstatement对象)防御sql注入。
代码审计后,将其安全设计流程简要总结如下图:
代码审计(核心部分):
<?php
if( isset( $_POST[ 'Login' ] ) ) {
// 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;
// 加入了PDO机制,通过使用PDO预处理对象来防范SQL注入攻击。
// 对于用户每次提交的登录,首先查询该用户提交的账号总共进行登录尝试(登录失败)的次数。
$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 ) ) {
//$html .= "<pre><br />This account has been locked due to too many incorrect logins.</pre>";
// Calculate when the user would be allowed to login again
$timenow = strtotime( "now" );
$last_login = $row[ 'last_login' ];
$last_login = strtotime( $last_login ); // 将日期转化为unix时间戳
$timeout = strtotime( "+ {$lockout_time} minutes",$last_login ); // 用户账户解封时间
/*
注意:此处源码不对,不能正常执行返回出$timeout的时间戳
- 原句源码:$timeout = strtotime( "{$last_login} + {$lockout_time} minutes" );
- 正确格式:$b = strtotime("+7days", $a);//获取在以$a时间戳为基础的七天后的时间戳
- 参照正确格式修改后:$timeout = strtotime( "+ {$lockout_time} minutes",$last_login );
*/
// Check to see if enough time has passed, if it hasn't locked the account
// 判断该锁定状态的账号,是否到达预设的解封时间。