ReDoS: 정규표현식을 이용한 서비스 거부 공격

재앙적 백트래킹(Catastrophic Backtracking)을 유발하는 정규표현식이 어떻게 서버를 마비시키는지, 취약한 패턴 식별법과 타임아웃·RE2 엔진으로 방어하는 방법을 다룹니다.

· 5 min read · PALDYN Team

지난 글에서 프로토타입 오염 공격을 살펴봤다. 이번에는 의외의 공격 벡터인 ReDoS(Regular Expression Denial of Service) 를 다룬다. 정규표현식 하나가 서버를 수 분간 멈출 수 있다.

ReDoS란?

ReDoS는 악의적으로 설계된 입력 문자열이 정규표현식 엔진에 재앙적 백트래킹(Catastrophic Backtracking) 을 유발해 CPU를 100%로 고갈시키는 공격이다.

백트래킹의 원리

// ❌ 취약한 정규식: (a+)+
const re = /^(a+)+$/

// 입력: "aaaaaaaaab" (a 9개 + b 1개)
// 엔진의 백트래킹 시도 횟수: 2^9 = 512회 이상!
// 입력: "aaaaaaaaaaaaaab" (a 14개 + b)
// → 2^14 = 16,384회 — 수 초 걸림

re.test("aaaaaaaaab")  // 오래 걸림...

왜 이런 일이 발생하는가? (a+)+ 는 외부 그룹과 내부 그룹이 모두 a를 탐욕적으로 소비하려 한다. aaab 에서 매칭이 실패하면 엔진은 다음 조합을 모두 시도한다: (a)(a)(a), (aa)(a), (a)(aa), (aaa), … 조합의 수가 지수적으로 증가한다.

ReDoS 재앙적 백트래킹

취약한 패턴 유형

// 유형 1: 중첩된 수량자 (Nested Quantifiers)
/(a+)+/           // ❌
/([a-zA-Z]+)*/    // ❌
/(a|a)+/          // ❌

// 유형 2: 교대와 중첩 (Alternation with Overlap)
/(a|aa)+/         // ❌ — a와 aa가 겹침
/(a+|ab)+c/       // ❌

// 유형 3: 실제 서비스에서 발견된 취약 패턴
// 이메일 검증 (2019년 npm 패키지 취약점)
/^([a-zA-Z0-9])(([\-.]|[_]+)?([a-zA-Z0-9]+))*(@){1}[a-z0-9]+[.]{1}(([a-z]{2,3})|([a-z]{2,3}[.]{1}[a-z]{2,3}))$/

// URL 검증 (실제 사용된 취약 패턴)
/^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/

실제 피해 사례

2016년 Stack Overflow는 ReDoS로 34분간 서비스 중단을 겪었다. 원인은 마크다운 파서의 취약한 정규식이었다.

# Stack Overflow 사고를 일으킨 패턴 (단순화)
/\s+$/  → 무해해 보이지만...

# 실제 취약 패턴
/^[\s‌]+|[\s‌]+$/
# 입력: " " * 많은 공백 + 비공백 문자

방어 전략

안전한 정규식 패턴

1. 안전한 패턴으로 교체

// ❌ 취약한 패턴 → ✅ 안전한 패턴

// 이메일 검증
const badEmail = /^([a-zA-Z0-9])(([\-.]|[_]+)?([a-zA-Z0-9]+))*@.../
const goodEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/  // 단순하고 안전

// URL 검증
const badUrl = /^(https?:\/\/)?([\da-z\.-]+)\.([\/\w \.-]*)*\/?$/
const goodUrl = /^https?:\/\/[^\s/$.?#].[^\s]*$/i

// 공백 트림
const badTrim = /^\s+|\s+$/g
const goodTrim = (s) => s.trim()  // 내장 함수 사용

2. 실행 타임아웃 적용

// Node.js: vm 모듈로 타임아웃 감싸기
const vm = require('vm')

function safeRegexTest(pattern, input, timeoutMs = 100) {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error('Regex timeout — possible ReDoS'))
    }, timeoutMs)

    try {
      const result = pattern.test(input)
      clearTimeout(timeout)
      resolve(result)
    } catch (e) {
      clearTimeout(timeout)
      reject(e)
    }
  })
}

// 사용
try {
  const isValid = await safeRegexTest(/^(a+)+$/, userInput, 100)
} catch (e) {
  console.warn('Regex timed out for input:', userInput.substring(0, 50))
  return false
}

3. RE2 엔진 사용 (선형 시간 보장)

// node-re2: Google RE2 엔진 (역참조 제외하고 선형 시간 O(n) 보장)
const RE2 = require('re2')

// RE2는 백트래킹이 없어 ReDoS 불가능
const re = new RE2('^(a+)+$')
re.test('aaaaaaaaab')  // 빠르게 반환

// 단, 역참조(\1, \2)는 지원 안 함
// npm install re2

4. 정적 분석 도구

# safe-regex: 취약한 정규식 탐지
npm install -g safe-regex
safe-regex '(a+)+'
# → false (취약!)

safe-regex 'a+'
# → true (안전)

# vuln-regex-detector: 더 정교한 분석
npm install -g @makenowjust-lre/lre

CI에 정규식 검사 통합

# GitHub Actions
- name: Check for ReDoS vulnerabilities
  run: |
    npx safe-regex-cli --check src/**/*.js src/**/*.ts

# pre-commit hook
- id: redos-check
  name: ReDoS vulnerability check
  entry: npx safe-regex-cli --check
  language: node
  files: \.(js|ts)$

입력 길이 제한

가장 간단한 첫 번째 방어선은 입력 길이를 제한하는 것이다.

function validateEmail(email) {
  if (email.length > 254) {  // RFC 5321 최대 길이
    throw new Error('이메일이 너무 깁니다')
  }
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

function validateInput(input) {
  const MAX_LENGTH = 1000
  if (input.length > MAX_LENGTH) {
    throw new Error(`입력이 너무 깁니다 (최대 ${MAX_LENGTH}자)`)
  }
  return validateRegex(input)
}

핵심 원칙

정규표현식을 작성할 때는 항상 “공격자가 최악의 입력을 넣으면 어떻게 될까?”를 생각해야 한다. 중첩된 수량자는 피하고, 취약한 패턴은 safe-regex 도구로 검사하며, 외부 입력에 대한 정규식 실행에는 항상 타임아웃을 설정한다.


지난 글: 프로토타입 오염: JavaScript 공격 심층 분석

다음 글: HTTP 요청 스머글링: 프록시 간 불일치 악용


읽어주셔서 감사합니다. 😊