安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

Vincent83發表於2019-05-30

本篇繼續對於安全性測試話題,結合DVWA進行研習。

Insecure Captcha不安全驗證碼

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

1. 驗證碼到底是怎麼一回事

這個Captcha狹義而言就是谷歌提供的一種使用者驗證服務,全稱為:Completely Automated Public Turing Test to Tell Computers and Humans Apart (全自動區分計算機和人類的圖靈測試)。

*很巧妙的是,Captcha單獨成詞的意思就是,抓到你了喲^_^*

Captcha在各種海外網站被廣泛用於使用者驗證。而在國內,由於眾所周知的原因,我們不用谷歌的服務,很多介面平臺都可以提供類似服務。

比如apishop的這個四位驗證碼服務介面:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

那麼驗證碼到底在使用者驗證的過程中起到什麼樣的作用呢?

驗證碼最大的作用就是防止攻擊者使用工具或者軟體自動呼叫系統功能

就如Captcha的全稱所示,他就是用來區分人類和計算機的一種圖靈測試,這種做法可以很有效的防止惡意軟體、機器人大量呼叫系統功能:比如註冊、登入功能。

我們前面講到的Brute Force字典式暴力破解,就必須要使用工具大量嘗試登入。如果這個時候系統有個嚴密的驗證碼機制,此類攻擊就無計可施了。

其工作流程如下所示:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

2. 驗證碼繞過

為什麼前文要在驗證碼機制前面黑體強調他要是嚴密的,那當然是如果驗證碼機制設計不得當,繞過它也只是分分鐘的事情。。。

DVWA提供的試驗模組長這個樣子:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

我們將其安全級別調到最低,使用ZAP做為代理進行抓包,填入任意密碼觸發請求,看到請求內容如下:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

這是個啥子意思呢,我們參考一下DVWA的後臺邏輯:

<?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
            $html .= "
                <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
        $html .= "<pre>Password Changed.</pre>";
    }
    else {
        // Issue with the passwords matching
        $html .= "<pre>Passwords did not match.</pre>";
        $hide_form = false;
    }

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

?>

程式碼有點長,但是可以明顯看出來,這個機制是很簡單的。整個驗證邏輯將驗證分為兩步:Step1,Step2,而對於captcha驗證的邏輯只存在於Step1中的小小一段:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

既然所有驗證邏輯都只存在於Step1,那麼如果我直接繞過它,有可能嗎?

其實非常簡單,這裡我們要用到抓包-改包-重發的方法,ZAP已經給我們提供了“請求斷點”功能。

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

點選上圖中綠色斷點按鈕,ZAP就進入請求斷點狀態,在此狀態下ZAP不再簡單的將客戶端和伺服器之間的請求互動轉發,而是像其他程式設計工具的斷點功能一樣,讓請求反饋變為單步執行。那麼我們就可以在請求發出,尚未傳遞至伺服器之前,對請求內容進行篡改:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

改包重發的結果:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

密碼修改成功,而這整個過程中我們完全沒有去處理captcha的驗證碼,也就是說這個驗證碼被完全繞過了!

3. DVWA的驗證碼機制完善防禦

既然驗證碼邏輯是有可能被繞過,接下來我們來研究一下,如何建立更完善的機制呢。

Medium級別

<?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
            $html .= "
                <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
        $html .= "<pre>Password Changed.</pre>";
    }
    else {
        // Issue with the passwords matching
        $html .= "<pre>Passwords did not match.</pre>";
        $hide_form = false;
    }

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

?>

主要的機制在這裡:
安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

加入了驗證第一步是否通過的判斷。

唔。。。其實沒什麼變化,同樣改包重發一鍵搞定,無非是多加了一個引數:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

High級別

<?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
            $html .= "<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級別的變動還比較多:

  • 驗證改成了單步

  • 加入了另一個引數'g-recaptcha-response'

  • 加入驗證user-agent

  • 加入Anti-CSRF-Token(本文雖未提及,但其實前面兩個級別通過CSRF攻擊也可以實現攻擊,可以參考上一篇中的方法)

通過前兩兩個級別的攻破,我們應該知道,增加的這個引數根本沒啥用;而user-agent也是完全可以改包的。

改包如下即可繞過:

安全性測試入門 (五):Insecure CAPTCHA 驗證碼繞過

Impossible

<?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
        $html .= "<pre><br />The CAPTCHA was incorrect. Please try again.</pre>";
        $hide_form = false;
        return;
    }
    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!
            $html .= "<pre>Password Changed.</pre>";
        }
        else {
            // Feedback for the end user - failed!
            $html .= "<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級別主要的改動在於,移除了High級別中多餘的g-recaptcha-response引數判斷,而只採用CAPTCHA本身的驗證結果進行判斷,並且要求輸入原始密碼。這樣要繞過驗證碼就基本不可能了。

3. 驗證碼機制測試

話題再回到測試,如本文開頭所說,驗證碼的主要作用就是防止所謂的"機器人" - 即計算機自動程式。

在驗證碼機制推行起來之前,許多知名網站都經受過“機器人”註冊的攻擊。由於“機器人”可能在短時間內大量呼叫系統功能,因此經常導致伺服器當機以及垃圾資料。

我們之前提到過的字典式破解等攻擊方式,也可以通過驗證碼進行防禦。

不過現在隨著人工智慧的發展,驗證碼的破解,圖形解析的技術門檻越來越低,圖形類驗證碼的破解已經不是很難的事情了。

而且通過此文,我們也應該知道,現在各大系統的驗證碼一般通過介面呼叫實現,而一般來說驗證碼的處理邏輯則是獨立的。而這個處理邏輯則是安全驗證和測試的主要要點,如果邏輯設計不合理,驗證碼就會變成徒勞。如本文所示,其實我們完全沒有去處理驗證碼破解的問題。

題外話

對於UI自動化而言,我們也會遇到驗證碼的問題。那麼UI自動化中應該如何處理驗證碼呢。

要知道做為一種圖靈測試機制,驗證碼防禦的就是類似selenium這樣的計算機自動化程式,即“機器人”。我們做UI自動化有沒有必要去引入驗證碼破解機制予以破解?

個人認為,沒有這個必要。

    1. 如果你使用自動化的手段破解了驗證碼,那麼只能說明你們系統的驗證碼是廢物!要更新升級!直到你破不掉為止。
    1. 破解驗證碼需要額外的程式碼量和技術手段,費力不討好
    1. 圖形驗證碼也許現在的技術手段可以破解,但是類似谷歌的第三代captcha驗證,現在還沒有完美的破解手段。

所以,如果UI自動化中遇到驗證碼怎麼辦?

其實很簡單,與開發協商將測試環境調為測試模式,即關閉驗證碼功能即可。驗證碼功能可以單獨予以測試。

相關文章