끄적끄적

[Secure Coding] Prepared Statement 훑어보기 본문

Security/Web

[Secure Coding] Prepared Statement 훑어보기

Go0G 2022. 1. 17. 15:33

개요

SQL Injection의 대응 방안으로 사용자의 입력 값을 바인딩하여 사용하는 Prepared Statement를 권장한다. 그렇다면 실제 Prepared Statement는 어떻게 동작하는 걸까? [JDBC].JAR 파일을 분석하여 Prepared Statement에서 사용자의 입력 값이 어떻게 설정되는지 살펴보고자 한다.

이에대해 살펴보기 전 몇가지 선행 지식이 필요하다.

  • Statement VS Prepared Statement
  • JDBC(Java Database Connectivity)
  • Class.ForName

Statement VS Prepared Statement

두 방식은 아래와 같은 공통적인 실행 과정을 거친다.

  1. 구문 분석 및 정규화: 쿼리 문법 확인 및 데이터베이스, 테이블 존재여부 확인
  2. 컴파일: 쿼리 컴파일
  3. 쿼리 최적화: 쿼리 실행 방법의 최적 계획 선택
  4. 캐시: 쿼리 최적화 단게에서 선택된 계획이 캐시에 저장되어 동일 쿼리 실행 시 1~3 단계를 실행하지 않고 캐시를 통해 찾음
  5. 실행: 쿼리가 실행된 값이 담긴 객체(Results)를 사용자에게 반환

Query Execution Step

차이점

Statement: 매번 쿼리 실행시 마다 1~5 과정 반복
Prepared Statement: 최초 쿼리 실행시 1~5 과정 후, 4번 과정부터 수행

Prepared Statement 방식 사용이 DB의 부하를 낮추고 속도를 높일 수 있다.

예제코드

사용자의 입력 값 "num"에 대해 서버가 DB에 쿼리를 전송하기 위한 예이다.

[Statement]

String sqlstr = "SELECT name.memo FROM TABLE WHERE num="+num;
Statement stmt = conn.credateStatment();
Resultset rst = stmt.executeQuery(sqlstr);

SQL 실행 과정에서 매번 컴파일 발생

[Prepared Statement]

String sqlstr = "SELECT name.memo FROM TABLE WHERE num= ?"
PreparedStatement stmt = conn.prepareStatement(sqlstr); 
pstmt.setInt(1, num);
ResultSet rst = pstmt.executeQuerey();

최초 실행시 컴파일을 수행하고 "?" 부분에만 변화를 주어 지속적으로 SQL을 수행

성능적인 차이점 외에도 보안적으로 Prepared Statment는 사용자의 입력 값을 컴파일된 쿼리문에 바인딩하여 정상적인 쿼리인지 확인하는 과정을 거친다.

사용자의 입력 값이 컴파일된 쿼리문에 바인딩?은 무슨 의미를 가질까?

Statement 상태에서 DBMS 내부적으로 아래의 과정이 일어난다.

Statement 상태에서 DMBS 동작 과정

1. Paser

정상적인 SQL 명령문인지 또는 문법, 의미적으로 맞는지 확인하는 단계를 수행한다.

Parsing이 진행되는 동안 SQL 명령문은 syntax tree라 불리는 데이터베이스 내부 표현으로 변형된다.

syntax tree

2. Optimizer

생성된 syntax tree에 따라 데이터를 가져오기 위한 가장 효율적인 알고리즘을 선택한다.

Optimizer 기술에 따라 인덱싱, 테이블 검색, 조인 등의 Access Path 설정을 통해 요청 질의에 대한 최적의 경로로 응답 데이터를 전달받을 수 있는 executeion plan을 제작한다.

3. Executor

Optimizer에서 전달된 execution plan은 Executor가 데이터를 가져오고 최종 결과를 작성하는데 사용된다.

execution plan은 스토리지 엔진(데이터 로딩), 트랜잭션 엔진(무결성 보증)을 사용하여 사용자가 요구하는 데이터를 가져오기 위한 runtime-gernerate 프로그램 처럼 execution plan 대로 실행하는 역할을 수행한다.

 


Prepared Statement는 Statement와 다른 방식으로 동작한다.

Prepared Statement는 쿼리문을 실행하기 전 구문 분석을 통해 Syntax Tree로 컴파일을 진행한다. 올바른 구문이여야만 컴파일이 완료될 수있으며 이를 pre-comilation 단계라 한다.

