MySQL은 SQL 인젝션을 어떻게 막을까?

Bellmin·2025년 8월 15일
post-thumbnail

조금 더 정확한 제목은, MySQL Connector는 SQL 인젝션을 어떻게 막을까? 이다.

SQL Injection

SQL 인젝션이란, 개발자가 작성한 SQL 코드 사이에 공격자가 공격할 SQL을 삽입하여

데이터베이스에 있는 데이터들을 무단으로 조회하거나 공격하는 기법이다.

공격이 성공하면, 데이터베이스에 대한 무단 접근을 얻거나, 데이터베이스에서 직접 정보를 검색할 수 있다.

심지어 DROP DATABASE {데이터베이스 이름} 을 삽입할 수만 있다면, 데이터베이스를 삭제시키는 것도 가능하다.

PreparedStatement

SQL Injection 공격을 방어하기 위해서, 사용하는 기법 중 하나는 바로 PreparedStatement 이다.

PreparedStatement 는 미리 준비된 Statement를 의미하며, 여기서 Statement 란

SQL 쿼리를 의미한다고 생각하면 이해가 편하다.

사용자의 입력에 따라서 SQL이 변하는 동적 쿼리를 정의할 때, 미리 SQL 템플릿을 만들어놓고 사용자가 입력한 값을

정해진 공간에 주입하는 방식이다.

INSERT INTO products (name, price) VALUES (?, ?);

개발자는 위와 같이 미리 준비된 Statement를 준비해놓고, 사용자가 입력한 데이터들을 ? 위치에 삽입하면 된다.

조금 더 상세한 차이는 아래에 실습하면서 알아보자.

JDBC

Java 진영의 공용 데이터베이스 인터페이스인, JDBC에서도 SQL 인젝션을 방지하기 위해

표준 PrepareStatement 를 제공한다.

PreparedStatement 인터페이스는 Statement 인터페이스를 extends 하고 있다.

setBoolean , setNull 등의 Setter 메소드들을 이용하여, 개발자가 정의한 PreparedStatement 의 ? 에 (슬롯이라고 부른다) 값을 할당할 수 있다.

각각의 데이터베이스 제조사들은 (Oracle, MySQL, PostGres 등) PreparedStatement 기능을 정교하게 제공하기 위해서 해당 인터페이스를 구현해야 한다.

이 글에서는 MySQL Connector에서 PreparedStatement는 어떻게 구현되어 있고, 어떤 시점에서 PreparedStatement 쿼리에 파라미터들을 삽입하는지 살펴보겠다.

MySQL Connector

MySQL Connector 의존성에서 제공하는 com.mysql.cj.jdbc 패키지에 있는,

JdbcPreparedStatement 인터페이스는 아래와 같다.

해당 인터페이스는 java.sql.PreparedStatementJdbcStatement를 extends 하고 있다.

JDBC 관련 인터페이스가 이렇게 다양하고 많은데, 클래스 구조를 그려보면 아래와 같다.

Statement 관련 최상위 인터페이스 java.sql.Statement 를 각각의 데이터베이스 제조사에서 구현했다고 생각하면된다.

사실상 java.sql.PreparedStatement 인터페이스는 java.sql.statement 를 확장하고 있으므로

JDBC의 PreparedStatement 를 구현한다는 것은, Statement 를 구현한다는 것과 다름이 없다.

그래서 아래와 같이 간략하게 표현할 수 있다.

여기서 아래 두 개의 PreparedStatement 구현체를 중심으로 살펴보아야 한다.

이는 useServerPrepStmts 속성값에 따라서 PreparedStatement가 동작하는 위치가 Client인지 Server인지 다르게 동작하도록 별도의 클래스로 만들어 놓았다.

SQL Injection 방어

PreparedStatement 가 어떻게 SQL Injection 을 막는지 살펴보자.

일반 Statement

먼저 SQL Injection 공격이 방어되지 않는 일반 Statement 를 사용했을 때의 결과를 보자.

searchKey 는 anything ' or '1' = '1 이다.

