Split View: SQL 인젝션과 파라미터 바인딩 — ORM을 써도 뚫리는 지점들
SQL 인젝션과 파라미터 바인딩 — ORM을 써도 뚫리는 지점들
- 들어가며 — 이스케이프 함수를 찾고 있다면 방향이 틀렸습니다
- 이스케이프가 아니라 바인딩인 이유 — 파서 수준의 분리
- 프리페어드 스테이트먼트는 서버에서 어떻게 처리되는가
- ORM을 써도 뚫리는 세 지점
- 바인딩할 수 없는 것들 — 식별자, ORDER BY, IN, LIKE
- 2차 인젝션과 NoSQL 인젝션 — 같은 원리, 다른 표면
- 방어 심층화 — 최소 권한 계정과 탐지
- 마치며 — execute 호출의 두 번째 인자를 보십시오
들어가며 — 이스케이프 함수를 찾고 있다면 방향이 틀렸습니다
SQL 인젝션 관련 코드 리뷰에서 가장 자주 보이는 수정 제안은 이런 형태입니다. "따옴표를 이스케이프하세요", "특수문자를 필터링하세요", "작은따옴표를 두 번 겹치세요". 검색 결과 상위에도 여전히 이스케이프 함수와 블랙리스트 정규식이 올라옵니다.
이 접근은 원리적으로 지는 싸움입니다. 이스케이프는 값을 문자열 리터럴 안에서 안전하게 만들려는 시도이고, 그 전제가 깨지는 경우가 계속 나오기 때문입니다. 다음 코드는 이스케이프를 제대로 했는데도 뚫립니다.
# 숫자 컨텍스트에는 이스케이프할 따옴표가 없다
order_id = request.args["id"] # "1 OR 1=1"
cur.execute("SELECT * FROM orders WHERE id = " + escape(order_id))
SELECT * FROM orders WHERE id = 1 OR 1=1
이스케이프 함수는 따옴표와 백슬래시를 다루지만, 값이 따옴표 없이 들어가는 자리에서는 할 일이 없습니다. LIMIT 절, 정렬 방향, 불리언 비교, 그리고 문자 집합이 어긋난 상황에서도 같은 문제가 생깁니다.
정답은 다른 층위에 있습니다. 값을 안전한 문자열로 만드는 대신, 값이 SQL 텍스트에 아예 들어가지 않게 하는 것입니다. 이 글은 그 구조가 실제로 어떻게 동작하는지, 그리고 ORM을 쓰면 자동으로 해결된다는 통념이 어디서 깨지는지 다룹니다.
이스케이프가 아니라 바인딩인 이유 — 파서 수준의 분리
데이터베이스가 질의를 처리하는 과정은 크게 파싱, 계획 수립, 실행입니다. 인젝션은 파싱 단계의 문제입니다. 사용자 입력이 SQL 텍스트의 일부가 되면, 파서는 그것을 문법 요소로 해석합니다. 파서에게는 "이 부분은 사용자가 넣은 것"이라는 정보가 없습니다.
바인딩은 이 순서를 뒤집습니다. 먼저 자리표시자가 들어 있는 SQL을 파서에 넘겨 구문 트리를 확정하고, 값은 그 뒤에 별도 메시지로 전달합니다. 값이 도착할 때는 이미 문법이 결정되어 있으므로 값 안에 무엇이 들어 있든 구조를 바꿀 수 없습니다.
취약한 코드와 안전한 코드를 나란히 놓으면 차이가 분명합니다.
import psycopg
email = request.args["email"]
# 취약: 값이 SQL 텍스트가 된다
with conn.cursor() as cur:
cur.execute(f"SELECT id, email, role FROM users WHERE email = '{email}'")
# 안전: 값은 별도 채널로 간다
with conn.cursor() as cur:
cur.execute("SELECT id, email, role FROM users WHERE email = %s", (email,))
여기서 반드시 짚어야 할 것이 %s의 정체입니다. 이것은 파이썬 문자열 포매팅이 아니라 DB-API 드라이버의 자리표시자입니다. 그래서 다음 두 줄은 완전히 다른 코드입니다.
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # 바인딩
cur.execute("SELECT * FROM users WHERE email = %s" % email) # 문자열 포매팅, 취약
두 번째 줄은 파이썬이 먼저 문자열을 완성해 버리므로 드라이버에는 이미 조립된 SQL만 도착합니다. 오타 한 글자 차이로 취약점이 됩니다. 코드 리뷰에서 execute 호출의 두 번째 인자가 있는지 확인하는 것이 가장 빠른 검사입니다.
자바스크립트도 같습니다.
// 취약: 템플릿 리터럴로 조립
const { rows } = await pool.query(
`SELECT id, email FROM users WHERE email = '` + req.query.email + `'`
)
// 안전: 자리표시자와 값 배열
const { rows } = await pool.query('SELECT id, email FROM users WHERE email = $1', [
req.query.email,
])
바인딩이 이스케이프보다 나은 이유를 한 문장으로 정리하면 이렇습니다. 이스케이프는 값이 안전하다는 것을 개발자가 증명해야 하지만 바인딩은 값이 코드가 될 경로 자체를 없앱니다. 전자는 입력의 모든 경우를 고려해야 하고 후자는 고려할 것이 없습니다.
프리페어드 스테이트먼트는 서버에서 어떻게 처리되는가
바인딩이 진짜로 값을 분리하는지 확인해 봅니다. PostgreSQL에서 확장 질의 프로토콜은 Parse, Bind, Execute 세 단계로 나뉩니다. 명시적으로 재현할 수 있습니다.
PREPARE user_by_email (text) AS
SELECT id, email, role FROM users WHERE email = $1;
EXECUTE user_by_email ('alice@example.com');
이제 공격 문자열을 그대로 넣어 봅니다.
EXECUTE user_by_email ('nobody@example.com'' OR ''1''=''1');
id | email | role
----+-------+------
(0 rows)
행이 나오지 않습니다. OR 1=1이 조건으로 해석되지 않고 이메일 값의 일부로 취급됐기 때문입니다. 서버 로그를 켜 두면 무슨 일이 벌어졌는지 정확히 보입니다.
psql -c "ALTER SYSTEM SET log_statement = 'all'" -c "SELECT pg_reload_conf()"
tail -f /var/log/postgresql/postgresql-16-main.log
LOG: execute user_by_email: SELECT id, email, role FROM users WHERE email = $1
DETAIL: parameters: $1 = 'nobody@example.com'' OR ''1''=''1'
질의 텍스트와 파라미터가 별도 줄로 기록됩니다. 이것이 바인딩의 본질입니다. 서버가 받은 SQL 텍스트에는 사용자 입력이 없습니다.
여기서 자주 놓치는 함정이 하나 있습니다. 모든 드라이버가 실제로 서버 프리페어를 쓰는 것은 아닙니다. 일부 드라이버는 클라이언트 측에서 값을 이스케이프해 SQL을 조립한 뒤 단순 질의로 보냅니다. 이것을 에뮬레이션이라고 합니다.
// PHP PDO의 기본값은 드라이버에 따라 에뮬레이션일 수 있다
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false, // 서버 프리페어 강제
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
# MySQL JDBC: 서버 프리페어를 쓰려면 명시해야 한다
jdbc:mysql://db:3306/app?useServerPrepStmts=true&cachePrepStmts=true
에뮬레이션이 곧 취약점은 아닙니다. 구현이 올바르면 안전합니다. 다만 안전성의 근거가 "파서가 분리했다"에서 "드라이버의 이스케이프가 정확하다"로 내려앉습니다. 과거 MySQL의 GBK 계열 문자 집합에서 이스케이프를 우회하는 기법이 알려졌던 것도 이 층위의 문제였습니다. 선택할 수 있다면 서버 프리페어를 켜는 편이 낫습니다.
ORM을 써도 뚫리는 세 지점
"ORM을 쓰니까 인젝션은 신경 안 써도 된다"는 말은 절반만 맞습니다. ORM의 질의 빌더 API를 통과하는 값은 바인딩되지만, ORM은 원시 SQL로 내려가는 문을 반드시 열어 둡니다. 그리고 실무 코드는 그 문을 자주 씁니다.
첫 번째 지점은 원시 질의입니다.
from sqlalchemy import text
# 취약: text()는 문자열을 그대로 SQL로 만든다
result = session.execute(
text(f"SELECT * FROM orders WHERE status = '{status}' ORDER BY created_at DESC")
)
# 안전: text()에도 바인딩 자리표시자가 있다
result = session.execute(
text("SELECT * FROM orders WHERE status = :status ORDER BY created_at DESC"),
{"status": status},
)
text()를 썼다는 사실 자체는 문제가 아닙니다. 그 안에 f-string을 쓴 것이 문제입니다. 이 구분이 코드 리뷰의 핵심 기준이 됩니다.
Django도 같습니다.
# 취약
User.objects.raw("SELECT * FROM auth_user WHERE username = '%s'" % username)
User.objects.extra(where=[f"last_login > '{since}'"])
# 안전
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
User.objects.filter(last_login__gt=since)
Django의 extra()는 문서에서도 사용을 권하지 않는 API입니다. 코드베이스에서 extra(, RawSQL(, .raw(를 검색해 보면 대개 몇 군데가 나옵니다.
rg -n --type py '\.extra\(|RawSQL\(|\.raw\(' src/
src/reports/views.py:88: qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
src/admin/search.py:41: rows = User.objects.raw("SELECT * FROM auth_user WHERE email LIKE '%%%s%%'" % q)
두 번째 지점은 조건절을 문자열로 조립하는 패턴입니다. 검색 필터가 여러 개인 화면에서 특히 흔합니다.
// 취약: knex를 쓰면서 조건만 문자열로 붙인다
let query = knex('orders')
if (req.query.status) {
query = query.whereRaw(`status = '${req.query.status}'`)
}
if (req.query.minAmount) {
query = query.whereRaw(`total_amount >= ${req.query.minAmount}`)
}
// 안전: 빌더 API 또는 whereRaw의 바인딩 인자
let query = knex('orders')
if (req.query.status) {
query = query.where('status', req.query.status)
}
if (req.query.minAmount) {
query = query.whereRaw('total_amount >= ?', [Number(req.query.minAmount)])
}
Sequelize도 마찬가지입니다. sequelize.query에는 반드시 replacements 또는 bind를 씁니다.
// 안전
const rows = await sequelize.query(
'SELECT id, email FROM users WHERE tenant_id = :tenantId AND email = :email',
{ replacements: { tenantId, email }, type: QueryTypes.SELECT }
)
세 번째 지점은 ORM의 필터 조건을 클라이언트 입력으로 직접 채우는 패턴입니다. 이것은 문법상 인젝션이 아니지만 결과는 같습니다.
// 취약: 요청 본문이 그대로 where 절이 된다
const users = await User.findAll({ where: req.body.filter })
클라이언트가 filter에 다른 테넌트의 조건이나 연산자를 넣으면 의도하지 않은 데이터가 나옵니다. 입력은 스키마로 검증한 뒤 명시적으로 매핑해야 합니다.
const schema = z.object({
status: z.enum(['pending', 'paid', 'refunded']).optional(),
minAmount: z.coerce.number().min(0).optional(),
})
const filter = schema.parse(req.body)
바인딩할 수 없는 것들 — 식별자, ORDER BY, IN, LIKE
여기가 실무에서 가장 자주 막히는 지점입니다. 바인딩은 값에만 됩니다. 구문 구조를 결정하는 자리에는 자리표시자를 쓸 수 없습니다.
-- 동작하지 않는다. 컬럼명이 문자열 리터럴로 해석된다
SELECT * FROM orders ORDER BY $1;
ERROR: cannot use column reference in ORDER BY with a parameter
정렬 컬럼을 사용자가 고르게 하려면 허용 목록 방식밖에 없습니다. 입력을 검사하는 것이 아니라, 입력을 미리 정해 둔 안전한 SQL 조각으로 치환하는 것입니다.
SORT_COLUMNS = {
"created": "o.created_at",
"amount": "o.total_amount",
"status": "o.status",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
def build_order_by(sort_key: str, direction: str) -> str:
column = SORT_COLUMNS.get(sort_key, "o.created_at")
order = SORT_DIRECTIONS.get(direction.lower(), "DESC")
return f"ORDER BY {column} {order}"
sql = f"SELECT o.id, o.total_amount FROM orders o WHERE o.tenant_id = %s {build_order_by(sort, dir)} LIMIT %s"
cur.execute(sql, (tenant_id, limit))
SORT_COLUMNS에 없는 입력은 조용히 기본값으로 떨어집니다. 사용자 입력이 SQL에 들어가는 것이 아니라 딕셔너리의 키로만 쓰이므로, 어떤 문자열이 와도 결과는 세 개의 안전한 값 중 하나입니다.
동적으로 테이블이나 컬럼 이름을 써야 한다면 드라이버의 식별자 인용 API를 씁니다. 문자열로 감싸지 마십시오.
from psycopg import sql
cur.execute(
sql.SQL("SELECT * FROM {} WHERE tenant_id = %s").format(sql.Identifier(table_name)),
(tenant_id,),
)
PostgreSQL 함수 안에서 동적 SQL을 만들 때도 같은 원칙이 있습니다.
-- %I는 식별자로, %L은 리터럴로 안전하게 인용한다
EXECUTE format('REFRESH MATERIALIZED VIEW %I', target_view);
EXECUTE format('SELECT count(*) FROM orders WHERE status = %L', status_value);
IN 목록은 자리표시자 개수가 가변이라 헷갈립니다. 두 가지 정공법이 있습니다.
# 방법 1: 자리표시자를 개수만큼 생성한다 (값은 여전히 바인딩된다)
placeholders = ", ".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM orders WHERE id IN ({placeholders})", tuple(ids))
# 방법 2: 배열 하나를 바인딩한다 (PostgreSQL)
cur.execute("SELECT * FROM orders WHERE id = ANY(%s)", (list(ids),))
방법 2가 더 낫습니다. 자리표시자 개수가 바뀌지 않으므로 실행 계획 캐시가 재사용되고, 목록 길이에 상한을 두기도 쉽습니다.
LIKE 절은 인젝션과는 다른 함정이 있습니다. 바인딩을 제대로 해도 사용자가 넣은 %와 _가 와일드카드로 동작합니다.
# 바인딩은 됐지만 사용자가 "%"만 입력하면 전체 행이 반환된다
cur.execute("SELECT * FROM users WHERE name LIKE %s", (f"%{q}%",))
이것은 보안 사고가 되기도 합니다. 전체 테이블 스캔을 유발해 서비스 거부로 이어지거나, 의도한 검색 범위를 넘겨 다른 데이터를 노출합니다. 와일드카드를 이스케이프해야 합니다.
def like_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
cur.execute(
r"SELECT * FROM users WHERE name LIKE %s ESCAPE '\'",
(f"%{like_escape(q)}%",),
)
정리하면 인젝션 표면은 자리마다 다르게 다뤄야 합니다.
| 자리 | 바인딩 가능 | 올바른 처리 | 흔한 오답 |
|---|---|---|---|
| WHERE 절의 값 | 가능 | 자리표시자로 바인딩 | 이스케이프 후 문자열 연결 |
| IN 목록 | 가능 | 배열 바인딩 또는 자리표시자 생성 | 콤마로 join한 문자열 삽입 |
| LIKE 패턴 | 가능 | 바인딩 + 와일드카드 이스케이프 + ESCAPE 절 | 바인딩만 하고 퍼센트 기호 방치 |
| LIMIT, OFFSET | 가능 | 정수 변환 후 바인딩 | 숫자니까 안전하다고 그대로 연결 |
| ORDER BY 컬럼 | 불가 | 허용 목록으로 안전한 SQL 조각에 매핑 | 정규식으로 특수문자 필터링 |
| 정렬 방향 | 불가 | 두 값짜리 매핑 테이블 | 문자열을 대문자로 바꿔 그대로 삽입 |
| 테이블, 스키마, 컬럼명 | 불가 | 식별자 인용 API 또는 허용 목록 | 큰따옴표로 감싸기 |
| 저장 후 재사용되는 값 | 상황에 따라 | 사용 시점에도 다시 바인딩 | 입력 시점 검증만 신뢰 |
| 몽고DB 필터 객체 | 해당 없음 | 스키마 검증 후 타입 고정 | 요청 본문을 그대로 전달 |
2차 인젝션과 NoSQL 인젝션 — 같은 원리, 다른 표면
입력 검증만으로는 잡히지 않는 유형이 2차 인젝션입니다. 저장은 안전하게 했는데 나중에 다른 코드가 그 값을 문자열로 조립하는 경우입니다.
# 가입 시점: 바인딩으로 안전하게 저장한다
cur.execute("INSERT INTO users (username, email) VALUES (%s, %s)", (username, email))
username에 report'; DROP TABLE audit_log; -- 같은 값이 들어가도 이 시점에는 아무 일도 없습니다. 그냥 문자열입니다. 문제는 며칠 뒤 실행되는 배치입니다.
# 야간 배치: 사용자별 뷰를 만든다
for row in cur.execute("SELECT username FROM users").fetchall():
name = row[0]
cur.execute(f"CREATE OR REPLACE VIEW report_{name} AS SELECT * FROM orders WHERE owner = '{name}'")
여기서 터집니다. 데이터베이스에서 읽은 값이라고 신뢰한 것이 원인입니다. 2차 인젝션이 위험한 이유는 공격 흐름이 두 코드베이스에 걸쳐 있어 리뷰에서 보이지 않고, 로그를 봐도 배치 잡이 자기 스스로 이상한 SQL을 실행한 것처럼 보인다는 점입니다.
원칙은 간단합니다. 값의 출처가 어디든, SQL에 넣을 때는 항상 바인딩합니다. "우리 DB에서 온 값"이라는 이유는 신뢰 근거가 되지 못합니다. 리포트 뷰 이름처럼 식별자로 써야 한다면 사용자 문자열이 아니라 사용자 ID 같은 내부 정수를 씁니다.
NoSQL도 원리가 같습니다. 문법이 SQL이 아닐 뿐, 사용자 입력이 질의 구조를 바꿀 수 있다는 점은 동일합니다.
// 취약: 요청 본문의 값이 그대로 필터가 된다
app.post('/login', async (req, res) => {
const user = await db.collection('users').findOne({
email: req.body.email,
password: req.body.password,
})
if (user) return res.json({ token: issueToken(user) })
return res.status(401).json({ error: 'invalid credentials' })
})
JSON 본문은 문자열만 담지 않습니다. 객체를 담을 수 있습니다.
curl -X POST https://api.example.com/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":{"$ne":null}}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
비밀번호를 몰라도 로그인됩니다. 드라이버는 값이 아니라 연산자 객체를 받았고, 그것을 질의 연산자로 해석했습니다. 폼 인코딩 요청에서도 대괄호 표기로 중첩 객체를 만들 수 있어 같은 일이 벌어집니다.
방어는 타입을 고정하는 것입니다.
import { z } from 'zod'
const LoginBody = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(200),
})
app.post('/login', async (req, res) => {
const parsed = LoginBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'invalid payload' })
const { email, password } = parsed.data
const user = await db.collection('users').findOne({ email })
if (!user || !(await argon2.verify(user.passwordHash, password))) {
return res.status(401).json({ error: 'invalid credentials' })
}
return res.json({ token: issueToken(user) })
})
같은 이유로 서버 측 자바스크립트 평가 기능은 꺼 두는 것이 좋습니다. 몽고DB의 where 연산자나 집계 파이프라인의 함수 실행은 입력이 코드가 되는 전형적인 경로입니다.
방어 심층화 — 최소 권한 계정과 탐지
바인딩을 철저히 해도 코드베이스 어딘가에 빠진 곳이 있다고 가정해야 합니다. 그때 피해 범위를 결정하는 것이 데이터베이스 계정 권한입니다.
애플리케이션 계정이 소유자 권한이나 슈퍼유저면 인젝션 한 건이 전체 스키마 삭제로 이어집니다. 반대로 필요한 테이블에 대한 DML 권한만 있으면 같은 인젝션이 데이터 조회에 그칩니다.
-- 애플리케이션 전용 롤: DDL 없음, 소유권 없음
CREATE ROLE app_rw LOGIN PASSWORD 'set-by-secret-manager';
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA app TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_rw;
-- 폭주하는 질의를 끊는다
ALTER ROLE app_rw SET statement_timeout = '5s';
ALTER ROLE app_rw SET idle_in_transaction_session_timeout = '30s';
리포트나 관리 화면처럼 읽기만 하는 경로는 계정을 분리합니다.
CREATE ROLE app_ro LOGIN PASSWORD 'set-by-secret-manager';
GRANT USAGE ON SCHEMA app TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;
권한을 확인하는 습관도 필요합니다.
SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privs
FROM information_schema.role_table_grants
WHERE grantee IN ('app_rw', 'app_ro')
GROUP BY grantee, table_name
ORDER BY grantee, table_name;
grantee | table_name | privs
---------+------------+--------------------------------
app_ro | orders | SELECT
app_rw | orders | DELETE, INSERT, SELECT, UPDATE
app_rw | users | DELETE, INSERT, SELECT, UPDATE
탐지는 정적 분석이 가장 비용 대비 효과가 좋습니다. 문자열 조립이 질의 실행 함수로 흘러가는 경로를 데이터 흐름으로 추적합니다.
semgrep --config 'p/sql-injection' --config 'p/nosql-injection' src/
src/reports/views.py
88┆ qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
⚠ python.django.security.injection.sql.sql-injection-extra
User-controlled data flows into a raw SQL clause.
src/api/search.js
34┆ knex.raw(`SELECT * FROM orders WHERE status = '${status}'`)
⚠ javascript.knex.security.knex-raw-sql-injection
CI에서 새로 추가된 위반만 실패시키면 기존 코드에 발이 묶이지 않습니다.
semgrep ci --config 'p/sql-injection' --baseline-commit "$(git merge-base origin/main HEAD)"
마지막으로, 웹 방화벽에 대해 한 가지만 정정합니다. WAF는 UNION SELECT 같은 알려진 패턴을 막지만 우회 기법이 계속 나오고, 정상 입력을 차단하는 오탐도 만듭니다. 수정 배포 전까지의 시간을 버는 완화 장치이지 대책이 아닙니다. WAF 규칙을 추가하고 티켓을 닫는 것이 가장 위험한 마무리입니다.
마치며 — execute 호출의 두 번째 인자를 보십시오
이 글의 내용은 코드 리뷰 규칙 세 줄로 압축됩니다.
첫째, 질의 실행 함수에 값이 별도 인자로 전달되는지 봅니다. f-string, 템플릿 리터럴, 문자열 덧셈, 퍼센트 포매팅이 질의 문자열 안에 있으면 그 자리가 취약점입니다. ORM을 쓰고 있다는 사실은 이 검사를 면제해 주지 않습니다.
둘째, 바인딩할 수 없는 자리는 허용 목록으로만 처리합니다. ORDER BY 컬럼, 정렬 방향, 테이블명은 검증이 아니라 매핑입니다. 사용자 입력이 SQL에 들어가는 것이 아니라 안전한 값을 고르는 키로만 쓰이게 만듭니다.
셋째, 값의 출처를 신뢰 근거로 삼지 않습니다. 데이터베이스에서 읽은 값도, 내부 서비스가 준 값도 SQL에 넣을 때는 똑같이 바인딩합니다. 2차 인젝션은 이 원칙 하나로 사라집니다.
그리고 이 모든 것이 실패했을 때를 대비해 애플리케이션 계정에서 DDL 권한을 걷어 두십시오. 인젝션이 발생했을 때 사고 보고서에 적힐 내용이 "일부 테이블 조회"인지 "스키마 삭제"인지는 코드가 아니라 GRANT 문이 결정합니다.
SQL Injection and Parameter Binding — The Places That Still Break When You Use an ORM
- Introduction — If You Are Looking for an Escaping Function, You Are Headed the Wrong Way
- Why Binding Rather Than Escaping — Separation at the Parser Level
- How a Prepared Statement Is Actually Handled on the Server
- Three Places That Still Break Under an ORM
- The Things That Cannot Be Bound — Identifiers, ORDER BY, IN, LIKE
- Second-Order Injection and NoSQL Injection — Same Principle, Different Surface
- Defense in Depth — Least Privilege Accounts and Detection
- Wrapping Up — Look at the Second Argument of the execute Call
Introduction — If You Are Looking for an Escaping Function, You Are Headed the Wrong Way
The fix most often suggested in code reviews about SQL injection takes this shape. "Escape the quotes," "filter the special characters," "double up the single quotes." Escaping functions and blacklist regexes still show up at the top of search results.
This approach is a losing fight in principle. Escaping tries to make a value safe inside a string literal, and cases where that premise breaks keep coming up. The following code escapes correctly and is still exploitable.
# In a numeric context there is no quote to escape
order_id = request.args["id"] # "1 OR 1=1"
cur.execute("SELECT * FROM orders WHERE id = " + escape(order_id))
SELECT * FROM orders WHERE id = 1 OR 1=1
An escaping function deals with quotes and backslashes, but where a value goes in without quotes it has nothing to do. The same problem arises in a LIMIT clause, a sort direction, a boolean comparison, and in situations where the character set is mismatched.
The right answer lives at a different layer. Instead of turning the value into a safe string, keep the value out of the SQL text altogether. This post covers how that structure actually works, and where the common belief that using an ORM solves it automatically falls apart.
Why Binding Rather Than Escaping — Separation at the Parser Level
A database processes a query in roughly three stages: parsing, planning, and execution. Injection is a problem of the parsing stage. Once user input becomes part of the SQL text, the parser interprets it as a grammatical element. The parser has no information saying "this part came from a user."
Binding reverses this order. First the SQL containing placeholders goes to the parser and the syntax tree is fixed; the values are delivered afterward in a separate message. By the time the values arrive, the grammar is already decided, so whatever is inside a value cannot change the structure.
Put the vulnerable code and the safe code side by side and the difference is obvious.
import psycopg
email = request.args["email"]
# Vulnerable: the value becomes SQL text
with conn.cursor() as cur:
cur.execute(f"SELECT id, email, role FROM users WHERE email = '{email}'")
# Safe: the value travels on a separate channel
with conn.cursor() as cur:
cur.execute("SELECT id, email, role FROM users WHERE email = %s", (email,))
One thing here absolutely must be pointed out: what %s really is. It is not Python string formatting but the placeholder of the DB-API driver. Which means the following two lines are completely different code.
cur.execute("SELECT * FROM users WHERE email = %s", (email,)) # binding
cur.execute("SELECT * FROM users WHERE email = %s" % email) # string formatting, vulnerable
In the second line Python completes the string first, so only already assembled SQL reaches the driver. A single character of difference makes it a vulnerability. Checking whether the execute call has a second argument is the fastest test in a code review.
JavaScript is the same.
// Vulnerable: assembled with a template literal
const { rows } = await pool.query(
`SELECT id, email FROM users WHERE email = '` + req.query.email + `'`
)
// Safe: placeholder plus an array of values
const { rows } = await pool.query('SELECT id, email FROM users WHERE email = $1', [
req.query.email,
])
Why binding beats escaping, in one sentence: escaping makes the developer responsible for proving that a value is safe, whereas binding removes the very path by which a value can become code. The former requires you to consider every possible input; the latter leaves nothing to consider.
How a Prepared Statement Is Actually Handled on the Server
Let us confirm that binding really does separate the value. In PostgreSQL the extended query protocol is split into three stages: Parse, Bind, Execute. You can reproduce it explicitly.
PREPARE user_by_email (text) AS
SELECT id, email, role FROM users WHERE email = $1;
EXECUTE user_by_email ('alice@example.com');
Now put the attack string straight in.
EXECUTE user_by_email ('nobody@example.com'' OR ''1''=''1');
id | email | role
----+-------+------
(0 rows)
No rows come back. OR 1=1 was not interpreted as a condition but treated as part of the email value. Turn on server logging and you see exactly what happened.
psql -c "ALTER SYSTEM SET log_statement = 'all'" -c "SELECT pg_reload_conf()"
tail -f /var/log/postgresql/postgresql-16-main.log
LOG: execute user_by_email: SELECT id, email, role FROM users WHERE email = $1
DETAIL: parameters: $1 = 'nobody@example.com'' OR ''1''=''1'
The query text and the parameters are recorded on separate lines. This is the essence of binding. The SQL text the server received contains no user input.
There is one trap here that is often missed. Not every driver actually uses server-side prepare. Some drivers escape the values on the client side, assemble the SQL, and send it as a simple query. This is called emulation.
// The PHP PDO default may be emulation depending on the driver
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false, // force server-side prepare
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
# MySQL JDBC: you have to say so explicitly to get server-side prepare
jdbc:mysql://db:3306/app?useServerPrepStmts=true&cachePrepStmts=true
Emulation is not automatically a vulnerability. If the implementation is correct it is safe. But the basis of that safety drops from "the parser separated it" down to "the escaping in the driver is correct." The known techniques for bypassing escaping under the GBK family of character sets in older MySQL were a problem at exactly this layer. If you have the choice, turning on server-side prepare is better.
Three Places That Still Break Under an ORM
"We use an ORM, so we do not have to think about injection" is only half right. Values that pass through the query builder API of an ORM are bound, but every ORM leaves open a door down to raw SQL. And production code uses that door often.
The first place is raw queries.
from sqlalchemy import text
# Vulnerable: text() turns the string straight into SQL
result = session.execute(
text(f"SELECT * FROM orders WHERE status = '{status}' ORDER BY created_at DESC")
)
# Safe: text() has binding placeholders too
result = session.execute(
text("SELECT * FROM orders WHERE status = :status ORDER BY created_at DESC"),
{"status": status},
)
The mere fact of using text() is not the problem. Using an f-string inside it is. This distinction becomes the central criterion in a code review.
Django is the same.
# Vulnerable
User.objects.raw("SELECT * FROM auth_user WHERE username = '%s'" % username)
User.objects.extra(where=[f"last_login > '{since}'"])
# Safe
User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
User.objects.filter(last_login__gt=since)
The Django extra() API is one the documentation itself discourages. Search the codebase for extra(, RawSQL( and .raw( and you usually turn up a few spots.
rg -n --type py '\.extra\(|RawSQL\(|\.raw\(' src/
src/reports/views.py:88: qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
src/admin/search.py:41: rows = User.objects.raw("SELECT * FROM auth_user WHERE email LIKE '%%%s%%'" % q)
The second place is the pattern of assembling the condition clause as a string. It is especially common on screens with several search filters.
// Vulnerable: using knex but gluing the conditions on as strings
let query = knex('orders')
if (req.query.status) {
query = query.whereRaw(`status = '${req.query.status}'`)
}
if (req.query.minAmount) {
query = query.whereRaw(`total_amount >= ${req.query.minAmount}`)
}
// Safe: the builder API, or the binding argument of whereRaw
let query = knex('orders')
if (req.query.status) {
query = query.where('status', req.query.status)
}
if (req.query.minAmount) {
query = query.whereRaw('total_amount >= ?', [Number(req.query.minAmount)])
}
Sequelize is no different. With sequelize.query you always use replacements or bind.
// Safe
const rows = await sequelize.query(
'SELECT id, email FROM users WHERE tenant_id = :tenantId AND email = :email',
{ replacements: { tenantId, email }, type: QueryTypes.SELECT }
)
The third place is the pattern of filling the ORM filter conditions directly from client input. Grammatically this is not injection, but the result is the same.
// Vulnerable: the request body becomes the where clause verbatim
const users = await User.findAll({ where: req.body.filter })
If the client puts another tenant condition or an operator into filter, unintended data comes out. Input has to be validated against a schema and then mapped explicitly.
const schema = z.object({
status: z.enum(['pending', 'paid', 'refunded']).optional(),
minAmount: z.coerce.number().min(0).optional(),
})
const filter = schema.parse(req.body)
The Things That Cannot Be Bound — Identifiers, ORDER BY, IN, LIKE
This is where people get stuck most often in practice. Binding only works for values. You cannot use a placeholder in a spot that determines the syntactic structure.
-- Does not work. The column name is interpreted as a string literal
SELECT * FROM orders ORDER BY $1;
ERROR: cannot use column reference in ORDER BY with a parameter
To let users choose the sort column, an allowlist is the only way. You are not inspecting the input; you are substituting the input with a safe SQL fragment decided in advance.
SORT_COLUMNS = {
"created": "o.created_at",
"amount": "o.total_amount",
"status": "o.status",
}
SORT_DIRECTIONS = {"asc": "ASC", "desc": "DESC"}
def build_order_by(sort_key: str, direction: str) -> str:
column = SORT_COLUMNS.get(sort_key, "o.created_at")
order = SORT_DIRECTIONS.get(direction.lower(), "DESC")
return f"ORDER BY {column} {order}"
sql = f"SELECT o.id, o.total_amount FROM orders o WHERE o.tenant_id = %s {build_order_by(sort, dir)} LIMIT %s"
cur.execute(sql, (tenant_id, limit))
Input that is not in SORT_COLUMNS quietly falls back to the default. User input never enters the SQL; it is used only as a dictionary key, so whatever string arrives, the result is one of three safe values.
If you have to use table or column names dynamically, use the identifier quoting API of the driver. Do not wrap them as strings.
from psycopg import sql
cur.execute(
sql.SQL("SELECT * FROM {} WHERE tenant_id = %s").format(sql.Identifier(table_name)),
(tenant_id,),
)
The same principle applies when you build dynamic SQL inside a PostgreSQL function.
-- %I quotes safely as an identifier, %L as a literal
EXECUTE format('REFRESH MATERIALIZED VIEW %I', target_view);
EXECUTE format('SELECT count(*) FROM orders WHERE status = %L', status_value);
IN lists are confusing because the number of placeholders varies. There are two straightforward approaches.
# Approach 1: generate as many placeholders as there are values (the values are still bound)
placeholders = ", ".join(["%s"] * len(ids))
cur.execute(f"SELECT * FROM orders WHERE id IN ({placeholders})", tuple(ids))
# Approach 2: bind a single array (PostgreSQL)
cur.execute("SELECT * FROM orders WHERE id = ANY(%s)", (list(ids),))
Approach 2 is better. The number of placeholders never changes, so the execution plan cache is reused, and it is easy to put an upper bound on the list length.
The LIKE clause has a trap of a different kind from injection. Even with correct binding, a % or _ supplied by the user acts as a wildcard.
# Bound correctly, but if the user enters just "%" every row is returned
cur.execute("SELECT * FROM users WHERE name LIKE %s", (f"%{q}%",))
This does become a security incident. It triggers a full table scan leading to denial of service, or it exceeds the intended search scope and exposes other data. The wildcards have to be escaped.
def like_escape(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
cur.execute(
r"SELECT * FROM users WHERE name LIKE %s ESCAPE '\'",
(f"%{like_escape(q)}%",),
)
To summarize, the injection surface has to be handled differently in each position.
| Position | Bindable | Correct handling | Common wrong answer |
|---|---|---|---|
| Value in a WHERE clause | Yes | Bind with a placeholder | String concatenation after escaping |
| IN list | Yes | Bind an array, or generate placeholders | Insert a comma-joined string |
| LIKE pattern | Yes | Bind plus wildcard escaping plus an ESCAPE clause | Bind only and leave the percent sign alone |
| LIMIT, OFFSET | Yes | Convert to an integer, then bind | Concatenate because it is a number |
| ORDER BY column | No | Map through an allowlist to a safe SQL fragment | Filter special characters with a regex |
| Sort direction | No | A two-value mapping table | Uppercase the string and insert it as is |
| Table, schema, column name | No | Identifier quoting API or an allowlist | Wrap it in double quotes |
| Value stored and reused later | It depends | Bind again at the point of use | Trust validation at input time only |
| MongoDB filter object | Not applicable | Fix the type after schema validation | Pass the request body straight through |
Second-Order Injection and NoSQL Injection — Same Principle, Different Surface
The type that input validation cannot catch is second-order injection. The value was stored safely, but later some other code assembles it into a string.
# At signup: stored safely via binding
cur.execute("INSERT INTO users (username, email) VALUES (%s, %s)", (username, email))
Even if username contains something like report'; DROP TABLE audit_log; --, nothing happens at this point. It is just a string. The problem is the batch job that runs a few days later.
# Nightly batch: create a view per user
for row in cur.execute("SELECT username FROM users").fetchall():
name = row[0]
cur.execute(f"CREATE OR REPLACE VIEW report_{name} AS SELECT * FROM orders WHERE owner = '{name}'")
This is where it detonates. The cause is trusting a value because it was read from the database. Second-order injection is dangerous because the attack flow spans two codebases, so it is invisible in review, and because in the logs it looks as though the batch job executed strange SQL all by itself.
The principle is simple. Wherever the value came from, always bind it when it goes into SQL. "It came from our own database" is not grounds for trust. If it has to be used as an identifier, like a report view name, use an internal integer such as the user ID rather than a user-supplied string.
NoSQL follows the same principle. Only the syntax is not SQL; the fact that user input can change the query structure is identical.
// Vulnerable: the value from the request body becomes the filter verbatim
app.post('/login', async (req, res) => {
const user = await db.collection('users').findOne({
email: req.body.email,
password: req.body.password,
})
if (user) return res.json({ token: issueToken(user) })
return res.status(401).json({ error: 'invalid credentials' })
})
A JSON body does not carry only strings. It can carry objects.
curl -X POST https://api.example.com/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":{"$ne":null}}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
Login succeeds without knowing the password. The driver received an operator object rather than a value, and interpreted it as a query operator. In form-encoded requests the bracket notation can also build a nested object, so the same thing happens.
The defense is to fix the type.
import { z } from 'zod'
const LoginBody = z.object({
email: z.string().email().max(254),
password: z.string().min(8).max(200),
})
app.post('/login', async (req, res) => {
const parsed = LoginBody.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'invalid payload' })
const { email, password } = parsed.data
const user = await db.collection('users').findOne({ email })
if (!user || !(await argon2.verify(user.passwordHash, password))) {
return res.status(401).json({ error: 'invalid credentials' })
}
return res.json({ token: issueToken(user) })
})
For the same reason it is better to turn off server-side JavaScript evaluation features. The MongoDB where operator and function execution in the aggregation pipeline are textbook paths by which input becomes code.
Defense in Depth — Least Privilege Accounts and Detection
Even with binding applied rigorously, you have to assume there is a spot somewhere in the codebase that was missed. What determines the blast radius at that moment is the database account privileges.
If the application account is the owner or a superuser, a single injection leads to the whole schema being dropped. Conversely, if it only has DML privileges on the tables it needs, the same injection ends at reading data.
-- Application-only role: no DDL, no ownership
CREATE ROLE app_rw LOGIN PASSWORD 'set-by-secret-manager';
REVOKE ALL ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA app TO app_rw;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA app TO app_rw;
-- Cut off runaway queries
ALTER ROLE app_rw SET statement_timeout = '5s';
ALTER ROLE app_rw SET idle_in_transaction_session_timeout = '30s';
For read-only paths such as reports or admin screens, separate the account.
CREATE ROLE app_ro LOGIN PASSWORD 'set-by-secret-manager';
GRANT USAGE ON SCHEMA app TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;
You also need the habit of checking the privileges.
SELECT grantee, table_name, string_agg(privilege_type, ', ' ORDER BY privilege_type) AS privs
FROM information_schema.role_table_grants
WHERE grantee IN ('app_rw', 'app_ro')
GROUP BY grantee, table_name
ORDER BY grantee, table_name;
grantee | table_name | privs
---------+------------+--------------------------------
app_ro | orders | SELECT
app_rw | orders | DELETE, INSERT, SELECT, UPDATE
app_rw | users | DELETE, INSERT, SELECT, UPDATE
For detection, static analysis has the best cost-benefit ratio. It traces, as a data flow, the path by which string assembly reaches a query execution function.
semgrep --config 'p/sql-injection' --config 'p/nosql-injection' src/
src/reports/views.py
88┆ qs = Order.objects.extra(where=[f"total_amount > {threshold}"])
⚠ python.django.security.injection.sql.sql-injection-extra
User-controlled data flows into a raw SQL clause.
src/api/search.js
34┆ knex.raw(`SELECT * FROM orders WHERE status = '${status}'`)
⚠ javascript.knex.security.knex-raw-sql-injection
If you fail CI only on newly added violations, you are not held hostage by the existing code.
semgrep ci --config 'p/sql-injection' --baseline-commit "$(git merge-base origin/main HEAD)"
Finally, one correction about web firewalls. A WAF blocks known patterns such as UNION SELECT, but bypass techniques keep appearing and it also produces false positives that block legitimate input. It is a mitigation that buys time until the fix ships, not a countermeasure. Adding a WAF rule and closing the ticket is the most dangerous way to finish.
Wrapping Up — Look at the Second Argument of the execute Call
The content of this post compresses into three lines of code review rules.
First, check whether values are passed to the query execution function as a separate argument. If an f-string, a template literal, string addition or percent formatting appears inside the query string, that spot is a vulnerability. The fact that you are using an ORM does not exempt you from this check.
Second, handle positions that cannot be bound with an allowlist only. The ORDER BY column, the sort direction and the table name are a mapping, not a validation. Make user input serve solely as a key for selecting a safe value, never as something that enters the SQL.
Third, do not treat the origin of a value as grounds for trust. A value read from the database and a value handed over by an internal service both get bound the same way when they go into SQL. Second-order injection disappears with this one principle.
And in case all of that fails, strip DDL privileges from the application account. Whether the incident report says "some tables were read" or "the schema was dropped" is decided not by the code but by the GRANT statements.