SonarQube ReDoS 정복 — 정규식 백트래킹 4가지 패턴

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

JDK 21 업그레이드 이후 SonarQube 스캔 결과를 정리하다가 SonarQube ReDoS 룰 S5852가 입력 검증 모듈에서 7건 떠 있는 걸 발견했다. 메시지는 “Using slow regular expressions is security-sensitive”로 짧았는데, 처음엔 “느린 정규식이면 그냥 최적화하면 되는 거 아냐?”라고 가볍게 봤다.

실제로는 달랐다. 부하 테스트 환경에서 이메일 검증 API에 특수한 입력을 넣자 Worker 스레드가 수십 초째 돌아가면서 다른 요청이 전부 블로킹됐다. ReDoS(Regular Expression Denial of Service)는 이론적인 취약점이 아니라 운영 장애로 직결된다. S5852를 제대로 이해하고 나서야 SonarQube ReDoS가 왜 Security Hotspot으로 분류되는지 납득했다.

이전에 다뤘던 S5135 Insecure Deserialization이나 S2245 Insecure Random과 달리, ReDoS는 입력값 자체를 무기로 CPU를 소모시키는 방식이다. 4가지 접근을 시도한 과정을 기록한다.

SonarQube ReDoS — S5852 룰 경고 화면

ReDoS를 유발하는 패턴 3가지

SonarQube ReDoS 룰 S5852는 입력에 따라 백트래킹 횟수가 기하급수적으로 증가할 수 있는 패턴을 감지한다. CWE-1333에 해당하며, 백트래킹이 폭발하는 구조 자체가 문제다. 현장에서 발견된 패턴을 세 가지로 분류했다.

패턴 1. nested quantifier — (a+)+ 형태

가장 전형적인 ReDoS 패턴이다. 외부에 +가 있고, 그 안의 그룹도 +를 가진다. 매칭이 실패하면 엔진이 내부 양화사와 외부 양화사의 모든 분기를 조합해서 다시 시도한다. 길이 n인 입력에서 O(2^n) 백트래킹이 발생할 수 있다.

// InputValidator.java — SonarQube S5852가 잡은 코드
public class InputValidator {

    // 패턴 1: nested quantifier
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^(\\w+)+@\\w+\\.\\w+$");  // S5852 여기서 잡힘

    // 패턴 2: alternation + quantifier 중첩
    private static final Pattern USERNAME_PATTERN =
        Pattern.compile("^([a-z]+|[A-Z]+)+$");  // S5852

    public static boolean isValidEmail(String input) {
        return EMAIL_PATTERN.matcher(input).matches();
    }
}

^(\\w+)+@\\w+\\.\\w+$에서 @가 없는 입력(“aaaaaaaaaaaaaaaaaaa!”)을 넣으면 매칭 실패까지 걸리는 시간이 입력 길이에 따라 폭발적으로 늘어난다. 20자짜리 입력에서 수 초가 걸리기 시작하고, 30자를 넘기면 실질적으로 hang 상태가 된다.

패턴 2. alternation 중첩 — (a|aa)+ 형태

alternation 자체는 문제가 아니다. 문제는 alternation 안의 선택지가 서로 겹치면서 그 전체에 다시 양화사가 붙을 때다. 엔진이 각 선택지를 조합해 무한히 재시도한다.

// 전화번호 패턴 — 실제로 잡힌 형태
private static final Pattern PHONE_PATTERN =
    Pattern.compile("^([0-9]{2,3}|[0-9]{3,4})+$");  // S5852

// 우편번호 패턴
private static final Pattern ZIP_PATTERN =
    Pattern.compile("^([0-9]+[-]?)+$");  // S5852

패턴 3. 앵커 없는 역참조 루프

정규식 앞뒤에 ^$가 없으면 엔진이 매칭 시작 위치를 한 칸씩 이동시키면서 전체를 다시 시도한다. nested quantifier와 결합하면 이중으로 폭발한다.

// 앵커 없는 케이스 — 더 위험
private static final Pattern TAG_PATTERN =
    Pattern.compile("([a-zA-Z]+\\s*)+");  // S5852, 앵커 없음

S5852는 이 세 구조를 정적 분석으로 감지한다. 실제 입력을 넣어보지 않아도 패턴 자체의 구조를 보고 잡는다는 게 정적 분석의 강점이다. S2092 Insecure Cookie 같은 설정 오류 룰과 달리, SonarQube ReDoS는 코드 패턴 자체에 집중하기 때문에 발견하기도 쉽고 수정도 명확하다.

시도 1. 패턴 단순화 — 부분 통과

