설명
XPath 인젝션은 외부에서 제어할 수 있는 텍스트를 데이터로 전달하지 않고 경로 표현식의 문법에 포함할 때 발생합니다. lxml은 완전한 XPath 1.0을 지원하므로 공격자가 따옴표, 연산자 또는 조건식을 주입하면 노드 선택을 바꾸거나 원래 조건을 무력화할 수 있습니다.
Python 표준 라이브러리의 xml.etree.ElementTree는 완전한 XPath가 아니라 제한된 ElementPath 문법을 지원합니다. 사용할 수 있는 연산은 더 적지만, find(), findall(), findtext(), iterfind()의 match를 공격자가 제어하면 여전히 의도하지 않은 노드를 선택할 수 있습니다.
잠재적 영향
- 데이터 노출: 애플리케이션이 반환하는 XML 노드의 범위를 공격자가 넓히면 다른 사용자나 테넌트의 정보가 노출될 수 있습니다.
- 인증 또는 인가 우회: XPath 조회 결과를 로그인이나 접근 제어 판단에 사용하면 조작된 조건이 보안 검사를 우회할 수 있습니다.
- 의도하지 않은 변경: 선택된 노드를 기준으로 후속 수정이나 삭제를 수행하는 애플리케이션에서만 잘못된 대상이 변경될 수 있습니다.
- 자원 고갈: 충분히 큰 문서에 대해 임의의 복잡한 XPath를 허용하는 기능에서는 비싼 평가가 CPU를 소모할 수 있습니다. 모든 동적 ElementPath 사용이 곧 서비스 거부로 이어지는 것은 아닙니다.
해결 방법
- XPath 또는 ElementPath 표현식을 상수로 유지하고 문자열 결합, f-string,
%포맷 또는str.format()으로 문법을 조립하지 않습니다. lxml에서는 신뢰할 수 없는 값을 XPath 변수로 바인딩합니다. 예:tree.xpath("//user[@id=$uid]", uid=value). 변수는 값만 대체하며 요소 이름, 연산자 또는 다른 표현식 구조를 대체하지 않습니다.- 사용자가 필드, 경로 또는 연산을 선택해야 한다면 입력값을 서버가 소유한 완전한 상수 표현식에 매핑합니다. 단순 토큰을 직접 사용할 때는 먼저 정규화하고 문자열 전체에 적용되는 허용 목록으로 검증합니다.
- ElementTree에는 XPath 변수 바인딩 API가 없습니다.
match를 상수로 유지한 뒤 Python 코드에서 값을 비교하거나 서버 소유 매핑에서 상수 표현식을 선택합니다. - 숫자 위치나 값은 Python 내장 숫자형으로 변환하고 애플리케이션의 범위 제약도 확인합니다.
- 함수 이름이나 범용 이스케이프를 안전성의 근거로 삼지 않습니다. XPath 문자열 리터럴의 이스케이프는 문맥에 따라 달라지며 단순한 따옴표 치환은 불완전하기 쉽습니다. 가능한 경우 변수 바인딩이나 상수 매핑을 사용합니다.
- 사용자가 작성한 임의 XPath를 평가하는 기능이 실제 요구사항이라면 일반 조회 기능과 격리하고 별도 인가, 입력 크기 제한, 시간 및 자원 제한을 적용합니다.
예시
lxml
변경 전
python
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
XML_DATA = b"""
<accounts>
<account tenant="blue" id="1001"><email>blue@example.com</email></account>
<account tenant="red" id="2001"><email>red@example.com</email></account>
</accounts>
"""
@app.get("/account")
def account():
account_id = request.args.get("id", "")
root = etree.fromstring(XML_DATA)
# 취약: account_id가 XPath 문법에 직접 포함됩니다.
expression = f"//account[@tenant='blue' and @id='{account_id}']"
matches = root.xpath(expression)
if not matches:
return ("not found", 404)
return etree.tostring(matches[0], encoding="unicode")
변경 후
python
from flask import Flask, request
from lxml import etree
app = Flask(__name__)
XML_DATA = b"""
<accounts>
<account tenant="blue" id="1001"><email>blue@example.com</email></account>
<account tenant="red" id="2001"><email>red@example.com</email></account>
</accounts>
"""
@app.get("/account")
def account():
account_id = request.args.get("id", "")
root = etree.fromstring(XML_DATA)
# 안전: 표현식은 상수이고, 신뢰할 수 없는 값은 변수로 전달됩니다.
expression = "//account[@tenant=$tenant and @id=$account_id]"
matches = root.xpath(
expression,
tenant="blue",
account_id=account_id,
)
if not matches:
return ("not found", 404)
return etree.tostring(matches[0], encoding="unicode")
취약한 예시는 account_id를 따옴표 안에 삽입하므로 입력이 XPath의 조건 구조를 바꿀 수 있습니다. 안전한 예시는 같은 값을 XPath 변수로 전달하므로 입력에 따옴표나 연산자가 포함되어도 표현식 문법으로 해석되지 않습니다. 형식과 길이 검증은 별도의 비즈니스 제약으로 추가할 수 있지만, 변수 바인딩을 대신하지 않습니다.
ElementTree
ElementTree에서 사용자가 조회 모드를 선택해야 한다면 표현식 전체를 매핑합니다.
python
import xml.etree.ElementTree as ET
from flask import request
def accounts_by_view():
account_paths = {
"active": ".//account[@active='true']",
"disabled": ".//account[@active='false']",
}
selected_path = account_paths.get(request.args.get("view"))
if selected_path is None:
return ("invalid view", 400)
root = ET.fromstring(XML_DATA)
return root.findall(selected_path)
적용 시 주의사항
허용 목록과 표현식 매핑은 서버가 관리해야 합니다. XML 파서의 외부 엔터티 설정과 입력 크기 제한도 XPath 표현식 보호와 별도로 확인하세요.