flawopen.com/SQL 인젝션/Python

Python의 SQL 인젝션

심각 CWE-89 초안 —— 검토 대기 중
Language: English Deutsch Español Français हिन्दी Bahasa Indonesia 日本語 한국어 Português (Brasil) Русский 简体中文
쉽게 설명하면

482처럼 티켓 번호만 입력받는 양식을 떠올려 보세요. SQL 인젝션은 누군가 그 칸에 숫자 대신 교묘한 문구를 입력해서, 시스템이 "482번 티켓만" 보여주는 대신 "모든 티켓을 보여줘"라고 응답하게 만드는 현상입니다. 시스템이 받은 값이 정말로 숫자뿐인지 한 번도 확인하지 않았기 때문에 벌어지는 일입니다.

이 페이지의 핵심 용어
사용자 제어 입력
애플리케이션을 사용하는 — 또는 공격하는 — 사람에게서 궁극적으로 비롯된 모든 값. 폼 필드, URL 파라미터, 업로드된 파일 이름, HTTP 헤더 등이 해당됩니다. 애플리케이션은 이 값이 올바른 형식이거나 안전하다고 가정할 수 없습니다.
SQL 쿼리
데이터베이스로 전송되는 명령 — 예를 들어 "이 행을 가져와라", "이 테이블을 삭제해라" 등. 그 의미는 정확한 텍스트 자체에서 전적으로 비롯되며, 이 때문에 여기에 추가 텍스트를 주입하는 것이 위험해집니다.

무슨 일이 일어나는가

SQL 인젝션은 사용자 제어 입력이 별도의 값으로 전달되지 않고 데이터베이스 쿼리의 텍스트에 직접 삽입될 때 발생합니다. 코드가 문자열을 이어 붙여 쿼리를 만들면, 공격자는 쿼리의 실제 구조를 바꿔버리는 입력을 제공할 수 있습니다 — "한 행 조회" 쿼리를 모든 행을 반환하거나 테이블을 삭제하는 쿼리로 바꿔버리는 식입니다.

Python에서는 거의 항상 같은 방식으로 나타납니다. 데이터베이스 드라이버에 내장된 매개변수 플레이스홀더 대신, f-문자열, % 포매팅, 또는 단순한 + 연결로 데이터베이스 호출을 구성하는 경우입니다.

실제 사례에서의 영향

2015년, 영국 통신사 TalkTalk는 인수합병으로 물려받은 오래된 웹페이지의 SQL 인젝션 취약점을 공격자가 악용하면서 15만 명 이상의 고객에게 영향을 미친 데이터 유출을 겪었습니다. 영국 데이터보호 규제기관은 TalkTalk에 40만 파운드의 과징금을 부과하며, 이 실패를 "예방 가능하고 기본적인 수준"이라고 지적했습니다.

출처: 영국 정보위원회(ICO) 제재 통지, 2016년 — 아래 참고 자료 참조.

취약한 코드 vs. 수정된 코드

취약함
# user_id는 요청에서 바로 넘어옴
def get_user(cursor, user_id):
    query = f"SELECT * FROM users WHERE id = {user_id}"
    cursor.execute(query)
    return cursor.fetchone()
수정됨
# 값을 별도로 전달, 텍스트에 삽입하지 않음
def get_user(cursor, user_id):
    query = "SELECT * FROM users WHERE id = %s"
    cursor.execute(query, (user_id,))
    return cursor.fetchone()

이 수정이 효과가 있는 이유

수정된 버전은 쿼리 텍스트와 값을 execute()에 두 개의 분리된 인자로 전달합니다. 데이터베이스 드라이버도 이 둘을 데이터베이스에 각각 따로 보냅니다 — 값이 붙기 전에 쿼리의 구조가 이미 고정되어 있으므로, 값에 어떤 문자가 들어 있든 SQL 문법의 일부로 해석될 수 없습니다. f-문자열은 이를 해낼 수 없습니다. execute()가 쿼리를 받는 시점에는 값이 이미 마치 처음부터 명령의 일부였던 것처럼 텍스트에 구워져 들어가 있기 때문입니다.

Python 특유의 주의사항