가장 먼저 시도한 건 nested quantifier를 단일 양화사로 풀어내는 것이었다. (\\w+)+\\w+로 바꾸면 된다는 건 알고 있었다. 이메일 패턴 하나는 쉽게 해결됐다.

// 시도 1 — 단순화
// Before (S5852)
private static final Pattern EMAIL_PATTERN =
    Pattern.compile("^(\\w+)+@\\w+\\.\\w+$");

// After (통과)
private static final Pattern EMAIL_PATTERN =
    Pattern.compile("^\\w+@\\w+\\.\\w+$");

이메일 패턴 1건은 해소됐다. 그런데 나머지 6건은 단순화가 쉽지 않았다. alternation이 들어간 패턴은 단순히 (a|b)+[ab]+로 바꾸면 의미가 달라지는 경우가 있었다. 특히 캡처 그룹을 다른 로직에서 참조하고 있거나, alternation의 각 선택지 길이가 다른 경우였다.

// 전화번호 패턴 — 단순화 시도
// Before
Pattern.compile("^([0-9]{2,3}|[0-9]{3,4})+$");  // S5852

// 단순화 시도 — 의미가 달라짐
Pattern.compile("^[0-9]{2,4}+$");  // 자릿수 조합 의도가 무너짐

// 이 방향으로는 비즈니스 로직 검토가 필요 — 즉시 적용 불가

단순화는 패턴 구조가 명확히 중복 양화사 하나만 있을 때만 쉽게 된다. 비즈니스 로직이 섞인 패턴에서는 단순화 전에 기존 패턴이 어떤 의미인지 검토해야 해서 시간이 걸렸다. 1건 해소, 6건 보류.

시도 2. atomic group (?>...) — 통과, 호환성 주의

단순화가 어려운 패턴에 대해 atomic group을 적용해봤다. Java 정규식은 (?>...) 문법으로 atomic group을 지원한다. 그룹 안에서 한 번 매칭이 되면 그 결과를 고정하고 백트래킹을 허용하지 않는다. 이를 통해 nested quantifier의 폭발적 백트래킹을 차단한다.

// 시도 2 — atomic group 적용
// Before (S5852)
private static final Pattern USERNAME_PATTERN =
    Pattern.compile("^([a-z]+|[A-Z]+)+$");

// After — atomic group으로 내부 그룹 고정
private static final Pattern USERNAME_PATTERN =
    Pattern.compile("^(?>([a-z]+|[A-Z]+))+$");

// 전화번호 패턴도 동일하게
// Before (S5852)
private static final Pattern PHONE_PATTERN =
    Pattern.compile("^([0-9]{2,3}|[0-9]{3,4})+$");

// After
private static final Pattern PHONE_PATTERN =
    Pattern.compile("^(?>([0-9]{2,3}|[0-9]{3,4}))+$");

SonarQube S5852는 atomic group이 적용된 패턴을 안전하다고 판단하고 경고를 내리지 않았다. 통과.

그런데 팀 리드가 다른 각도에서 우려를 제기했다. atomic group (?>...) 문법 자체는 Java 1.4 이상에서 표준 지원이라 JVM 호환성 이슈는 사실상 없지만, 매칭 의미가 미묘하게 바뀔 수 있어서 같은 유틸 클래스를 공유 라이브러리로 쓰는 다른 팀에서 회귀가 날 수 있다는 점이었다. 테스트 커버리지가 약한 소비자가 있다면 위험했다. 더 명시적이고 의도가 한눈에 드러나는 방식이 있는지 찾아봤다.

시도 3. possessive quantifier a++ — 통과

possessive quantifier는 atomic group의 단축 표현이다. a++a를 가능한 한 많이 잡고, 한 번 잡은 것을 절대 돌려주지 않는다. 백트래킹이 원천 차단된다. (?>a+)와 동일한 효과지만 문법이 더 짧고 명확하다.

// 시도 3 — possessive quantifier 적용
// Before (S5852)
private static final Pattern EMAIL_LOCAL_PATTERN =
    Pattern.compile("^(\\w+)+$");  // 로컬 파트 검증용

// After — possessive quantifier
private static final Pattern EMAIL_LOCAL_PATTERN =
    Pattern.compile("^\\w++$");  // \\w++ : possessive

// alternation 케이스
// Before (S5852)
private static final Pattern USERNAME_PATTERN =
    Pattern.compile("^([a-z]+|[A-Z]+)+$");

// After — 각 alternation 선택지에도 possessive 적용
private static final Pattern USERNAME_PATTERN =
    Pattern.compile("^([a-z]++|[A-Z]++)+$");

