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

  • 비밀번호 저장은 왜 암호화가 아니라 단방향 password hashing 문제인가?
  • salt와 work factor는 각각 어떤 공격 비용을 바꾸는가?
  • Spring Security에서 hash migration과 rehash 정책을 어떻게 설계해야 하는가?

개요

비밀번호 저장의 목표는 비밀번호를 숨겨 두었다가 나중에 복호화하는 것이 아니다. 사용자가 입력한 비밀번호가 저장된 값과 일치하는지 확인하되, DB dump가 유출되어도 공격자의 오프라인 추측 비용을 높이는 것이다.

이 목표에는 빠른 해시 함수가 맞지 않는다. SHA-256, MD5 같은 빠른 해시는 무결성 확인에는 쓸 수 있어도 password storage에는 부적합하다. 공격자는 GPU와 전용 장비로 초당 매우 많은 후보를 계산할 수 있다.

password hashing은 salt와 work factor를 사용한다. salt는 같은 비밀번호도 사용자마다 다른 hash가 되게 하고, work factor는 추측 한 번의 계산 비용을 높인다.

이 문서의 핵심은 “비밀번호를 저장하지 않는다”가 아니라 “저장된 verifier가 오프라인 공격에 버티도록 설계한다”는 것이다.

공격 시나리오

  • 공격자는 SQL Injection, 백업 유출, 운영자 계정 탈취, 로그 유출로 사용자 테이블을 얻는다.
  • 저장값이 MD5, SHA-1, SHA-256 단독 hash이면 대규모 사전 대입으로 빠르게 비밀번호를 찾는다.
  • salt가 없으면 같은 비밀번호를 쓰는 사용자가 한 번에 드러나고 rainbow table 공격 비용도 낮아진다.
  • work factor가 낮으면 최신 장비 기준으로 hash 계산이 너무 싸다.
  • 로그인 로그에 raw password 또는 hash가 남으면 DB dump가 없어도 공격 재료가 된다.
  • 알고리즘 업그레이드 경로가 없으면 오래된 약한 hash가 계속 남는다.

취약한 요청/응답 예시

POST /api/register HTTP/1.1
Host: api.example.com
Content-Type: application/json
 
{"email":"user@example.com","password":"spring1234"}
String hash = DigestUtils.md5DigestAsHex(password.getBytes(StandardCharsets.UTF_8));
userRepository.save(new User(email, hash));
log.info("saved user email={} password={} hash={}", email, password, hash);
INFO saved user email=user@example.com password=spring1234 hash=16d7a4fca7442dda3ad93c9a726597e4

문제는 빠른 hash와 raw password 로그가 동시에 있다는 점이다. 이 설계는 DB dump와 log dump 모두를 비밀번호 공격 재료로 만든다.

주제별 핵심 판단

  • 비밀번호 verifier는 단방향 password hashing scheme으로 저장한다.
  • salt는 사용자별로 고유해야 하며 보통 modern password encoder 출력에 포함된다.
  • work factor는 운영 환경에서 실제 인증 지연을 측정해 정한다.
  • work factor는 시간이 지나면 부족해지므로 재평가와 재해시 정책이 필요하다.
  • pepper는 선택적 추가 방어이며 salt의 대체재가 아니다.
  • raw password, password hash, reset token은 로그에 남기지 않는다.
  • 비밀번호 변경과 로그인 성공 시점은 hash upgrade를 적용하기 좋은 지점이다.

개선된 요청/응답 예시

@Bean
PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Service
public class PasswordService {
    private final PasswordEncoder passwordEncoder;
 
    public String encodeForStorage(String rawPassword) {
        return passwordEncoder.encode(rawPassword);
    }
 
    public boolean matches(String rawPassword, String encodedPassword) {
        return passwordEncoder.matches(rawPassword, encodedPassword);
    }
}

Spring Security의 delegating encoder는 저장값에 {bcrypt}처럼 알고리즘 id를 포함해 여러 hash 형식을 점진적으로 다룰 수 있게 한다.

