상세 컨텐츠

본문 제목

DVWA : Brute Force - Impossible level

Vulnerability Assessment/Web Application

by DarkSoul.Story 2024. 12. 29. 16:37

본문

반응형

[ 환경 ]

DVWA v1.9
Burp Suite Community Edition v2024.11.2

1. Source code analysis

[그림 1] Impossible level Source Code

 

Impossible level은 보안조치가 된 소스코드로 CSRF 보호, Brute Force 방지, 계정 잠금과 같은 추가적인 보안 메커니즘을 포함하고 있다.

 

로그인 입력 확인 및 CSRF 보호

if( isset( $_POST[ 'Login' ] ) && isset ($_POST['username']) && isset ($_POST['password']) ) {
    checkToken( $_REQUEST[ 'user_token' ], $_SESSION[ 'session_token' ], 'index.php' );
  • 사용자가 POST 요청을 통해 Login, username, password를 제공했는지 확인한다.
  •  checkToken( ) 함수를 호출하여 사용자가 보낸 CSRF 토큰( user_token )과 세션에 저장된 CSRF 토큰( session_token )을 비교하며, 토큰이 유효하지 않으면 index.php로 리다레션한다.

사용자 입력값 처리

$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)) ? "" : ""));

$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 );
  • 사용자 이름(username)과 비밀번호(password)를 POST 요청에서 가져온다.
  • stripslashes ) 함수로 사용자 이름(username)과 비밀번호(password) 문자열에서 백슬러시를 제거한다.
  • mysqli_real_escape_string( )으로 SQL Injection을 방지하기 위해 사용자 이름(username)과 비밀번호(password) 에 포함된 특수문자를 이스케이프 처리한다.
  • $pass md5$pass ); 를 이용하여 비밀번호를 md5로 해싱한다.

계정 잠금 확인

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

    if( ( $data->rowCount() == 1 ) && ( $row[ 'failed_login' ] >= $total_failed_login ) )  {
   
        $last_login = strtotime( $row[ 'last_login' ] );
        $timeout    = $last_login + ($lockout_time * 60);
        $timenow    = time();

        if( $timenow < $timeout ) {
            $account_locked = true;
        }
    }
  • failed_login(실패한 로그인 시도 횟수)과 last_login(마지막 로그인 시간)을 조회한다.
  • failed_login 값이 최대 허용 로그인 실패 횟수를 초과하면 계정이 잠금 상태인지 확인한
  • $account_locked true;  : lockout_time(잠금 유지 시간)이 지나지 않았으면 계정을 잠근 상태로 유지한다.

로그인 확인

    $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( ( $data->rowCount() == 1 ) && ( $account_locked == false ) ) {
        $avatar       = $row[ 'avatar' ];
        $failed_login = $row[ 'failed_login' ];
        $last_login   = $row[ 'last_login' ];

        echo "<p>Welcome to the password protected area <em>{$user}</em></p>";
        echo "<img src=\"{$avatar}\" />";
  • 입력된 사용자 이름과 비밀번호가 데이터베이스의 값과 일치하는지 확인하고, 일치하고 계정이 잠금 상태가 아니라면 로그인 성공
  • 로그인 성공 시 환영 메시지와 아바타 이미지를 출력한다.

로그인 실패 처리

sleep( rand( 2, 4 ) );

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>";

$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();
  • 로그인 실패 시 무작위로 2~4초 동안 응답을 지연시키며, 실패 메시지를 출력한다.
  • $data $db->prepare'UPDATE users SET failed_login = (failed_login + 1) WHERE user = (:user) LIMIT 1;' ); : failed_login 값을 증가시켜 실패 횟수를 기록한다.

마지막 로그인 시간 기록

$data = $db->prepare( 'UPDATE users SET last_login = now() WHERE user = (:user) LIMIT 1;' );
$data->bindParam( ':user', $user, PDO::PARAM_STR );
$data->execute();
  • 로그인 성공 또는 실패 시 last_login 필드를 현재 시간으로 업데이트한다.

Anti-CSRF 토큰 생성

generateSessionToken();
  • 새로운 CSRF 토큰을 생성하고 세션에 저장한다. 
  • 다음 요청에서 CSRF 보호를 계속 유지

보안 기능 요약

  • CSRF 보호 : checkToken 함수로 요청의 유효성을 확인
  • Brute Force 방지 : 로그인 실패 횟수를 기록하고 일정 시간 동안 계정을 잠금, 응답 지연(sleep(rand(2, 4)))으로 공격 속도를 제한
  • SQL Injection 방지 : Prepared Statement를 사용하여 쿼리를 안전하게 처리
  • XSS 방지 : 사용자 입력값을 직접 출력하기 때문에 htmlspecialchars()를 사용하는 것이 안전하다.
  • 최신 로그인 정보 기록 : 마지막 로그인 시간을 저장하여 모니터링 가능

이 코드는 보안 기능이 강력하지만 몇가지 개선점을 반영하면 더욱 안전한 로그인 시스템을 구축할 수 있다. 

2. Source code improvements

다음은 앞서 살펴본 코드를 기반으로 개선한 코드의 예제로 주요 보안 및 기능적 향상 방안을 설명하겠다.

비밀번호 해싱 방식 개선

