Cloudturing blog

짧은 Markdown 한 줄이 Node.js를 멈추게 한 ReDoS 대응

Cloudturing Team 발행: 2026. 09. 02 16:00 수정: 2026. 08. 25 10:04

Markdown ReDoS 패치를 실행 시간과 출력 호환성 계약으로 검증한 흐름

3줄 요약

  • 공개 Markdown renderer에서 짧은 link 계열 입력만으로 정규식 backtracking이 길어져 Node.js 이벤트 루프가 멈출 수 있는 문제를 발견했다.
  • parser를 패치 버전으로 exact pin하고, 취약 입력은 별도 child process에서 timeout 안에 끝나는지 검증해 테스트 자체가 멈추는 위험을 차단했다.
  • 보안 업데이트를 버전 변경으로 끝내지 않고 기존 GFM 출력, 줄바꿈, 이미지 alt escaping과 manifest·lockfile 정합성까지 하나의 회귀 계약으로 고정했다.

짧은 입력이 서버를 오래 붙잡았다

Markdown은 보통 가벼운 문자열 변환처럼 보인다. 하지만 parser 내부 tokenizer가 복잡한 정규식에 의존하면 특정 문법 조합에서 입력 길이에 비해 탐색 시간이 급격히 늘어날 수 있다. 이번에는 link와 reference link의 경계가 완성되지 않은 짧은 입력이 문제였다.

취약한 정규식은 가능한 분기들을 되돌아가며 반복해서 시도했다. 서버가 예외를 던지는 것도, 메모리를 한 번에 크게 쓰는 것도 아니었다. 한 요청의 동기 parsing이 이벤트 루프를 오래 점유해 같은 Node.js process가 다른 요청에 응답하지 못했다.

네트워크 요청 크기 제한만으로 막기 어려운 이유도 여기에 있다. 거대한 문서가 아니라 비교적 짧은 문자열로도 CPU 시간을 과도하게 사용할 수 있었다. 이런 유형을 Regular Expression Denial of Service, ReDoS라고 부른다.

신뢰 경계를 먼저 나눴다

당시 Markdown을 다루는 경로가 둘 이상이었다. 관리 화면은 원문을 작성·검증하고, 공개 Pages는 저장된 원문을 실제 HTML로 렌더링했다. 같은 제품 안에 있어도 공격 표면과 실행 책임이 달랐다.

  • 원문만 저장하는 경로는 해당 parser를 호출하지 않았다.
  • 공개 renderer는 방문자의 요청 경로에서 Markdown을 HTML로 변환했다.
  • 관리자 preview도 브라우저 또는 서버에서 실제 lexer와 renderer를 호출했다.

“Markdown 기능이 있다”는 이유로 모든 서비스를 한꺼번에 고치지 않았다. 실제로 취약한 parser 버전을 실행하는 곳과 원문만 전달하는 곳을 코드와 dependency tree로 확인했다. 패치 범위를 정확히 잡는 것이 불필요한 변경과 누락을 동시에 줄였다.

왜 exact version으로 고정했나

보안 패치에서 ^18.0.7 같은 범위를 사용하면 다음 설치 때 아직 검증하지 않은 버전으로 움직일 수 있다. 반대로 package.json만 고치고 lockfile이 예전 버전을 가리키면 개발과 배포 결과가 달라진다.

그래서 manifest, lockfile과 실제 설치 모듈이 동일한 패치 버전을 가리키는지 테스트했다. 당시 공개 renderer와 관리자 renderer는 서로 다른 major line에서 먼저 안전한 버전으로 이동했고, 출력 호환성을 각각 확인했다. 후속 정리 뒤 현재 두 경로는 같은 패치 버전으로 맞춰져 있다.

중요한 점은 특정 숫자가 아니라 세 위치의 일치다.

package.json 선언
      = package-lock.json 해석
      = node_modules 실제 설치 버전

이 중 하나라도 다르면 CI가 실패하도록 만들었다.

취약 입력 테스트가 테스트 러너를 멈추지 않게 하기

ReDoS 회귀 테스트를 일반 단위 테스트 process에서 직접 실행하면 취약 버전으로 되돌아간 순간 전체 test runner가 멈춘다. timeout assertion이 실행될 기회조차 오지 않는다. JavaScript timer도 같은 이벤트 루프가 막히면 제시간에 실행되지 않는다.