방어 설계

  • 회원 가입과 비밀번호 변경에서는 modern password encoder로 새 hash를 만든다.
  • 로그인에서는 stored hash의 algorithm id와 cost를 확인한다.
  • 약한 알고리즘이나 낮은 cost가 감지되면 로그인 성공 후 새 hash로 재저장한다.
  • 비밀번호 검증 실패 응답은 계정 존재 여부를 노출하지 않는다.
  • 비밀번호 후보는 유출 비밀번호 목록, 너무 짧은 값, 반복 패턴과 비교해 거부한다.
  • password reset token은 비밀번호 hash와 별도 수명주기로 저장한다.
  • raw password는 method boundary를 짧게 지나가고 로그, exception, event payload에 들어가지 않게 한다.
  • hash 계산 비용이 로그인 DoS로 이어지지 않게 rate limit와 병행한다.

Spring/HTTP 예시

public void login(String email, String rawPassword) {
    Member member = memberRepository.findByEmail(email)
        .orElseThrow(BadCredentialsException::new);
 
    if (!passwordEncoder.matches(rawPassword, member.passwordHash())) {
        audit.recordPasswordFailure(email);
        throw new BadCredentialsException();
    }
 
    if (passwordEncoder.upgradeEncoding(member.passwordHash())) {
        member.changePasswordHash(passwordEncoder.encode(rawPassword));
        memberRepository.save(member);
        audit.recordPasswordRehash(member.id());
    }
}
level=INFO event=password_rehash_completed result=allowed memberId=1004 oldAlgorithm=bcrypt oldCost=10 newAlgorithm=argon2id traceId=733ab0c1

재해시는 로그인 성공 후에만 수행한다. 실패한 password 후보로 새 hash를 만들면 안 된다.

운영 로그와 감사 지점

level=WARN event=password_storage_policy_violation result=blocked artifact=application.log reason=raw_password_detected traceId=733ab0c1
level=INFO event=password_hash_policy_changed result=allowed oldAlgorithm=bcrypt oldCost=10 newAlgorithm=argon2id rollout=login_rehash traceId=733ab0c1
  • raw password와 hash 원문은 남기지 않는다.
  • hash upgrade 이벤트는 member id, old algorithm, new algorithm, rollout 방식만 남긴다.
  • 로그인 실패가 급증하면 password spraying과 credential stuffing 탐지로 연결한다.
  • hash 계산 지연과 CPU 사용량을 metric으로 본다.
  • 유출 비밀번호 목록 검증 실패와 일반 형식 검증 실패를 구분한다.

실전 판단 기준

  • “암호화해서 비밀번호 저장”이라는 표현이 나오면 목적을 다시 확인한다.
  • 빠른 hash는 아무리 salt를 붙여도 password storage에 적합하지 않다.
  • salt는 보통 저장 hash 문자열에 포함되므로 별도 secret으로 숨길 필요가 없다.
  • pepper를 쓴다면 password DB와 분리된 secret manager에서 관리해야 한다.
  • work factor를 너무 높이면 로그인 DoS와 장애를 만들 수 있다.
  • hash 알고리즘 변경은 일괄 변환보다 로그인 시점 점진적 rehash가 현실적이다.

테스트 포인트

  • 저장된 password hash가 raw password와 다른지 확인한다.
  • 같은 비밀번호로 가입한 두 사용자의 저장 hash가 다른지 확인한다.
  • stored hash에 알고리즘 id와 salt/cost 정보가 포함되는지 확인한다.
  • raw password와 hash가 application log, access log, trace에 남지 않는지 확인한다.
  • 낮은 cost hash로 로그인 성공 시 새 cost로 재해시되는지 확인한다.
  • 로그인 실패 응답이 계정 존재 여부를 알려 주지 않는지 확인한다.

위험 신호!

  • MD5, SHA-1, SHA-256 단독 hash로 비밀번호를 저장한다.
  • 비밀번호를 복호화해야 한다는 요구가 있다.
  • salt가 모든 사용자에게 동일하다.
  • work factor가 수년째 재평가되지 않았다.
  • raw password 또는 hash가 로그와 metric에 남는다.
  • 기존 약한 hash를 업그레이드할 경로가 없다.

확인 질문

확인 질문

  • salt는 무엇을 해결하는가?
    • 같은 비밀번호라도 사용자마다 다른 hash가 되게 해 사전 계산과 동일 비밀번호 노출을 어렵게 한다.
  • work factor는 무엇을 해결하는가?
    • 공격자의 오프라인 추측 한 번당 계산 비용을 높인다.
  • 비밀번호 hash upgrade는 언제 하는 것이 안전한가?
    • 사용자가 올바른 비밀번호로 로그인했거나 비밀번호를 변경할 때 새 정책으로 다시 hash하는 것이 안전하다.

참고 문서