프로젝트/DVWA 실습

DVWA 실습 #7 - Insecure CAPTCHA

하루히즘 2021. 1. 14. 21:39

DVWA의 여섯 번째 실습 대상인 Insecure CAPTCHA다. 현재 문제에서는 CAPTCHA를 이용하여 비밀번호 변경 기능을 다른 프로그램이 공격하지 못하도록 방어하고 있다. 이 잘못 적용된 CAPTCHA 시스템을 악용하여 현재 로그인한 사용자 모르게 비밀번호를 바꾸는 것이 목적이다.

CAPTCHA?

CAPTCHA란 현재 서비스를 사용하고 있는 주체가 진짜 사람인지 컴퓨터 프로그램인지 구분하기 위해 고안된 프로그램이다. 뒤틀린 형태의 텍스트나 여러 방향으로 잘린 이미지 등 사람이 아니면 알아보기 힘든 콘텐츠로 시험하여 자동화된 봇이 악의적인 작업(spamming 등)을 수행하지 못하도록 하는 게 주목적이며 브루트 포스 방지, 추가 인증 등 다양한 용도로 사용되고 있다. 옛날에는 왜곡된 텍스트를 분석하여 원본 텍스트를 입력하는 유형이 많았지만 요즘은 여러 개의 그림 타일에서 주어진 단어에 해당하는 타일을 선택하거나 드래그 앤 드롭으로 작동하는 캡차들이 등장하고 있다. 그러나 결국 사람인지를 판단하기 위한 캡차 특성상 값싼 노동력으로 실제 사람을 시켜 캡차를 통과(클릭 팜)하거나 텍스트 인식, 인공 지능 기술을 이용하여 캡차를 통과하는 알고리즘도 등장하고 있다(위키피디아).

 

최근에는 reCAPTCHA라는 방법도 자주 사용되고 있는데 이는 사용자에게 두 개의 단어를 입력하도록 요구한다. 하나는 컴퓨터(OCR)가 인식하지 못한 단어(예를 들면 오래된 책에 나와있는 단어나 구절), 다른 하나는 컴퓨터가 답을 알고 있는 단어를 제시하여 사용자가 후자를 올바르게 입력했다면 전자도 올바르게 입력했다고 판단하여 사용자를 인증하면서 오래된 책에 있던 단어도 사용자가 입력한 값으로 학습도 하는 것이다. 사용자에게는 간단한 인증과정이지만 수많은 사용자들이 이를 입력한다면 컴퓨터가 인식하지 못한 단어들도 해석하면서 수많은 책을 디지털화할 수 있다는 장점이 있다.

 

DVWA에서 사용하고 있는 캡차는 단순히 클릭을 통해 검증하는 방식이다.

이는 무의미해 보일 수 있지만 사용자가 이 체크박스를 클릭하기까지, 즉 커서를 이동하는 과정을 파악하여 어느 정도의 무작위성으로 실제 사용자인지 판단한다. 추가적으로 쿠키나 접속 기록을 이용해 판단할 수도 있다(클라우드플레어 문서).

 

하지만 근본적인 문제점은 해당 언어를 모르거나 시각 장애인, 너무 복잡한 문자, 노인, 어린이 등 취약 계층의 접근을 차단할 수 있다는 것이다. 그리고 확률은 낮지만 봇도 테스트를 통과할 수 있기 때문에 CAPTCHA에 전적으로 의존해서는 안된다.

Security Level: low

이번 실습에서는 요청이 두 번 전송된다. 첫 번째는 CAPTCHA 인증 및 변경하고자 하는 비밀번호 값을 전송하며 두 번째는 비밀번호 변경 확인 요청을 전송한다.

이 단계에서는 다음과 같은 요청이 전송된다.

POST /dvwa/vulnerabilities/captcha/ HTTP/1.1
Host: 192.168.26.201
Content-Length: 549
Cache-Control: max-age=0
Origin: http://192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.26.201/dvwa/vulnerabilities/captcha/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=low; PHPSESSID=1uhh7s47eqg2dtha71c57t22s6
Connection: close

step=1&password_new=password&password_conf=password&g-recaptcha-response=03AGdBq248IGIqpAiAv4JLTsu0nRpwISFxE8ZxLxv2v6OW_wohqQBKNbcXj5zre9Vosh8stzJDyYDmMYPZ75FkRYUcETIFRc4fD0ZaU1UZ_XFbs7JGugM5zDLdfw71di7B7K2VwwdrCLZQ49rcRfa2sxJssxoo0OZtwrRu3VgqHBUMH9QugOp0vLfyfLqrSl1sA1ddvmGWAuU5vT8XAmLje8Xljqy9BrVB9p8jVQ0GoJcVdB5G1tqp_u1wR7kH-mNPe5L8jG_bARkAZwYeO4-XXqUEWuwiLJFmMc7o3jIOam-Pb33uflMJRBjUOxTheImPxd_nuE0JYF4So1Il3KmxnesUeD8qh03kwdv6KzKPOqORuHCK_8cmXv1_PqKvOhmoyEO6zZBSZCXsJbBbX1QV09IQeRh1oOW1_vwKkqBO3APUjD_xmcr1GGjUjjblpPPk3QrsZZEv71Dn&Change=Change

