flawopen.com/SQL 인젝션/Python
482처럼 티켓 번호만 입력받는 양식을 떠올려 보세요. SQL 인젝션은 누군가 그 칸에 숫자 대신 교묘한 문구를 입력해서, 시스템이 "482번 티켓만" 보여주는 대신 "모든 티켓을 보여줘"라고 응답하게 만드는 현상입니다. 시스템이 받은 값이 정말로 숫자뿐인지 한 번도 확인하지 않았기 때문에 벌어지는 일입니다.
SQL 인젝션은 사용자 제어 입력이 별도의 값으로 전달되지 않고 데이터베이스 쿼리의 텍스트에 직접 삽입될 때 발생합니다. 코드가 문자열을 이어 붙여 쿼리를 만들면, 공격자는 쿼리의 실제 구조를 바꿔버리는 입력을 제공할 수 있습니다 — "한 행 조회" 쿼리를 모든 행을 반환하거나 테이블을 삭제하는 쿼리로 바꿔버리는 식입니다.
Python에서는 거의 항상 같은 방식으로 나타납니다. 데이터베이스 드라이버에 내장된 매개변수 플레이스홀더 대신, f-문자열, % 포매팅, 또는 단순한 + 연결로 데이터베이스 호출을 구성하는 경우입니다.
2015년, 영국 통신사 TalkTalk는 인수합병으로 물려받은 오래된 웹페이지의 SQL 인젝션 취약점을 공격자가 악용하면서 15만 명 이상의 고객에게 영향을 미친 데이터 유출을 겪었습니다. 영국 데이터보호 규제기관은 TalkTalk에 40만 파운드의 과징금을 부과하며, 이 실패를 "예방 가능하고 기본적인 수준"이라고 지적했습니다.
출처: 영국 정보위원회(ICO) 제재 통지, 2016년 — 아래 참고 자료 참조.# 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()가 쿼리를 받는 시점에는 값이 이미 마치 처음부터 명령의 일부였던 것처럼 텍스트에 구워져 들어가 있기 때문입니다.
cursor.execute("... WHERE id = %s" % user_id)는 f-문자열만큼이나 취약합니다. 플레이스홀더는 값이 execute() 자체의 두 번째 인자로 전달될 때만 안전해집니다 — cursor.execute("...WHERE id = %s", (user_id,))처럼 작성해야, Python의 문자열 포매팅이 아니라 드라이버가 대체를 수행합니다.
Django의 ORM과 SQLAlchemy의 쿼리 빌더는 일반적인 쿼리에서는 자동으로 매개변수화됩니다. 하지만 Model.objects.raw()나 SQLAlchemy의 text()를 사용해 그 원시 SQL을 f-문자열로 조립하는 순간 위험이 다시 돌아옵니다.
psycopg2(PostgreSQL)는 컬럼 타입과 무관하게 %s를 사용하고, sqlite3는 ?를 사용합니다. 한 드라이버의 문서에서 본 플레이스홀더 스타일을 다른 드라이버에 그대로 옮기면 조용히 실패합니다 — 가정하지 말고 실제 사용 중인 드라이버의 매개변수 스타일을 확인하세요.
ORM의 일반적인 쿼리 API에는 맞는 말이지만, raw()나 text()를 사용해 직접 문자열을 조립하는 순간 더 이상 성립하지 않습니다.
위험은 런타임 값의 타입에 있는 것이 아니라, 애초에 쿼리가 문자열 보간으로 구성된다는 사실 자체에 있습니다. 코드 생애주기 중 어느 시점에서든 이 가정이 깨지는 순간, 취약점은 이미 그 자리에서 기다리고 있습니다.
수동 이스케이프는 드라이버마다 다르며, 미묘하게 틀리기 쉽습니다. 매개변수화된 쿼리는 "더 엄격한 이스케이프"가 아니라, 값이 애초에 쿼리 텍스트의 일부가 되지 않게 함으로써 문제 자체를 완전히 피해갑니다.
grep -rn "execute(f\"" --include="*.py" .
grep -rn "execute(.*%\s*(" --include="*.py" .
grep -rn "\.raw(\|text(" --include="*.py" .
일반적인 쿼리 메서드에서는 그렇습니다. 하지만 원시 쿼리용 예외 탈출구는 그렇지 않습니다 — 이런 경우는 손으로 작성한 SQL과 정확히 똑같은 수준의 안전성만 가질 뿐, 더 안전하지 않습니다.
아닙니다. 외부 당사자가 실질적으로 제어할 수 있는 모든 것이 해당됩니다 — HTTP 헤더, 업로드된 파일 이름, 심지어 애플리케이션이 신뢰하는 제3자 API에서 받은 값도 포함됩니다.
가능은 하지만 취약하고 드라이버에 의존적입니다. 매개변수화된 쿼리가 진짜 해결책이며, 더 엄격한 형태의 이스케이프가 아닙니다.