설명
ReDoS는 정규식 엔진의 과도한 백트래킹을 유발하여 CPU를 점유하게 만들어 응답 지연 또는 서비스 중단을 일으키는 취약점입니다. 주로 사용자 입력을 그대로 정규식으로 컴파일하거나, 중첩 수량자 같은 위험 패턴을 사용했을 때 발생합니다. 공격자는 (a+)+$, (.*)+ 같은 패턴이나 매우 긴 입력을 보내 서버 스레드를 장시간 붙잡아 두어 자원을 소모시키고 서비스 거부 상태를 만들 수 있습니다.
잠재적 영향
- 서비스 거부(DoS): 공격자가 조작한 정규식/입력으로 CPU를 고갈시켜 요청 처리가 지연되거나 중단됩니다.
- 리소스 고갈: 다중 스레드 환경에서 워커 스레드가 정규식 평가에 묶여 연결 풀/스레드 풀이 고갈됩니다.
- 가용성 저하 및 비용 증가: 지연 확대, 타임아웃 증가, 오토스케일링 비용 상승 등 운영 비용과 SLO가 악화됩니다.
해결 방법
- 사용자 입력을 정규식으로 직접 사용하지 않기: 고정된 허용 목록(allowlist) 패턴만 사용하거나, 사용자 입력은 텍스트로만 취급합니다.
- 입력 리터럴화: 사용자 입력을 패턴으로 쓸 필요가 없다면 Pattern.quote(input) 또는 Pattern.compile(input, Pattern.LITERAL)로 메타문자를 무력화합니다.
- 위험 패턴 금지: 중첩 수량자 ((.*)+, (a+)+ 등), 광범위한 백트래킹 유발 패턴, 역참조(backreference) 등을 피하고, 수량자에 상한을 둡니다(예: \d{1,6}).
- 길이/시간 제한: 패턴과 검사 대상 문자열 길이를 제한하세요. Java 정규식은 스레드 중단만으로 종료를 보장하지 않으므로, 강제 제한이 필요하면 이를 지원하는 엔진이나 별도 프로세스를 사용하세요.
- 안전한 엔진 고려: 백트래킹이 없는 엔진 사용(re2j 등) 또는 사전 검증된 유틸을 사용합니다.
- 사전 컴파일: 애플리케이션 시작 시 검토된 패턴만 미리 컴파일하여 재사용하고, 런타임 동적 컴파일을 피합니다.
- 대체 로직: 단순 포함 여부/치환은 String.indexOf, String.replace 등 비-정규식 API로 대체합니다.
예시
변경 전
java
import org.springframework.web.bind.annotation.*;
@RestController
public class MaskController {
// 사용자 제공 정규식을 그대로 사용 - ReDoS 유발 가능
@PostMapping("/mask")
public String mask(@RequestParam String rx, @RequestBody String text) {
// 예: rx = "(a+)+$", text = "aaaaaaaaaaaaaaaaaaaa!" -> 과도한 백트래킹
return text.replaceAll(rx, "***");
}
}
변경 후
java
import org.springframework.web.bind.annotation.*;
import java.util.Set;
import java.util.regex.Pattern;
@RestController
public class SafeMaskController {
// 1) 허용 목록 패턴만 사용하거나
private static final Set<String> ALLOW_PATTERNS = Set.of("[0-9]{1,6}", "[A-Za-z]+\"?");
@PostMapping("/mask")
public String mask(@RequestParam(required = false) String rx,
@RequestParam(defaultValue = "false") boolean literal,
@RequestBody String text) {
if (text.length() > 10000 || (rx != null && rx.length() > 128)) {
throw new IllegalArgumentException("input too long");
}
// 사용 목적에 따라 두 가지 안전 접근
if (literal) {
// 2) 사용자 입력을 텍스트로만 처리: Pattern.quote로 리터럴화
String keyword = rx == null ? "" : rx;
if (keyword.isEmpty()) {
throw new IllegalArgumentException("empty keyword");
}
Pattern p = Pattern.compile(Pattern.quote(keyword));
return p.matcher(text).replaceAll("***");
} else {
// 허용된 정규식만 사용 (동적 입력 금지)
if (rx == null || !ALLOW_PATTERNS.contains(rx)) {
throw new IllegalArgumentException("invalid pattern");
}
// 수량자 상한 등 검토된 안전 패턴만 컴파일
Pattern p = Pattern.compile(rx);
return p.matcher(text).replaceAll("***");
}
}
}
변경 전에는 replaceAll()이 요청 값을 정규식으로 해석합니다. 변경 후에는 리터럴 검색과 사전에 검토한 패턴 선택을 구분하고 입력 길이를 제한합니다. 예시의 길이 상한은 업무 요구에 맞게 조정하세요. Pattern.quote()는 패턴 문법의 해석을 막지만, 입력 크기나 출력량까지 제한하지는 않습니다. 미리 컴파일하는 것만으로 비효율적인 패턴이 안전해지는 것도 아닙니다.