본문 바로가기

프로젝트/DVWA 실습

DVWA 실습 #2 - Brute Force

DVWA의 첫 번째 실습 대상인 Brute Force다. Username과 Password를 입력하여 admin 계정으로 로그인하는 것을 목표로 하고 있다.

브루트 포스?

브루트 포스는 무작위로 비밀번호를 조합하거나 사전 파일에 정의된 비밀번호를 하나하나 대입해보며 공격하는 방식(또는 두 가지를 합한 hybrid 방식)이다. 본래 어떤 시스템에서 특정 개체를 인증하려면 인증서라던지, OTP, 생체 인식 등 여러 분야의 인증 수단을 활용할 수 있지만 웹 애플리케이션에서는 대개 아이디와 비밀번호만을 이용하여 사용자를 인증하고 있다. 이 브루트 포스 공격은 비밀번호(혹은 아이디도)를 가능한 모든 문자를 조합하거나 많이 사용되는 문자를 조합하여 반복해서 로그인을 시도하는 공격으로 전통적이지만 시간만 충분하다면 언젠가는 성공하는 공격이다. 하지만 경우의 수가 너무 많기 때문에, 그리고 로그인 시도 제한 등의 제약이 있으면 공격에 소요되는 시간은 기하급수적으로 늘어나기 때문에 조금만 방어 체계가 되어있다면 어렵지 않게 막을 수 있다.

 

브루트 포스 공격을 수행하려면 해당 웹 애플리케이션의 로그인, 인증 과정에 대한 사전 조사가 필요하다. 대개 접근 개체에 대한 인증은 HTTP 헤더(WWW-Authenticate, Authorization 헤더)를 사용하거나 HTML 폼(흔히 보는 로그인 폼)을 사용하여 구현한다(OWASP 문서).

 

Testing for Brute Force (OWASP-AT-004) - OWASP

OWASP Testing Guide v3 Table of Contents This article is part of the OWASP Testing Guide v3. The entire OWASP Testing Guide v3 can be downloaded here. OWASP at the moment is working at the OWASP Testing Guide v4: you can browse the Guide here Brief Summary

wiki.owasp.org

당연히 해당 계정의 비밀번호에 대한 힌트나 웹사이트의 인증 구조에 대한 힌트(아이디, 비밀번호의 최대 길이 제한 또는 비밀번호에 사용 가능한 문자)가 있다면 경우의 수를 많이 줄일 수 있을 것이다. 또는 자주 사용하는 비밀번호 형태를 우선적으로 탐색하는 방법도 있다.

 

브루트 포스 공격은 스크립트를 작성하거나 Burp Suite의 Intruder, OWASP ZAP의 Fuzzer나 Hydra 등 여러 툴을 사용하여 수행할 수 있다. 먼저 현재 실습에서 어떤 소스 코드를 사용하고 있으며 로그인 시도가 어떤 결과를 나타내는지, 어떤 특징을 가지는지 등을 확인해보자.

Security Level: low

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

GET /dvwa/vulnerabilities/brute/?username=admin&password=1234&Login=Login HTTP/1.1
Host: 192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66
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/brute/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=low; PHPSESSID=pfmi1u57nuc6ka01j6g1f55cd3
Connection: close

username과 password가 파라미터에 포함되는 GET 메서드를 사용한 HTTP 요청을 보내고 있다. 이렇게 되면 아이디와 비밀번호가 그대로 노출되기 때문에 로그인 시에는 POST를 활용해야 한다. 이 단계에서는 다음과 같은 소스 코드가 적용됀다.

<?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 = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

    if( $result && mysql_num_rows( $result ) == 1 ) {
        // Get users details
        $avatar = mysql_result( $result, 0, "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>";
    }

    mysql_close();
}

?>

별다른 필터링이나 처리 없이 사용자의 입력을 쿼리에 포함시키고 있기 때문에 SQL Injection 공격에 취약하며 쿼리 에러가 있을 때 이를 에러 메시지로 출력시키고 있기 때문에 Error based SQL Injection 가능성이 있다고 할 수 있다. 제일 중요한 것은 로그인 시도가 여러 번 발생했을 때 이를 차단하는 기능이 없기 때문에 브루트 포스 공격에 취약한 것이다. 로그인을 시도하면 다음처럼 아이디 또는 비밀번호가 틀렸다는 메시지만 출력된다.

Security Level: medium

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

GET /dvwa/vulnerabilities/brute/?username=admin&password=1234&Login=Login HTTP/1.1
Host: 192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66
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/brute/?username=admin&password=1&Login=Login
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=medium; PHPSESSID=pfmi1u57nuc6ka01j6g1f55cd3
Connection: close

low 레벨과 요청은 동일하다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.

<?php

