AYEONG.DEV
모든 글 보기

Database

데이터베이스에서 수학 문제를 찾다 - SQL Injection 케이스

똑똑똑. 나 참이야.

·

1. SQL Injection이란?


SQL Injection(SQLi)은 외부에서 전달된 입력값이 SQL 쿼리의 일부로 해석되면서 애플리케이션이 원래 의도한 쿼리의 동작이 변경되는 취약점이다. 일반적으로 백엔드 서버는 사용자의 입력을 받아 데이터베이스에 전달한다.

예를 들어 상품 검색 기능을 예시로 들어보자.

정상적인 사용자는 상품명에 cookie 같은 검색어를 입력할 것이다. 그런데 사용자가 검색어 대신 SQL 문법으로 해석될 수 있는 문자열을 입력한다면? 개발자가 SQL 문자열을 안전하게 구성하지 않았다면, 사용자의 입력이 단순한 검색 데이터가 아니라 SQL 명령문의 일부로 처리될 수 있다.

바로 여기서 SQL Injection이 발생한다.

중요한 것은 입력값 자체가 아니다

특수문자가 포함된 입력을 받았다고 해서 무조건 SQL Injection 취약점이 존재하는 것은 아니다. 핵심은 애플리케이션이 입력값을 SQL 코드와 분리해서 처리하고 있는가이다.


2. SQL Injection은 어떻게 발생할까?

먼저 취약한 쿼리 작성 방식을 살펴보자. 다음은 Node.js와 MySQL을 사용하는 가상의 상품 검색 API다.

typescript
async function searchProduct(keyword: string) {
  const sql = `
    SELECT id, name, price
    FROM products
    WHERE name = '${keyword}'
  `;

  return database.query(sql);
}

코드는 간단하다. 사용자로부터 전달받은 keyword를 SQL 문자열에 직접 삽입한다. 정상적인 검색어를 전달하면 다음과 같은 쿼리가 만들어진다.

plain text
SELECT id, name, price
FROM products
WHERE name = 'cookie';

별문제 없어 보인다. 그런데 입력값에 작은따옴표나 SQL 조건문이 포함되면 어떻게 될까? 가장 큰 문제는 SQL 문자열을 직접 연결하면서 데이터와 SQL 문법의 경계가 무너진다는 점이다. SQL 파서는 개발자가 의도한 문자열과 사용자가 입력한 문자열을 구분하지 않는다. 완성된 SQL 문장을 해석할 뿐이다. 따라서 입력값이 SQL 문법에 영향을 줄 수 있는 구조라면, 의도하지 않은 조건이 실행될 위험이 생긴다.


3. SQL문에 갑자기 5×5=25가 등장하는 이유

SQL Injection 패턴을 공부하다 보면 다음과 같이 항상 참인 조건을 사용하는 사례를 발견할 수 있다.

plain text
5 * 5 = 25

이게 왜 등장하는 걸까?

5×5는 25다. 당연함.

그런데 SQL에서도 이러한 연산과 비교를 수행할 수 있다.

plain text
SELECT 5 * 5 = 25 AS result;

MySQL에서는 참인 비교 결과가 1로 표현된다.

반대로 다음은 거짓이다.

plain text
SELECT 5 * 5 = 26 AS result;

결과는 0이다.

즉, 데이터베이스 내부에서 참과 거짓을 구분할 수 있다. 공격자는 이러한 특성을 이용해 입력이 SQL 조건식에 영향을 주는지 확인하려 할 수 있다.

Boolean-based SQL Injection

Boolean-based SQL Injection은 참과 거짓에 따라 달라지는 애플리케이션의 응답을 관찰하는 기법이다.

원리는 단순하다.

예를 들어 개발자가 만든 안전한 실습 환경에서 특정 조건을 변경했을 때 결과가 달라진다면 그 변화가 SQL 조건식에서 비롯된 것인지 분석할 수 있다. 다만 여기서 주의할 부분이 있다.

SQL 문자열 안에 OR이나 수식이 보인다고 해서 SQL Injection이 성공한 것은 아니다.

다음 쿼리를 보자.

plain text
SELECT *
FROM products
WHERE name LIKE '%5*5=25%';

