SonarQube Weak Cryptography 해결 — MD5에서 BCrypt까지 4단계

이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.

SonarQube XXE(S2755)까지 시리즈를 정리하고 나서 마무리 단계인가 싶었는데, 이번엔 SonarQube Weak Cryptography 룰 S4790이 12건 떴다. 메시지는 “Make sure this weak hash algorithm is not used in a sensitive context”다. MD5로 비밀번호를 해싱하던 사용자 테이블 관련 코드가 전부 걸린 거다.

금융권 레거시에서 이 패턴은 생각보다 흔하다. 2010년대 초반에 짜여진 회원 인증 모듈이 MessageDigest.getInstance("MD5") 한 줄로 비밀번호를 처리하고, 그게 10년 넘게 운영 중인 경우가 있다. 이번 JDK 21 업그레이드 작업에서 SonarQube Quality Profile이 최신으로 갈리면서 S4790이 한꺼번에 켜졌다. SonarQube Weak Cryptography 룰은 이런 오래된 코드를 정확하게 찾아낸다.

S4790은 CWE-328에 해당하는 Security Hotspot이다. MD5나 SHA-1 같은 약한 해시 알고리즘이 민감한 컨텍스트(비밀번호 저장)에서 쓰이면 잡는다. 단순히 SHA-256으로 교체하면 끝날 거라고 생각했다. 그게 틀렸다는 걸 4단계 시도 끝에 알게 됐다.

SonarQube Weak Cryptography — S4790 룰 경고 화면

레거시에서 발견된 패턴

12건을 분류하니 두 패턴이 전부였다. 하나는 직접 MessageDigest를 쓰는 케이스, 다른 하나는 Apache Commons Codec의 DigestUtils를 쓰는 케이스다.

패턴 1. MessageDigest.getInstance(“MD5”) 직접 사용

// MemberAuthService.java
public class MemberAuthService {

    public boolean authenticate(String userId, String rawPassword) {
        String hashedInput = hashMd5(rawPassword);
        MemberVO member = memberMapper.findById(userId);
        return hashedInput.equals(member.getPassword());
    }

    private String hashMd5(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");  // S4790 여기서 잡힘
            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("MD5 hash failed", e);
        }
    }
}

이게 8건이었다. MessageDigest.getInstance("MD5") 한 줄만 보고 SonarQube Weak Cryptography 분석기가 잡는다. 알고리즘명이 MD5SHA-1이고 비밀번호 처리 컨텍스트에 있으면 예외 없이 flagging된다.

패턴 2. DigestUtils.md5Hex 사용

// PasswordUtil.java (Apache Commons Codec 사용)
import org.apache.commons.codec.digest.DigestUtils;

public class PasswordUtil {

    public static String encode(String rawPassword) {
        return DigestUtils.md5Hex(rawPassword);  // S4790 잡힘
    }

    public static boolean matches(String rawPassword, String stored) {
        return encode(rawPassword).equals(stored);
    }
}

나머지 4건이 이 패턴이었다. DigestUtils.md5HexDigestUtils.sha1Hex도 내부적으로 MessageDigest를 사용하기 때문에 S4790이 동일하게 뜬다. 래퍼 메서드로 감쌌다고 해서 Sonar를 피할 수 없다.

12건 모두 MEMBER 테이블의 비밀번호 해싱 관련 코드였다. SHA-1 패턴은 없었는데, 해당 시스템이 이전에 SHA-1을 거쳐 MD5로 통일한 이력이 있다고 했다. SHA-1도 S4790이 잡는 대상이니, 만약 남아 있었다면 건수가 더 많았을 거다.

시도 1. SHA-256 변경 — 룰은 통과, 보안은 낙제

제일 먼저 떠올린 게 “MD5를 SHA-256으로 바꾸면 되는 거 아닌가”였다. SHA-256은 약한 알고리즘이 아니니 S4790이 잡지 않을 거라는 생각이었다.

// SHA-256으로 교체 시도
private String hashSha256(String input) {
    try {
        MessageDigest md = MessageDigest.getInstance("SHA-256");  // S4790 사라짐
        byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
        StringBuilder sb = new StringBuilder();
        for (byte b : digest) {
            sb.append(String.format("%02x", b));
        }
        return sb.toString();
    } catch (NoSuchAlgorithmException e) {
        throw new RuntimeException("SHA-256 hash failed", e);
    }
}

