비밀번호가 맞아도: 비밀번호 저장, 크리덴셜 스터핑, 토큰 수명

아프로·2026년 11월 2일

비밀번호가 맞아도 표지

GS샵 회원 계정에 약 8개월 동안 대량 로그인이 이어졌고, 회사는 알아차리지 못했습니다. 서버가 침해된 사고도 아니었습니다. 다른 곳에서 새어 나간 비밀번호 목록이 출발점이었고, 로그인 서버는 비밀번호만 확인했습니다. 개인정보보호위원회(개인정보위)는 GS리테일에 과징금 128억 3,600만 원을 매겼습니다. 사고 경위는 국내편 GS리테일·티머니·알바몬: 비밀번호가 맞아도 막아야 했던 로그인에 있습니다.

이 시리즈의 공격을 실제 쇼핑몰 코드에서 직접 재연하고 막아 보는 인프런 강의가 열렸습니다. 실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩 · 이 글과 이어지는 수업: 4-1, 4-2, 4-3, 4-8

한눈에 보기

로그인 한 번의 흐름에서 막는 네 자리: 가입 때 유출 비밀번호 거부, 시도 횟수 제한, 느린 해시 비교, 짧은 토큰과 회전·폐기

위험어디서 생기나막는 법 한 줄OWASP
약한 비밀번호 저장빠른 해시나 평문으로 비밀번호를 저장하는 곳느린 해시로 저장하고, 유출된 비밀번호는 가입 때 거부한다A07·A04
크리덴셜 스터핑시도 횟수를 세지 않는 로그인비밀번호를 보기 전에 계정·IP 축으로 시도를 센다A07
긴 토큰 수명로그아웃해도 만료까지 통하는 토큰접근 토큰은 짧게, 갱신 토큰은 회전, 로그아웃한 토큰은 폐기 목록으로 거절한다A07

크리덴셜 스터핑(credential stuffing)은 다른 곳에서 새어 나간 아이디·비밀번호 목록을 다른 서비스에 대량으로 넣어 보는 공격입니다. 같은 비밀번호를 여러 곳에 쓰는 사람이 많아서 대부분은 실패하고 일부는 맞습니다. 해외에서도 같은 구조가 반복됐습니다. 스노우플레이크(Snowflake) 고객 계정 침해에서 공격에 쓰인 계정의 79.7%가 과거에 이미 유출된 적이 있었고(맨디언트 조사), 그 계정에는 MFA가 없었습니다.

1. 비밀번호 저장과 유출 비밀번호 거부

무엇이 문제인가

비밀번호를 평문이나 SHA-256 같은 빠른 해시 한 번으로 저장하면, DB가 유출됐을 때 짧은 시간에 원래 비밀번호를 대량으로 맞혀 볼 수 있습니다. 빠른 해시는 원래 빨리 계산하려고 만든 함수라서, 맞혀 보는 쪽에도 빠릅니다.

저장 방식이 좋아도 비밀번호 자체가 이미 유출 목록에 있다면 소용이 적습니다. 크리덴셜 스터핑은 바로 그 목록으로 시작하기 때문입니다.

막는 코드

특정 회사 코드가 아니라 개념을 보여 주는 예시입니다. 값은 가짜입니다.

// 위험한 모양: 빠른 해시 한 번, 솔트 없음
String stored = HexFormat.of().formatHex(
        MessageDigest.getInstance("SHA-256").digest(raw.getBytes(UTF_8)));
// 고친 모양: 느린 해시(기본 bcrypt) + 가입 때 유출 비밀번호 거부
@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();  // 저장값 앞에 {bcrypt} 표시
}

public void signUp(SignUpRequest req) {
    if (breachedPasswords.contains(req.password()))       // 오프라인 유출 목록과 대조
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "유출된 적 있는 비밀번호입니다");
    memberMapper.insert(req.username(), passwordEncoder.encode(req.password()));
}

