[하루한줄] CVE-2025-57833: Django ORM에서 발생한 SQL Injection 취약점
URL
Target
- Django < 4.2.24
- Django < 5.1.12
- Django < 5.2.6
Explain
background
Python 기반 웹 프레임워크인 Django에서는 데이터베이스와의 쿼리를 위한 ORM(Object-Relational Mapping) 기능이 제공됩니다. ORM을 이용하면 개발자가 직접 SQL을 작성하지 않고도 QuerySet API를 통해 데이터 조회, 삽입, 갱신 등의 연산을 추상화 계층을 통해 수행할 수 있습니다.
Django ORM의 쿼리 처리 과정은 내부적으로 QuerySet에 저장된 조건, 정렬, 조인 정보 등을 기반으로 SQLCompiler가 SQL 템플릿을 생성하고 이후 데이터베이스 커넥터를 통해 최종 SQL 구문이 실행되는 구조로 이루어집니다. 이 과정에서 ORM은 일반적으로 파라미터 바인딩(Parameter Binding) 방식을 사용하여 사용자 입력값을 쿼리 문자열과 분리하여 처리함으로써 SQL 인젝션을 방지합니다.
파라미터 바인딩의 구현 예시
sql = 'SELECT * FROM user WHERE username = %s'
cursor.execute(sql, [username])
일반적으로 파라미터 바인딩에서는 컬럼명, 테이블명, JOIN alias, annotation alias 등 SQL 식별자에 해당하는 값에는 적용되지 않습니다. Django ORM 역시 데이터 값에 대해서만 파라미터 바인딩을 수행하며 식별자를 포함하는 SQL 템플릿 자체는 ORM 레벨에서 직접 문자열로 생성됩니다. 따라서 식별자에 해당하는 값은 파라미터 바인딩으로 보호되지 않으며 검증이 부족할 경우 SQL 인젝션으로 이어지게 됩니다.
취약점은 QuerySet의 annotate() 메서드와 함께 사용되는 FilteredRelation에서 발생하였습니다. annotate()는 쿼리 결과의 각 행에 대해 계산된 값을 추가하기 위해 사용되며 모델 필드로 저장할 필요는 없지만 조회 시 동적으로 필요한 값을 계산할 때 활용됩니다. 이때, 인자는 {alias : value}와 같은 키 형태로 전달되며 alias는 SQL 상에서 컬럼 또는 조인 별칭으로 사용됩니다.
annotate()사용 예시
from django.db.models import Count
qs = User.objects.annotate(login_cnt=Count("loginlog"))
# 생성되는 SQL 예시
SELECT
"user"."id",
COUNT("loginlog"."id") AS "login_cnt"
FROM "user"
LEFT OUTER JOIN "loginlog"
ON ("loginlog"."user_id" = "user"."id")
GROUP BY "user"."id";
FilteredRelation은 JOIN 수행 시 ON 절에 조건을 추가하기 위해 제공되는 객체입니다. 해당 기능은 JOIN 대상 테이블의 alias와 ON 조건을 ORM 내부에서 직접 구성한다는 특징을 가집니다. 이 과정에서 alias 및 조건에 대한 검증이 충분하지 않을 경우, 개발자나 사용자가 입력한 값이 최종적인 Raw SQL에 그대로 포함될 수 있다는 가능성이 존재합니다.
취약한 코드 패턴: 브라우저에서 전달된 파라미터를 그대로 alias에 사용하는 코드
def search(request):
if request.method == 'POST':
try:
data = json.loads(request.body)
search_field = data.get('search_field')
condition = FILTER_MAP.get(search_field, Q(id__gte=0))
queryset = (
Book.objects.annotate(**{
search_field: FilteredRelation(
'author',
condition=condition), # vuln
})
.filter(**{f'{search_field}__isnull': False})
)
Root Cause
취약점은 사용자가 입력한 문자열을 그대로 FilteredRelation의 alias로 사용할 때 발생합니다. Django ORM 계층에서 FilteredRelation이 사용될 때 annotate() 내부에서 호출되는 경우에만 alias에 대한 검증이 미흡[1]했습니다.
따라서 파라미터 바인딩 이전에 SQL 템플릿이 생성될 때 검증되지 않은 alias 문자열이 그대로 삽입[2]되어 SQL 인젝션이 발생하게 됩니다.
def add_filtered_relation(self, filtered_relation, alias):
filtered_relation.alias = alias # [1] 별도의 검증없이 내부 인스턴스 변수로 사용
relation_lookup_parts, relation_field_parts, _ = self.solve_lookup_type(
filtered_relation.relation_name
)
if relation_lookup_parts:
raise ValueError(
"FilteredRelation's relation_name cannot contain lookups "
"(got %r)." % filtered_relation.relation_name
)
for lookup in get_children_from_q(filtered_relation.condition):
lookup_parts, lookup_field_parts, _ = self.solve_lookup_type(lookup)
shift = 2 if not lookup_parts else 1
lookup_field_path = lookup_field_parts[:-shift]
for idx, lookup_field_part in enumerate(lookup_field_path):
if len(relation_field_parts) > idx:
if relation_field_parts[idx] != lookup_field_part:
raise ValueError(
"FilteredRelation's condition doesn't support "
"relations outside the %r (got %r)."
% (filtered_relation.relation_name, lookup)
)
else:
raise ValueError(
"FilteredRelation's condition doesn't support nested "
"relations deeper than the relation_name (got %r for "
"%r)." % (lookup, filtered_relation.relation_name)
)
filtered_relation.condition = rename_prefix_from_q(
filtered_relation.relation_name,
alias,
filtered_relation.condition,
)
self._filtered_relations[filtered_relation.alias] = filtered_relation # [2]
patch
패치 커밋 4c044fcc866ec226f612c475950b690b0139d243에서 django/db/models/sql/query.py:add_filtered_relation() 내부에 alias 검증 로직을 도입하여 패치되었습니다.

취약점이 패치된 버전에서는 별도의 alias 검증 함수가 추가되어 alias가 SQL 키워드, 예약어, 메타 문자, 공백, 특수 문자를 포함할 경우 즉시 예외가 발생합니다.
Reference
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.