본문으로 건너뛰기
yutils

SQL injection 은 어떻게 동작할까?

UNION 기반 vs blind vs time-based injection, 왜 문자열 결합은 항상 지나, prepared statement 가 wire level 에서 실제 동작, ORM 이 완전 안전 아닌 이유, 저장된 데이터로 인한 second-order injection.

약 9분 읽기

1998 년부터 OWASP Top 10 단골. 그런데 25 년 지나도 새 시스템에서 발견. 이유 — string concatenation 의 유혹이 너무 자연스러움. 이 가이드는 SQL injection 의 3 type (UNION / blind / time-based), prepared statement 가 wire level 에서 실제 어떻게 막는지, ORM 도 완전 안전 아닌 이유를 정리한다.

Classic Injection — UNION-based

// 취약 코드
const sql = "SELECT * FROM users WHERE id = " + userInput;
db.query(sql);

input: 1
SQL: SELECT * FROM users WHERE id = 1   ← OK

input: 1 OR 1=1
SQL: SELECT * FROM users WHERE id = 1 OR 1=1   ← 모든 user!

input: 1 UNION SELECT username, password FROM admin
SQL: SELECT * FROM users WHERE id = 1 UNION SELECT username, password FROM admin
     ← admin 의 데이터를 user 데이터인 척 반환

input: 1; DROP TABLE users; --
SQL: SELECT * FROM users WHERE id = 1; DROP TABLE users; --
     ← DB 파괴 (multi-statement 허용 시)

Blind Injection — error 안 보여도 가능

현대 application 은 SQL error 를 사용자에 안 보임.
그래도 가능 — true/false 응답만으로 데이터 한 비트씩 추출.

// 취약 코드
const sql = "SELECT * FROM users WHERE id = " + userInput;
const found = db.query(sql).length > 0;
return found ? "exists" : "not found";

공격:
input: 1 AND substring((SELECT password FROM admin), 1, 1) = 'a'
→ admin 의 password 첫 글자가 'a' 면 "exists" 반환

자동화:
  for char in [a, b, c, ..., z, 0-9]:
    if response == "exists": 첫 글자 = char
  → 그 다음 글자, 그 다음 ... 전체 password 추출 (수분 내)

Time-based Blind — 응답 X, 응답 시간만

true/false 응답조차 동일 (둘 다 "OK").
그래도 공격 가능 — 응답 시간으로 한 비트씩.

input: 1; IF (SELECT password FROM admin WHERE id=1) LIKE 'a%' WAITFOR DELAY '0:0:5'

→ 응답이 5초 걸리면 → 첫 글자 'a'
→ 즉시 응답 → 'a' 아님

자동화 가능. application 의 모든 응답이 "200 OK" 여도 추출 가능.

Second-Order Injection — 저장된 데이터로

악성 input 이 처음에 저장만 (실행 X). 나중에 다른 query 에서 사용 → 실행.

Step 1: 회원가입
  username = "admin' --"
  → DB 에 그대로 저장 (단순 INSERT, escape 됐다고 가정)

Step 2: 사용자가 자기 정보 수정 (이름 → SQL 에 다시 들어감)
  sql = "UPDATE users SET active=1 WHERE name='" + name + "'"
  →     "UPDATE users SET active=1 WHERE name='admin' --'"
                                                    ↑↑↑↑↑
                                                    주석 처리, 모두 영향

→ 첫 escape 가 OK 였어도, 저장 후 재사용 시점에 다시 escape 없으면 폭발.

Prepared Statement — wire level 에서 어떻게 막는가

// 안전 코드
const sql = "SELECT * FROM users WHERE id = ?";
db.query(sql, [userInput]);

→ wire protocol 에서 2 메시지로 분리:

1. Prepare:
   Server <- "PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?'"
   Server -> "Statement ID: 42, expects 1 parameter"

2. Execute:
   Server <- "EXECUTE 42 WITH params: [user_input_as_bytes]"
   Server -> result rows

핵심:
- 1단계의 SQL string 에 user input 없음 — 미리 parsed
- 2단계의 parameter 는 별도 binary slot 으로 전달
- DB 가 그 byte 들을 절대 SQL 로 parse 안 함 → 그냥 value

→ user input 이 무엇이든 (1 OR 1=1, ', DROP TABLE 등) "value" 로 처리.
   SQL injection 구조적으로 불가능.

ORM 도 완전 안전 아니다

// Sequelize (Node.js) - 안전
User.findOne({where: {id: userInput}});  // ✓ parameterized

// Sequelize - 위험
const sql = "SELECT * FROM users WHERE id = " + userInput;
sequelize.query(sql);  // ❌ raw query, 직접 inject 위험

// Active Record (Rails) - 안전
User.where(name: params[:name])  // ✓

// Active Record - 위험
User.where("name = '#{params[:name]}'")  // ❌ string interp
User.find_by_sql("SELECT * WHERE id = #{params[:id]}")  // ❌

→ ORM 의 raw query / native SQL 옵션은 escape hatch — 사용 시 manual check.

JS template literal (`SELECT * WHERE id = ${id}`) 도 같은 함정.

NoSQL injection — 같은 원리 다른 syntax

MongoDB:
  db.users.find({username: req.body.username, password: req.body.password});

공격:
  POST {username: "admin", password: {"$ne": null}}
  → password = "$ne null" 의미 → "null 이 아닌 어떤 값" 매칭 → 로그인 통과

해결:
- input type validate (string 만 허용, object 거부)
- ORM/ODM 의 typed query (Mongoose schema)
- mongo-sanitize 같은 라이브러리

관련 도구

  • SQL 포매터 — SQL 의 정확한 구조 확인 (parser 가 어떻게 해석하는지)

흔한 함정

  • escape 함수 직접 작성 — quote escape 만으로 못 막음 (UNION, comment, multi-statement 등). prepared statement 만이 구조적 방어.
  • integer 라 escape 불필요 가정 — "id 는 숫자 니까 안전" — input 이 실제 문자열이면 폭발. 항상 parameterize.
  • ORM 의 raw query 사용 — 위 예시. ORM 신뢰가 모든 query 에 transitive X.
  • second-order 무시 — 첫 escape 만 보고 안전 가정. 어디서든 user data 가 SQL 에 들어가면 parameterize.
  • DB user 의 권한 과다 — application user 가 DROP TABLE 권한 가지면 SQL injection 시 catastrophic. principle of least privilege (read user / write user 분리).

마무리

SQL injection 은 1998 년부터 알려진 공격이지만 매년 새 시스템에서 발견 — string concatenation 의 유혹이 너무 자연스러움. 방어는 단 하나: 모든 user data 를 prepared statement parameter 로.

실용 — ORM 사용 시 default API 만 (raw query 회피), template literal 로 SQL 짓지 마라, DB user 권한 최소화, application 에서는 SQL error 메시지 노출 X (정보 leak).

가이드 목록으로