기존 코드에서 md5()를 사용해 비밀번호를 해싱한다. md5는 오래된 해싱 알고리즘으로, 보안성이 약하며 충돌 가능성이 높다. 이를 개선하기 위해 password_hash()와 password_verify()를 사용하여 안전한 해싱과 검증을 구현한다.

 

데이터베이스에 비밀 번호를 저장할 때

$hashed_password = password_hash($pass, PASSWORD_BCRYPT);

 

 

비밀번호 검증 시

if (password_verify($pass, $row['password'])) {
    // 비밀번호 검증 성공
} else {
    // 비밀번호 검증 실패
}

 

CSRF 보호 강화

기존 코드에서는 CSRF 토큰 검증을 수행하지만, 토큰 재생성 및 만료 관리는 코드에 포함되지 않았다. 이를 개선하기 위해 CSRF 토큰 생성 시 고유성과 만료 시간을 관리하는 코드를 추가한다. 유효 기간이 지난 토큰은 무효화하고 새로 생성한다.

function generateCSRFToken() {
    $token = bin2hex(random_bytes(32));
    $_SESSION['csrf_token'] = $token;
    $_SESSION['csrf_token_expiry'] = time() + 3600; // 1시간 유효
    return $token;
}

function validateCSRFToken($user_token) {
    if (!isset($_SESSION['csrf_token']) || !isset($_SESSION['csrf_token_expiry'])) {
        return false;
    }
    if (time() > $_SESSION['csrf_token_expiry']) {
        return false; // 토큰 만료
    }
    return hash_equals($_SESSION['csrf_token'], $user_token);
}

 

XSS 방지

기존 코드에서는 사용자 입력값을 출력할 때 htmlspecialchars( )로 이스케이프 처리하지 않아 XSS 공격에 취약하다. 이를 개선하기 위해 출력 시 항상 htmlspecialchars()를 사용하여 특수 문자를 이스케이프 처리한다.

echo "<p>Welcome to the password protected area <em>" . htmlspecialchars($user, ENT_QUOTES, 'UTF-8') . "</em></p>";
echo "<img src=\"" . htmlspecialchars($avatar, ENT_QUOTES, 'UTF-8') . "\" />";

 

Brute Force 방지 강화

기존 코드에서는 실패 횟수를 기록하고 계정을 잠그는 방식은 좋지만, IP 기반의 제한 기능이 없다. 이를 개선하기 위해 IP 주소를 기록하고 실패 횟수가 많은 경우 IP 기반 제한을 추가하는 것이 좋다.

function logFailedLoginAttempt($user, $ip_address) {
    global $db;
    $stmt = $db->prepare('INSERT INTO login_attempts (user, ip_address, attempt_time) VALUES (:user, :ip, NOW())');
    $stmt->bindParam(':user', $user, PDO::PARAM_STR);
    $stmt->bindParam(':ip', $ip_address, PDO::PARAM_STR);
    $stmt->execute();
}

function isIPBlocked($ip_address, $threshold, $timeframe) {
    global $db;
    $stmt = $db->prepare('SELECT COUNT(*) FROM login_attempts WHERE ip_address = :ip AND attempt_time > (NOW() - INTERVAL :timeframe MINUTE)');
    $stmt->bindParam(':ip', $ip_address, PDO::PARAM_STR);
    $stmt->bindParam(':timeframe', $timeframe, PDO::PARAM_INT);
    $stmt->execute();
    $count = $stmt->fetchColumn();
    return $count >= $threshold;
}

사용자 피드백 개선

기존 코드에서는 실패 메시지에서 계정이 잠겼는지, 비밀번호가 틀렸는지를 명확히 구분하지 않고 제공하면 정보 노출 가능성이 있다. 이를 개선하기 위해 실패 메시지를 일반화하여 정보 노출을 방지하는 것이 좋다.

echo "<pre><br />Invalid login credentials. Please try again later.</pre>";

SQL Injection 방지 강화

기존 코드에서는 Prepared Statements를 사용하고 있지만, 사용자 입력값을 이중으로 필터링 (stripslashes() mysqli_real_ escape_string ())하는 불필요한 처리가 포함되어 있다. 이를 개선하기 위해 Prepared Statements로 사용자 입력값을 안전하게 처리하므로 추가적인 필터링은 필요하지 않니다. 기존 필터링을 제거하고 다음과 같이 간단히 작성한다.

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

 

로그인 성공/실패 기록 개선

기존 코드에서는 실패 시 로그는 기록하지만 성공적인 로그인 시도에 대한 로그는 포함되어 있지 않이 때문에 성공적인 로그인 기록을 추가하는것이 좋다.

function logSuccessfulLogin($user, $ip_address) {
    global $db;
    $stmt = $db->prepare('INSERT INTO login_logs (user, ip_address, login_time) VALUES (:user, :ip, NOW())');
    $stmt->bindParam(':user', $user, PDO::PARAM_STR);
    $stmt->bindParam(':ip', $ip_address, PDO::PARAM_STR);
    $stmt->execute();
}

세션 관리 강화

기존 코드에서는 세션 하이재킹 방지를 위한 추가적인 조치가 포함되어 있지 않기 때문에 로그인 성공 시 세션 재재성을 통해 하이재킹을 방지한다.

session_regenerate_id(true); // 세션 ID 재생성

 

반응형

관련글 더보기