DB 라이브러리들의 SQL Injection 대처 방법

irenett·2025년 2월 3일

📐 우리가 서버 개발 과정에서 사용하는 수많은 DB 라이브러리들은 이 문제를 어떻게 해결할까요?

📐 DB 라이브러리란?

DB 라이브러리는 데이터베이스와 애플리케이션 사이의 통신을 도와주는 소프트웨어 도구들을 말하며, 대표적으로 낮은 수준의 드라이버/커넥터높은 수준의 추상화 프레임워크(ORM 등)가 있습니다.


📐 낮은 수준의 드라이버/커넥터

  • JDBC
  • ODBC (Open Database Connectivity)
  • ADO.NET
  • Python DB-API (psycopg2, pymysql, sqlite3..)
  • Node.js 드라이버 (각 데이터베이스에 맞춘 드라이버)

SQL 쿼리를 직접 작성하고 실행하며, 결과 집합을 직접 다루게 됩니다.

저수준에서 데이터베이스와의 직접적인 통신을 다루므로, SQL 쿼리를 직접 작성하고 파라미터 바인딩, 결과 처리 등을 개발자가 직접 관리해야 합니다.

📐 높은 수준의 추상화 프레임워크 (ORM, Query Builder)

데이터베이스 작업을 보다 추상화하여, 객체지향 프로그래밍 방식이나 함수 호출을 통해 데이터베이스에 접근할 수 있게 도와줍니다.

내부적으로는 앞서 언급한 저수준 드라이버(JDBC, ODBC 등)를 사용하지만, 직접 SQL을 작성하는 수고는 줄어듭니다.

  • ORM (Object-Relational Mapping):

    • 데이터베이스 테이블과 애플리케이션의 객체(클래스)를 매핑하여, 객체를 다루듯이 데이터베이스 작업을 수행할 수 있게 해줍니다.

      저수준 라이브러리 위에 구축된 고수준 추상화 도구입니다.

    • Java: Hibernate, JPA

    • PHP: Laravel

    • Python: Django ORM, SQLAlchemy

    • Ruby: ActiveRecord (Ruby on Rails)

    • Node.js: Sequelize, TypeORM

    => 내부적으로는 parameterized query 방식을 사용하여 보안 문제를 예방합니다.

  • SQL Query Builder:

    • ORM과 달리 객체와 테이블 간의 매핑보다는 SQL 쿼리 자체를 프로그램 코드 내에서 조립할 수 있도록 도와주는 도구입니다.
    • 문자열을 일일이 연결하여 쿼리를 만들 필요 없이 메서드 체이닝이나 함수 호출을 통해 쿼리를 구성할 수 있습니다.
    • 예를 들어, Node.js의 Knex.js
      마치 람다와 같이 함수형 프로그래밍 요소 활용
    • // users 테이블에서 username과 password가 일치하는 레코드를 조회하는 예제
      knex('users')
        .select('*')
        .where('username', '=', 'hi')
        .andWhere('password', '=', 'hi')
        .then(rows => {
          console.log('조회 결과:', rows);
        })
        .catch(err => {
          console.error('쿼리 실행 오류:', err);
        })
        .finally(() => {
          // 연결 종료
          knex.destroy();
        });

📐 Prepared Statement / Parameterized Query

SQL 인젝션 방어쿼리의 재사용을 위해 SQL 코드와 데이터를 분리하는 목적에서 사용되지만, 약간 개념이 다릅니다.

📐 Prepared Statement

  • 개념:
    • SQL 쿼리의 구조(쿼리 템플릿)를 미리 데이터베이스에 전달하여 컴파일 및 최적화를 수행한 후, 실행 시점에 파라미터만 전달하여 실행하는 기법입니다.
  • 특징:
    • DB 서버가 쿼리의 실행 계획 미리 생성
    • 동일한 쿼리를 여러 번 실행할 때 성능상의 이점 존재 가능
    • 많은 DB 드라이버에서 제공
    • 구현 방법:
      • 쿼리를 실행하기 전 prepare() 메서드를 통해 쿼리를 준비
      • 이후 bind 또는 setParameter 같은 메서드로 파라미터 전달
    • Parameterized Query의 한 형태:
      • Prepared Statement는 내부적으로 parameterized query 방식을 사용합니다. 즉, 자리표시자에 실제 값만 나중에 바인딩하여 실행합니다.

