🔒보안

SQL Injection

SQL 인젝션

악의적인 SQL 코드를 삽입하여 데이터베이스를 조작하는 공격입니다. 데이터 유출, 삭제, 인증 우회, 서버 장악까지 가능하며 Prepared Statement(파라미터화 쿼리)로 방어합니다.

📖 상세 설명

SQL Injection(SQLi)은 웹 애플리케이션이 사용자 입력을 SQL 쿼리에 직접 삽입할 때 발생하는 보안 취약점입니다. 공격자는 입력 필드(로그인 폼, 검색창 등)에 악의적인 SQL 코드를 주입하여 의도치 않은 쿼리를 실행시킵니다. 1998년 처음 보고된 이후 지금까지 OWASP Top 10에서 상위권을 차지하는 가장 위험한 웹 취약점 중 하나입니다.

SQL Injection의 대표적인 유형으로는 In-band SQLi, Blind SQLi, Out-of-band SQLi가 있습니다. In-band SQLi는 결과가 직접 화면에 표시되는 가장 기본적인 형태입니다. Union-based는 UNION 연산자로 추가 데이터를 추출하고, Error-based는 에러 메시지에서 정보를 추출합니다. Blind SQLi는 결과가 보이지 않지만, 참/거짓 응답 차이(Boolean-based)나 응답 시간(Time-based)으로 데이터를 유추합니다.

공격이 성공하면 피해가 심각합니다. 인증 우회로 관리자 계정에 접근하거나, 전체 데이터베이스 내용을 추출(Data Exfiltration)할 수 있습니다. DROP TABLE로 데이터를 삭제하거나, xp_cmdshell(MS SQL) 같은 기능으로 운영체제 명령어를 실행하여 서버를 장악할 수도 있습니다. 2017년 Equifax 해킹 사건에서 1억 4천만 명의 개인정보가 SQL Injection으로 유출되었습니다.

방어의 핵심은 Prepared Statement(Parameterized Query)입니다. SQL 쿼리 구조와 데이터를 분리하여, 사용자 입력이 SQL 문법으로 해석되지 않고 순수한 데이터로만 처리됩니다. ORM(Object-Relational Mapping) 사용, 최소 권한 원칙(DB 계정 권한 제한), 입력 검증(화이트리스트), WAF(Web Application Firewall) 적용도 중요한 방어 계층입니다.

💻 코드 예제

# ❌ SQL Injection 취약한 코드 예시

# ===== Python (취약) =====
import sqlite3

def login_vulnerable(username, password):
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    # 위험: 문자열 연결로 쿼리 생성
    query = f"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'"
    cursor.execute(query)
    return cursor.fetchone()

# 공격 예시 1: 인증 우회
# username: admin' --
# password: (아무거나)
# 결과 쿼리: SELECT * FROM users WHERE username = 'admin' --' AND password = 'xxx'
# -- 이후는 주석 처리됨 → 비밀번호 검증 우회!

# 공격 예시 2: 항상 참인 조건
# username: ' OR '1'='1
# password: ' OR '1'='1
# 결과 쿼리: SELECT * FROM users WHERE username = '' OR '1'='1' AND password = '' OR '1'='1'
# → 모든 사용자 반환!


# ===== Node.js (취약) =====
# const query = `SELECT * FROM users WHERE id = ${req.params.id}`;
# connection.query(query);  // 위험!

# 공격: /user/1; DROP TABLE users; --
# 결과: 전체 테이블 삭제!


# ===== PHP (취약) =====
# $query = "SELECT * FROM products WHERE id = " . $_GET['id'];
# mysql_query($query);  // 위험!


# ===== SQL Injection 공격 페이로드 예시 =====
# (교육 목적, 실제 공격 금지)

# 인증 우회
# ' OR '1'='1' --
# admin'--
# ' OR 1=1#

# Union 기반 데이터 추출
# ' UNION SELECT username, password FROM users --
# ' UNION SELECT NULL, table_name FROM information_schema.tables --