특별한 점은 없으며 POST 데이터로 step, password_new, password_conf, g-recaptcha-response, Change 파라미터가 전송되고 있다. g-recaptcha-response 파라미터는 구글 reCAPTCHA 관련 파라미터인 것 같다.

POST /dvwa/vulnerabilities/captcha/ HTTP/1.1
Host: 192.168.26.201
Content-Length: 65
Cache-Control: max-age=0
Origin: http://192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.26.201/dvwa/vulnerabilities/captcha/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=low; PHPSESSID=1uhh7s47eqg2dtha71c57t22s6
Connection: close

step=2&password_new=password&password_conf=password&Change=Change

최종적으로 비밀번호 변경을 확인하면 위처럼 POST로 step, password_new, password_conf, Change 파라미터가 전송되고 있다. 그런데 step 파라미터 값이 1, 2 인걸 보니 순서가 있는 것 같은데 이 순서를 검증하고 있을지 확인해볼 필요가 있다.

이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?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 = mysql_real_escape_string( $pass_new );
        $pass_new = md5( $pass_new );

        // Update database
        $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "';";
        $result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</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;
    }

    mysql_close();
}

?>

다른 단계들에 비해 Low 레벨부터 코드가 길지만 살펴보면 각 step 별로 코드가 따로 구현된 것을 알 수 있다. POST 메서드로 데이터를 받기 때문에 다른 실습들에 비해 눈에 띄진 않겠지만 step이 1일 경우, 즉 사용자로부터 CAPTCHA 인증과 변경할 비밀번호를 받은 경우 이를 검증하고 문제가 없다면 step 2로 진행할 수 있는 폼을 출력한다. 이후 step 2에서는 SQL 쿼리를 통해 비밀번호를 업데이트한다. 하지만 step 1과 step 2간 상태를 저장하고 있지 않기 때문에 사용자가 step 1을 거치고 step 2로 바로 왔는지 아닌지 구분할 수 없다.

Security Level: medium

이 단계에서는 첫 번째 요청의 경우 이전과 동일한 요청이 전송된다. 두 번째 요청의 경우 다음과 같은 요청이 전송된다.

POST /dvwa/vulnerabilities/captcha/ HTTP/1.1
Host: 192.168.26.201
Content-Length: 85
Cache-Control: max-age=0
Origin: http://192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.26.201/dvwa/vulnerabilities/captcha/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=medium; PHPSESSID=1uhh7s47eqg2dtha71c57t22s6
Connection: close

step=2&password_new=password&password_conf=password&passed_captcha=true&Change=Change

Low 단계와 달리 passed_captcha 파라미터가 추가된 것을 볼 수 있다. 하지만 어떤 인증 요소가 들어가지 않은 단순 파라미터기 때문에 사용자가 요청을 조합하여 전송하면 구분할 수 없다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.


<?php
    ...
    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;
    }

    ...
}

?>

중복되는 부분을 빼고 달라진 점만 비교하면 위와 같다. 변경할 비밀번호와 비밀번호 확인 값이 일치하고 캡차를 통과했다면 step 2로 진행할 수 있는 폼을 제공하는데 'passed_captcha'라는 숨겨진 항목을 추가하여 POST 요청 시 파라미터로 넘기고 있다. 그리고 step 2 코드에서는 이 항목을 확인하여 인증하고 있다. 이외에는 별다른 인증 방식이 없기 때문에 쉽게 우회할 수 있다.

Security Level: high

이 단계에서는 다음과 같은 요청이 전송된다.

POST /dvwa/vulnerabilities/captcha/ HTTP/1.1
Host: 192.168.26.201
Content-Length: 593
Cache-Control: max-age=0
Origin: http://192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.26.201/dvwa/vulnerabilities/captcha/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=high; PHPSESSID=1uhh7s47eqg2dtha71c57t22s6
Connection: close

step=1&password_new=password&password_conf=password&g-recaptcha-response=03AGdBq26Wdek7JsQ_TFGKa1khkqok9PElkyio4AbLDmlX86uiH4oMYw21Km6IMzVVKWyepzql9-a2b59ddRJEIGcFug6TK0f49snO_9ltOe5b_PN091E3Nd_LEdBCLagvdxZ002UXN0gAwaUjr504ADHE9W4osBF5JWtDr1_xNrdJON_xTA3xlkwQPo4Lzu4J9-t9ZdIeQVBsicc11gp2-GQwuLOTlSmlA6ahQFM9pU4RF8j1SUNMY6mj07TSuqJeiMgqTIcv9522tOA29XGA8jYxdKGbWZvR-gUOG4ipWFMTDqVuflK5n8NmH-b9AKfjXQ5-U6QpWFMlq0Cj5eE_0faOzj8ahpJonIOVRFzn8WXt9N4xsjcndCHnVInrb8oZkYozY5yhBber9QjS26_dWxIfAarUOS4ymsQUdJqq6MCZrGEQdrLF8ABphNj2zh6HArkqnPxk7DX9&user_token=e18cad385ee6bfd234b0c1b0d3d5d942&Change=Change