위 쿼리에서 5*5=25는 연산되는 조건식이 아니라 검색 대상 문자열이다. 데이터베이스는 해당 문자열을 포함한 상품명을 검색한다. 이처럼 실제 SQL에서 입력값이 어디에 위치하는지 살펴봐야 공격이 성공했는지 판단할 수 있다.


4. Blind SQL Injection은 또 뭐야?

SQL Injection은 항상 데이터베이스의 결과를 직접 보여주는 방식으로 진행되는 것은 아니다. 어떤 애플리케이션은 SQL 오류 메시지나 조회 결과를 외부에 노출하지 않는다. 그럼 공격자가 아무것도 알아낼 수 없을까? 반드시 그렇지는 않다.

애플리케이션의 반응 차이를 이용해 데이터베이스 내부 상태를 추론하려는 방식이 존재한다. 이를 Blind SQL Injection이라고 부른다.

대표적으로 두 가지를 공부할 수 있다.

Boolean-based Blind SQLi

조건이 참인지 거짓인지에 따라 결과가 달라지는지를 분석하는 방식이다. 예를 들어 다음과 같은 반응 차이를 생각해 볼 수 있다.

중요한 것은 공격에 사용한 문자열 자체가 아니라, 조건에 따른 응답 차이가 일관되게 발생하는지다.

Time-based Blind SQLi

Time-based 방식은 쿼리 실행 시간을 관찰하는 기법이다. 특정 조건에 따라 데이터베이스가 대기하도록 만들 수 있다면 그 시간 차이를 통해 SQL 실행 여부를 추론하려고 할 수 있다. 예를 들어 정상 요청과 비정상적으로 지연된 요청을 비교하는 방식이다.

plain text
정상 요청
|---- 100ms ----|

지연된 요청
|---------------------- 5000ms ----------------------|

물론 응답 시간이 길어졌다는 사실만으로 SQL Injection이 성공했다고 판단해서는 안 된다. 네트워크 지연, 데이터베이스 부하, 잠금 대기, 애플리케이션 처리 시간 등의 원인으로도 응답 시간이 증가할 수 있기 때문이다.


5. DBMS마다 SQL 문법이 다르다

SQL은 표준 언어지만 DBMS마다 제공하는 함수와 확장 기능이 다르다. MySQL, PostgreSQL, Oracle, SQL Server는 모두 SQL을 사용하지만 내부 구현까지 동일하지는 않다. 특히 시간 지연 방식의 SQL Injection을 분석할 때 이러한 차이가 드러난다.

위 기능들은 DBMS별로 제공되는 함수나 명령이며, 사용하는 권한과 실행 문맥에도 차이가 있다.

특히 Oracle의 DBMS_PIPE.RECEIVE_MESSAGE는 메시지 수신 대기 기능을 제공한다. 이를 시간 차이를 관찰하는 SQL Injection 탐침에 악용하려는 시도가 알려져 있다.

하지만 해당 함수 이름이 HTTP 요청에 포함되었다고 해서 데이터베이스에서 실행되었다는 의미는 아니다. 요청이 DB까지 전달되었는지, 실제 SQL 문법으로 해석되었는지, DBMS와 문법이 일치했는지 등을 별도로 확인해야 한다.

여기서 재미있는 점은 공격자가 대상 데이터베이스를 정확히 알고 있지 않을 수도 있다는 것이다. 자동화된 취약점 스캐너는 여러 DBMS의 특징을 가진 입력을 시도할 수 있다.

그렇기 때문에 특정 DBMS의 함수가 요청에 포함되었다는 이유만으로 서버가 해당 DBMS를 사용한다고 판단해서는 안 된다.


6. SQL Injection의 대표적인 유형 정리

SQL Injection은 관찰 방식과 사용하는 기법에 따라 여러 종류로 구분할 수 있다.

실무에서 중요한 것은 이러한 이름을 외우는 데 그치지 않는 것이다. 각 유형이 애플리케이션에서 어떤 정보를 외부에 노출하는지 이해해야 한다. 예를 들어 오류 메시지를 감췄다고 SQL Injection 취약점이 사라지는 것은 아니다. 마찬가지로 검색 결과를 직접 반환하지 않는다고 해서 모든 정보 노출 가능성이 사라지는 것도 아니다. 근본적으로는 사용자 입력이 SQL 구조에 영향을 주지 못하도록 설계해야 한다.