따라서 renderer 호출을 별도 Node.js child process로 격리했다. 부모 process는 명시적인 wall-clock timeout을 갖고 자식을 실행한다.

부모 test runner
  └─ child process에서 위험 입력 render
       ├─ 시간 안에 종료 → 통과
       └─ 응답 없음 → child 강제 종료, 테스트 실패

취약 입력 전문은 공개 글에 싣지 않는다. 보안 회귀의 목적은 복사 가능한 공격 payload를 배포하는 것이 아니라, parser가 제한 시간 안에 종료되는 계약을 저장소에서 계속 검증하는 것이다.

빠르게 끝난다고 안전한 것은 아니다

버전을 올려 실행 시간이 정상화됐더라도 렌더링 결과가 바뀔 수 있다. Markdown parser major update에는 token 분리, 빈 줄 처리, 단일 tilde와 code span처럼 미묘한 변화가 포함될 수 있다. 보안 패치 때문에 도움말 문서의 HTML 구조가 바뀌면 화면 깨짐이나 sanitizer 우회라는 다른 문제가 생긴다.

대표 입력에 대해 다음 출력을 함께 고정했다.

  • GFM 취소선과 줄바꿈
  • 빈 줄과 인접 block의 token 경계
  • code span과 tilde 조합
  • inline image와 reference image의 alt attribute escaping
  • 실제 도움말·게시글에서 사용하는 대표 Markdown의 exact HTML

특히 image alt에 따옴표처럼 attribute 경계를 흔드는 문자가 들어가도 새 HTML event attribute가 만들어지지 않는지 확인했다. parser escaping과 별도의 sanitizer 책임도 혼동하지 않았다. parser 업데이트가 sanitizer를 대체한다고 가정하지 않았다.

CPU 복잡도도 보안 계약이다

일반 회귀 테스트는 입력과 출력이 같은지를 본다. ReDoS 테스트는 여기에 실행 시간 상한을 추가한다. 정확한 HTML을 반환하더라도 한 줄을 처리하는 데 수십 초가 걸리면 공개 서버에서는 안전한 구현이 아니다.

다만 timeout 값은 micro benchmark가 아니다. CI 장비 차이에도 정상 버전이 안정적으로 통과하고 취약 버전은 확실히 실패할 정도의 넉넉한 경계로 둔다. 성능을 밀리초 단위로 보장하려는 테스트와 catastrophic regression을 막는 테스트는 목적이 다르다.

패치된 upstream도 link 외에 HTML block, tilde interrupt와 inline tokenizer의 비선형 처리 문제를 추가로 고쳤다. 후속 renderer에서는 이 입력군도 같은 격리 방식으로 검증했다.

배포 전 확인한 것

dependency 설치 script를 최소화한 깨끗한 설치에서 manifest·lock·설치 버전이 일치하는지 확인했다. 공개 renderer의 함수 경계를 직접 호출해 HTTP 서버나 DB 없이도 보안 회귀를 재현했다. 관리자 renderer는 실제 preview와 lexer 경로, TypeScript typecheck와 build까지 함께 확인했다.

스키마나 저장 데이터에는 손대지 않았다. Markdown 원문은 그대로 두고 읽는 parser만 바꿨기 때문에 rollback도 애플리케이션 image 단위로 가능했다. 다만 취약 버전으로의 rollback은 허용 가능한 운영 복구가 아니므로, 출력 차이는 새 버전 안에서 해결했다.

지금 다시 한다면

Markdown renderer를 처음 도입할 때부터 공개 입력 경계의 작은 adapter 함수로 감싼다. route 안에서 직접 library 전역 설정을 바꾸지 않고, production과 test가 같은 함수를 호출하게 만든다.

dependency update 정책에도 버전 취약점 검사만 넣지 않는다. parser, sanitizer, template engine처럼 비신뢰 문자열을 해석하는 의존성에는 시간 제한 fixture와 대표 출력 fixture를 함께 둔다. upstream release note의 “performance fix”도 보안 영향이 있는지 살펴본다.

이 사례의 핵심은 최신 버전을 쓰라는 말이 아니다. 문자열 parser의 실행 시간과 출력 형태도 외부에 제공하는 API 계약이며, 보안 패치는 두 계약을 함께 검증해야 한다는 점이다.

참고 자료