execute() 내부의 % 포매팅은 매개변수화된 것처럼 보이지만 그렇지 않다

cursor.execute("... WHERE id = %s" % user_id)는 f-문자열만큼이나 취약합니다. 플레이스홀더는 값이 execute() 자체의 두 번째 인자로 전달될 때만 안전해집니다 — cursor.execute("...WHERE id = %s", (user_id,))처럼 작성해야, Python의 문자열 포매팅이 아니라 드라이버가 대체를 수행합니다.

ORM은 기본적으로 매개변수화하지만, 예외 탈출구는 그렇지 않다

Django의 ORM과 SQLAlchemy의 쿼리 빌더는 일반적인 쿼리에서는 자동으로 매개변수화됩니다. 하지만 Model.objects.raw()나 SQLAlchemy의 text()를 사용해 그 원시 SQL을 f-문자열로 조립하는 순간 위험이 다시 돌아옵니다.

플레이스홀더 구문은 드라이버마다 다르다

psycopg2(PostgreSQL)는 컬럼 타입과 무관하게 %s를 사용하고, sqlite3?를 사용합니다. 한 드라이버의 문서에서 본 플레이스홀더 스타일을 다른 드라이버에 그대로 옮기면 조용히 실패합니다 — 가정하지 말고 실제 사용 중인 드라이버의 매개변수 스타일을 확인하세요.

흔한 오해

"내 ORM이 자동으로 나를 보호해준다"

ORM의 일반적인 쿼리 API에는 맞는 말이지만, raw()text()를 사용해 직접 문자열을 조립하는 순간 더 이상 성립하지 않습니다.

"이 ID는 항상 숫자니까 그대로 넣어도 안전하다"

위험은 런타임 값의 타입에 있는 것이 아니라, 애초에 쿼리가 문자열 보간으로 구성된다는 사실 자체에 있습니다. 코드 생애주기 중 어느 시점에서든 이 가정이 깨지는 순간, 취약점은 이미 그 자리에서 기다리고 있습니다.

"따옴표를 직접 이스케이프하니까 매개변수화된 쿼리는 필요 없다"

수동 이스케이프는 드라이버마다 다르며, 미묘하게 틀리기 쉽습니다. 매개변수화된 쿼리는 "더 엄격한 이스케이프"가 아니라, 값이 애초에 쿼리 텍스트의 일부가 되지 않게 함으로써 문제 자체를 완전히 피해갑니다.

영향을 받는지 확인하는 방법

grep -rn "execute(f\"" --include="*.py" . grep -rn "execute(.*%\s*(" --include="*.py" . grep -rn "\.raw(\|text(" --include="*.py" .
grep만 쓰는 것보다 나은 방법: CI에서 Bandit(규칙 B608, hardcoded_sql_expressions)을 실행하세요 — 이 패턴을 자동으로 감지하고, 새로운 사례가 나타나면 빌드를 실패시킵니다.

예방 체크리스트

자주 묻는 질문

ORM을 사용하면 SQL 인젝션으로부터 보호받나요?

일반적인 쿼리 메서드에서는 그렇습니다. 하지만 원시 쿼리용 예외 탈출구는 그렇지 않습니다 — 이런 경우는 손으로 작성한 SQL과 정확히 똑같은 수준의 안전성만 가질 뿐, 더 안전하지 않습니다.

이 위험은 검색창 같은 곳에서만 발생하나요?

아닙니다. 외부 당사자가 실질적으로 제어할 수 있는 모든 것이 해당됩니다 — HTTP 헤더, 업로드된 파일 이름, 심지어 애플리케이션이 신뢰하는 제3자 API에서 받은 값도 포함됩니다.

그냥 따옴표만 직접 이스케이프하면 안 되나요?

가능은 하지만 취약하고 드라이버에 의존적입니다. 매개변수화된 쿼리가 진짜 해결책이며, 더 엄격한 형태의 이스케이프가 아닙니다.

참고 자료

언어로 보기: Python JavaScriptGoJava PHPC#Ruby C/C++RustKotlin Swift Solidity(해당 없음)
함께 보기: 커맨드 인젝션경로 순회 XSS안전하지 않은 역직렬화