S4790은 사라졌다. 룰은 통과다.

근데 여기서 보안 리뷰에서 제동이 걸렸다. “SHA-256은 암호화 해시인데 비밀번호 저장에 쓰면 안 된다”는 지적이었다. 이유는 간단하다. SHA-256은 빠르다. 현대 GPU로 초당 수십억 번 계산이 가능하다. 비밀번호 없이 salt도 없는 SHA-256 해시는 rainbow table이나 brute force 앞에 사실상 무방비다.

SonarQube Weak Cryptography 룰은 통과했지만, 보안 측면에서 MD5를 SHA-256으로 바꾼 게 실질적 개선이 아니었다. Sonar는 “이 알고리즘이 약한가”를 보지, “비밀번호 저장에 적합한가”까지 판단하지는 않는다. 룰 통과 = 보안 완료가 아니라는 걸 다시 확인했다.

시도 2. salt + SHA-256 — 개선됐지만 여전히 부족

rainbow table을 막으려면 salt를 붙이면 된다는 건 알고 있었다. salt를 랜덤하게 생성해서 비밀번호와 함께 해싱하면 같은 비밀번호도 다른 해시값이 나온다.

// salt + SHA-256 시도
import java.security.SecureRandom;
import java.util.Base64;

public class PasswordUtil {

    private static final int SALT_LENGTH = 16;

    public static String encode(String rawPassword) {
        byte[] salt = generateSalt();
        byte[] hash = hashWithSalt(rawPassword, salt);
        // salt + hash를 같이 저장 (Base64 인코딩)
        String saltBase64 = Base64.getEncoder().encodeToString(salt);
        String hashBase64 = Base64.getEncoder().encodeToString(hash);
        return saltBase64 + ":" + hashBase64;
    }

    public static boolean matches(String rawPassword, String stored) {
        String[] parts = stored.split(":");
        byte[] salt = Base64.getDecoder().decode(parts[0]);
        byte[] expectedHash = Base64.getDecoder().decode(parts[1]);
        byte[] actualHash = hashWithSalt(rawPassword, salt);
        return MessageDigest.isEqual(actualHash, expectedHash);
    }

    private static byte[] generateSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[SALT_LENGTH];
        random.nextBytes(salt);
        return salt;
    }

    private static byte[] hashWithSalt(String password, byte[] salt) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            md.update(salt);
            return md.digest(password.getBytes(StandardCharsets.UTF_8));
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

rainbow table 공격은 막힌다. salt가 있으니 사전 계산이 의미 없다. S4790도 여전히 통과 상태다.

그런데 문제는 속도다. SHA-256은 빠른 해시다. 빠르다는 게 일반 데이터 처리에는 장점이지만, 비밀번호 해싱에서는 단점이다. 공격자 입장에서 GPU 클러스터로 초당 수십억 번 시도할 수 있다는 뜻이다. salt를 추가해도 빠른 해시 함수라는 본질은 그대로다. NIST와 OWASP 모두 비밀번호 저장에 salt만 붙인 SHA-256은 권장하지 않는다.

보안팀에서 “여기서 멈추면 안 된다”고 했다. 맞는 말이었다. SQL Injection(S3649)처럼 룰 통과만 목표로 잡으면 안 되는 케이스였다. SonarQube Weak Cryptography 대응은 알고리즘 교체와 마이그레이션 전략을 함께 고려해야 한다.

시도 3. PBKDF2WithHmacSHA256 — 통과는 되는데

PBKDF2(Password-Based Key Derivation Function 2)는 비밀번호 해싱을 위해 설계된 알고리즘이다. 반복 횟수(iteration count)를 높여서 의도적으로 느리게 만들 수 있고, JDK에 내장돼 있어서 별도 의존성이 없다.

// PBKDF2WithHmacSHA256 시도
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.security.SecureRandom;
import java.util.Base64;

public class PasswordUtil {

    private static final int ITERATIONS = 310_000;  // OWASP 권장: 310,000 이상
    private static final int KEY_LENGTH = 256;
    private static final int SALT_LENGTH = 16;

