SQL 인젝션

C/C++ SQL 인젝션

설명

신뢰할 수 없는 값을 SQL 명령 문자열에 결합하면 데이터가 아니라 SQL 문법으로 해석될 수 있습니다. 공격자는 따옴표, 연산자, 주석 또는 문장 구분자를 삽입해 조건과 실행할 문장을 바꿀 수 있습니다. sqlite3_exec()나 PQexec()처럼 즉시 실행하는 함수뿐 아니라 sqlite3_prepare_v2(), PQprepare(), mysql_stmt_prepare(), SQLPrepare()에 외부 입력으로 만든 SQL 텍스트를 전달하는 경우도 취약합니다. 준비된 문장이라는 API 이름만으로는 안전하지 않으며, SQL 템플릿을 고정하고 값만 별도로 바인딩해야 합니다.

문자열 치환과 파라미터 바인딩은 서로 다른 보호 수단입니다. 바인딩은 SQL 코드와 데이터 값을 프로토콜 또는 드라이버 수준에서 분리합니다. 반면 따옴표를 수동으로 바꾸거나 sanitize_sql() 같은 함수를 호출하는 방식은 데이터베이스 종류, 연결 문자 인코딩, 값·식별자 위치 및 주변 따옴표 문맥을 모두 정확히 처리했다는 증거가 아닙니다.

잠재적 영향

  • 인증 또는 접근 제어 조건을 우회하고 다른 사용자의 데이터를 읽을 수 있습니다.
  • 데이터를 생성·변경·삭제하거나 데이터베이스 스키마와 설정을 조작할 수 있습니다.
  • 데이터베이스 계정과 서버 기능에 따라 파일 접근, 운영체제 명령 실행 또는 서비스 중단으로 이어질 수 있습니다.
  • 오류 메시지와 응답 차이를 이용해 데이터베이스 구조나 민감정보가 노출될 수 있습니다.

해결 방법

SQL 템플릿을 상수로 유지하고 외부에서 온 모든 값을 해당 데이터베이스의 파라미터 API로 전달합니다.

  1. SQLite에서는 sqlite3_prepare_v2() 또는 sqlite3_prepare_v3()로 ?, ?NNN, :name, @name, $name 파라미터가 포함된 고정 SQL을 준비하고 sqlite3_bind_*()로 값을 바인딩합니다.
  2. PostgreSQL libpq에서는 한 번 실행할 쿼리에 PQexecParams()를 사용하거나, PQprepare()로 고정 쿼리를 준비한 뒤 PQexecPrepared()로 값을 전달합니다. 비동기 코드에서는 대응하는 파라미터 API를 사용합니다.
  3. MySQL 8.4 C API에서는 mysql_stmt_prepare(), mysql_stmt_bind_named_param(), mysql_stmt_execute() 순서로 사용합니다. 호환 클라이언트에서 기존 mysql_stmt_bind_param()을 사용할 때도 고정 SQL의 각 ? 마커에 대응하는 값을 바인딩해야 합니다.
  4. ODBC에서는 SQLPrepare(), SQLBindParameter(), SQLExecute()를 사용합니다. 한 번 실행하는 SQLExecDirect()도 파라미터 마커와 사전에 바인딩한 값을 사용할 수 있지만, SQL 텍스트 자체는 고정해야 합니다.
  5. 파라미터는 일반적으로 값만 나타냅니다. 테이블명, 열 이름, 정렬 방향, 연산자 또는 SQL 키워드 같은 구조는 바인딩할 수 없습니다. 구조 선택이 필요하면 외부 입력을 소수의 허용된 식별자와 비교한 뒤 각 식별자를 코드에 고정된 SQL 조각으로 매핑합니다.
  6. 준비·바인딩·실행 함수의 반환값을 모두 확인하고 문장 및 결과 객체를 올바른 API로 해제합니다. 데이터베이스 계정에는 업무에 필요한 최소 권한만 부여합니다.

동적 SQL이 정말 불가피한 경우에도 데이터베이스가 제공하는 정확한 문맥별 함수를 사용하고 그 결과를 지정된 SQL 문법 위치에만 넣어야 합니다. SQLite의 %q는 작은따옴표를 중복할 뿐 주변 따옴표를 추가하지 않고, %Q는 SQL 문자열 리터럴용 따옴표를 추가합니다. libpq의 PQescapeLiteral()과 PQescapeIdentifier()도 서로 다른 문법 위치를 위한 함수입니다. 이러한 이스케이프를 다른 위치나 이미 열린 따옴표 안에서 재사용하면 안전성이 깨질 수 있으므로 일반 정제 함수로 취급하지 않습니다. OWASP도 전체 입력 이스케이프보다 파라미터화된 쿼리를 우선하도록 권고합니다.