bcrypt는 해시마다 솔트(같은 비밀번호도 다른 값이 되게 섞는 무작위 값)를 넣고, 일부러 느리게 계산합니다. 저장값 앞의 {bcrypt} 표시 덕분에 나중에 Argon2 같은 다른 방식으로 옮길 때도 기존 회원의 비밀번호를 한꺼번에 바꿀 필요가 없습니다. 로그인에 성공할 때 새 방식으로 다시 저장하면 됩니다.

유출 비밀번호 목록은 외부 서비스로 비밀번호를 보내지 않도록 오프라인 목록으로 둡니다.

확인하는 법

흔한 비밀번호로 가입하면 거부되는지 테스트합니다. DB의 비밀번호 칼럼이 {bcrypt}$2a$…처럼 시작하는지, 같은 비밀번호로 가입한 두 계정의 저장값이 다른지 봅니다.

2. 크리덴셜 스터핑과 시도 제한

무엇이 문제인가

크리덴셜 스터핑에 쓰이는 비밀번호는 맞는 비밀번호입니다. 비밀번호 확인만으로는 정상 로그인과 구별되지 않습니다. 구별할 수 있는 것은 횟수와 분포입니다. 한 IP가 짧은 시간에 수많은 계정을 시도하거나, 한 계정에 여러 IP가 몰리는 모습입니다.

GS 사례에서 개인정보위는 같은 IP에서 짧은 시간에 로그인이 쏟아져도 탐지도, 차단도 하지 못했다고 밝혔습니다. GS25 공격에 쓰인 IP 327개는 GS샵 공격에도 그대로 쓰였습니다.

막는 코드

강의에서 만드는 방어 부품 예시는 설정 한 줄입니다.

# application.yml (강의에서 만드는 방어 부품 secure-kit 예시, 값은 예시)
secure-kit:
  rate-limit:
    login: { per-account: 5/10m, per-ip: 50/10m }

부품 없이 직접 짜면 Redis 카운터 두 개입니다. 비밀번호를 확인하기 전에 호출합니다.

public void checkLoginAttempts(String username, String ip) {
    long byAccount = count("login:acct:" + username);
    long byIp = count("login:ip:" + ip);
    if (byAccount > 5 || byIp > 50)                       // 계정·IP 축을 따로 센다
        throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS);
}

private long count(String key) {
    Long n = redis.opsForValue().increment(key);
    if (n != null && n == 1) redis.expire(key, Duration.ofMinutes(10));
    return n == null ? Long.MAX_VALUE : n;                // 셀 수 없으면 막는다
}

Redis에 연결하지 못해 횟수를 셀 수 없을 때는 로그인을 막는 쪽으로 둡니다(fail-closed). 한도를 넘었을 때의 응답과 오류 문구는 계정이 있든 없든 같게 합니다. 응답 시간 차이까지 맞추는 법은 07편에서 다뤘습니다.

확인하는 법

정상 사용자의 로그인은 통과하고, 한 IP에서 여러 계정을 빠르게 시도하면 429가 나오는지 테스트합니다. 운영에서는 로그인 실패율이 평소보다 갑자기 오르면 경보를 받습니다.

3. 토큰 수명, 회전, 폐기

무엇이 문제인가

JWT 같은 토큰 방식 인증은 요청마다 서명과 만료 시각만 확인합니다. 로그아웃은 브라우저에서 토큰을 지울 뿐이고, 서버에 기록이 없으면 그 토큰은 만료될 때까지 통합니다. 데모 프로젝트 기본값처럼 수명이 7일이면, 새어 나간 토큰도 7일 동안 통합니다.

막는 코드

# application.yml (강의에서 만드는 방어 부품 secure-kit 예시, 값은 예시)
secure-kit:
  token: { access-ttl: 15m, refresh-rotate: true, revoke-list: redis }

직접 짠다면 갱신과 로그아웃 두 곳을 고칩니다.

// 갱신: 한 번 쓴 갱신 토큰은 바로 폐기하고 새 쌍을 발급한다(회전)
public TokenPair refresh(String presented) {
    RefreshToken saved = refreshTokens.findActive(sha256(presented))
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
    refreshTokens.revoke(saved);
    return issue(saved.memberId());            // 15분짜리 접근 토큰 + 새 갱신 토큰
}