    public static String encode(String rawPassword) {
        byte[] salt = generateSalt();
        byte[] hash = pbkdf2(rawPassword.toCharArray(), salt);
        String saltBase64 = Base64.getEncoder().encodeToString(salt);
        String hashBase64 = Base64.getEncoder().encodeToString(hash);
        return saltBase64 + ":" + hashBase64;
    }

    public static boolean matches(String rawPassword, String stored) {
        String[] parts = stored.split(":");
        byte[] salt = Base64.getDecoder().decode(parts[0]);
        byte[] expectedHash = Base64.getDecoder().decode(parts[1]);
        byte[] actualHash = pbkdf2(rawPassword.toCharArray(), salt);
        return MessageDigest.isEqual(actualHash, expectedHash);
    }

    private static byte[] pbkdf2(char[] password, byte[] salt) {
        try {
            PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, KEY_LENGTH);
            SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
            byte[] hash = skf.generateSecret(spec).getEncoded();
            spec.clearPassword();
            return hash;
        } catch (Exception e) {
            throw new RuntimeException("PBKDF2 hash failed", e);
        }
    }

    private static byte[] generateSalt() {
        SecureRandom random = new SecureRandom();
        byte[] salt = new byte[SALT_LENGTH];
        random.nextBytes(salt);
        return salt;
    }
}

S4790 통과, 보안성도 SHA-256 단독보다 훨씬 낫다. ITERATIONS를 310,000으로 설정하면 서버에서 비밀번호 하나를 검증하는 데 수십~수백 밀리초가 걸린다. 공격자 입장에서도 같은 속도 제약을 받는다.

여기서 실제 문제가 생겼다. 기존 DB에는 MD5로 해싱된 비밀번호가 수만 건 저장돼 있다. 이걸 PBKDF2로 일괄 마이그레이션하려면 원문 비밀번호를 알아야 하는데, 해시는 단방향이라 역산이 불가능하다. “기존 사용자 비밀번호를 어떻게 BCrypt로 옮길지”가 진짜 문제였다.

일괄 변환이 안 되니 “로그인할 때 재해싱” 방식을 써야 한다는 결론이 나왔다. 그 방식을 구현하다 보니 PBKDF2보다 Spring Security 생태계에서 표준으로 사용하는 BCrypt로 최종 전환하는 게 낫다는 결론에 이르렀다.

최종 해결 — BCrypt + 점진적 마이그레이션

Spring Security의 BCryptPasswordEncoder를 쓰는 방향으로 결정했다. BCrypt는 내부에 salt를 자동으로 생성하고, cost factor로 연산 강도를 조절할 수 있다. SonarQube Command Injection(S2076) 작업에서 Spring Security 의존성을 이미 올려둔 상태라 추가 비용이 없었다.

1) BCryptPasswordEncoder 적용

// Spring Security BCrypt 적용
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Service
public class MemberAuthService {

    // cost factor 12 — 로그인 시 약 200~400ms (서버 사양에 따라 다름)
    private static final BCryptPasswordEncoder passwordEncoder =
        new BCryptPasswordEncoder(12);

    /**
     * 신규 가입 또는 비밀번호 변경 시 사용
     */
    public String encodePassword(String rawPassword) {
        return passwordEncoder.encode(rawPassword);
        // 결과: "$2a$12$salt+hash" 형태 (60자)
    }

    /**
     * 로그인 인증 — MD5 레거시 호환 포함
     */
    public boolean authenticate(String userId, String rawPassword) {
        MemberVO member = memberMapper.findById(userId);
        if (member == null) {
            return false;
        }

        String stored = member.getPassword();

        // BCrypt 해시인지 확인 (BCrypt 해시는 $2a$ 또는 $2b$로 시작)
        if (stored.startsWith("$2a$") || stored.startsWith("$2b$")) {
            return passwordEncoder.matches(rawPassword, stored);
        }

        // MD5 레거시 비밀번호 — 매칭 후 자동 재해싱
        if (stored.equals(legacyMd5(rawPassword))) {
            String newHash = passwordEncoder.encode(rawPassword);
            memberMapper.updatePassword(userId, newHash);  // 로그인 성공 시 BCrypt로 교체
            return true;
        }

        return false;
    }

