이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 로그인 실패 이벤트를 사용자 실수와 공격 시도로 어떻게 구분하는가?
  • credential stuffing, password spraying, 계정 열거를 보려면 어떤 로그 필드가 필요한가?
  • 차단 정책이 정상 사용자 잠금 DoS로 바뀌지 않게 하려면 무엇을 고려해야 하는가?

개요

로그인 실패는 단순한 사용자 오류가 아니다. 공격자가 인증 경계를 두드리는 가장 흔한 신호다.

문제는 실패가 항상 공격은 아니라는 점이다. 사용자는 비밀번호를 잘못 입력하고, 키보드 언어를 착각하고, 오래된 비밀번호를 기억한다.

따라서 로그인 abuse 방어는 “몇 번 실패하면 잠금”보다 정교해야 한다. 계정, IP, device, ASN, user agent, credential pair, 시간대, 성공 전환 여부를 함께 봐야 한다.

공격 시나리오

공격자는 세 가지 방식으로 로그인 경계를 시험한다.

첫째, credential stuffing이다.

  • 유출 목록의 이메일과 비밀번호를 그대로 시도한다.
  • 여러 IP와 user agent를 섞는다.
  • 성공하면 즉시 MFA 등록, 이메일 변경, 결제 정보 조회를 시도한다.

둘째, password spraying이다.

  • 많은 계정에 같은 약한 비밀번호를 낮은 빈도로 시도한다.
  • 계정별 실패 횟수만 보면 탐지가 늦다.

셋째, 계정 열거다.

  • 오류 메시지, 응답 시간, 잠금 메시지 차이를 비교한다.
  • 존재하는 계정 목록을 만든 뒤 공격 효율을 높인다.

취약한 요청/응답 예시

POST /login HTTP/1.1
Host: api.example.com
X-Forwarded-For: 198.51.100.40
Content-Type: application/json
 
{
  "email": "ceo@example.com",
  "password": "Company2026!"
}
HTTP/1.1 423 Locked
Content-Type: application/json
 
{
  "code": "ACCOUNT_LOCKED",
  "message": "ceo@example.com 계정이 5회 실패로 30분 잠겼습니다"
}

이 응답은 공격자에게 계정 존재 여부와 잠금 정책을 알려 준다. 또한 공격자는 의도적으로 특정 계정을 잠가 업무 방해를 만들 수 있다.

주제별 핵심 판단

  • 로그인 실패 abuse는 실패 횟수보다 계정 수, 출처 수, credential 패턴, 실패 후 성공 여부가 중요하다.
  • 장애와 공격을 구분하려면 인증 서버 오류와 분산된 credential 시도를 같은 지표로 섞지 않는다.

개선된 요청/응답 예시

로그인 abuse 방어는 여러 기준을 함께 본다.

  • 개선된 로그는 auth.login.failed, auth.spray.detected, auth.account.protected처럼 이벤트를 분리한다.
  • 응답은 일반화하되 로그에는 account hash, ip, user agent hash, reason, trace id를 남긴다.

방어 설계

identifier_hash
account_id or unknown
ip
subnet
asn
user_agent_hash
device_id_hash
password_fingerprint_hash
failure_reason_internal
result

차단 정책은 단계적으로 설계한다.

  • 낮은 위험: 짧은 지연을 둔다.
  • 중간 위험: 추가 challenge나 CAPTCHA를 검토한다.
  • 높은 위험: credential 검증 전 차단하거나 MFA step-up을 강제한다.
  • 특정 계정 보호: 완전 잠금보다 step-up, 알림, 기존 session 보호를 고려한다.
  • 관리자 계정: 더 낮은 threshold와 강한 MFA를 둔다.

IP 하나만 기준으로 rate limit를 걸면 NAT 뒤 정상 사용자가 피해를 볼 수 있고, 계정 하나만 기준으로 잠그면 공격자가 잠금 DoS를 만들 수 있다.

Spring/HTTP 예시