// 로그아웃: 접근 토큰 ID(jti)를 남은 수명만큼 폐기 목록에 넣는다
public void logout(Jwt access, String refresh) {
    Duration left = Duration.between(Instant.now(), access.getExpiresAt());
    redis.opsForValue().set("revoked:" + access.getId(), "1", left);
    refreshTokens.revoke(sha256(refresh));
}

요청을 인증하는 필터에서는 서명 확인 뒤 revoked: 키가 있는지 한 번 더 봅니다. 갱신 토큰은 DB에 해시로만 저장합니다. 비밀번호를 바꾸면 그 회원의 갱신 토큰을 모두 폐기합니다.

확인하는 법

로그아웃한 직후 같은 접근 토큰으로 요청하면 401이 나오는지, 한 번 쓴 갱신 토큰을 다시 쓰면 거절되는지 테스트합니다.

점검 체크리스트

  • □ 비밀번호는 bcrypt·Argon2 같은 느린 해시로 저장하고, 방식 표시가 저장값에 남는다
  • □ 가입과 비밀번호 변경 때 오프라인 유출 목록과 대조한다
  • □ 로그인 시도는 비밀번호 확인 전에 계정·IP 축으로 따로 세고, 셀 수 없으면 막는다
  • □ 로그인 실패율 급증에 경보가 있다
  • □ 접근 토큰은 분 단위 수명이고, 갱신 토큰은 쓸 때마다 바뀐다
  • □ 로그아웃과 비밀번호 변경 뒤 기존 토큰이 401로 거절된다

정리

비밀번호가 맞는다는 사실만으로는 정상 사용자를 가려낼 수 없습니다. 저장은 느린 해시로 하고, 이미 유출된 비밀번호는 가입 때 거부합니다. 로그인은 비밀번호를 보기 전에 누가 몇 번 시도했는지부터 셉니다. 토큰은 짧게 발급하고, 로그아웃은 서버의 폐기 목록에서 끝나게 합니다.

함께 보면 좋은 글

보안을 더 알고 싶다면

오늘 본 사고의 원인은 대부분 서버와 코드의 기본기에서 막을 수 있는 것들이었습니다. 직접 손으로 막아 보고 싶다면 이런 강의가 있습니다.

실제 해킹 사고로 배우는 Spring Boot·Express 시큐어 코딩

  • AI로 자동화된 해킹 시대, 오픈소스 쇼핑몰 코드에서 실제 사고 22건을 재연하고 직접 취약점을 찾아 한 건씩 막습니다.
  • OWASP Top 10:2025 전체와 목록 밖 위험 20가지를 102개 수업으로 다루고, 취약점 37개를 모두 막은 최종 소스를 가져갑니다. Spring Boot와 Express 모두 다룹니다.
  • 이 글과 이어지는 수업: 4-1, 4-2, 4-3, 4-8

서버 보안 기초부터 차근차근 시작하고 싶다면 이 강의도 있습니다.

백엔드 개발자를 위한 서버 보안 실무: 해킹 방어부터 정보유출 방지까지

  • 서버를 공격하는 흐름을 로그로 보고, 내 서버와 Spring·Express 코드에서 직접 막아 봅니다.
  • SSH 하드닝, 인젝션, 인증·인가, 시크릿과 개인정보 보호, 침해사고 첫 한 시간까지 8개 섹션 54개 수업입니다.
  • 이 글과 이어지는 수업: 3-1, 3-2, 3-5

출처와 확인 날짜

수치와 사실은 2026년 11월 2일 기준 공개 자료입니다. 이 글은 법률 자문이 아닙니다.


이전 글 입력이 명령이 될 때: SQL 바인딩, 정렬 허용 목록, 저장형 XSS, 파일 업로드
다음 글 교체할 수 있는 키: 서명키 회전과 봉투 암호화
시리즈 전체 목록 해킹 방식 VS 방어 방법 전체 목록

profile
고인물 개발자(OG)

0개의 댓글