이후 실행에 필요한 Execution Plan 작성을 위해 Driver에 전달(사용자의 입력 등)되는 파라미터를 Syntax Tree에 바인딩 시키고 이를 컴파일하여 Execution Plan을 생성한다. 

Executor는 생성된 Execution Plan을 통해 결과 값을 받는다.

Prepared Statement 상태에서 DMBS 동작 과정

개발자가 의도한 Syntax Tree를 생성하는 기능 자체가 Prepared Statement라 볼 수 있다.

Prepared Statement는 개발자가 작성한 SQL 구문을 컴파일 한 쿼리를 서버로 전송하고 적절한 사용자 입력만을 처리할 수 있도록한다. 이후, 두번째 요청에 Set메소드로 데이터를 바인딩하여 전송하게 된다.

만일 악의적인 사용자의 SQL Injection 구문이 입력될 경우 Syntax Tree상 문자열로써 취급하기 때문에 명령어로써 동작할 수 없다.


JDBC(Java Database Connectivity)

JDBC는 Oracle, MySQL, MSSQL 등 다양한 관계형 데이터베이스와 Java의 상호작용을 위해 JDK 1.1에서 처음 도입되었다.

*JDBC이전 OOBC API가 있었지만 C언어로 작성되어 있어 플랫폼에 따라 달라지는 ODBC 드라이브와 함꼐 사용 되었음

JDBC 흐름을 다이어 그램으로 표현하면 아래와 같다.

JDBC 다이어 그램

데이터베이스 마다 서로 다른 SQL 구문을 사용하기 때문에 JDBC 역시, 각 데이터베이스마다 다른 API(*.jar)파일를 제공한다.

*JDK내 Statement, Prepared Statement 클래스가 존재하며, 데이터베이스의 각 벤더사에서 만든 JDBC API 파일은 해당 클래스를 상속받아 사용한다.

관련된 내용은 "[DB명] Connector"라 검색하면 관련 API를 다운로드 받을 수 있다.


Class.Forname

Class 클래스는 클래스들의 정보(필드, 메서드, 인터페이스 등)을 담는 메타 클래스로 JVM(Java Virtual Machine)은 이 Class 클래스를 통해 클래스들에 대한 정보를 로드한다.

이는 JDK내 Statement, Prepared Statement 클래스가 틀로서 존재하고 이기종의 JDBC 클래스들을 Class.Forname을 통해 JVM에 업로드하여 사용하는 것이다

예제코드

import java.sql.Connection;
import java.sql.DriverManager;

private Connection conn;
Class.forName("org.mariadb.jdbc.Driver"); //드라이버 로드
conn =DriverManager.getConnection(dbURL,dbID,dbPassword); // 연결 얻기

위 코드는 Java에서 MariaDB에 연결하기 위한 소스코드 예이다. 기존 JDK에는 Java에서 어떤 데이터베이스로 연결할 지 알지 못한다. 이때 Class.forName("JDBC.jar")를 사용하여 JVM에 JDBC에 대한 정보를 로드한다. 이후 Driver Manager가 getConnection()을 이용하여 해당 데이터베이스에 연결을 시도하는 것이다.


분석

분석 도구 및 환경

분석도구 JADX 1.3.1
JDK  1.8.301(rt.jar)
SQL Connector MariaDB(mariadb-java-client-2.3.0.jar)

예제코드

데이터베이스 연결

import java.sql.Connection;

private Connection conn;
String dbURL="jdbc:mariadb://127.0.0.1";
String dbID="TEST";
String dbPassword="TEST";
Class.forName("org.mariadb.jdbc.Driver");
conn =DriverManager.getConnection(dbURL,dbID,dbPassword);

데이터베이스 연결을 위한 소스코드로 실제 연결이 이루어지는 부분은 아래와 같은 라인이다.

conn =DriverManager.getConnection(dbURL,dbID,dbPassword);

MariaDbDataSource Class

getConnection에서 "dbURL" 값이 null일 경우 initalize()를 실행하게 되는데, initalize() 메소드는 URL이나 PORT의 값이 존재하지 않을 경우 "localhost:3306"으로 설정한다.

UrlParser 클래스는 개발자가 입력한 데이터베이스 URL의 유효성을 검증하고 필요한 내용들을 변수에 저장하는 역할을 수행한다.

데이터베이스에서 값 조회

import java.sql.ResultSet;

private ResultSet rs;
String SQL="SELECT * FROM [Table Name] WHERE [찾고자하는 컬럼]=?";
PreparedStatement pstmt=conn.prepareStatement(SQL); //앞 코드의 conn 변수확인 
pstmt.setString(1,[찾고자하는 컬럼의 레코드(컬럼)]);
rs=pstmt.executeQuery();

