금융권 Brute Force 공격 실전 시나리오

WEB-SER-032: 인증 오류 횟수 제한 | 위험도: 5 (매우 높음) | 전자금융: 5회 이내 제한 필수

🔴 공격자 화면

자동화 도구로 무차별 대입 공격

시나리오를 선택하세요

공격 통계

총 시도 횟수
0
실패 횟수
0
경과 시간
0s
초당 시도
0/s

공격 진행 단계

시나리오를 시작하세요...

실전 공격 시나리오

취약한 서버 코드

문제 원인
# Python Flask
@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']
    
    user = User.query.filter_by(
        username=username
    ).first()
    
    # ❌ 문제점 1: 비밀번호 무제한 시도!
    # ❌ 문제점 2: 계정 잠금 없음!
    # ❌ 문제점 3: 딜레이 없음!
    
    if user and user.password == password:
        return {"status": "success"}
    else:
        return {"status": "fail"}

# Java Spring
@PostMapping("/login")
public ResponseEntity login(
    @RequestParam String username,
    @RequestParam String password) {
    
    User user = userRepository
        .findByUsername(username);
    
    // ❌ 실패 횟수 체크 없음!
    if (user != null && 
        user.getPassword().equals(password)) {
        return ResponseEntity.ok()
            .body("success");
    }
    
    return ResponseEntity.status(401)
        .body("fail");
}

🟢 서버 내부

XX은행 인증 서버 처리 과정

[SERVER] Waiting for request...
서버 상태: IDLE

안전한 서버 코드

해결책
# Python Flask - 안전한 로그인
from datetime import datetime, timedelta
from flask_limiter import Limiter

# ✅ Rate Limiter 설정
limiter = Limiter(
    app,
    default_limits=["5 per minute"]
)

@app.route('/login', methods=['POST'])
@limiter.limit("5 per minute")  # 1분에 5회 제한
def login():
    username = request.form['username']
    password = request.form['password']
    
    # ✅ 1단계: 계정 잠금 확인
    attempt = LoginAttempt.query.filter_by(
        username=username
    ).first()
    
    if attempt:
        # 5회 실패 시 계정 잠금
        if attempt.failed_count >= 5:
            if attempt.locked_until > datetime.now():
                return {
                    "status": "locked",
                    "message": "계정이 잠겼습니다. " +
                        "30분 후 시도하세요."
                }, 403
            else:
                # 잠금 시간 경과 - 리셋
                attempt.failed_count = 0
                attempt.locked_until = None
    else:
        attempt = LoginAttempt(username=username)
        db.session.add(attempt)
    
    # ✅ 2단계: 사용자 인증
    user = User.query.filter_by(
        username=username
    ).first()
    
    if user and check_password_hash(
        user.password, password):
        
        # 성공 - 시도 횟수 리셋
        attempt.failed_count = 0
        attempt.last_success = datetime.now()
        db.session.commit()
        
        session['user_id'] = user.id
        return {"status": "success"}
    
    # ✅ 3단계: 실패 처리
    attempt.failed_count += 1
    attempt.last_attempt = datetime.now()
    
    # 5회 실패 시 30분 잠금
    if attempt.failed_count >= 5:
        attempt.locked_until = (
            datetime.now() + timedelta(minutes=30)
        )
    
    db.session.commit()
    
    # ✅ 4단계: 로깅
    logger.warning(
        f"Failed login: {username}, "
        f"attempts: {attempt.failed_count}, "
        f"ip: {request.remote_addr}"
    )
    
    # ✅ 5단계: 딜레이 추가 (타이밍 공격 방어)
    time.sleep(2)  # 2초 딜레이
    
    return {
        "status": "fail",
        "remaining": max(0, 5 - attempt.failed_count)
    }, 401

# Java Spring Security - 안전한 로그인
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
    @RequestParam String username,
    @RequestParam String password,
    HttpServletRequest request) {
    
    // ✅ 1단계: 계정 잠금 확인
    LoginAttempt attempt = attemptRepository
        .findByUsername(username)
        .orElseGet(() -> {
            LoginAttempt newAttempt = 
                new LoginAttempt();
            newAttempt.setUsername(username);
            return attemptRepository
                .save(newAttempt);
        });
    
    // 5회 실패 시 잠금
    if (attempt.getFailedCount() >= 5) {
        if (attempt.getLockedUntil() != null &&
            attempt.getLockedUntil()
                .isAfter(LocalDateTime.now())) {
            
            return ResponseEntity
                .status(HttpStatus.FORBIDDEN)
                .body(new LoginResponse(
                    "locked",
                    "계정이 잠겼습니다"
                ));
        }
        // 잠금 시간 경과
        attempt.setFailedCount(0);
        attempt.setLockedUntil(null);
    }
    
    // ✅ 2단계: 사용자 인증
    User user = userRepository
        .findByUsername(username)
        .orElse(null);
    
    if (user != null && passwordEncoder
        .matches(password, user.getPassword())) {
        
        // 성공 - 리셋
        attempt.setFailedCount(0);
        attempt.setLastSuccess(
            LocalDateTime.now()
        );
        attemptRepository.save(attempt);
        
        // 세션 생성
        String token = jwtUtil.generateToken(user);
        return ResponseEntity.ok(
            new LoginResponse("success", token)
        );
    }
    
    // ✅ 3단계: 실패 처리
    attempt.setFailedCount(
        attempt.getFailedCount() + 1
    );
    attempt.setLastAttempt(LocalDateTime.now());
    
    if (attempt.getFailedCount() >= 5) {
        attempt.setLockedUntil(
            LocalDateTime.now().plusMinutes(30)
        );
    }
    
    attemptRepository.save(attempt);
    
    // ✅ 4단계: 로깅
    auditLogger.logFailedLogin(
        username,
        request.getRemoteAddr(),
        attempt.getFailedCount()
    );
    
    // ✅ 5단계: 딜레이
    Thread.sleep(2000);
    
    return ResponseEntity
        .status(HttpStatus.UNAUTHORIZED)
        .body(new LoginResponse(
            "fail",
            "로그인 실패 (" + 
            (5 - attempt.getFailedCount()) +
            "회 남음)"
        ));
}

// DB 스키마
CREATE TABLE login_attempts (
    id BIGINT PRIMARY KEY,
    username VARCHAR(255) NOT NULL,
    failed_count INT DEFAULT 0,
    locked_until TIMESTAMP,
    last_attempt TIMESTAMP,
    last_success TIMESTAMP,
    UNIQUE(username)
);