SQL Injection은 사용자의 입력값을 검증하지 않고 SQL에 그대로 포함 시켰을 때 발생하는 공격이다. 공격자는 입력값에 SQL 구문을 삽입하여 원래 의도하지 않은 쿼리를 실행하게 만든다. 에제를 통해 알아보자
String sql = "SELECT name FROM users WHERE id = '" + id + "' and pw = '" + pw + "'";
위 코드는 사용자의 id와 pw를 입력받아 사용자 정보를 조회하는 SQL을 생성한다. 하지만 사용자 입력값을 검증하거나 분리하지 않고 문자열로 SQL을 구성하고 있다.
만약 공격자가 다음과 같은 값을 입력한다면 문제가 발생한다.
pw = ' OR '1'='1
그러면 실제 실행되는 SQL은 다음과 같이 변한다.
SELECT name
FROM users
WHERE id = 'user'
AND pw = '' OR '1'='1'
여기서 '1'='1'은 항상 참이기 때문에 WHERE 조건이 무력화된다.
그 결과 조건을 만족하지 않는 데이터까지 조회될 수 있으며, 로그인 로직이라면 인증을 우회하는 상황이 발생할 수 있다.
이처럼 사용자 입력값이 SQL 문법의 일부로 해석되면 공격자가 쿼리의 동작을 조작할 수 있으며, 이를 SQL Injection이라고 한다.
SQL Injection은 데이터와 SQL이 분리되지 않은 채로 실행되어 문제를 일으킨다.
바인딩 변수를 사용하면 사용자 입력값을 SQL 문장에 직접 포함시키지 않고 데이터로 전달할 수 있다. 이 방식은 SQL 구조와 입력값을 분리하기 때문에 SQL Injection을 방지할 수 있다.
JDBC에서는 PreparedStatement를 통해 바인딩 변수를 사용할 수 있다.
String sql =
"SELECT * FROM users WHERE id = ? AND password = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, id);
ps.setString(2, password);
위 코드에서 ?는 바인딩 변수이며, 실제 값은 setString()을 통해 전달된다. 이때 데이터베이스는 SQL 구조를 먼저 파싱하고 이후에 값을 바인딩하기 때문에 사용자 입력값이 SQL 문법으로 해석되지 않는다.
프레임워크에서도 동일한 개념을 사용할 수 있다. MyBatis에서는 #{} 문법을 통해 바인딩 변수를 사용할 수 있다.
<select id="findUser">
SELECT *
FROM users
WHERE id = #{id}
AND pw = #{pw}
</select>
#{}는 내부적으로 PreparedStatement의 바인딩 변수로 변환되어 SQL Injection을 방지한다.
반면 MyBatis의 ${} 문법은 문자열 치환 방식이기 때문에 사용자 입력값이 SQL에 그대로 삽입된다.
WHERE id = '${id}'
이 방식은 SQL Injection 취약점이 발생할 수 있으므로 사용 시 주의가 필요하다.
입력값 검증을 통해 SQL Injection을 방지할 수 있다. 입력값 검증은 사용자가 입력한 값이 애플리케이션에서 허용된 형식인지 확인하는 과정이다.
사용자 ID가 영문과 숫자만 허용되는 값이라면 다음과 같이 검증할 수 있다.
if(!id.matches("[a-zA-Z0-9]+")){
throw new IllegalArgumentException("Invalid id format");
}
이처럼 애플리케이션에서 허용하지 않는 입력값을 사전에 차단하면 공격 시도를 줄일 수 있다.