이전 데이터베이스에 연결된 핸들러 변수인 "conn"에 대하여 SQL 쿼리를 JDK내 PreparedStatement에 저장하는 부분은 아래와 같다.

1. 구문 분석

PreparedStatement pstmt=conn.prepareStatement(SQL);

PreparedStatement 메소드는 MariaDbConnection 클래스에서 찾아볼 수 있다.

MariaDbConnection Class

위 클래스를 살펴보면 결과적으로 internalPrepareStatment 메소드로 리턴되는 소스코드인 것을 살펴볼 수 있다. 동일 클래스에서 해당 메소드를 살펴보면 아래와 같다.

MariaDbConnection Class의 internalPrepareStatment Method

SQL에 담긴 값이 NULL이 아닐 경우 하위 분기문을 실행한다.

하위 분기문에서는 "useServerPrepStmts" 옵션이 설정되어 있거나 SQL 값이 PREPARABLE_STATEMENT_PATTERN 변수에 저장되어 있는 값에 매칭될 경우 하위 쿼리를 실행한다.

PREPARABLE_STATEMENT_PATTERN 변수의 값

useServerPrepStmts: HikariCP(DB Connection Pool)을 사용하는 경우에 "useServerPrepStmts"를 통해 별도의 Prepared Statement를 사용하도록 한다.

HikariCP 옵션을 설정하지 않았기 때문에 아래 라인의 코드인 MariaDbPreparedStatementClient 인스턴스가 선언&실행된다.

MariaDbPreparedStatementClient Class

MariaDbPreparedStatementClient 인스턴스가 선언되면 해당 클래스의 생성자가 실행된다. prepareResult(결과)값이 존재하지 않기 때문에 하위 분기문으로 진행된다.  

하위 분기문에서는 "rewriteBatchedStatements" 옵션에 따라 ClientPrepareResult의 각기 다른 메소드를 실행한다.

rewriteBatchedStatements: Batch Insert로 Insert 합치기 기능을 제공함

이또한 별도의 옵션을 설정하지 않았기 때문에 ClientPrepareResult.parameterParts()가 실행되게 된다.

아래는 ClientPrepareResult Class의 parameterParts 메소드의 일부이다.

 

    public enum LexState {
        Normal, /*Insert Query*/
        String, /*Insert String*/
        SlashStarComment, /*Insert slash-start Comment*/
        Escape, /*Found backslash*/
        EOLComment, /* # comment or // commnet, or -- comment */
        Backtick /*Insert backtick */
    }

	public static ClientPrepareResult parameterParts(String queryString, boolean noBackslashEscapes) {
        try {
            boolean multipleQueriesPrepare = true;
            List<byte[]> partList = new ArrayList<>();
            LexState state = LexState.Normal;
            char lastChar = 0;
            boolean endingSemicolon = false;
            boolean singleQuotes = false;
            int lastParameterPosition = 0;
            char[] query = queryString.toCharArray();
            int queryLength = query.length;
            for (int i = 0; i < queryLength; i++) {
                char car = query[i];
                if (state == LexState.Escape && ((car != '\'' || !singleQuotes) && (car != '\"' || singleQuotes))) {
                    state = LexState.String;
                }
                switch (car) {
                    case '\n':
                        if (state == LexState.EOLComment) {
                            multipleQueriesPrepare = true;
                            state = LexState.Normal;
                            break;
                        } else {
                            break;
                        }
                    case '\"':
                        if (state != LexState.Normal) {
                            if (state != LexState.String || singleQuotes) {
                                if (state == LexState.Escape && !singleQuotes) {
                                    state = LexState.String;
                                    break;
                                }
                            } else {
                                state = LexState.Normal;
                                break;
                            }
                        } else {
                            state = LexState.String;
                            singleQuotes = false;
                            break;
                        }
                        break;
                    case '#':
                        if (state == LexState.Normal) {
                            state = LexState.EOLComment;
                            break;
                        } else {
                            break;
                        }
                    case ParameterHolder.QUOTE /* 39 */:
                        if (state != LexState.Normal) {
                            if (state != LexState.String || !singleQuotes) {
                                if (state == LexState.Escape && singleQuotes) {
                                    state = LexState.String;
                                    break;
                                }
                            } else {
                                state = LexState.Normal;
                                break;
                            }
                        } else {
                            state = LexState.String;
                            singleQuotes = true;
                            break;
                        }
                        break;
                    case '*':
                        if (state == LexState.Normal && lastChar == '/') {
                            state = LexState.SlashStarComment;
                            break;
                        }
                        break;
                    case '-':
                        if (state == LexState.Normal && lastChar == '-') {
                            state = LexState.EOLComment;
                            multipleQueriesPrepare = false;
                            break;
                        }
                        break;
                    case '/':
                        if (state != LexState.SlashStarComment || lastChar != '*') {
                            if (state == LexState.Normal && lastChar == '/') {
                                state = LexState.EOLComment;
                                break;
                            }
                        } else {
                            state = LexState.Normal;
                            break;
                        }
                        break;
                    case ';':
                        if (state == LexState.Normal) {
                            endingSemicolon = true;
                            multipleQueriesPrepare = false;
                            break;
                        } else {
                            break;
                        }
                    case '?':
                        if (state == LexState.Normal) {
                            partList.add(queryString.substring(lastParameterPosition, i).getBytes("UTF-8"));
                            lastParameterPosition = i + 1;
                            break;
                        } else {
                            break;
                        }
                    case '\\':
                        if (!noBackslashEscapes && state == LexState.String) {
                            state = LexState.Escape;
                            break;
                        }
                        break;
                    case '`':
                        if (state != LexState.Backtick) {
                            if (state == LexState.Normal) {
                                state = LexState.Backtick;
                                break;
                            } else {
                                break;
                            }
                        } else {
                            state = LexState.Normal;
                            break;
                        }
                    default:
                        if (state == LexState.Normal && endingSemicolon && ((byte) car) >= 40) {
                            endingSemicolon = false;
                            multipleQueriesPrepare = true;
                            break;
                        }
                        break;
                }
                lastChar = car;
            }
            if (lastParameterPosition == 0) {
                partList.add(queryString.getBytes("UTF-8"));
            } else {
                partList.add(queryString.substring(lastParameterPosition, queryLength).getBytes("UTF-8"));
            }
            return new ClientPrepareResult(queryString, partList, false, multipleQueriesPrepare, false);
        } catch (UnsupportedEncodingException e) {
            return null;
        }
    }

