XML 외부 엔터티(XXE) 인젝션(CWE-611): 동작 원리와 방어 방법
문서의 DTD를 따르는 XML 파서는 file://이나 http:// URL에서 SYSTEM 엔터티를 가져와 그 결과를 파싱된 데이터에 붙여 넣으므로, 업로드된 문서 하나로 서버 파일을 읽거나 서버가 내부 주소를 호출하게 만들 수 있습니다. 외부 엔터티와 엔터티 확장이 어떻게 동작하는지, 어떤 파서 설정이 이를 일으키는지, 그리고 forbid_dtd=True를 준 defusedxml이 이를 어떻게 막는지 Flask 예제로 설명합니다.
알기 쉬운 설명 (ELI5)
인쇄소가 서식 편지를 채워 넣고 있습니다. 한 고객의 편지에는 '[여기에 귀사 서류함 4번 서랍의 내용을 넣으세요] 님께'라고 적혀 있습니다. 꼼꼼한 인쇄공은 고객이 준 글자만 넣습니다. 부주의한 인쇄공은 다른 고객들의 계약서가 든 4번 서랍으로 가서 그 내용을 편지에 찍어 우편으로 돌려보냅니다. XXE가 바로 이 부주의한 인쇄공입니다. XML 문서가 파서에게 파일이나 웹 주소를 가져와 끼워 넣으라고 지시하면 파서는 그대로 합니다. 해결책은 '다른 곳에서 가져와 넣어라'는 지시가 조금이라도 있는 편지는 모두 거절하는 것입니다.
핵심 개념 및 용어
- DTD와 엔터티
<!DOCTYPE ...>안의 문서 형식 정의는 엔터티를 선언할 수 있습니다. 엔터티는 이름이 붙은 텍스트 조각으로, 파서는&이름;이 나오는 곳마다 이를 치환합니다. 내부 엔터티는 텍스트 자체를 담고, 외부 엔터티는 다른 곳을 가리킵니다.- 외부 엔터티
<!ENTITY x SYSTEM "file:///etc/hostname">은 파서에게 그 URL을 읽어&x;가 쓰인 자리에 내용을 넣으라고 지시합니다.http://URL이면 요청을 보내는 쪽이 서버이므로 XXE가 서버 측 요청 위조로 이어집니다.- 엔터티 확장(Billion Laughs)
- 다른 엔터티를 열 번씩 참조하는 엔터티를 겹치면 1KB 문서가 메모리에서 수 GB의 텍스트로 불어납니다(CWE-776). 외부 접근이 필요 없으므로 DTD를 거부하거나 확장에 한도를 두는 방법만이 막을 수 있습니다.
- 파서 기본 설정
- 동작은 라이브러리마다 다릅니다. lxml 5는 기본적으로 내부 엔터티만 확장하지만
resolve_entities=True와load_dtd=True가 구멍을 다시 엽니다. Java의DocumentBuilderFactory는disallow-doctype-decl을 설정하지 않으면 외부 엔터티를 확장하고, .NET은DtdProcessing.Prohibit이 필요합니다. - defusedxml
- 표준 파서를 감싸서 위험한 구문을 처리하는 대신
EntitiesForbidden,ExternalReferenceForbidden,DTDForbidden을 발생시키는 Python 패키지입니다.forbid_dtd=True는 DOCTYPE이 있는 모든 문서를 거부합니다.
단계별 공격 실행 흐름
공격자가 XML 문서를 업로드
문서는 외부 엔터티를 선언하고, 서버가 되돌려 주는 필드에서 이를 참조합니다. file:///etc/hostname을 가리키는 엔터티가 파일 읽기 경우를 보여 줍니다.
파서가 DTD를 처리
애플리케이션이 DTD 로딩과 엔터티 확장을 켜 두었기 때문에 파서가 참조된 파일이나 URL을 읽습니다.
파일 내용이 데이터가 됨
텍스트가 customer 요소에 들어가고, 엔드포인트는 이를 JSON 응답으로 돌려줍니다.
공격자가 파일을 읽거나 내부 호스트에 닿음
설정 파일, 키, 클라우드 메타데이터를 이런 식으로 읽을 수 있습니다. 2014년 Reginaldo Silva는 서버 파일을 읽을 수 있는 Facebook OpenID 처리의 XXE를 찾아 33,500달러의 포상금을 받았습니다.
소스 코드 비교: 취약한 구현 vs 보안 패치
# invoices.py: 업로드된 DTD가 요구하는 대로 파서가 따른다
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
# 엔터티 확장, DTD 로딩, 네트워크 접근을 모두 허용
@app.post("/invoices/import")
def import_invoice():
parser = etree.XMLParser(resolve_entities=True, load_dtd=True, no_network=False)
root = etree.fromstring(request.get_data(), parser)
return {"customer": root.findtext("customer"), "total": root.findtext("total")}
# invoices.py: DTD가 있는 문서는 파싱 전에 거부된다
from defusedxml import DefusedXmlException
from defusedxml.ElementTree import ParseError, fromstring
from flask import Flask, abort, request
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 1_000_000
# DOCTYPE, 엔터티, 외부 참조 모두 금지; 본문은 1MB까지
@app.post("/invoices/import")
def import_invoice():
try:
root = fromstring(request.get_data(), forbid_dtd=True)
except (DefusedXmlException, ParseError):
abort(400)
return {"customer": root.findtext("customer"), "total": root.findtext("total")}
엔지니어링 및 시스템 보안 강화 체크리스트
- 신뢰할 수 없는 XML은 Python에서는
defusedxml로, 다른 언어에서는 DTD를 끈 상태로 파싱합니다. Java는disallow-doctype-decl을 true로, .NET은DtdProcessing.Prohibit을, libxml2는XML_PARSE_NOENT와XML_PARSE_DTDLOAD없이 사용합니다. - 통제하지 않는 입력에 대해 lxml에
resolve_entities=True,load_dtd=True,no_network=False를 절대 넘기지 말고, CI에서 Bandit(B320이lxml.etree를 표시)을 실행합니다. - 요청 크기를 제한하고(
MAX_CONTENT_LENGTH) DOCTYPE이 들어 있는 문서는 모두 거부합니다. - 업로드된 SVG, SAML, DOCX, XLSX 파일도 XML로 보고 같은 강화 설정으로 파싱합니다.
file:///etc/hostname을 가리키는 외부 엔터티가 든 문서를 보내 400을 기대하는 CI 테스트를 추가합니다.