본문 바로가기

spring

Spring Security에서 많이 쓰는 패스워드 암호화(Bcrypt)

계기

최근 사내에서 유지보수하는 legacy 시스템 중 개발계 admin 사이트의 관리자 계정 패스워드를 모르는 경우가 발생했다. 인수인계 받은지 얼마 안되어 관리자 계정이 현업 1명에게만 부여되어 있다는 사실을 발견했는데, 나도 관리자로 넣어서 테스트해보고자 현업에게 요청했으나 비밀번호를 까먹었다는 답변이 돌아왔다.

비밀번호 재설정에 대한 프로세스가 없어서 인증 프로세스를 skip하게 만들 생각도 해보았으나 리소스가 많이 들 것 같아 우선 배제해두었다. legacy 시스템의 유지보수는 외부 협력업체가 담당하고 있어서 개발계라 해도 내 마음대로 소스코드를 고치고 배포하고 하지 못하게 하였는데, 시간도 좀 걸리고 소스코드 분석도 해야하고 배포 프로세스도 새로 파악해야 하는 리소스가 꽤 드는 일이었기에 이건 최후에 선택해야 할 방법으로 생각했다.

DB 회원 테이블을 보니 패스워드 필드에 난수화된 문자열이 있었다. 음.. 뭔가 해싱해서 저장해둔 것인가 하였다. 뭘로 해싱해둔 것일까, 보통 패스워드 같은 데이터는 단방향 암호화를 해서 디코딩 하기는 어려울 것으로 생각되었으나, legacy 시스템의 허점이 있지 않을까.. 하는 생각에 일단 소스코드를 뜯어보았다.

인증 프로세스가 spring security로 구현되어 있었고, 패스워드 암호화 함수는 BCryptPasswordEncoder를 사용하고 있었다. 별다른 파라미터를 설정하지 않았으니 기본 설정의 encoding이지 않을까 생각했다. 예전에 사이드 프로젝트를 하며 비밀번호 해싱에 BCrypt를 사용했어서 대강의 내용은 알고 있었다. 단방향 해싱, salt, key stretching 등.. 오해하고 있었던 것이 매번 random salt가 생성되므로 같은 raw string이더라도 최종 hashing 값은 다른 값이 나올 수 있다는 점이었다.

salt를 모르면 결국 hashing 값을 적절히 만들 수 없어 bcrypt로 해싱한 문자열을 넣는다고 해도 matches에서 통과되지 못할 것이라고 추측했다. 뭔가 서버나 security 설정에서 salt 값에 대한 seed 같은 것들을 만들어서 메모리에 들고 있지 않을까 하는 생각이었다.(최근에 hsm 관련 세션 초기화 작업 지원을 하면서 어딘가에 꽂혀있었던 것 같다..)

그러한 가설을 세우니 local에서 내가 BcryptPasswordEncoder로 raw string을 인코딩해서 패스워드 데이터를 update 한다고 해도 결국 인증 과정에서 실패할 것이라 생각했다. 그런데 실제로 해보니 엥? 성공하고 말았다. 뭐지, 내가 뭘 잘못알고 있었던건가?

bcrypt를 다시 찾아보게 된 계기가 되었는데, 결론은 salt 값을 인코딩된 문자열에서 들고 있기 때문에 가능한 일이었다.

bcrypt 해싱 알고리즘

$2<a/b/x/y>$[cost]$[22-character salt][31-character hash]

기본적인 bcrypt 인코딩 문자열이다.

$ 구분자를 기준으로 bcrypt 알고리즘 버전, 해싱 횟수, salt+hash value 로 구성된다. 만약 '$2a$10$DOWSD.yRxaZZbVt0VOXT2OT9vRz8jQ0kjo/Nj8ZJq7fnERc9UQU1y' 이라는 인코딩 값이 나온다면, '2a' 버전에, 10번의 hashing을 하고, 'DOWSD.yRxaZZbVt0VOXT2O' 까지가 salt, 그 이후가 hash value가 된다.

위에서 설명한 raw string이 달라도 결국 matches가 동일하게 된 것은 아래를 보면 확인할 수 있다.