위 소스코드를 간단하게 살펴보면 아래의 과정을 거친다.

  1. SQL 구문을 배열(query)에 저장
  2. 반복문을 통해 문자열에 하나씩 접근하며 바인딩 부분("?")을 찾아 문자열을 분리하여 partList에 저장
  3. 반복문 종료 후, 바인딩 위치("?") ~ 쿼리 최종 부분까지 partList에 저장(결과적으로 "?"를 제외한 문자열만 partList에 저장)
  4. 기존 쿼리(queryString)와 partList가 담긴 ClientPrepareResult 객체 리턴

MariaDbPreparedStatementClient Class(객체 리턴 후)

이후 parameters에 저장되는 값은 바인딩의 개수만큼 PrameterHolder 배열이 생성된다.

prepareResult.getParamCount()

결과적으pstmt에는 MariaDbPreparedStatementClientParameterHolder[]에 접근 가능한 객체가 저장된다.

2. Set 메소드

pstmt.setString(1,[찾고자하는 컬럼의 레코드(컬럼)]);

BasePrepareStatment Class

setString 메소드의 값이 null이 아니면 setPrameter를 이용해 값을 설정한다.

MariaDbPreparedStatementClient Class

앞서 선언해두었던 premeters에 사용자의 입력 값을 삽입한다.

3. 실행

rs=pstmt.executeQuery();

MariaDbPreparedStatementClient Class

execute 메소드에서 값이 존재하지 않을 경우 SelectResultSet의 createEmptyResultSet 메소드가 실행되며 빈 배열이 출력된다. 이후 execute 메소드는 executeInternal 메소드를 실행한다. 

MariaDbPreparedStatementClient Class

executeInternal 메소드에서는 쿼리 실행을 수행하고 result 클래스의 commandEnd 메소드를 실행한다.

Results class

commandEnd 메소드는 실행결과를 SelectResultSet resultSet에 저장한다. 

MariaDbPreparedStatementClient Class

결과적으로 results 클래스의 getResultSet 메소드를 호출하여 쿼리 결과가 저장된 resultSet을 리턴한다.


Reference

'Security > Web' 카테고리의 다른 글

[vulnerability] File Upload  (0) 2022.04.25
[Exploit] 예제 코드  (0) 2022.03.03
[vulnerability] Nginx alias traversal  (0) 2021.10.07
[vulnerability] Python Pickle Module Exploit  (0) 2021.10.07
PentesterLab 1  (1) 2021.09.24
Comments