63. XXE Injection: XML이 공격 벡터가 되는 순간
XXE(XML External Entity)는 XML이 문서 형식인 동시에 다른 리소스를 참조할 수 있는 언어라는 사실에서 생긴다. 공격의 본질은 DOCTYPE 문자열이 아니라 파서가 외부 엔티티와 네트워크를 해석하도록 허용된 구성이다. 실습은 소유한 로컬 파서와 더미 파일·테스트 DNS에서만 수행한다.
파서의 결정이 경계를 만든다
다음 문서는 외부 엔티티를 선언하지만, 안전한 파서에서는 거부되거나 엔티티가 해석되지 않아야 한다.
<?xml version="1.0"?>
<!DOCTYPE note [
<!ENTITY sample SYSTEM "file:///tmp/xxe-test-marker">
]>
<note>&sample;</note>
응답에 marker가 나타나는지는 파일 읽기 가능성을 보여주는 최소 증거다. /etc/passwd, 클라우드 메타데이터, 실제 비밀 파일을 읽을 이유는 없다. 외부 엔티티가 HTTP 요청을 만들 수 있는지 확인해야 한다면 자기 소유의 로컬 콜백 서버를 사용하고, 요청 발생 여부만 기록한다.
python3 -m http.server 8765 --bind 127.0.0.1
Blind XXE는 응답 본문이 없어도 DNS나 HTTP 콜백으로 엔티티 해석을 관찰하는 형태다. 파일 내용을 외부로 보내는 페이로드는 테스트에 불필요하고 위험하므로 문서화하지 않는다. XML 업로드가 있는 docx, xlsx, SVG, SOAP 엔드포인트도 같은 parser 설정을 공유하는지 확인한다.
탐지와 영향도
Content-Type: application/xml, text/xml, SOAP 요청, 파일 변환기를 우선 살핀다. 파서 오류에서 DOCTYPE is disallowed 같은 메시지가 보이는 것은 방어 신호일 수 있지만, 서로 다른 라이브러리·경로가 같은 정책을 쓰는지 확인해야 한다.
영향도는 세 가지로 나눈다.
- 로컬 파일을 읽는가.
- 서버가 내부 주소로 나가는가.
- 엔티티 확장으로 CPU·메모리·시간을 고갈시킬 수 있는가.
읽기·네트워크·자원 고갈은 별도 테스트와 별도 완화책으로 기록한다. DOCTYPE만 필터링하는 방식은 인코딩, 다른 파서 경로, 기본값 변화에 의존하므로 충분한 방어가 아니다.
안전한 구성
외부 엔티티와 DTD를 끄고 네트워크 접근을 차단한다. Python lxml의 예시는 다음과 같다.
from lxml import etree
parser = etree.XMLParser(
resolve_entities=False,
load_dtd=False,
no_network=True,
huge_tree=False,
)
root = etree.fromstring(xml_bytes, parser=parser)
라이브러리마다 옵션 이름과 기본값이 다르므로 사용 중인 버전의 공식 문서를 확인한다. Java, .NET, PHP, Go 등에서도 “보안 파서”라는 이름만 믿지 말고 외부 엔티티, DTD, XInclude, 스키마 로딩을 각각 점검한다. XML이 필요 없다면 JSON으로 바꾸는 것이 운영 복잡도를 줄이는 선택이지만, 파일 형식을 바꿀 수 없는 경우에는 파서 설정을 코드로 고정하고 회귀 테스트를 둔다.
입력 크기·깊이·엔티티 수·처리 시간을 제한하고, 변환 작업을 격리한다. 에러 응답은 내부 파일 경로와 파서 스택을 노출하지 않으며, 네트워크 egress 정책으로 변환기가 관리망에 접근하지 못하게 한다. 수정 검증은 marker 파일과 로컬 콜백으로 “읽지 않음”과 “연결하지 않음”을 각각 확인해야 한다.