Skip to content

필사 모드: SQL 인젝션과 파라미터 바인딩 — ORM을 써도 뚫리는 지점들

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — 이스케이프 함수를 찾고 있다면 방향이 틀렸습니다

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))

usernamereport'; 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 문이 결정합니다.

현재 단락 (1/241)

SQL 인젝션 관련 코드 리뷰에서 가장 자주 보이는 수정 제안은 이런 형태입니다. "따옴표를 이스케이프하세요", "특수문자를 필터링하세요", "작은따옴표를 두 번 겹치세요". 검색...

작성 글자: 0원문 글자: 11,801작성 단락: 0/241