anything ' or '1' = '1 이 그대로 where 절의 조건식에 들어가게 된다.

SELECT * FROM actor WHERE actor.first_name = 'anything ' or '1' = '1' LIMIT 10

그렇다면 쿼리는 위와 같이 생성될 것이며, 어떻게 동작하는지 한 번 살펴보자.

코드

결과

anything ' or '1' = '1' 이 first_name인 데이터를 가져오고, 일치하는 결과가 없으면 빈 Set을 반환해야 하는데 SQL Injection이 성공해서 엉뚱한 데이터를 가져온 것을 볼 수 있다.

PreparedStatement

이제 같은 쿼리를 PreparedStatement 를 사용하여 쿼리문을 날려보자.

똑같이 first_name 필드에 anything ' or '1' = '1 를 입력하고 어떻게 동작하는지 살펴보자.

이 때, 생성된 SQL은 어떤 형태일까?

미리 말하면 아래와 같이 SQL이 생성된다.

왜 이렇게 SQL이 생성되는지는 아래에서 더 자세하게 다룰 예정이다.

실행 결과를 보자.

코드

결과

PareparedStatement를 사용하니, 일치하는 결과가 없어 정상적으로 빈 Set이 가져와지는 것을 확인했다.

그렇다면, 어떻게 SQL Injection을 막는 걸까?

이 부분 부터는 정말 궁금한 사람만 읽는 것을 추천한다.

동작 방식

디버깅 모드로 코드가 실제로 어떻게 동작하는지 내부를 들여다보기로 했다.

setString

일단, 쿼리를 날리기 전에, setString 메소드를 호출하는 부분을 보자.

setString 메소드는 ClientPreparedStatement 객체에 존재하는 메소드로,

setString 이 수행하는 작업은, 이 객체가 필드로 가지고 있는, query (정확히 말하면 ClientPreparedQuery)로부터 QueryBindings 의 객체를 가져와서 setString 을 수행한다.

계속 깊이 들어가서 디버깅하면 너무 오래걸리므로, setString 메소드에 대해서 결론만 말하자면

해당 메소드에서 사용자가 입력한 문자열을 검증하여, 이스케이프 문자열을 추가로 붙여주지는 않는다.

그렇다면, QueryBindings 등의 역할은 뭘까?

QueryBindings

PreparedStatement의 ? 자리에 들어갈 값들을 관리하는 바인딩 저장소로 아래와 같은 작업을 수행한다.

  • 파라미터 슬롯 관리
  • 타입/포맷 결정
  • 전송용 인코딩

QueryBindings 는, 개발자가 미리 준비한 Statement 와 사용자가 입력한 데이터를 Binding 해주는 역할을 가진다.

하지만, setString 단계에서 실제로 바인딩을 해주지는 않고, 바인딩을 할 때 필요한 메타 정보만 가지고 있다. (자료형, 인덱스 값 등)

이 자리에는 이 데이터가, SQL의 VARCHAR 타입으로 들어간다.” 처럼 메타 정보만 가지고 있는 것이다.

executeQuery

setString 에서는 실제로, 사용자가 입력한 정보에 이스케이프 문자 (SQL 인젝션을 위한 따옴표 등)가 있는지 확인하여, 치환해주는 작업을 하지 않았다.

그래서 그 다음 단계인 실제로 쿼리를 실행시키는 executeQuery 메소드를 살펴보았다.

executeQuery 의 내부는 아래와 같이 구성되어 있는데, 동시성 제어를 위한 락을 할당받는 부분 등 복잡한 로직들이 모여있다.

하지만, 핵심은 사진에 표시된 부분만 보면된다.

Message sendPacket = ((PreparedQuery) this.query).fillSendPacket(((PreparedQuery) this.query).getQueryBindings());

해당 부분은, 실제 데이터베이스 서버로 SQL 쿼리를 날리기 위해, packet을 만드는 과정이라고 볼 수 있다.

이때, 파라미터로 setString 단계에서 만들어두었던 바인딩 정보들을 활용하는 것을 볼 수 있다.

