XXE 취약점: 엑셀·문서 업로드 파싱의 원인과 방어법
XXE 취약점이 엑셀(xlsx)·docx·SVG·SAML 업로드에서 어떻게 생기는지 짚고, Java·Apache POI·Python lxml·defusedxml·Node 파서별로 안전한 설정 코드를 비교해 정리합니다.
XXE(XML External Entity) 취약점은 XML 파서가 문서 안의 DTD 선언을 그대로 따르다가 서버 로컬 파일을 읽거나, 서버가 내부망으로 요청을 보내거나, 엔티티 확장으로 CPU·메모리를 소진하는 문제입니다. 원인은 거의 항상 "신뢰할 수 없는 XML을 기본 설정 파서로 읽었다"는 한 줄로 정리됩니다. 그래서 방어도 단순합니다. 파서에서 DTD(DOCTYPE)를 아예 허용하지 않거나, 최소한 외부 엔티티와 외부 DTD 로딩을 끄면 됩니다.
"우리는 REST·JSON만 쓰니 상관없다"는 팀도 안심하기는 이릅니다. xlsx·docx·pptx는 XML 파일 여러 개를 압축한 ZIP이고, SVG는 그 자체가 XML이며, SAML 로그인 응답도 XML입니다. 회원 엑셀 일괄 등록, 계약서 docx 미리보기, 프로필 SVG 업로드, 기업 고객용 SSO가 있다면 이미 XML 파서를 쓰고 있는 셈입니다. 이 글은 그런 숨은 지점을 찾는 법과 Java·Python·Node 파서별 안전한 설정을 나란히 보여줍니다.
공격 기법보다는 "어디를 어떻게 고치면 닫히는가"에 초점을 맞췄습니다. 예시 코드는 모두 가상의 코드입니다.
XXE는 어떻게 생기나: DTD 한 줄의 문제
XML에는 문서 앞부분에 DTD를 선언하고 그 안에서 "엔티티"라는 치환 변수를 정의하는 기능이 있습니다. 문제는 엔티티 값을 외부 경로(URI)에서 가져오도록 정의할 수 있다는 점입니다. 구조만 보면 이렇습니다.
<?xml version="1.0"?>
<!DOCTYPE member [
<!ENTITY ext SYSTEM "http://internal.example.com/">
]>
<member><name>&ext;</name></member>
파서가 외부 엔티티를 해석하도록 설정돼 있으면 &ext; 자리에 서버가 직접 가져온 내용이 들어갑니다. 그 결과로 생길 수 있는 피해는 크게 세 가지입니다.
- 파일 노출: 서버 로컬 파일 내용이 파싱 결과에 섞여 응답이나 오류 메시지로 돌아옴
- SSRF: 서버가 내부망이나 클라우드 메타데이터 주소로 요청을 보냄 (SSRF 취약점 원인과 방어법 참고)
- 서비스 거부: 엔티티를 중첩 확장시키는 이른바 Billion Laughs 류 문서로 자원 소진
OWASP XXE Prevention Cheat Sheet가 가장 먼저 권하는 방어도 이 구조에서 나옵니다. DTD 자체를 비활성화하는 것이 가장 안전하고, 그럴 수 없다면 외부 엔티티·외부 DTD 로딩을 끄고 엔티티 확장에 상한을 두라는 것입니다. XInclude, XSLT의 import/include처럼 외부 엔티티와 별개로 외부 자원을 불러오는 기능도 따로 꺼야 합니다.
XML API가 없어도 XML은 숨어 있다: 국내 서비스 기능 점검
국내 B2B·커머스 서비스에서 자주 보는 기능 중 XML 파싱이 숨어 있는 곳을 정리하면 다음과 같습니다.
| 기능 | 숨어 있는 XML | 주로 쓰는 라이브러리 |
|---|---|---|
| 회원·상품·주문 엑셀 일괄 등록 | xlsx 내부의 sheet1.xml, sharedStrings.xml 등 | Apache POI, openpyxl, SheetJS 계열 |
| 계약서·견적서 미리보기, 텍스트 추출 | docx·pptx·hwpx 내부 XML | POI, python-docx, 문서 변환 서버 |
| 프로필·로고 SVG 업로드, 썸네일 생성 | SVG 파일 자체 | 이미지 변환 라이브러리, 렌더러 |
| 기업 고객용 SSO | SAML Response·메타데이터 | SAML 라이브러리 |
| 외부 연동 | 결제·물류·공공 API의 XML 응답, RSS, sitemap | 각 언어 표준 XML 파서 |
특히 주의할 것은 "엑셀을 라이브러리로 읽지 않고 직접 압축을 풀어 XML을 파싱하는 코드" 입니다. 대용량 엑셀 처리 속도 때문에 xlsx를 ZIP으로 열고 sheet1.xml을 DocumentBuilderFactory나 lxml로 직접 읽는 경우가 꽤 있는데, 이때는 라이브러리가 해 주던 보안 설정이 전부 빠집니다.
또 하나는 인증 전 입력입니다. SAML 응답은 서명 검증을 하기 전에 먼저 XML로 파싱됩니다. 즉 로그인하지 않은 사용자가 보낸 XML이 파서를 통과한다는 뜻이라, SAML은 직접 구현하지 말고 유지보수가 활발한 라이브러리를 최신 버전으로 쓰는 것이 원칙입니다.
Java: DocumentBuilderFactory와 Apache POI
DocumentBuilderFactory 기본 설정은 신뢰하지 않는다
취약한 코드는 대개 이렇게 생겼습니다.
// 취약: 기본 설정 그대로 업로드된 XML을 파싱
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(uploadedFile.getInputStream());
고친 코드는 DOCTYPE 선언 자체를 거부하는 것이 핵심이고, 나머지는 방어를 겹겹이 두는 설정입니다.
// 안전: DTD 금지 + 외부 엔티티/외부 DTD/XInclude 차단
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
Java에는 파서 팩토리가 여러 개라는 점이 함정입니다. SAXParserFactory, XMLInputFactory(StAX), TransformerFactory, SchemaFactory도 각자 설정해야 합니다. 예를 들어 StAX는 다음처럼 막습니다.
XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
실무에서는 팩토리 생성을 SecureXml.newDocumentBuilder() 같은 유틸리티 하나로 모으고, 코드 리뷰나 정적 분석으로 DocumentBuilderFactory.newInstance() 직접 호출을 찾아내는 방식이 관리하기 쉽습니다.
Apache POI: 버전과 "직접 파싱" 두 가지를 본다
Apache POI는 OOXML을 읽을 때 내부적으로 보안 설정된 파서를 쓰지만, 과거에 XXE 관련 취약점이 여러 차례 보고되고 수정됐습니다. 예를 들어 CVE-2019-12415는 4.1.0 이하에서 XSSFExportToXml 도구로 사용자가 올린 엑셀을 변환할 때 로컬 파일이나 내부망 자원을 읽을 수 있었던 문제입니다. 그래서 POI는 다음 두 가지를 확인합니다.
- 버전: 오래된 POI가 다른 라이브러리의 전이 의존성으로 들어와 있지 않은지 확인합니다.
mvn dependency:tree나gradle dependencies로 실제 해석된 버전을 봅니다. - 이벤트(SAX) 방식으로 직접 읽는 코드: 대용량 엑셀을
XSSFReader로 스트리밍할 때 파서를 직접 만드는 코드가 흔합니다.
// 취약할 수 있음: 시트 XML을 읽을 파서를 기본 설정으로 직접 생성
SAXParserFactory spf = SAXParserFactory.newInstance();
XMLReader reader = spf.newSAXParser().getXMLReader();
// 권장: POI가 제공하는 보안 설정된 헬퍼 사용
XMLReader reader = org.apache.poi.util.XMLHelper.newXMLReader();
POI 공식 예제도 스트리밍 처리에서 XMLHelper를 쓰도록 안내합니다. POI를 쓰더라도 시트 XML을 직접 꺼내 다른 파서로 읽는 순간, 안전장치는 개발자의 몫이 된다는 점을 기억하면 됩니다.
Python: 표준 라이브러리, lxml, defusedxml, openpyxl
Python 공식 문서의 XML 모듈 페이지는 표준 XML 모듈이 악의적으로 만든 데이터에 안전하지 않을 수 있다고 경고하고, 신뢰할 수 없는 XML을 다루는 서버 코드에는 defusedxml 사용을 권합니다. 같은 문서에 따르면 최신 버전의 표준 라이브러리는 외부 일반 엔티티를 기본적으로 처리하지 않지만, 엔티티 확장류 공격에 대한 안전성은 함께 링크된 Expat 버전에 따라 달라집니다.
# 취약할 수 있음: 표준 라이브러리로 업로드 XML 파싱
import xml.etree.ElementTree as ET
root = ET.fromstring(uploaded_bytes)
# 권장: defusedxml로 교체 (DTD·엔티티 관련 공격을 예외로 차단)
import defusedxml.ElementTree as DET
root = DET.fromstring(uploaded_bytes, forbid_dtd=True)
lxml은 성능 때문에 많이 쓰이는데, 파서 옵션을 명시하는 습관이 중요합니다. lxml은 5.0에서 일반 XML 파서의 기본값을 더 안전하게 바꿨지만, iterparse()와 ETCompatXMLParser()에는 그 변경이 적용되지 않아 기본 설정에서 로컬 파일을 읽을 수 있는 문제(CVE-2026-41066)가 보고됐고 6.1.0에서 수정됐습니다. 업그레이드가 어렵다면 resolve_entities를 명시적으로 끄는 것이 우회책으로 안내됩니다.
from lxml import etree
# 안전: 엔티티 미해석, 네트워크 차단, DTD 미로딩
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
load_dtd=False,
huge_tree=False,
)
root = etree.fromstring(uploaded_bytes, parser=parser)
# 대용량 스트리밍도 옵션을 명시
for event, elem in etree.iterparse(fp, resolve_entities=False, no_network=True):
...
엑셀을 openpyxl로 읽는 서비스라면, openpyxl 패키지 설명에 "기본 상태로는 quadratic blowup이나 billion laughs 공격을 막지 않으며, 막으려면 defusedxml을 설치하라"고 적혀 있습니다. 즉 requirements.txt에 defusedxml이 들어 있는지가 곧 점검 항목입니다. FastAPI·Django로 업로드를 받는다면 FastAPI 보안 가이드의 업로드 크기 제한 항목도 함께 보세요.
Node.js: libxmljs와 fast-xml-parser
Node 생태계에서는 순수 JavaScript 파서와 libxml2 바인딩의 성격이 다릅니다.
libxmljs 계열은 libxml2 옵션을 그대로 넘기는데, noent: true는 엔티티를 치환하라는 옵션입니다. 이름이 "no entity"처럼 보여 반대로 이해하고 켜는 실수가 실제로 자주 보입니다.
// 취약: noent를 켜면 엔티티가 치환됨
const doc = libxmljs.parseXml(xml, { noent: true });
// 안전: 엔티티 치환·네트워크·외부 DTD 로딩을 명시적으로 끔
const doc = libxmljs.parseXml(xml, { noent: false, nonet: true, dtdload: false });
fast-xml-parser는 외부 엔티티를 불러오지 않는 쪽으로 알려져 있지만, DOCTYPE 안에서 정의한 엔티티를 처리하는 옵션이 있고 과거 엔티티 확장 관련 취약점도 보고됐습니다. 사용자 입력을 다룬다면 다음처럼 명시하고, 버전을 최신으로 유지합니다.
const { XMLParser } = require('fast-xml-parser');
const parser = new XMLParser({ processEntities: false });
const result = parser.parse(xml);
Node 쪽은 라이브러리마다 기본값이 다르고 버전에 따라 바뀌기도 하므로, "기본값이 안전하다더라"보다 옵션을 코드에 명시해 두는 것이 리뷰와 인수인계에 유리합니다. 의존성 점검은 오픈소스 라이브러리 취약점 점검에서 다룬 방식대로 전이 의존성까지 확인합니다.
파서 설정 외에 겹쳐 둘 방어선
파서 설정이 1차 방어선이지만, 실무에서는 다음을 함께 두면 설정 누락 하나로 사고가 나는 일을 줄일 수 있습니다.
- DOCTYPE 사전 거부: 정상적인 xlsx·docx 내부 XML, SAML 응답에는 DOCTYPE이 필요 없습니다. 압축을 푼 XML 파트나 업로드 SVG에
<!DOCTYPE가 있으면 파싱 전에 거부하는 규칙은 오탐이 적습니다. 단, 인코딩 변형이 있을 수 있으므로 이 검사는 보조 수단으로만 쓰고 파서 설정을 대신하지 않습니다. - ZIP 자체 검증: 압축 해제 후 전체 크기, 파일 개수, 압축률 상한을 둬서 압축 폭탄과 엔티티 확장 공격의 피해를 함께 줄입니다.
- 파싱 작업 격리: 문서 변환·미리보기는 별도 워커에서 실행하고, 해당 워커는 아웃바운드 네트워크를 막거나 허용 목록으로 제한합니다. XXE가 SSRF로 번지는 경로가 끊깁니다.
- 오류 메시지 축소: 파싱 예외 원문을 사용자 응답에 그대로 내보내지 않습니다. 파일 내용이 오류 메시지를 통해 새어 나가는 경로를 줄입니다.
- SVG 처리 방침: SVG를 꼭 받아야 하는 게 아니라면 PNG·JPG만 허용하고, 받아야 한다면 서버 측에서 안전한 파서로 정제하거나 래스터 이미지로 변환한 결과만 저장합니다.
점검 체크리스트
- 코드에서
DocumentBuilderFactory,SAXParserFactory,XMLInputFactory,TransformerFactory,etree.XMLParser,iterparse,parseXml을 검색했는가 - 각 호출 지점에서 DTD 금지 또는 외부 엔티티 차단 옵션이 명시돼 있는가
- xlsx·docx를 라이브러리 대신 직접 압축 해제해 파싱하는 코드가 있는가
- Apache POI, lxml, openpyxl, SAML 라이브러리의 실제 해석 버전이 최신 보안 패치 이후인가
- Python 프로젝트에
defusedxml이 의존성으로 들어 있는가 - 문서 변환 워커의 아웃바운드 네트워크가 제한돼 있는가
- 업로드 크기·압축 해제 크기 상한이 있는가
자주 묻는 질문
JSON API만 쓰는데도 XXE 점검이 필요한가요?
요청 본문이 JSON이어도 엑셀 일괄 등록, 문서 미리보기, SVG 업로드, SAML SSO가 있다면 서버 어딘가에서 XML을 파싱합니다. 기능 목록을 기준으로 위 표의 항목이 있는지부터 확인하면 됩니다. 일부 프레임워크는 Content-Type에 따라 XML 본문도 받아 주므로 그 설정도 함께 봅니다.
Apache POI나 openpyxl 같은 라이브러리를 쓰면 안전한가요?
라이브러리가 내부적으로 보안 설정을 해 두는 경우가 많지만, 오래된 버전이거나 라이브러리를 거치지 않고 XML을 직접 파싱하면 그 보호를 받지 못합니다. openpyxl은 패키지 설명에서 엔티티 확장 공격 방어를 위해 defusedxml 설치를 권하고 있습니다. "버전 확인 + 직접 파싱 코드 검색" 두 가지가 기본입니다.
WAF로 막을 수는 없나요?
xlsx·docx는 압축 파일이라 요청 본문에서 DOCTYPE 문자열이 보이지 않습니다. WAF는 보조 수단일 뿐이고, 근본 조치는 파서 설정과 라이브러리 업데이트입니다.
정리
- XXE는 신뢰할 수 없는 XML을 기본 설정 파서로 읽을 때 생기며, DTD를 금지하거나 외부 엔티티·외부 DTD 로딩을 끄면 막힙니다.
- 엑셀 일괄 등록, 문서 미리보기, SVG, SAML처럼 XML API가 아닌 기능에 XML 파싱이 숨어 있습니다.
- Java는 팩토리별 설정과 POI의
XMLHelper, Python은defusedxml과 lxml 옵션 명시, Node는noent: false와processEntities: false처럼 옵션을 코드에 남겨 두는 것이 핵심입니다. - 파서 설정 위에 DOCTYPE 사전 거부, 압축 해제 상한, 변환 워커 네트워크 격리를 겹쳐 두면 설정 누락 하나가 사고로 이어지는 일을 줄일 수 있습니다. 직접 점검이 어렵다면 무료 분석 신청으로 업로드 기능의 파싱 경로를 함께 살펴볼 수 있습니다.
참고 자료
- OWASP XML External Entity Prevention Cheat Sheet
- Python 공식 문서: XML Processing Modules (XML vulnerabilities)
- CodeQL: XML external entity expansion (Python)
- Endor Labs: CVE-2026-41066 (lxml)
- BellSoft: CVE-2026-41066
- Ubuntu Security: CVE-2019-12415 (Apache POI)
- Apache Bugzilla 63899: xxe vulnerability (POI)
- openpyxl (PyPI)
- XML External Entity (XXE) via libxml in Express
함께 읽으면 좋은 글
개인정보 손해배상책임보험 의무가입 기준과 스타트업 준비
개인정보 손해배상책임보험 의무 대상인지 매출 10억·정보주체 1만명 기준으로 판단하는 법, 정보주체 수 산정 SQL, 최저가입금액, 과태료, 보험사 질문에 답할 보안 증빙을 개발팀 관점에서 정리했습니다.
NestJS 보안 가이드: Guard·ValidationPipe 설정에서 놓치는 6가지
NestJS 보안 점검 가이드. Guard 적용 범위, @Public 남용, ValidationPipe 옵션, 예외 필터, 응답 직렬화, Swagger 운영 노출까지 배포 전 확인할 6가지를 취약한 코드와 고친 코드로 정리했다.
개인정보 처리방침 작성법: 실제 데이터 흐름과 어긋나는 6곳
개인정보 처리방침 작성 시 템플릿과 실제 서비스가 어긋나는 6곳을 다룹니다. 코드와 인프라에서 분석 도구, 처리위탁, 국외이전, 로그 보관 기간, 쿠키를 찾아내 처리방침과 대조하는 방법을 정리했습니다.