7. 그렇다면 SQL Injection을 어떻게 방어해야 할까?

SQL Injection 방어에서 가장 중요한 방법은 Parameterized Query다. Prepared Statement와 파라미터 바인딩을 활용하면 외부 입력값을 SQL 코드와 분리할 수 있다.

잘못된 방식: SQL 문자열 직접 연결

typescript
async function findProduct(name: string) {
  return database.query(`
    SELECT *
    FROM products
    WHERE name = '${name}'
  `);
}

사용자 입력이 SQL 문장에 직접 결합된다.

안전한 방식: Parameterized Query

typescript
async function findProduct(name: string) {
  return database.execute(
    'SELECT * FROM products WHERE name = ?',
    [name],
  );
}

여기서 ?는 값이 바인딩되는 위치다.

SQL 문장의 구조는 고정되어 있고, name은 검색 데이터로 전달된다. 예를 들어 사용자가 SQL 문법처럼 보이는 문자열을 입력하더라도 해당 문자열 전체가 하나의 검색 값으로 처리된다. 이 방식은 사용자 입력을 SQL 코드와 구분하기 때문에 SQL Injection 방어의 핵심이 된다.

TypeORM에서는 어떻게 할까?

NestJS에서 TypeORM을 사용하는 경우 다음과 같이 작성할 수 있다.

typescript
const products = await productRepository
  .createQueryBuilder('product')
  .where('product.name = :name', {
    name: keyword,
  })
  .getMany();

:name에 값을 바인딩하는 구조다.

반면 다음과 같은 코드는 피해야 한다.

typescript
.where(`product.name = '${keyword}'`)

ORM을 사용하더라도 문자열을 직접 연결하면 SQL Injection에 취약한 쿼리를 만들 수 있다. ORM을 사용한다는 사실 자체가 안전성을 보장하는 것은 아니다.

LIKE 검색에서는?

상품명 부분 검색에도 파라미터 바인딩을 사용할 수 있다.

typescript
const products = await productRepository
  .createQueryBuilder('product')
  .where('product.name LIKE :keyword', {
    keyword: `%${keyword}%`,
  })
  .getMany();

이렇게 하면 SQL Injection은 방지할 수 있지만, %와 _는 여전히 SQL LIKE 패턴에서 특별한 의미를 갖는다. 따라서 사용자가 입력한 %나 _까지 문자 그대로 검색해야 하는 기능이라면 LIKE 패턴 이스케이프도 별도로 고려해야 한다.


8. 입력값 검증만 잘하면 되는 거 아닐까?

SQL Injection을 처음 공부하면 이런 생각이 들 수 있다.

작은따옴표나 OR 같은 문자열을 막으면 되는 거 아닌가?

하지만 이것만으로는 충분하지 않다. 사용자가 실제로 검색해야 하는 값에 특수문자가 포함될 수도 있다. 예를 들어 상품명에 작은따옴표가 포함될 수도 있고, 일반적인 문장에서 OR이라는 단어가 사용될 수도 있다. 특정 문자열을 완전히 차단하는 방식은 정상적인 사용자 입력까지 제한할 수 있다. 또한 공격 문자열은 다양한 형태로 변형될 수 있기 때문에 단순 문자열 필터만으로 모든 경우를 처리하기 어려운 것 같다.

따라서 SQL Injection 방어에서는 다음 원칙이 중요하다.

  1. 사용자 입력은 기본적으로 신뢰하지 않는다.
  2. SQL 값은 파라미터로 바인딩한다.
  3. 컬럼명이나 정렬 방향처럼 바인딩할 수 없는 SQL 구조 요소는 허용 목록으로 제한한다.
  4. 데이터베이스 계정에는 필요한 최소 권한만 부여한다.
  5. 입력 검증과 오류 로깅을 보조적인 방어 수단으로 사용한다.

특히 ORDER BY 컬럼처럼 동적으로 선택해야 하는 식별자는 파라미터 바인딩만으로 처리할 수 없는 경우가 있다. 이때는 클라이언트가 전달한 컬럼명을 그대로 SQL에 삽입하지 않고 서버에서 허용한 컬럼만 선택하도록 해야 한다.