# Time-based Blind SQLi
# ' OR IF(1=1, SLEEP(5), 0) --
# ' OR (SELECT SLEEP(5) FROM users WHERE username='admin') --

# Error-based (정보 추출)
# ' AND 1=CONVERT(int, (SELECT TOP 1 table_name FROM information_schema.tables)) --

# 데이터베이스 버전 확인
# ' UNION SELECT @@version --       (MySQL/MSSQL)
# ' UNION SELECT version() --       (PostgreSQL)
# ✅ SQL Injection 방어 - Prepared Statement

# ===== Python (sqlite3) =====
import sqlite3

def login_secure(username, password):
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    # 안전: 파라미터 바인딩 (? 플레이스홀더)
    query = "SELECT * FROM users WHERE username = ? AND password = ?"
    cursor.execute(query, (username, password))
    return cursor.fetchone()

# ' OR '1'='1 입력해도 문자열로 처리됨
# 쿼리 구조와 데이터가 분리되어 SQL로 해석되지 않음


# ===== Python (psycopg2 - PostgreSQL) =====
import psycopg2

def get_user_secure(user_id):
    conn = psycopg2.connect(database="mydb")
    cursor = conn.cursor()

    # %s 플레이스홀더 사용
    cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
    return cursor.fetchone()


# ===== Python (mysql-connector) =====
import mysql.connector

def search_products_secure(keyword):
    conn = mysql.connector.connect(host='localhost', database='shop')
    cursor = conn.cursor(prepared=True)

    # 이름 있는 파라미터
    query = "SELECT * FROM products WHERE name LIKE %(keyword)s"
    cursor.execute(query, {'keyword': f'%{keyword}%'})
    return cursor.fetchall()


# ===== Node.js (mysql2) =====
# const mysql = require('mysql2/promise');
#
# async function getUser(userId) {
#     const connection = await mysql.createConnection({...});
#
#     // 파라미터화된 쿼리
#     const [rows] = await connection.execute(
#         'SELECT * FROM users WHERE id = ?',
#         [userId]
#     );
#     return rows[0];
# }


# ===== Java (JDBC) =====
# String query = "SELECT * FROM users WHERE username = ? AND password = ?";
# PreparedStatement pstmt = connection.prepareStatement(query);
# pstmt.setString(1, username);
# pstmt.setString(2, password);
# ResultSet rs = pstmt.executeQuery();


# ===== 추가 방어 계층 =====

# 1. 입력 검증 (화이트리스트)
def validate_order_column(column):
    allowed = ['id', 'name', 'created_at', 'price']
    if column not in allowed:
        raise ValueError(f"Invalid column: {column}")
    return column

# 2. 최소 권한 원칙
# - 애플리케이션 DB 계정에 SELECT, INSERT, UPDATE만 부여
# - DROP, ALTER, CREATE 권한 제거
# - 민감한 테이블에 대한 접근 제한

# 3. 에러 메시지 숨기기
# - 프로덕션에서 상세 SQL 에러 메시지 숨김
# - 로그에만 기록, 사용자에게는 일반 에러 표시
# ✅ ORM 사용 - SQL Injection 자동 방어

# ===== Python (SQLAlchemy) =====
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    username = Column(String(50))
    email = Column(String(100))
    password = Column(String(255))

# DB 연결
engine = create_engine('postgresql://user:pass@localhost/mydb')
Session = sessionmaker(bind=engine)
session = Session()

# 안전한 쿼리 - ORM이 자동으로 파라미터화
def login_with_orm(username, password):
    # filter()는 내부적으로 Prepared Statement 사용
    user = session.query(User).filter(
        User.username == username,
        User.password == password
    ).first()
    return user

# LIKE 검색도 안전
def search_users(keyword):
    return session.query(User).filter(
        User.username.like(f'%{keyword}%')
    ).all()


