본문 바로가기
SpringBoot

[Spring Boot] 26. Prevent SQL Injection

by 청양호박이 2021. 3. 15.

지난번에 동적쿼리에 대해서 알아보았습니다. Mybatis에서는 ${ }를 통해서 Parameter를 바로 Binding하는 경우를 동적쿼리(Dynamic Query)라고 합니다. 이런 동적쿼리를 프로그램에 적용했을때, 발생할 수 있는 보안이슈가 바로 SQL Injection 입니다. 

 

SQL Injection의 경우는, 이점을 악용해서 권한이 없는 사람이 해당 Application의 전체 데이터를 쿼리해서 볼 수 있고, 경우에 따라서는 해당 DB내 Table의 삭제(Delete)가 가능합니다. 따라서 이 부분을 막을 수 있는 방법에 대해서 여러가지 알아보겠습니다. 

 

 

1. #{ } 사용하기 (성공)


SQL Injection을 막기위해서 가장 완벽한 방법은 prepareStatement를 사용하는 것 입니다. 이는 mybatis에서 #{ }을 사용할 경우에 자동으로 적용이 됩니다. 예를 들어볼까요?? 지난 시간에 사용한 코드를 재활용해 보겠습니다. 

 

[Mapper.java]

@Mapper
public interface Mapper {

	ArrayList<EachInfoBackDTO> EachInfo(@Param("Id") String Id);

}

[Mapper.xml]

<select id="EachInfo" resultType="EachInfoBackDTO">
<![CDATA[
    SELECT
        * 
    FROM [table name] WHERE userId = #{Id};
]]>
</select>

이렇게 작성된 코드는 실제적으로 아래와 같이 변경이 되어 동작하게 됩니다.

 

[Changed Code Logic]

/* JDBC code */
PreparedStatement stmt = con.prepareStatement("SELECT * FROM [Table Name] WHERE userId = ?");
stmt.setString(1, Id);

그럼 한번 테스트를 해보겠습니다. 테스트에는 대표적인 SQL Injection의 입력값인...

'임의의 String' OR 'a' = 'a'

해당 입력값에 의해서 Where의 조건 중 [임의의 String]은 불일치 되겠지만... 'a' = 'a' 로 인해서 일치가 되어서 모든 데이터가 출력되게 됩니다.

 

[SQL Injection 성공]

입력값 : Id = "'20210129' OR 'a' = 'a'";

출력값
INFO 10256 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
INFO 10256 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
5236

해당 테스트 코드에는 parameter를 ${ }로 binding하였기 때문에 전체 데이터가 검색되었습니다. 그렇다면 해당 부분을 단지 #{ } 로 수정하여 binding해보겠습니다.

 

[SQL Injectino 실패]

입력값 : Id = "'20210129' OR 'a' = 'a'";

출력값
INFO 7984 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
INFO 7984 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
0

정확히 방어를 수행하였습니다. 이는 결국, binding되는 parameter가 String으로 입력되는지 그 자체로 입력되는지에 대한 차이입니다. #{ }의 경우 String으로 binding되기 때문에 해당 입력값 전체가 하나의 조건으로 주어지게 되어 일치하는 결과가 0이 되는 것 입니다.

 

 

2. 입력 값 Escape Library 사용 (실패)


