● CWE-611 · OWASP A05:2021

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이 있는 모든 문서를 거부합니다.

단계별 공격 실행 흐름

단계 1

공격자가 XML 문서를 업로드

문서는 외부 엔터티를 선언하고, 서버가 되돌려 주는 필드에서 이를 참조합니다. file:///etc/hostname을 가리키는 엔터티가 파일 읽기 경우를 보여 줍니다.

단계 2

파서가 DTD를 처리

애플리케이션이 DTD 로딩과 엔터티 확장을 켜 두었기 때문에 파서가 참조된 파일이나 URL을 읽습니다.

단계 3

파일 내용이 데이터가 됨

텍스트가 customer 요소에 들어가고, 엔드포인트는 이를 JSON 응답으로 돌려줍니다.

단계 4

공격자가 파일을 읽거나 내부 호스트에 닿음

설정 파일, 키, 클라우드 메타데이터를 이런 식으로 읽을 수 있습니다. 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")}

엔지니어링 및 시스템 보안 강화 체크리스트

출처

← 전체 보안 디렉터리 보기 모든 취약점 가이드 →