possessive quantifier를 적용한 패턴도 S5852를 통과했다. atomic group보다 문법이 더 직관적이고, Java 1.4 이상에서 표준 지원이라 사실상 모든 현역 JVM에서 동작한다.

단, possessive quantifier를 무분별하게 붙이면 매칭 의미 자체가 바뀔 수 있다. 핵심은 “한 번 잡으면 절대 돌려주지 않는다”는 점이다. 예를 들어 \d++3으로 123을 매칭하려고 하면 \d++123을 전부 잡아버리고, 마지막 패턴의 3이 매칭할 자리를 남기지 않아 실패한다. 일반 greedy \d+3이라면 백트래킹으로 끝의 3을 양보해서 매칭이 성공한다. 패턴을 바꿀 때는 원래 의도를 보존하는지 반드시 테스트 케이스로 검증했다.

// PatternTest.java — possessive 적용 전후 동작 검증
class PatternTest {

    @Test
    void testUsernamePattern() {
        Pattern original = Pattern.compile("^([a-z]+|[A-Z]+)+$");
        Pattern possessive = Pattern.compile("^([a-z]++|[A-Z]++)+$");

        String[] valid = {"hello", "WORLD", "testUser"};
        String[] invalid = {"hello123", "te st", ""};

        for (String s : valid) {
            assertTrue(original.matcher(s).matches(), "original: " + s);
            assertTrue(possessive.matcher(s).matches(), "possessive: " + s);
        }
        for (String s : invalid) {
            assertFalse(original.matcher(s).matches(), "original: " + s);
            assertFalse(possessive.matcher(s).matches(), "possessive: " + s);
        }
    }
}

alternation을 포함한 패턴에서 [a-z]++[A-Z]++ 각각에 possessive를 적용하면 대소문자가 섞인 문자열 처리에 주의해야 한다. testUser처럼 대소문자가 섞이면 [a-z]++test를 가져가고 이후 U에서 실패 → [A-Z]++ 선택지로 넘어가는 흐름이 그대로 유지되는지 확인했다. 이 케이스는 possessive를 그룹 바깥 +에만 적용하면 안전하다.

최종 — 입력 길이 제한 + 패턴 재설계 + 안전한 엔진

possessive quantifier와 atomic group으로 5건을 해소했지만, 나머지 2건은 패턴 구조가 복잡해서 단순히 양화사를 바꾸는 것만으로는 충분하지 않다고 판단했다. 최종 접근은 세 가지를 함께 적용하는 것이었다.

1. 입력 길이 제한

정규식 실행 전에 입력 길이를 제한하면 최악의 경우라도 백트래킹 횟수에 상한이 생긴다. 이메일은 RFC 5321 기준 254자, 전화번호는 20자 이내면 충분하다.

// InputValidator.java — 길이 제한 추가
public class InputValidator {

    private static final int MAX_EMAIL_LENGTH = 254;
    private static final int MAX_PHONE_LENGTH = 20;
    private static final int MAX_USERNAME_LENGTH = 50;

    public static boolean isValidEmail(String input) {
        if (input == null || input.length() > MAX_EMAIL_LENGTH) {
            return false;
        }
        return EMAIL_PATTERN.matcher(input).matches();
    }

    public static boolean isValidPhone(String input) {
        if (input == null || input.length() > MAX_PHONE_LENGTH) {
            return false;
        }
        return PHONE_PATTERN.matcher(input).matches();
    }
}

2. 패턴 재설계 — quantifier 중첩 제거

possessive로 처리가 어렵거나 의미 변화가 우려되는 패턴은 처음부터 중첩 구조가 없게 다시 설계했다. 이메일 로컬 파트를 검증할 때 (\\w+)+ 같은 구조가 필요한 경우는 없다. \\w+ 하나면 충분하다.

// 재설계된 최종 패턴들
public class InputValidator {

    // 이메일 — nested quantifier 완전 제거
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[\\w.+-]{1,64}@[\\w.-]{1,253}\\.[a-zA-Z]{2,}$");

    // 전화번호 — alternation 중첩 제거, 단순 자릿수로
    private static final Pattern PHONE_PATTERN =
        Pattern.compile("^[0-9]{2,4}-?[0-9]{3,4}-?[0-9]{4}$");

    // 사용자명 — possessive quantifier 적용
    private static final Pattern USERNAME_PATTERN =
        Pattern.compile("^[a-zA-Z][\\w.-]{1,49}$");

    // 우편번호 — 구조 단순화
    private static final Pattern ZIP_PATTERN =
        Pattern.compile("^[0-9]{5}$");  // 한국 5자리