이번에는 입력되는 값에 대해서 특정한 기호를 Escape한 후에 적용하는 방법입니다. SQL Injection의 경우 특수기호 (') 홑따옴표, (") 쌍따옴표 등이 그에 해당 되겠습니다. 그럼 한번 검색에 돌입해 볼까요?? 일단 단순하게 EscapeSQL로 검색을 해 보겠습니다. 

 

어라...!! 바로 검색이 되네요?? commons.apache.org 에서 제공하는 것이 있는 것 같습니다. 해당 사이트에서 관련된 내용으로 검색된 결과는 아래와 같습니다. 그런데... 상당히 오래전 이야기 같습니다. 2011년이 나오는거 보면... 벌써 10년 전이네요;;;

org.apache.commons.lang
Class StringEscapeUtils

java.lang.Object
  extended by org.apache.commons.lang.StringEscapeUtils
  
Escapes and unescapes Strings for Java, Java Script, HTML, XML, and SQL.

Since:
2.0
Version:
$Id: StringEscapeUtils.java 1057072 2011-01-10 01:55:57Z niallp $

apache.org에서 제공하는 library인 것으로 보이고... StringEscapeUtils라는 Class에서 해당 Escape를 제공하는 것 같습니다. 좀 더 살펴보니... sql을 escape하는 method를 제공하고 있습니다.

692        public static String escapeSql(String str) {
693            if (str == null) {
694                return null;
695            }
696            return StringUtils.replace(str, "'", "''");
697        }

아주 단순하게 입력 String에 대해서 한가지 기호에 대해서 몽땅 Replace를 해주고 있습니다. 그렇다면 두말할 것 없이 Maven Repository에 들러서 해당 Library를 찾아서 내 project에 구성해 보겠습니다. 현재 찾은 아이는 apache commons lang입니다. 

 

[commons-lang3]

EscapeSQL로 검색해서 찾은 version은 2.0 이였는데... 현재 3.12.x가 2021년3월에 release되었습니다. 그 사이에 어마어마한 변화가 있었을 것을 감지하고... commons.apache.org에서 제공하는 홈페이지에 들어가 보겠습니다. 

commons.apache.org/proper/commons-lang/apidocs/index.html

 

Apache Commons Lang 3.12.0 API

 

commons.apache.org

동일하게 StirngEscapeUtils Class를 찾아보겠습니다.

org.apache.commons.lang3
Class StringEscapeUtils

java.lang.Object
org.apache.commons.lang3.StringEscapeUtils

Deprecated. 
as of 3.6, use commons-text StringEscapeUtils instead

@Deprecated
public class StringEscapeUtils
extends Object

Escapes and unescapes Strings for Java, Java Script, HTML and XML.

Since:
2.0

몇가지 변경사항이 있네요?? commons.lang에서 commons.lang3으로 업그레이드 되었습니다. 그리고 동일한 Class를 제공하고 있으며... 엇!!! v3.6부터 Deprecated 되었습니다;;; commons-text library에서 동일한 Class를 사용하라고 하네요.

그런데... 잠깐 살펴본 내용으로는 SQL에 대한 escape를 제공하는 method는 없어보였습니다;; 하지만 당황하지 않고 commons text를 다시 maven repository에서 검색하여 확인해 보겠습니다.

 

[commons-text]

그림과 같이 현재 1.9.x가 2020년7월에 release되었습니다. 역시나 제공하는 javadoc을 찾아가서 StirngEscapeUtils Class에서 어떤 method를 제공하는지 확인해 보겠습니다.

이게 전체 method인데... SQL에 관련된 부분은 없습니다. 그나마 유사해 보이는 것은... JAVA정도?? 그래서 들어가서 보면, 제공하는 기능은 아래와 같으며.... 왠지 SQL Injection하고는 관련이 없어보입니다.

Escapes the characters in a String using Java String rules.

Deals correctly with quotes and control-chars (tab, backslash, cr, ff, etc.)

So a tab becomes the characters '\\' and 't'.

The only difference between Java strings and JavaScript strings is that in JavaScript, a single quote and forward-slash (/) are escaped.

Example:

 input string: He didn't say, "Stop!"
 output string: He didn't say, \"Stop!\"

그래도... 구성을 해보고 되는지 눈으로 확인해 보겠습니다.

 

[pom.xml 에 dependency추가]

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-text -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-text</artifactId>
    <version>1.9</version>
</dependency>

내부에 <dependencies/> 안에 추가를 해 줍니다. 저장을 하게되면 eclipse가 자동적으로 다운로드를 진행하고, 프로젝트 내부에 Maven Dependencies를 확인해 보면...

다음과 같이, commons-text-1.9.jar와 commons-lang3-3.11.jar가 설치되었습니다. 이제... 간단하게 구성하고 구동해 보겠습니다. 방식은... 입력값을 escape처리하고, 그 결과를 Mapper parameter로 전달하고 ${ }로 binding하여 과연 SQL Injection을 방지했는지 확인해 보겠습니다.

 

[Controller.java 에 EscapeJAVA추가]

@RequestMapping(value = "eachInfo", method = RequestMethod.GET)
public ResponseEntity<EachInfoFrontDTO> EachInfo(String Id){
    Id = "'20210129' OR 'a' = 'a'";
    String escapeId = StringEscapeUtils.escapeJava(Id);
    System.out.println(escapeId);
    return ResponseEntity.ok(Service.EachInfo(escapeId));
}

어차피 parameter로 전달될 값은 Controller에서 1차 들어오기 때문에, 해당 부분에 Escape를 추가해 줍니다. 그럼 결과를 살펴보겠습니다.

'20210129' OR 'a' = 'a'
INFO 13904 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
INFO 13904 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
5236

EscapeJava를 통해서 변경을 시도는 했지만... 변경결과로 보여지는 String의 차이는 전혀 없습니다. 또한, SQL Query의 결과도 전체 Data가 검색되어 나왔습니다. 결국, 해당 방법으로는 SQL Injection을 방지할 수 없습니다. 추후, 해당 Library에서 제공하는 StringEscapeUtils의 다양한 method로 적용이 가능한 범위에서 적용할 경우에는 도움이 될 것 같습니다.

 



 

 

3. prepareStatement 직접 구현 (성공)


결국 Mybatis에서 #{ } 이 간접적으로 제공하는 prepareStatement의 변경을 직접 수동으로 구현하는 방법이 있습니다. 뭔가 코드의 통일성을 저해하는 점이 있지만, 죽어도 나는 Mapper를 통해서 parameter를 전달할때 ${ }로 받아야 한다면... 그냥 Service.java에서 prepareStatement를 구현해 버리는 것이 Application의 안정정에서 더 나은 방법입니다.

 

안정성과 통일성을 가지고 선택을 해야한다면 과감하게 안정성을 고른다는 것 이지요. 그럼 간단하게 동일한 항목에 대해서 구현해 보겠습니다.

 

[Service.java]

	public EachInfoFrontDTO EachInfo(String Id) {
		ArrayList<EachInfoBackDTO> res = Mapper.EachInfo(Id);
		System.out.println("Mapper REQ size : " + res.size());
		
		Connection con = null;
		PreparedStatement stmt = null;
		ResultSet rs = null;
		
		int cnt = 0;
		try {
			con = dataSource.getConnection();
			stmt = con.prepareStatement("SELECT * FROM [Table Name] WHERE userId = ?");
			stmt.setString(1, Id);
			
			rs = stmt.executeQuery();
			
			while(rs.next()) {
			    cnt++;
			}
			System.out.println("Local stmt REQ size : " + cnt);
		}catch (Exception e) {
			System.out.println(e);
		}
		return null;
	}

우선 정상적으로 Mapper를 통해서 ${ } 로 parameter를 binding하는 방식과, PreparedStatement로 작성하여 DB에 접근하는 방식을 동시에 사용해 보았습니다. 간단하게 절차는...

 

  • DataSource로 부터 Connection을 수행
  • preparedStatement에 SQL Query를 인자로 넣고 객체를 생성
  • binding할 데이터는 preparedStatment객체의 setString을 통해서 수행
  • 해당 Query의 결과를 ResultSet에 저장
  • 원하는 DTO에 ResultSet에 저장된 결과를 하나하나 next( ) method를 호출하여 저장 (생략)

 

다음과 같습니다. 이를 통해서, 각 입력당 도출되는 결과를 확인해 보겠습니다.

입력값 : Id = "20210129";

출력값
INFO 12904 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
INFO 12904 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
Mapper REQ size : 1
Local stmt REQ size : 1
=====================================================================

입력값 : Id = "20210129 OR 'a' = 'a'";

출력값
INFO 12836 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
INFO 12836 --- [nio-8080-exec-1] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
Mapper REQ size : 5236
Local stmt REQ size : 0

정상입력에 대해서는 Mapper를 통한 결과나 PreparedStatement 결과는 동일합니다. 하지만 SQL Injection의 입력이 들어온 경우에는... Mapper를 통해서는 전체 결과가 나오며, PreparedStatement에는 Mapper의 #{ }를 통한 binding과 동일하게 결과가 나오지 않습니다.

 

 

4. 최종결정


mybatis에서 #{ }은 자동으로 prepareStatement를 적용해 준다고 말씀드렸습니다. 따라서, 어쩔수 없이 ${ }를 사용해야 하는 환경이라면... 차라리 강제적으로 mybatis를 사용하지 않고, 수동으로 prepareStatement를 구현하는 것이 SQL Injection으로부터 안전하게 구현이 가능합니다. 

 

지금까지는 동적쿼리가 where절 이후 조건에 대해서 parameter가 binding될 때, SQL Injection에 대해서 알아보았습니다. 하지만 실제 동적쿼리는 from 이후 table에 대해서도 적용이 될 수 있습니다. 하지만, 테이블에 위의 방법들을 적용하기는 불가능한 부분이 있어서 이부분은 이후에 다시한번 생각해 보도록 하겠습니다.

 

- Ayotera Lab -

댓글