if( isset( $_GET[ 'Login' ] ) ) {
    // Sanitise username input
    $user = $_GET[ 'username' ];
    $user = mysql_real_escape_string( $user );

    // Sanitise password input
    $pass = $_GET[ 'password' ];
    $pass = mysql_real_escape_string( $pass );
    $pass = md5( $pass );

    // Check the database
    $query  = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";
    $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

    if( $result && mysql_num_rows( $result ) == 1 ) {
        // Get users details
        $avatar = mysql_result( $result, 0, "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>";
    }

    mysql_close();
}

?>

이번 단계부터는 mysql_real_escape_string()을 활용하여 입력을 필터링하고 있기 때문에 기존과 같은 SQL Injection은 불가능하다. 또한 로그인이 실패했다면 sleep(2) 함수 호출을 통해 입력에 대한 출력 시간을 지연시킴으로써 브루트 포스를 방지하고 있다. 이전과 동일하게 로그인을 시도했을 때 다음처럼 아이디 또는 비밀번호가 틀렸다는 메시지가 출력된다.

Security Level: high

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

GET /dvwa/vulnerabilities/brute/?username=admin&password=1234&Login=Login&user_token=cf932b2252ac5984ebb97c072f2120ed HTTP/1.1
Host: 192.168.26.201
Upgrade-Insecure-Requests: 1
DNT: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66
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/brute/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=high; PHPSESSID=pfmi1u57nuc6ka01j6g1f55cd3
Connection: close

low, medium 레벨과는 달리 user_token이라는 파라미터가 추가되었다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.

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

    // Sanitise password input
    $pass = $_GET[ 'password' ];
    $pass = stripslashes( $pass );
    $pass = mysql_real_escape_string( $pass );
    $pass = md5( $pass );

    // Check database
    $query  = "SELECT * FROM `users` WHERE user = '$user' AND password = '$pass';";
    $result = mysql_query( $query ) or die( '<pre>' . mysql_error() . '</pre>' );

    if( $result && mysql_num_rows( $result ) == 1 ) {
        // Get users details
        $avatar = mysql_result( $result, 0, "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>";
    }

    mysql_close();
}

// Generate Anti-CSRF token
generateSessionToken();

?>

이번 단계부터는 토큰을 확인하는 로직이 추가되었는데 checkToken()과 generateSessionToken() 함수를 호출하여 토큰을 생성, 검증함으로써 CSRF를 방지하고 있다. 이를 CSRF 토큰이라 하며 이는 고유한, 예측할 수 없는 비밀스러운 값으로 서버 측에서 생성하여 클라이언트에게 전송함으로써 클라이언트의 세션과 함께 유지한다. 클라이언트가 다시 서버에게 요청(여기서는 로그인 아이디, 비밀번호)을 보낼 때 서버는 내가 알고 있는 클라이언트가 요청을 전송하고 있는지, 즉 CSRF 토큰을 포함해서 전송하고 있는지 검증하는 방법이다.

보통 이렇게 보이지 않는 input 태그에 토큰값이 숨어있다. submit 시 서버측으로 같이 전송된다.

만약 우리가 어떤 툴을 이용하여 URL의 파라미터 값만 바꿔가면서 GET 하는 방식의 브루트 포스로 공격을 시도한다면 이렇게 랜덤으로 생성되어 숨어있는 CSRF 토큰 값을 예측할 수 없기 때문에 브루트 포스가 성공했든 아니든 요청은 실패하게 된다. 자세한 내용은 이 문서를 참고하자.

 

CSRF tokens | Web Security Academy

In this section, we'll explain what CSRF tokens are, how they protect against CSRF attacks, and how CSRF tokens should be generated and validated. What are ...

portswigger.net

이것 말고도 sleep() 함수가 기존과 달리 랜덤 값으로 지정되었다. 이는 로그인 실패 시 호출되던 sleep(2) 같은 함수에서 일정한 규칙을 파악한 공격자가 이를 정보로 활용하는 것을 방지하기 위함이다. 0초에서 3초 사이의 랜덤 한 시간으로 sleep() 함수를 호출함으로써 공격자는 이 대기시간이 단순 인터넷 지연인지 함수 호출에 의한 것인지 구분할 수 없다.

Security Level: impossible

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

POST /dvwa/vulnerabilities/brute/ HTTP/1.1
Host: 192.168.26.201
Content-Length: 84
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.88 Safari/537.36 Edg/87.0.664.66
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/brute/
Accept-Encoding: gzip, deflate
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cookie: security=impossible; PHPSESSID=pfmi1u57nuc6ka01j6g1f55cd3
Connection: close

username=admin&password=1234&Login=Login&user_token=ce3e6a1b6df3fd1a656f34eb044b49ac

이번 단계에서는 이전과 달리 POST 메서드를 활용하여 아이디, 비밀번호를 전송하고 있다. HTTP 요청에 Content-Length, Cache-Control, Origin, Content-Type 헤더가 추가되었는데 POST 메서드기 때문에 필요한 Content-Length, Content-Type 헤더를 제외한 Cache-Control, Origin 헤더를 살펴보면 'max-age=0' 설정과 접속한 주소가 담겨있다. 이 단계에서는 다음과 같은 소스 코드가 적용된다.

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

    // Sanitise password input
    $pass = $_POST[ 'password' ];
    $pass = stripslashes( $pass );
    $pass = mysql_real_escape_string( $pass );
    $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 = $row[ 'last_login' ];
        $last_login = strtotime( $last_login );
        $timeout    = strtotime( "{$last_login} +{$lockout_time} minutes" );
        $timenow    = strtotime( "now" );

        // Check to see if enough time has passed, if it hasn't locked the account
        if( $timenow > $timeout )
            $account_locked = true;
    }

    // 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();

?>

Impossible 단계에서는 모든 공격에 대해 안전한 것을 목표로 하고 있기 때문에 위의 Low, Medium, High 단계에서 적용된 기법들이 모두 합해져 있다. 또한 SQL 쿼리 자체도 이전과는 다른 방식으로 조합되고 있어 브루트 포스뿐 아니라 SQL Injection 공격에도 안전하다고 할 수 있다.

  • CSRF 토큰 검사

  • 아이디, 비밀번호 입력값 필터링

  • 로그인 시도 임계값(threshold) 설정

  • PHP Data Object(PDO) 사용

어떤 코드가 적용되었고 HTTP 요청이 어떻게 보내지는지 파악했으니 각 레벨에 대한 실습은 따로 포스트를 작성해서 진행하겠다.