예시

C

변경 전

c
#include <stddef.h>
#include <sqlite3.h>

int find_user_unsafe(sqlite3 *database, const char *name) {
    char *sql = sqlite3_mprintf(
        "SELECT id FROM users WHERE name = '%s'", name);
    if (sql == NULL) {
        return SQLITE_NOMEM;
    }

    int result = sqlite3_exec(database, sql, NULL, NULL, NULL);
    sqlite3_free(sql);
    return result;
}

변경 후

c
#include <stddef.h>
#include <sqlite3.h>

int find_user(sqlite3 *database, const char *name) {
    static const char query[] =
        "SELECT id FROM users WHERE name = ?";
    sqlite3_stmt *statement = NULL;

    int result = sqlite3_prepare_v3(
        database, query, -1, 0, &statement, NULL);
    if (result != SQLITE_OK) {
        return result;
    }

    result = sqlite3_bind_text(
        statement, 1, name, -1, SQLITE_TRANSIENT);
    if (result != SQLITE_OK) {
        sqlite3_finalize(statement);
        return result;
    }

    result = sqlite3_step(statement);
    int finalize_result = sqlite3_finalize(statement);
    if (result != SQLITE_ROW && result != SQLITE_DONE) {
        return result;
    }
    return finalize_result;
}

설명:

  • 변경 전: 외부 값이 %s로 SQL 문자열 안에 그대로 삽입되어 따옴표와 SQL 문법을 바꿀 수 있습니다.
  • 변경 후: SQL 구조와 ? 파라미터는 고정되어 있고 name은 sqlite3_bind_text()를 통해 데이터 값으로 전달됩니다. sqlite3_prepare_v3()를 사용한다는 사실만이 아니라, 준비할 SQL이 고정되어 있다는 점이 핵심입니다.

C++

변경 전

cpp
#include <sqlite3.h>
#include <string>

int find_user_unsafe(sqlite3 *database, const std::string &name) {
    const std::string sql =
        "SELECT id FROM users WHERE name = '" + name + "'";
    return sqlite3_exec(
        database, sql.c_str(), nullptr, nullptr, nullptr);
}

변경 후

cpp
#include <memory>
#include <sqlite3.h>
#include <string>

struct StatementDeleter {
    void operator()(sqlite3_stmt *statement) const noexcept {
        sqlite3_finalize(statement);
    }
};

int find_user(sqlite3 *database, const std::string &name) {
    constexpr char query[] =
        "SELECT id FROM users WHERE name = ?";
    sqlite3_stmt *raw_statement = nullptr;

    int result = sqlite3_prepare_v3(
        database, query, -1, 0, &raw_statement, nullptr);
    if (result != SQLITE_OK) {
        return result;
    }

    const std::unique_ptr<sqlite3_stmt, StatementDeleter>
        statement{raw_statement};
    result = sqlite3_bind_text(
        statement.get(), 1, name.c_str(), -1, SQLITE_TRANSIENT);
    if (result != SQLITE_OK) {
        return result;
    }

    result = sqlite3_step(statement.get());
    if (result == SQLITE_ROW || result == SQLITE_DONE) {
        return SQLITE_OK;
    }
    return result;
}

설명:

  • C++ 변경 전: std::string을 사용해도 외부 값이 SQL 텍스트에 결합되면 코드와 데이터가 분리되지 않습니다. c_str()은 표현만 바꿀 뿐 SQL 인젝션을 막지 않습니다.
  • C++ 변경 후: 고정된 SQL 문을 준비하고 name을 값으로 바인딩합니다. std::unique_ptr의 사용자 정의 삭제자는 모든 반환 경로에서 준비된 문장을 sqlite3_finalize()로 해제합니다.

식별자처럼 바인딩할 수 없는 구조는 고정 매핑으로 선택합니다.

c
#include <sqlite3.h>
#include <string.h>

int list_users(sqlite3 *database, const char *sort_key) {
    const char *query = NULL;
    if (strcmp(sort_key, "name") == 0) {
        query = "SELECT id, name FROM users ORDER BY name";
    } else if (strcmp(sort_key, "created") == 0) {
        query = "SELECT id, name FROM users ORDER BY created_at";
    } else {
        return SQLITE_MISUSE;
    }

    return sqlite3_exec(database, query, NULL, NULL, NULL);
}

참조