fillSendPacket 메소드는 MessageBuilder 라는 메세지를 만드는 객체의 buildComQuery 메소드를 호출한 후, 메세지를 만들어 리턴한다.

MessageBuilder

DB로 보낼 네이티브 패킷을 만들어 주는 역할

  • QueryBindings가 넘겨주는 값을 문자열 리터럴로 안전하게 이스케이프/포맷한 후 최종 문자열 생성

빌더를 만드는 부분, 프로토콜을 가져오는 부분 등은 데이터를 준비하는 작업이므로 생략하고, 핵심인 bulidComQuery 메소드로 들어가보자.

위와 같이 긴 코드로 구성되어 있는 것을 볼 수 있다.

이 메소드에서는 매핑된 Binding 정보를 바탕으로, 실제 Native Query를 생성하는 작업을 수행한다.

buildComQuery 메소드에서, 어느 정도 아래로 내려오다보면 아래와 같은 for 문을 만날 수 있다.

이 부분이 핵심인데, bindValues 의 각 원소를 순회하며, writeAsText 를 호출한다.

writeAsText 내부로 들어가보면, NativeQueryBindValue 라는 구현체 클래스의 내부로 이동한다.

writeAsText 내부에서는, BindValue 객체가 가지고 있는, valueEncoder 를 사용하여

Text로 인코딩을 수행한다.

encodeAsText 내부로 들어가면, Mesage를 NativePacketPayload 타입으로 캐스팅하고,

캐스팅한 타입의 writeBytes 를 호출한다.

또한 여기서, Binding 객체를 바이트 배열로 변환하여 넘겨준다.

자 이제 거의 다 왔다. 핵심은 바로, ValueEncoder 의 내부 메소드인 getBytes에 있다.

BindValue 객체를 바이트 배열로 변환할 때, 바인딩 했던 정보를 가지고 바이트 배열로 변환한다.

getBytes 는 BindValue 객체가 가지고 있는 정보 (SQL에서 어떤 자료형으로 변환되어야 하는지 등)를 가지고

바이트 배열로 변환한다.

우리가 처음에 setString 메소드를 호출하여 바인딩 정보를 만들었을 때, VARCHAR로 만들어진 것을 확인할 수 있다.

이제 getBytes 메소드 아래로 점점 내려가 보면 아래와 같은 분기문이 하나 보인다.

이제 이 부분이 정말로, 실제 이스케이프 문자를 추가로 삽입하여, SQL 인젝션을 무력화하는 로직이다.

isEscapeNeededForString 메소드를 호출하여, 이스케이프가 필요한 String인지 확인한다. 이스케이프 문자가 하나라도 포함되어 있으면, true를 반환한다.

이후, StringUtilsescapeString 을 호출하여, 이스케이프 문자를 삽입한다.

SQL 인젝션을 방지하기 위해서, 단어 사이사이에 문자를 추가한다.

이제 호출되었던 함수들이 모두 종료되고 마지막으로 fillSendPacket 메소드가 종료된 후, 리턴된 Message 객체를 보면 이스케이프 문자가 삽입된 것을 볼 수 있다.

요약

  1. setString 처럼 preparedStatement에 사용자가 입력한 데이터를 바인딩 해준다.
  2. 이후 executeQuery 가 호출되었을 때 내부적으로 이스케이프 문자를 추가로 삽입한다.
    a. PreparedQuery의 fillSendPacket 메소드를 호출하여, 바인딩 된 정보를 넘기고, 바인딩 된 정보를 바탕으로 쿼리를 Byte 배열로 만든다.
    b. 이스케이프 문자는 바인딩 된 정보를 Byte 배열로 만드는 과정에서 추가로 하나씩 삽입된다.

왜 이스케이프 문자를 패킷을 생성할 때 추가로 삽입하는지 생각해보았는데, 높은 계층에서(setString 단계 등) 삽입하는 방식보다 전송하기 전 실제 패킷을 만드는 단계에서 검증하여 삽입하는 방식이 더 빈틈이 없지 않을까 라는 생각이 들었다.

참고 자료

0개의 댓글