설명
SQL 인젝션은 신뢰할 수 없는 데이터가 별도의 값으로 전달되지 않고 실행할 SQL 텍스트의 일부가 될 때 발생합니다. 공격자는 쿼리 구조를 바꾸어 조건을 우회하거나 애플리케이션의 데이터베이스 계정이 접근할 수 있는 데이터를 조회, 변경 또는 삭제할 수 있습니다.
execute() 같은 API를 사용하거나 원시 SQL을 작성한다는 사실만으로 취약한 것은 아닙니다. 취약성은 신뢰할 수 없는 값과 SQL 구조를 분리하지 않은 채 실제 데이터베이스 API에 전달할 때 발생합니다.
잠재적 영향
- 데이터 유출: 조건 우회 또는 추가 쿼리 구문을 통해 애플리케이션 계정이 읽을 수 있는 정보가 노출될 수 있습니다.
- 데이터 변조·삭제: 애플리케이션 계정에 쓰기 권한이 있으면 데이터 무결성이 훼손될 수 있습니다.
- 인증·인가 우회: 데이터베이스 조회 결과를 인증이나 권한 판단에 사용하는 경우 보호 조건이 우회될 수 있습니다.
- 추가 영향: 서비스 거부나 운영체제 명령 실행 가능성은 데이터베이스 종류, 활성화된 기능 및 계정 권한에 따라 달라집니다.
해결 방법
- 데이터 값은 API가 문서화한 자리표시자와 파라미터 인자를 사용해 SQL 텍스트와 분리합니다. 드라이버에 따라
?,%s,%(name)s,:name등 형식이 다르므로 해당 API 문서를 따르고 자리표시자를 따옴표로 감싸지 않습니다. - 파라미터 바인딩으로는 테이블명, 컬럼명, 정렬 방향과 같은 SQL 구조를 바인딩할 수 없습니다. 쿼리 빌더를 우선 사용하고,
psycopg.sql.Identifier같은 식별자 조합 API를 사용하거나 소수의 허용된 선택지를 개발자가 제어하는 SQL 조각에 매핑합니다. - SQLAlchemy에서는 Core 또는 ORM 표현식을 우선 사용합니다. 텍스트 SQL이 필요하면
text()템플릿을 고정하고 값을 바인드 파라미터로 전달합니다.exec_driver_sql()에는 기반 드라이버의 자리표시자 형식을 사용합니다. - Django에서는 ORM을 우선 사용합니다.
Manager.raw()또는 커서를 사용할 때 값은params로 전달합니다. - Peewee의
execute_sql()과 pandas의read_sql_query()에서는 값만params로 전달합니다. - 숫자처럼 형식이 제한된 입력도 가능하면 타입 변환 후 바인딩합니다. 패턴 검사, SQL 이스케이프 도우미, 도우미 함수 이름 또는 클라이언트가 제어하는 "허용 목록"을 파라미터화의 대체 수단으로 사용하지 않습니다.
- 데이터베이스 계정에는 필요한 최소 권한만 부여해 취약성이 악용되었을 때의 영향을 줄입니다.
예시
변경 전
python
import sqlite3
from flask import request
def find_users():
keyword = request.args.get("q", "")
sort_column = request.args.get("sort", "id")
with sqlite3.connect("app.db") as connection:
cursor = connection.cursor()
# 비준수: 값과 식별자를 모두 SQL 텍스트에 직접 삽입
sql = (
"SELECT id, name, created_at FROM users "
f"WHERE name LIKE '%{keyword}%' ORDER BY {sort_column} DESC"
)
return cursor.execute(sql).fetchall()
변경 후
python
import sqlite3
from flask import request
def find_users():
keyword = request.args.get("q", "")
choice = request.args.get("sort", "id")
# 입력은 선택에만 사용하고 SQL 식별자는 코드의 고정 리터럴에서 가져옴
if choice == "name":
sort_column = "name"
elif choice == "created_at":
sort_column = "created_at"
else:
sort_column = "id"
# SQLite의 ? 자리표시자는 값에만 사용
sql = (
"SELECT id, name, created_at FROM users "
"WHERE name LIKE ? "
f"ORDER BY {sort_column} DESC"
)
with sqlite3.connect("app.db") as connection:
cursor = connection.cursor()
return cursor.execute(sql, (f"%{keyword}%",)).fetchall()
설명:
- 취약한 예시는 검색 값과 정렬 컬럼을 같은 f-string에 넣어 둘 다 SQL 구문으로 해석될 수 있게 합니다.
- 안전한 예시는 검색 값을
execute()의 두 번째 인자로 전달하고, 바인딩할 수 없는 정렬 컬럼은 서버 코드에 고정된 리터럴로 변환합니다. 알 수 없는 선택값은 기본id정렬을 사용합니다. - 허용 목록 자체를 요청 파라미터, 본문 또는 쿠키에서 만들면 공격자가 검증 정책을 제어하므로 안전하지 않습니다.
적용 시 주의 사항
사용자 정의 데이터베이스 래퍼나 여러 함수를 거치는 쿼리도 SQL 텍스트와 값이 최종 실행 시점까지 분리되는지 확인하세요. 테이블명과 쿼리를 모두 받는 API에서는 입력의 용도를 구분하고, 동적 쿼리의 값은 해당 드라이버 방식으로 바인딩하세요.