String rawPassword = "myPw";
PasswordEncoder encoder = new BCryptPasswordEncoder();
​
String encoded1 = encoder.encode(rawPassword);
String encoded2 = encoder.encode(rawPassword);
​
boolean isMatch1 = encoder.matches(rawPassword, encoded1);
boolean isMatch2 = encoder.matches(rawPassword, encoded2);
​
System.out.println(encoded1 + " / " + encoded2);
System.out.println(isMatch1 + " / " + isMatch2);
  • $2a$10$PxhefeLFApFtyW0tTLQ.ieNLUG2ty9lD24FC5M.DEE3WOxL2TEOiW / $2a$10$Ok2n8P08a33AAgvgp.wbAuwP.4rQIFoWYOLZnz98BoicgjOj6bR3S
  • true / true

salt + hashed value가 encoding 될 때마다 다르다. 그렇지만 같은 raw string의 matches 결과는 동일하다. spring security를 사용하면 비밀번호가 같은지 검증할 때 matches 메서드를 내부적으로 사용하기 때문에 인증에 성공하는 것이다.

그렇다면 어떻게 구현되어 있길래 같은 것일까? 우선 matches 메서드를 살펴보자.

public boolean matches(CharSequence rawPassword, String encodedPassword) {
    if (rawPassword == null) {
        throw new IllegalArgumentException("rawPassword cannot be null");
    } else if (encodedPassword != null && encodedPassword.length() != 0) {
        if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
            this.logger.warn("Encoded password does not look like BCrypt");
            return false;
        } else {
            return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
        }
    } else {
        this.logger.warn("Empty encoded password");
        return false;
    }
}

나름 간단하게 구현되어 있는데, BCryptPasswordEncoder의 BCRYPT_PATTERN을 보면 위에 나온 인코딩 문자열 규칙을 따르게 된다. 해당 규칙을 만족하지 않으면 bcrypt로 인코딩된 것이 아닌 것으로 판단한다. 결국 패스워드 확인은 checkpw() 메서드로 확인하게 되는데, 내부를 살펴보면 다음과 같다.

public static boolean checkpw(String plaintext, String hashed) {
    byte[] passwordb = plaintext.getBytes(StandardCharsets.UTF_8);
    return equalsNoEarlyReturn(hashed, hashpwforcheck(passwordb, hashed));
}
​
private static String hashpwforcheck(byte[] passwordb, String salt) {
    return hashpw(passwordb, salt, true);
}
​
private static String hashpw(byte[] passwordb, String salt, boolean for_check) {
    //생략...
    int rounds = Integer.parseInt(salt.substring(off, off + 2));
    String real_salt = salt.substring(off + 3, off + 25);
    byte[] saltb = decode_base64(real_salt, 16);
​
    BCrypt B = new BCrypt();
    byte[] hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 65536 : 0, for_check);
    rs.append("$2");
    rs.append(minor);
    rs.append("$");
    rs.append(rounds);
    rs.append("$");
    encode_base64(saltb, saltb.length, rs);
    encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
    return rs.toString();
}

생략한 부분이 많은데, 결국에는 plaintext를 기존의 hashing 된 text의 round와 base64-decoded salt(saltb)의 값을 기준으로 인코딩하여 일치하는지 검사한다.

즉, round와 salt 값을 알고 있다면 같은 Bcrypt 알고리즘 버전으로 인코딩했을 때는 같은 값이 나오는 것이다.

BCryptPasswordEncoder 생성자

public BCryptPasswordEncoder(int strength, SecureRandom random);

생성자들 중에 위와 같이 strength와 random 값을 지정할 수 있는데, 지정하지 않는다면 기본 10 round(2의 10제곱 만큼 해싱을 한다는 것)와 기본 난수 생성기를 활용해 해싱한다. 예를 들어 strength를 높이면(최대 31까지) 2의 31승까지 해싱을 반복하므로 해시값을 알아내기가 더 어려워질 것이다. 반대로 해시값을 알아내기 어려워진만큼 해시값을 생성하는 데 드는 비용도 같이 증가한다.

기타 hashing 알고리즘

PasswordEncoder 인터페이스의 구현체들을 보면 Argon2, Pbkdf2, Scrypt, MD4, Lazy 등의 여러 알고리즘이 나오는데, 각 알고리즘의 장단점과 trade-off가 있다. 대강 찾아본 바로는 BCrypt가 해싱 코스트가 다른 알고리즘에 비해서 상대적으로 가벼운 데 비해, 보안성이 충분히 강력하다고 한다. 만약 필요하다면 다른 알고리즘들을 더 찾아볼 필요가 있겠다.