    // 태그 — 앵커 추가 + possessive
    private static final Pattern TAG_PATTERN =
        Pattern.compile("^[a-zA-Z]++(?:\\s++[a-zA-Z]++)*+$");

    private static final int MAX_EMAIL_LENGTH = 254;
    private static final int MAX_PHONE_LENGTH = 20;
    private static final int MAX_USERNAME_LENGTH = 50;
    private static final int MAX_TAG_LENGTH = 100;

    public static boolean isValidEmail(String input) {
        if (input == null || input.length() > MAX_EMAIL_LENGTH) return false;
        return EMAIL_PATTERN.matcher(input).matches();
    }

    public static boolean isValidPhone(String input) {
        if (input == null || input.length() > MAX_PHONE_LENGTH) return false;
        return PHONE_PATTERN.matcher(input).matches();
    }

    public static boolean isValidUsername(String input) {
        if (input == null || input.length() > MAX_USERNAME_LENGTH) return false;
        return USERNAME_PATTERN.matcher(input).matches();
    }

    public static boolean isValidTag(String input) {
        if (input == null || input.length() > MAX_TAG_LENGTH) return false;
        return TAG_PATTERN.matcher(input).matches();
    }
}

3. 안전한 정규식 엔진 옵션 (RE2/J)

Java 표준 java.util.regex는 NFA 기반 엔진이라 백트래킹이 존재한다. 구글이 공개한 SonarSource S5852 공식 룰 페이지에도 언급되어 있는 RE2/J는 DFA/NFA 혼합 방식으로 입력 길이에 선형 시간을 보장한다. 매칭 시간이 O(n)으로 고정되기 때문에 ReDoS가 원천적으로 불가능하다.

// build.gradle — RE2/J 의존성 추가
dependencies {
    implementation 'com.google.re2j:re2j:1.7'
}
// RE2/J 사용 예시
import com.google.re2j.Matcher;
import com.google.re2j.Pattern;

public class SafeInputValidator {

    // RE2/J Pattern — 복잡한 패턴도 선형 시간 보장
    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[\\w.+-]{1,64}@[\\w.-]{1,253}\\.[a-zA-Z]{2,}$");

    public static boolean isValidEmail(String input) {
        if (input == null || input.length() > 254) return false;
        Matcher m = EMAIL_PATTERN.matcher(input);
        return m.matches();
    }
}

RE2/J의 단점은 Java 표준 정규식과 100% 호환되지 않는다는 점이다. backreference(\\1)와 look-behind 같은 일부 기능을 지원하지 않는다. 입력 검증용 패턴처럼 단순한 경우에는 RE2/J로 전환해도 기능 손실이 없었다. 복잡한 파싱 로직에서는 표준 엔진을 유지하면서 possessive + 길이 제한으로 대응했다.

최종 적용 결과

  • 패턴 단순화로 1건 해소
  • atomic group / possessive quantifier로 4건 해소
  • 패턴 재설계 + 입력 길이 제한으로 나머지 2건 해소
  • SonarQube S5852 7건 전부 클리어

정리

SonarQube ReDoS(S5852)는 “느린 정규식”이라는 이름이 주는 인상보다 훨씬 심각한 취약점이다. 공격자가 의도적으로 최악의 입력을 만들어 CPU를 독점하면 DoS가 된다. Security Hotspot으로 분류된 이유가 있다.

해결 방향은 세 가지다. 첫째, nested quantifier나 겹치는 alternation을 패턴 수준에서 제거한다. 둘째, 제거가 어려우면 possessive quantifier(a++)나 atomic group((?>...))으로 백트래킹을 차단한다. 셋째, 입력 길이 제한을 정규식 실행 전에 반드시 건다.

RE2/J 같은 DFA 기반 엔진을 쓰면 패턴 설계 실수가 있어도 ReDoS가 원천 차단된다. 입력 검증 전용 유틸에서는 RE2/J 전환이 가장 확실한 방어다. S5135 Insecure Deserialization처럼 런타임 방어가 필요한 룰과 달리, SonarQube ReDoS는 패턴 문자열 수정만으로 해소되는 경우가 많아서 실제 수정 비용은 낮다. 중요한 건 SonarQube가 잡은 패턴을 “느리다”가 아닌 “잠재적 DoS 벡터”로 인식하는 것이다.

다음 편은 SonarQube SSRF 룰 S5144다 — new URL(userInput).openConnection()을 그대로 호출하다가 내부 서버 접근이 가능해진 케이스를 다룬다. 호스트 화이트리스트부터 내부 IP 차단까지 3단계 방어선을 정리할 예정이다.

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