개발 이야기4분 읽기

XML 엔티티가 파서를 무너뜨리는 방식

309바이트 문서가 3,000자로 부풀고, DTD 한 줄이 서버의 파일을 읽습니다. 파서 기본값을 실제로 확인해 보고 무엇을 꺼야 하는지 정리했습니다.

XML에는 문서 안에서 이름을 정의하고 본문에서 치환하는 엔티티 기능이 있습니다. 그 정의를 문서가 스스로 들고 오기 때문에, 파서에 무엇을 시킬지도 문서가 정하게 됩니다.

내부 엔티티

문서 상단의 DTD에서 정의하고 본문에서 &이름;으로 씁니다.

<?xml version="1.0"?>
<!DOCTYPE note [ <!ENTITY who "Alice"> ]>
<note><to>&who;</to></note>

파싱하면 to의 값은 Alice입니다. 여기까지는 XML 명세대로입니다.

엔티티 폭탄

엔티티는 다른 엔티티를 참조할 수 있습니다. 그걸 겹치면 크기가 지수로 늘어납니다.

<!DOCTYPE lolz [
 <!ENTITY lol "lol">
 <!ENTITY lol1 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
 <!ENTITY lol2 "&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;">
 <!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
]>
<lolz>&lol3;</lolz>

파이썬 xml.etree.ElementTree로 파싱해 봤습니다.

원본 크기       : 309바이트
전개 결과 길이  : 3,000자

단계를 세 개 더 얹으면 3,000,000자가 되고, 아홉 개면 10억 자입니다. 이름이 여기서 나왔습니다. 원본은 여전히 1KB 아래라 요청 크기 제한에 걸리지 않습니다.

이건 인증이 필요 없는 엔드포인트에서 특히 문제가 됩니다. XML을 받는 자리가 있으면 메모리가 그대로 소진됩니다.

외부 엔티티

SYSTEM 키워드를 쓰면 엔티티의 내용을 다른 곳에서 가져옵니다.

<!DOCTYPE note [ <!ENTITY leak SYSTEM "file:///etc/hostname"> ]>
<note><to>&leak;</to></note>

파서가 이 지시를 따르면 서버의 파일을 읽어 응답에 담아 돌려줍니다. http://를 쓰면 서버가 대신 요청을 보내므로 내부망 스캔에도 쓰입니다. 이것을 XXE(XML External Entity)라고 부릅니다.

파이썬은 어떻게 동작하나

실제로 확인해 봤습니다. 파이썬 3.14 기준입니다.

파서 내부 엔티티 외부 엔티티(file://) 엔티티 폭탄
xml.etree.ElementTree 전개함 ParseError: undefined entity 전개함
xml.dom.minidom 전개함 빈 요소 반환 전개함

파일을 읽지는 않습니다. ElementTree는 아예 오류를 내고, minidom은 조용히 빈 값을 돌려줍니다. 파이썬 표준 라이브러리에서 XXE로 파일이 새는 시나리오는 기본 설정으로는 성립하지 않습니다.

대신 엔티티 폭탄은 그대로 통합니다. 309바이트가 3,000자로 늘어난 것이 위에서 본 결과입니다. 파일 유출은 막혀 있고 자원 소진은 열려 있는 상태입니다.

이 구분이 중요한 이유는, "파이썬은 XXE에 취약하다"는 요약이 절반만 맞기 때문입니다. 실제로 대응해야 하는 것은 폭탄 쪽입니다.

다른 언어는 기본값이 다르다

파서 기본값은 언어·라이브러리·버전마다 다릅니다. 확인하지 않고 짐작하면 틀립니다. 코드에서 찾아볼 이름은 이렇습니다.

  • JavaDocumentBuilderFactory, SAXParserFactoryFEATURE_SECURE_PROCESSINGXMLConstants.ACCESS_EXTERNAL_DTD / ACCESS_EXTERNAL_SCHEMA를 빈 문자열로 설정
  • .NETXmlReaderSettings.DtdProcessingProhibit으로, XmlResolvernull
  • PHPlibxml_disable_entity_loader, LIBXML_NOENT 플래그를 쓰고 있는지
  • 파이썬 — 폭탄 대응이 필요하면 defusedxml
  • Goencoding/xml은 DTD를 처리하지 않습니다
  • Node — 파서마다 다릅니다. fast-xml-parser, libxmljs, xml2js가 각각 다른 기본값을 씁니다

방어

  1. DTD 자체를 거부합니다. 대부분의 애플리케이션은 들어오는 XML에 DTD가 필요 없습니다. <!DOCTYPE이 있으면 파싱 전에 거절하는 것이 가장 확실합니다
  2. 외부 엔티티 해석을 끕니다. DTD를 허용해야 한다면 최소한 이것은 끕니다
  3. 엔티티 전개 한도를 둡니다. 파서에 따라 전개 횟수나 결과 크기 상한을 설정할 수 있습니다
  4. 요청 크기와 파싱 시간을 제한합니다. 폭탄은 작은 입력으로 큰 자원을 쓰므로 크기 제한만으로는 부족하고 시간 제한이 함께 있어야 합니다
  5. 가능하면 XML을 받지 않습니다. 새로 만드는 API라면 JSON에는 이 문제 자체가 없습니다

SVG도 XML입니다. 사용자가 올린 SVG를 서버에서 파싱하거나 변환한다면 같은 검토가 필요합니다.

확인 순서

  1. 코드베이스에서 XML을 파싱하는 지점을 전부 찾습니다. SOAP 클라이언트, 설정 로더, SVG 처리, 오피스 문서 파싱이 흔히 빠집니다
  2. 각 지점의 파서 옵션을 봅니다. 기본값을 믿지 말고 명시적으로 끕니다
  3. 위 폭탄 문서를 그대로 넣어 봅니다. 메모리가 튀면 대응이 필요합니다
  4. file:///etc/hostname을 참조하는 문서를 넣어 응답에 값이 섞여 나오는지 봅니다
  5. 오류 메시지에 파일 내용이나 내부 경로가 섞이지 않는지 확인합니다. 엔티티가 막혀도 오류 메시지로 정보가 새는 경우가 있습니다

tools.onuel.devXML 검사기는 태그 짝과 중첩이 맞는지 확인하고 깨진 줄 번호를 알려 줍니다. 구조를 눈으로 확인할 때는 XML 포매터로 정렬해 놓고 보면 DTD 블록이 어디까지인지 분명해집니다. 다만 두 도구 모두 브라우저 안에서 구조만 다루므로, 파서 옵션 점검은 실제 서버 코드에서 해야 합니다.

  • #XML
  • #XXE
  • #보안