9. SQL Injection과 느린 쿼리는 같은 문제일까?

SQL Injection 탐지 로그에는 종종 쿼리 실행 시간이나 읽은 Row 수가 함께 기록된다. 그렇다면 읽은 Row 수가 많거나 실행 시간이 길면 SQL Injection에 성공했다고 볼 수 있을까?

정답은 아니다.

예를 들어 다음과 같은 쿼리가 있다고 가정하자.

sql
SELECT *
FROM products
WHERE name LIKE '%cookie%'
ORDER BY created_at DESC
LIMIT 10;

이 쿼리는 SQL Injection과 무관하게 성능 문제가 발생할 수 있다. 특히 일반적인 B-Tree 인덱스에서는 앞에 %가 있는 LIKE 조건을 인덱스 범위 탐색에 효과적으로 사용하기 어려울 수 있다. 조회 조건, 데이터 분포, 정렬 방식 등에 따라 많은 Row를 읽을 수 있다.

따라서 다음 두 개념은 분리해서 생각해야 한다.

SQL Injection은 쿼리 구조가 외부 입력에 의해 변경될 수 있는 보안 문제다. Slow Query는 데이터 조회와 실행 계획 등의 이유로 쿼리 처리에 많은 시간이 소요되는 성능 문제다.

하나의 쿼리에서 두 문제가 동시에 나타날 수는 있다. 하지만 느린 쿼리가 발생했다는 사실만으로 SQL Injection의 성공 여부를 판단할 수는 없다. SQL Injection 여부를 확인할 때는 입력값이 실제 SQL 문법으로 해석되었는지 살펴봐야 한다.

성능을 확인할 때는 EXPLAIN, 실행 계획, 인덱스 사용 여부와 실제 처리 Row 수 등을 분석해야 한다.


10. 공부하면서 정리한 핵심

SQL Injection을 공부하기 전에는 공격자가 특수한 SQL 명령어를 전달해 데이터베이스를 조작하는 방식이라고만 생각했다. 하지만 원리를 공부해 보니 핵심은 조금 달랐다. 문제의 시작은 특정 공격 문자열이 아니다.

애플리케이션이 외부 입력을 SQL 코드의 일부로 해석할 수 있게 만드는 구조 자체가 문제다.

5*5=25처럼 단순한 수식이 등장하는 이유도 이제 이해할 수 있었다. SQL에서는 조건식의 결과가 참인지 거짓인지 평가된다. 공격자는 그 특성을 이용해 입력이 SQL 실행 결과에 영향을 미치는지 관찰하려고 할 수 있다. 반면 LIKE 조건 안에 해당 문자열이 안전하게 포함되어 있다면 단순 검색어로 처리될 뿐이다.

또한 응답 시간 차이를 이용하는 Time-based SQL Injection을 살펴보면서, SQL 취약점이 반드시 오류 메시지나 조회 결과를 직접 노출하는 형태로만 발생하는 것은 아니라는 점도 알게 되었다.

마무리하며

SQL Injection을 방어할 때 가장 먼저 떠올려야 하는 것은 복잡한 필터링 코드가 아니다. Prepared Statement와 Parameterized Query를 통해 SQL 코드와 사용자 데이터를 명확하게 분리하는 것이다. 이번 공부를 통해 쿼리를 작성할 때 결과가 제대로 나오는지만 확인하는 것이 아니라, 외부 입력이 어떤 방식으로 처리되는지도 함께 생각해야겠다고 느꼈다.

개발자는 정상적인 입력을 기준으로 기능을 만들지만, 애플리케이션에는 언제든 예상하지 못한 입력이 들어올 수 있다. 결국 안전한 코드는 사용자가 정상적인 값만 입력해 줄 것이라고 가정하지 않는 코드인 것 같다.


참고 자료

본 글은 SQL Injection의 동작 원리를 학습하기 위해 작성한 글이며, 코드와 데이터베이스 구조는 실제 케이스를 가상의 예시로 변환했습니다. 실습은 본인이 소유하거나 명시적으로 허가받은 환경에서만 수행해야 합니다.

조회 0

댓글

익명 캐릭터로 가볍게 이야기를 남겨 주세요.

0/1000