public LoginAbuseDecision checkBeforeAuthentication(
        NormalizedIdentifier identifier,
        ClientSignal signal) {
 
    LoginAttemptWindow window = loginAttemptRepository.loadWindow(
        identifier.safeHash(),
        signal.ip(),
        signal.asn(),
        signal.deviceIdHash());
 
    if (window.passwordSprayingSuspected() || window.credentialStuffingSuspected()) {
        audit.loginBlocked(identifier.safeHash(), signal, window.reason());
        return LoginAbuseDecision.blocked(window.reason());
    }
 
    if (window.requiresStepUp()) {
        return LoginAbuseDecision.stepUpRequired(window.reason());
    }
 
    return LoginAbuseDecision.allowed();
}

인증 실패 이벤트는 별도로 적재한다.

@Component
class AuthenticationEventRecorder {
 
    @EventListener
    void onFailure(AbstractAuthenticationFailureEvent event) {
        LoginFailureSignal signal = LoginFailureSignal.from(event);
        audit.loginFailed(signal.withoutRawCredential());
        loginAttemptRepository.recordFailure(signal);
    }
 
    @EventListener
    void onSuccess(AuthenticationSuccessEvent event) {
        LoginSuccessSignal signal = LoginSuccessSignal.from(event);
        audit.loginSucceeded(signal.withoutSessionId());
        loginAttemptRepository.recordSuccess(signal);
    }
}

로그 수집 전에 request body logging filter가 원문 비밀번호를 저장하지 않는지 반드시 확인한다.

운영 로그와 감사 지점

level=WARN event=login_failed result=denied identifier_hash=91ad account_id=unknown ip=203.0.113.10 asn=64500 user_agent_hash=8c21 reason=bad_credentials risk=low trace_id=9012af01
level=WARN event=login_abuse_detected result=blocked pattern=password_spraying identifier_count=438 ip_count=19 asn=64500 reason=shared_password_fingerprint trace_id=9012af02
level=INFO event=login_succeeded_after_failures result=allowed account_id=1004 prior_failures=7 mfa_step_up=true trace_id=9012af03

운영 로그는 개별 실패와 집계 탐지를 모두 담아야 한다. 개별 이벤트만 있으면 패턴을 보기 어렵고, 집계만 있으면 사건 재구성이 어렵다.

실전 판단 기준

  • 존재하지 않는 계정과 존재하는 계정의 실패 응답이 같은가?
  • rate limit은 단일 IP 기준만으로 충분하지 않다.
  • 탐지 규칙은 정상 장애 시간대와 공격 시간대를 구분할 수 있어야 한다.

테스트 포인트

  • 여러 계정에 같은 비밀번호를 낮은 빈도로 시도하는 password spraying을 탐지하는가?
  • 한 계정에 여러 IP가 접근하는 credential stuffing을 탐지하는가?
  • 계정 잠금 정책이 공격자의 업무 방해 수단이 되지 않는가?
  • 원문 비밀번호가 access log, exception log, APM, metric label에 남지 않는가?
  • 성공 직전 실패가 많았던 로그인에 MFA step-up이나 알림이 붙는가?

위험 신호!

  • 로그인 실패를 단순 401 카운트로만 본다.
  • 계정별 실패 횟수만 보고 IP, ASN, device, user agent를 보지 않는다.
  • 잠금 메시지가 계정 존재 여부와 실패 횟수를 알려 준다.
  • 원문 credential이 request body log에 남는다.
  • 실패 이벤트와 성공 이벤트를 연결할 trace id나 account id가 없다.
  • 관리자 계정과 일반 계정의 threshold가 같다.

확인 질문

확인 질문

  • credential stuffing과 password spraying의 차이는 무엇인가?
    • credential stuffing은 유출된 계정/비밀번호 쌍을 시도하는 공격이고, password spraying은 많은 계정에 적은 수의 흔한 비밀번호를 나누어 시도하는 공격이다.
  • 로그인 실패 로그에서 원문 identifier 대신 hash를 쓰는 이유는 무엇인가?
    • 개인정보 노출을 줄이면서도 같은 계정 후보에 대한 반복 실패를 집계하기 위해서다.
  • 계정 잠금만으로 abuse를 막기 어려운 이유는 무엇인가?
    • 공격자가 특정 계정을 의도적으로 잠그는 DoS를 만들 수 있고, 분산 공격은 단일 계정 threshold를 천천히 우회할 수 있기 때문이다.

참고 문서