XML 내부 엔티티 확장으로 인한 서비스 거부 (DoS)

XML Entity Expansion (Billion Laughs)

설명

신뢰할 수 없는 XML을 엔티티 확장이 가능한 상태로 파싱할 때, 공격자가 DTD 내부에 매우 깊이 중첩된 내부 엔티티를 정의하여 작은 입력으로도 기하급수적으로 큰 문자열을 생성하게 만드는 취약점입니다. 이로 인해 파서는 메모리와 CPU를 과도하게 소모하며 서비스 거부(DoS)가 발생합니다. node-expat 같은 파서를 사용할 때는 엔티티 확장 동작과 해당 버전의 자원 제한을 확인해야 합니다. 확장이 충분히 제한되지 않으면 빌리언 래프스 형태의 XML이 가용성을 해칠 수 있습니다.

잠재적 영향

  • 서비스 거부(DoS): 과도한 엔티티 확장으로 프로세스가 응답 불가 상태가 됩니다.
  • 메모리 고갈: 확장된 텍스트가 메모리를 점유하여 OOM(메모리 부족)로 프로세스가 종료될 수 있습니다.
  • CPU 자원 고갈: 엔티티 치환/파싱 반복으로 CPU 사용률이 급상승합니다.
  • 시스템 안정성 저하: 애플리케이션 충돌, 자동 재시작 루프, 장애 전파로 가용성이 떨어집니다.
  • 비용 증가: 오토스케일링 환경에서 불필요한 리소스 확장과 비용이 발생할 수 있습니다.

해결 방법

  • 엔티티 확장 비활성화: 가능한 경우 XML 파서 옵션에서 DTD/Entity 확장을 끄고, DOCTYPE 선언을 거부하세요.
  • 안전한 파서 사용: node-expat처럼 내부 엔티티를 기본 확장하는 라이브러리 대신 sax 같은 스트리밍 파서(사용자 정의 엔티티를 확장하지 않음) 또는 엔티티 확장 제어가 가능한 파서를 사용하세요.
  • 입력 제한: 요청 본문 크기(limit), 파싱 시간(timeout), 노드/깊이/텍스트 길이 제한을 적용하세요.
  • 유효성 검증: 스키마(whitelist) 검증을 적용하고, 입력 내 <!DOCTYPE 등 DTD 관련 토큰이 발견되면 요청을 거부하세요.
  • 방어적 구성: 리버스 프록시/웹서버에서 요청 크기 제한, 애플리케이션에서 작업 큐와 동시성 제한으로 폭주를 제한하세요.

예시

변경 전

javascript
const express = require("express");
const expat = require("node-expat");
const app = express();
app.use(express.text({ type: "application/xml" }));

app.post("/parse", (req, res) => {
  const xml = req.body; // 신뢰할 수 없는 입력
  const p = new expat.Parser();
  p.on("startElement", () => {});
  p.on("text", () => {});
  // 취약: 내부 엔티티가 자동 확장되어 XML bomb에 취약
  p.write(xml);
  res.send("parsed");
});

module.exports = app;

변경 후

javascript
const express = require("express");
const sax = require("sax");
const app = express();
// 본문 크기 제한 등 선제적 방어
app.use(express.text({ type: "application/xml", limit: "200kb" }));

app.post("/safe-parse", (req, res) => {
  const xml = req.body || "";

  // DOCTYPE/ENTITY 등 DTD 관련 토큰이 보이면 거부
  const suspicious = /<!DOCTYPE|<!ENTITY/i.test(xml);
  if (suspicious) {
    return res.status(400).send("DTD/ENTITY not allowed");
  }

  // sax는 사용자 정의 엔티티를 확장하지 않는 스트리밍 파서
  const parser = sax.parser(true); // strict mode
  parser.onopentag = () => {};
  parser.ontext = () => {};
  parser.onerror = (e) => {
    throw e;
  };
  try {
    parser.write(xml).close();
  } catch {
    return res.status(400).send("invalid xml");
  }
  return res.send("ok");
});

module.exports = app;

설명:

  • 변경 전: 신뢰되지 않은 XML을 엔티티를 확장하는 파서에 전달합니다. 파서 버전과 자원 제한이 과도한 확장을 막지 못하면 작은 입력도 메모리와 CPU를 고갈시킬 수 있습니다.
  • 변경 후: DOCTYPE/ENTITY가 포함된 XML을 사전에 차단해 DTD 기반 엔티티 확장을 원천 봉쇄합니다.
  • sax 같은 스트리밍 파서는 사용자 정의 엔티티를 확장하지 않아 내부 엔티티 확장 공격에 강합니다.
  • 요청 본문 크기 제한으로 대용량 입력을 차단하고, 오류가 나면 파싱을 중단하고 오류 응답을 한 번만 보냅니다.

참조