    // 레거시 MD5 검증용 — 마이그레이션 완료 후 제거
    private String legacyMd5(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
}

여기서 한 가지 짚고 넘어갈 게 있다. legacyMd5 메서드 안에 여전히 MessageDigest.getInstance("MD5")가 있다. S4790이 여기서 또 뜰 수 있다. 이 메서드는 신규 해싱이 아니라 레거시 값과 비교하는 검증 전용 코드다. SonarQube에서는 이 경우 @SuppressWarnings("java:S4790")으로 억제 처리하거나, SonarQube 콘솔에서 “Won’t Fix”로 마킹하면 된다. 이유를 주석에 명시하는 게 중요하다.

// SonarQube S4790 억제 처리 예시
@SuppressWarnings("java:S4790")
// MD5는 레거시 해시 검증 전용. 신규 해싱에 사용하지 않음.
// BCrypt 마이그레이션 완료 후 이 메서드와 어노테이션을 함께 제거할 것.
private String legacyMd5(String input) {
    ...
}

2) 마이그레이션 현황 추적

점진적 마이그레이션이 얼마나 진행됐는지 모니터링하는 쿼리도 만들었다. BCrypt 해시는 $2a$로 시작하니 간단하게 카운트할 수 있다.

<!-- MemberMapper.xml -->
<select id="countLegacyPasswords" resultType="int">
  SELECT COUNT(*)
    FROM MEMBER
   WHERE PASSWORD NOT LIKE '$2a$%'
     AND PASSWORD NOT LIKE '$2b$%'
</select>

<update id="updatePassword">
  UPDATE MEMBER
     SET PASSWORD = #{newPassword},
         MOD_DT   = SYSDATE
   WHERE USER_ID  = #{userId}
</update>

3) 핵심 포인트 4가지

  1. BCrypt는 salt 자동 포함encode() 호출 때마다 다른 salt가 생성된다. 별도로 salt를 관리할 필요 없다.
  2. cost factor 12 권장 — 낮을수록 빠르고 보안이 약하다. 서버 사양에 맞게 조절하되, 최소 10 이상 권장. 100ms 이상이 걸리면 브루트포스에 충분히 강하다.
  3. 레거시 MD5는 비교 전용 — 새 해시를 MD5로 생성하지 않는다. 기존 값과 비교하는 코드에만 남긴다. S4790 억제 주석 필수.
  4. 마이그레이션 완료 기준 설정 — 레거시 MD5 잔여 건수를 주기적으로 모니터링. 6개월 후 전체 회원이 로그인했을 가능성은 낮으니, 남은 MD5 계정은 비밀번호 강제 재설정 정책을 검토한다.

Path Traversal(S2083)이나 XSS(S5131)처럼 코드 한 줄 수정으로 끝나는 룰과 달리, SonarQube Weak Cryptography S4790은 DB 데이터 마이그레이션 전략까지 같이 설계해야 하는 룰이다. “어떻게 기존 사용자 비밀번호를 안전하게 교체할 것인가”가 기술 문제의 본질이었다.

정리

SonarQube Weak Cryptography(S4790)의 본질은 비밀번호 저장에 느린 해시를 써야 한다는 거다. SHA-256으로 바꾸면 S4790은 통과하지만 실제 보안은 MD5와 큰 차이가 없다. 비밀번호 해싱에는 BCrypt, Argon2id, PBKDF2WithHmacSHA256 중 하나를 써야 한다. 레거시 마이그레이션은 “로그인 시 재해싱” 패턴으로 점진적으로 진행하면 된다.

다음 편은 SonarQube Open Redirect(S5146)다 — 로그인 후 리다이렉트 URL을 외부 입력으로 받다가 터진 케이스다. response.sendRedirect(request.getParameter("url")) 한 줄이 어떻게 피싱 공격 벡터가 되는지, 그리고 화이트리스트 없이 host 검증만으로 Sonar를 통과시키려다 실패한 과정을 정리한다.

룰 본문은 SonarSource 공식 룰 페이지(S4790)에서 확인할 수 있다.