이번 단계에서는 CSRF 토큰을 파라미터로 전송하고 있다. 그리고 이전 단계들과 달리 비밀번호 변경을 확인하는 step 2가 존재하지 않는다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?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 = mysql_real_escape_string( $pass_new );
            $pass_new = md5( $pass_new );

            // Update database
            $insert = "UPDATE `users` SET password = '$pass_new' WHERE user = '" . dvwaCurrentUser() . "' LIMIT 1;";
            $result = mysql_query( $insert ) or die( '<pre>' . mysql_error() . '</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;
    }

    mysql_close();
}

// Generate Anti-CSRF token
generateSessionToken();

?>

step 1과 step 2를 구분하지 않기 때문에 코드가 간소화됐으며 비밀번호 변경 폼, 캡차 인증을 거친 후 바로 SQL 쿼리를 통해 비밀번호를 변경하고 있다. 그런데 CSRF 토큰을 생성했는데 이를 검증하는 부분이 보이지 않는다. 게다가 구글 CAPTCHA 인증이 실패하더라도 요청 헤더의 User Agent와 CAPTCHA 값을 다른 값과 비교하여 검증하고 있는데 이는 디버깅 후 남겨둔 개발자의 실수인 것 같다. 이런 점을 공략하여 문제를 해결할 수 있다.

Security Level: impossible

이 단계에서는 변경 전 패스워드를 입력하기 때문에 해당 항목이 추가된 요청이 전송된다.

POST /dvwa/vulnerabilities/captcha/ HTTP/1.1
Host: 192.168.26.201
Content-Length: 619
Cache-Control: max-age=0
Origin: http://192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36 Edg/87.0.664.75
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.26.201/dvwa/vulnerabilities/captcha/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=impossible; PHPSESSID=1uhh7s47eqg2dtha71c57t22s6
Connection: close

step=1&password_current=password&password_new=password&password_conf=password&g-recaptcha-response=03AGdBq26y_27aCj8khQsNz3m-oTsDd4lI13Jdc19FyOHiQ_7ozgAvzp_uScYDNfOjmqPE1oNt1nhPv-8CA5E3jMtqpnkIXKeWFn_Nvn5a9bnCbPRshc0zyJUBH8SdfKQuKa3SC0xu0JbE5aEKKw8ZSo57EaPWLpSft_Z-iO4aXf6OufsmRYdLgHr1zoWpFIA0MzxA2fsIIZ5vcaJwK63SVoE4BqP4vtHUcIcoYmbCJdYOdiFkoU42ok-Yo6Zjmm6RF28mgaFKu9c2uoyJIoIgWhp2FwHbsw3mjrJ-78oWo5zZPeZSby6SACItEgfNZSTnNpniEwh2cSwYVtGj7R28q12X8J1nTGSwI7JdZUZr-elrw96S3FiTAqrdUPBkn-6kfo27fuaS-p5mR-t6FxewbcxNrPk2UeptLAY6i13KKGsjXJkLOPERBHPzcSqBkDJylElgxngzTVT0&user_token=6ebb48e70dfc94d20af6a0341dfc1037&Change=Change

이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?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  = mysql_real_escape_string( $pass_new );
    $pass_new  = md5( $pass_new );

    $pass_conf = $_POST[ 'password_conf' ];
    $pass_conf = stripslashes( $pass_conf );
    $pass_conf = mysql_real_escape_string( $pass_conf );
    $pass_conf = md5( $pass_conf );

    $pass_curr = $_POST[ 'password_current' ];
    $pass_curr = stripslashes( $pass_curr );
    $pass_curr = mysql_real_escape_string( $pass_curr );
    $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;
        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!
            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();

?>

이전 단계에서 빠뜨렸던 CSRF 토큰 검증 로직이 추가되었다. 불필요한 step 2는 역시 존재하지 않으며 다른 Impossible 단계처럼 기본적인 필터링과 PDO를 활용한 SQL Injection 방지책과 캡차 인증이 적용되어 있다. 그리고 변경 전 비밀번호를 입력하도록 요구하여 CSRF 공격도 방어하고 있다. High 단계에 비해 개발자의 실수가 줄어든 것이 특징이다.

 

Low, Medium 단계처럼 굳이 step 1과 step 2를 나누는 것은 두 프로세스 사이의 인증을 관리하기도 어렵기 때문에 부적절하며 CAPTCHA 인증 외에 취약한 추가적인 인증(Medium 단계의 'passed_captcha')을 적용해서 취약점을 늘리지 않는 것이 좋다. High 단계에 있던 디버깅 정보 노출도 주의해야 할 사례 중 하나일 것이다.