# ===== Python (Django ORM) =====
# from myapp.models import User
#
# # 자동으로 안전한 쿼리 생성
# user = User.objects.filter(username=username, password=password).first()
#
# # Raw SQL 사용 시에도 파라미터 바인딩
# User.objects.raw('SELECT * FROM users WHERE id = %s', [user_id])


# ===== Node.js (Prisma) =====
# const user = await prisma.user.findFirst({
#     where: {
#         username: username,
#         password: password
#     }
# });


# ===== Node.js (Sequelize) =====
# const user = await User.findOne({
#     where: {
#         username: username,
#         password: password
#     }
# });


# ===== 주의: Raw Query 사용 시 =====

# SQLAlchemy raw query (안전)
from sqlalchemy import text

result = session.execute(
    text("SELECT * FROM users WHERE id = :user_id"),
    {"user_id": user_id}
)

# Django raw query (안전)
# User.objects.raw('SELECT * FROM users WHERE id = %s', [user_id])

# ❌ 위험한 사용 (문자열 포맷팅)
# session.execute(f"SELECT * FROM users WHERE id = {user_id}")  # 절대 금지!


# ===== 정렬/컬럼명은 파라미터화 불가 =====

# 동적 ORDER BY는 화이트리스트로 검증
ALLOWED_SORT_COLUMNS = ['id', 'name', 'created_at']

def get_sorted_users(sort_column, sort_order):
    if sort_column not in ALLOWED_SORT_COLUMNS:
        sort_column = 'id'
    if sort_order not in ['asc', 'desc']:
        sort_order = 'asc'

    # 검증된 값만 사용
    from sqlalchemy import asc, desc
    order_func = asc if sort_order == 'asc' else desc
    column = getattr(User, sort_column)

    return session.query(User).order_by(order_func(column)).all()

🗣️ 실무에서 이렇게 말하세요

💬 코드 리뷰에서
"이 부분에서 문자열 포맷팅으로 SQL 쿼리를 생성하고 있네요. SQL Injection 취약점입니다. Prepared Statement로 바꿔주세요. 파라미터 바인딩을 사용하면 사용자 입력이 SQL 문법이 아닌 순수 데이터로 처리됩니다."
💬 보안 감사 대응에서
"모든 데이터베이스 쿼리를 점검한 결과 3곳에서 동적 쿼리 생성을 발견했습니다. 모두 ORM 또는 Prepared Statement로 수정하고, DB 계정 권한도 최소 권한 원칙에 따라 재설정했습니다. 추가로 WAF에 SQL Injection 탐지 규칙을 활성화했습니다."
💬 면접에서
"SQL Injection 방어의 핵심은 쿼리 구조와 데이터의 분리입니다. Prepared Statement는 쿼리를 먼저 컴파일하고 데이터를 나중에 바인딩하므로, 악의적인 입력도 SQL 문법으로 해석되지 않습니다. ORM을 사용하면 기본적으로 이 방식이 적용됩니다."

⚠️ 주의사항 & 베스트 프랙티스

문자열 연결/포맷팅으로 쿼리 생성

f"SELECT * FROM users WHERE id = {user_id}"는 SQL Injection의 근본 원인입니다. 절대 사용하지 마세요. Prepared Statement만 사용하세요.

입력 검증만으로 방어

블랙리스트 필터링(따옴표 제거 등)은 우회 가능합니다. 입력 검증은 보조 수단이며, Prepared Statement가 핵심 방어입니다.

DB 계정 과도한 권한

애플리케이션 DB 계정에 DBA 권한을 주면 SQLi 성공 시 피해가 극대화됩니다. SELECT, INSERT, UPDATE만 부여하고 DROP, ALTER는 제거하세요.

SQL Injection 방어 베스트 프랙티스

Prepared Statement 필수, ORM 사용 권장, 최소 권한 원칙, 에러 메시지 숨김, WAF 적용, 정기적인 코드 스캔(SAST), 동적 컬럼/테이블명은 화이트리스트 검증.

🔗 관련 용어

📚 더 배우기