📐 Parameterized Query

  • 개념:
    • SQL 쿼리 내에 PlaceHolder(예: ?, %s 등)를 사용하고, 별도로 파라미터 값을 전달하는 방식입니다.
      내부적으로는 prepared statement를 생성하거나, prepared statement와 유사한 방식으로 동작할 수 있습니다.
    • SQL 문자열 내에 값은 별도로 전달되므로 SQL 인젝션 위험을 줄일 수 있습니다.
  • 특징:
    • 쿼리와 데이터 분리: SQL 코드와 데이터(파라미터)를 분리하여, 문자열 포매팅에 따른 보안 문제를 방지합니다.
    • API 인터페이스 중심: 사용자는 단순히 쿼리와 값을 전달하는 메서드를 호출하면 되고, 내부적으로 prepared statement를 생성해 실행하는 경우가 많습니다.
    • 추상화: 일부 라이브러리(Python의 sqlite3 등)는 prepared statement 객체를 명시적으로 다루지 않고, 단일 메서드 호출(execute, executemany)로 이 기능을 추상화합니다.

요약

  • Prepared Statement는 데이터베이스에 미리 해석된 쿼리 객체를 직접 다루어 재사용과 성능 최적화를 도모할 수 있는 개념입니다.
  • Parametrized Query는 placeholder를 사용해 SQL과 파라미터를 분리하는 기법으로, 내부적으로 Prepared Statement를 생성해 사용할 수도 있고, 단순히 파라미터를 안전하게 전달하는 역할을 합니다.

둘다 SQL 코드와 데이터를 분리하여 보안과 성능을 개선하려는 목적을 갖지만,

사용자가 직접 prepared statement 객체를 다루는지(Prepared Statement)

내부적으로 자동으로 준비 과정을 처리하는지(Parametrized Query)

의 차이가 있습니다.

요약


  • 실제 공격 예시
import java.sql.*;

public class Main {

    // 테이블 생성 메서드
    public static void createTable(Connection conn) throws SQLException {
        try (Statement stmt = conn.createStatement()) {
            // 기존 테이블이 있으면 삭제
            stmt.execute("DROP TABLE IF EXISTS users");

            // 테이블 생성
            String sql = "CREATE TABLE users (" +
                    "id int auto_increment primary key, " +
                    "username varchar(10), " +
                    "password varchar(10)" +
                    ")";
            stmt.execute(sql);
        }
    }

    // 더미 데이터 삽입
    public static void insertData(Connection conn) throws SQLException {
        try (Statement stmt = conn.createStatement()) {
            stmt.execute("INSERT INTO users (username, password) VALUES ('hihi', 'password')");
            stmt.execute("INSERT INTO users (username, password) VALUES ('hoho', 'password')");
        }
    }

    /**
     * SQL Injection 공격에 취약한 로그인 (입력값을 직접 SQL에 포함)
     * @return 로그인 성공 시 true, 실패 시 false
     */
    public static boolean vulnerableLogin(Connection conn, String username, String password) throws SQLException {
        String query = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";
        System.out.println("실행 쿼리 (취약): " + query);

        try (Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(query)) {
            return rs.next();
        }
    }

    /**
     * 안전한 로그인 메서드 (PreparedStatement)
     * @return 로그인 성공 시 true, 실패 시 false
     */
    public static boolean safeLogin(Connection conn, String username, String password) throws SQLException {
        String query = "SELECT * FROM users WHERE username = ? AND password = ?";

        System.out.println("실행 쿼리 (안전): " + query);

        try (PreparedStatement pstmt = conn.prepareStatement(query)) {
            pstmt.setString(1, username);
            pstmt.setString(2, password);
            try (ResultSet rs = pstmt.executeQuery()) {
                return rs.next();
            }
        }
    }
}
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;
import java.sql.*;

public class MainTest {
    private Connection conn;

    @BeforeEach
    public void setUp() throws Exception {
        // H2 연결
        Class.forName("org.h2.Driver");
        conn = DriverManager.getConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", "sa", "");
        Main.createTable(conn);
        Main.insertData(conn);
    }

    @AfterEach
    public void tearDown() throws SQLException {
        if (conn != null && !conn.isClosed()) {
            conn.close();
        }
    }

    @Test
    public void 취약로그인() throws SQLException {
        boolean result = Main.vulnerableLogin(conn, "hihi", "password");
        assertTrue(result, "로그인 성공");
    }

    @Test
    public void 취약SQLInjection공격() throws SQLException {
        boolean result = Main.vulnerableLogin(conn, "hihi", "anything' OR '1'='1");
        assertTrue(result, "취약 쿼리에 Injection 공격 성공");
    }

    @Test
    public void 안전로그인() throws SQLException {
        boolean result = Main.safeLogin(conn, "hihi", "password");
        assertTrue(result, "로그인 성공");
    }

    @Test
    public void 안전SQLInjection공격() throws SQLException {
        boolean result = Main.safeLogin(conn, "hihi", "anything' OR '1'='1");
        assertFalse(result, "pstmt 쿼리에 Injection 공격 성공");
    }
}

참고 문헌

0개의 댓글